Files
radius/radiuscontroller/Services/RadiusService.cs
T
Tygozwolle 3b20b7481e
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
feat: implement RadiusService for guest registration, session management, and usage tracking with AccountEnforcementService support.
2026-07-24 20:33:08 +02:00

429 lines
15 KiB
C#

using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using radiuscontroller.Data;
using radiuscontroller.Models;
namespace radiuscontroller.Services;
public interface IRadiusService
{
Task<SystemConfigDto> GetSystemConfigAsync();
Task UpdateSystemConfigAsync(int defaultSessionTimeMinutes, long defaultDataLimitMb);
Task<RegistrationResultDto> RegisterGuestAsync(string guestName);
Task<List<UserUsageDto>> GetAllUsersWithUsageAsync();
Task<List<RadAcct>> GetActiveSessionsAsync();
Task UpdateUserLimitsAsync(int profileId, int newSessionTimeMinutes, long newDataLimitMb);
Task RevokeUserAsync(int profileId);
Task DeleteUserAsync(int profileId);
Task ResetUserPasswordAsync(int profileId, string newPassword);
Task EnsureDatabaseCreatedAndSeededAsync();
}
public class RadiusService : IRadiusService
{
private readonly RadiusDbContext _db;
private readonly ILogger<RadiusService> _logger;
public RadiusService(RadiusDbContext db, ILogger<RadiusService> logger)
{
_db = db;
_logger = logger;
}
public async Task<SystemConfigDto> GetSystemConfigAsync()
{
var settings = await _db.SystemSettings.ToDictionaryAsync(s => s.Key, s => s.Value);
int sessionTime = 60; // 60 minutes default
if (settings.TryGetValue("default_session_time", out var sessionStr) && int.TryParse(sessionStr, out var parsedSession))
{
sessionTime = parsedSession;
}
long dataLimit = 500; // 500 MB default
if (settings.TryGetValue("default_data_limit", out var dataStr) && long.TryParse(dataStr, out var parsedData))
{
dataLimit = parsedData;
}
return new SystemConfigDto
{
DefaultSessionTimeMinutes = sessionTime,
DefaultDataLimitMb = dataLimit
};
}
public async Task UpdateSystemConfigAsync(int defaultSessionTimeMinutes, long defaultDataLimitMb)
{
await SetOrUpdateSettingAsync("default_session_time", defaultSessionTimeMinutes.ToString());
await SetOrUpdateSettingAsync("default_data_limit", defaultDataLimitMb.ToString());
await _db.SaveChangesAsync();
}
private async Task SetOrUpdateSettingAsync(string key, string value)
{
var existing = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key);
if (existing != null)
{
existing.Value = value;
existing.UpdatedAt = DateTime.UtcNow;
}
else
{
_db.SystemSettings.Add(new SystemSetting
{
Key = key,
Value = value,
UpdatedAt = DateTime.UtcNow
});
}
}
public async Task<RegistrationResultDto> RegisterGuestAsync(string guestName)
{
if (string.IsNullOrWhiteSpace(guestName))
{
throw new ArgumentException("Guest name cannot be empty.", nameof(guestName));
}
var config = await GetSystemConfigAsync();
// Format clean username base
string cleanName = Regex.Replace(guestName.Trim().ToLowerInvariant(), @"[^a-z0-9]", "");
if (string.IsNullOrEmpty(cleanName)) cleanName = "guest";
if (cleanName.Length > 12) cleanName = cleanName[..12];
string username = $"{cleanName}_{RandomNumberGenerator.GetInt32(1000, 9999)}";
string password = GenerateReadablePassword(8);
// Save Cleartext-Password for FreeRADIUS
_db.RadCheck.Add(new RadCheck
{
Username = username,
Attribute = "Cleartext-Password",
Op = ":=",
Value = password
});
// Set Session-Timeout 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
{
Username = username,
Attribute = "Session-Timeout",
Op = "=",
Value = sessionTimeoutSeconds.ToString()
});
// Set Max-Octets (in bytes)
long maxOctetsBytes = config.DefaultDataLimitMb * 1024 * 1024;
_db.RadReply.Add(new RadReply
{
Username = username,
Attribute = "Max-Octets",
Op = "=",
Value = maxOctetsBytes.ToString()
});
// 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
{
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.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 (for session time and connection status)
var acctStats = await _db.RadAcct.AsNoTracking()
.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);
// 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
{
ProfileId = p.Id,
GuestName = p.GuestName,
Username = p.Username,
Password = pwd ?? "••••••••",
CreatedAt = p.CreatedAt,
SessionTimeMinutes = p.SessionTimeMinutes,
DataLimitMb = p.DataLimitMb,
Status = p.Status,
TotalInputOctets = usage?.TotalInputOctets ?? 0,
TotalOutputOctets = usage?.TotalOutputOctets ?? 0,
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
IsCurrentlyConnected = acct?.IsConnected ?? false
});
}
return result;
}
public async Task<List<RadAcct>> GetActiveSessionsAsync()
{
return await _db.RadAcct.AsNoTracking()
.Where(ra => ra.AcctStopTime == null)
.OrderByDescending(ra => ra.AcctStartTime)
.ToListAsync();
}
public async Task UpdateUserLimitsAsync(int profileId, int newSessionTimeMinutes, long newDataLimitMb)
{
var profile = await _db.GuestProfiles.FindAsync(profileId);
if (profile == null) return;
profile.SessionTimeMinutes = newSessionTimeMinutes;
profile.DataLimitMb = newDataLimitMb;
// Update RadReply Session-Timeout to the full allowed time
var sessionReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Session-Timeout");
if (sessionReply != null)
{
sessionReply.Value = (newSessionTimeMinutes * 60).ToString();
}
else
{
_db.RadReply.Add(new RadReply
{
Username = profile.Username,
Attribute = "Session-Timeout",
Op = "=",
Value = (newSessionTimeMinutes * 60).ToString()
});
}
// Update RadReply Max-Octets
var dataReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Max-Octets");
long maxOctetsBytes = newDataLimitMb * 1024 * 1024;
if (dataReply != null)
{
dataReply.Value = maxOctetsBytes.ToString();
}
else
{
_db.RadReply.Add(new RadReply
{
Username = profile.Username,
Attribute = "Max-Octets",
Op = "=",
Value = maxOctetsBytes.ToString()
});
}
await _db.SaveChangesAsync();
}
public async Task RevokeUserAsync(int profileId)
{
var profile = await _db.GuestProfiles.FindAsync(profileId);
if (profile == null) return;
profile.Status = "Revoked";
// Remove Cleartext-Password and insert Auth-Type := Reject
var checks = await _db.RadCheck.Where(rc => rc.Username == profile.Username).ToListAsync();
_db.RadCheck.RemoveRange(checks);
_db.RadCheck.Add(new RadCheck
{
Username = profile.Username,
Attribute = "Auth-Type",
Op = ":=",
Value = "Reject"
});
await _db.SaveChangesAsync();
// Disconnect active sessions immediately
await DisconnectActiveSessionsAsync(profile.Username);
}
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();
// Disconnect active sessions immediately
await DisconnectActiveSessionsAsync(username);
}
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);
}
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);
}
}
}
}