74326870f7
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m8s
427 lines
15 KiB
C#
427 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.OrderByDescending(p => p.CreatedAt).ToListAsync();
|
|
var passwords = await _db.RadCheck
|
|
.Where(rc => rc.Attribute == "Cleartext-Password")
|
|
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
|
|
|
|
// Group accounting data by username (for session time and connection status)
|
|
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);
|
|
|
|
// Fetch data usage from the new dedicated table
|
|
var dataUsage = await _db.GuestDataUsage.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
|
|
.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 })
|
|
.ToListAsync();
|
|
|
|
var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass";
|
|
|
|
foreach (var session in activeSessions)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(session.NasIpAddress)) continue;
|
|
|
|
try
|
|
{
|
|
var payload = $"User-Name=\\\"{username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\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);
|
|
}
|
|
}
|
|
}
|
|
}
|