Count9 #7
@@ -14,6 +14,7 @@ public class RadiusDbContext : DbContext
|
|||||||
public DbSet<RadReply> RadReply { get; set; } = null!;
|
public DbSet<RadReply> RadReply { get; set; } = null!;
|
||||||
public DbSet<RadAcct> RadAcct { get; set; } = null!;
|
public DbSet<RadAcct> RadAcct { get; set; } = null!;
|
||||||
public DbSet<GuestProfile> GuestProfiles { 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<AdminUser> AdminUsers { get; set; } = null!;
|
||||||
public DbSet<SystemSetting> SystemSettings { 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<RadReply>().ToTable("radreply");
|
||||||
modelBuilder.Entity<RadAcct>().ToTable("radacct");
|
modelBuilder.Entity<RadAcct>().ToTable("radacct");
|
||||||
modelBuilder.Entity<GuestProfile>().ToTable("guest_profiles");
|
modelBuilder.Entity<GuestProfile>().ToTable("guest_profiles");
|
||||||
|
modelBuilder.Entity<GuestDataUsage>().ToTable("guest_data_usage");
|
||||||
modelBuilder.Entity<AdminUser>().ToTable("admin_users");
|
modelBuilder.Entity<AdminUser>().ToTable("admin_users");
|
||||||
modelBuilder.Entity<SystemSetting>().ToTable("system_settings");
|
modelBuilder.Entity<SystemSetting>().ToTable("system_settings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,20 @@ public class GuestProfile
|
|||||||
public string Status { get; set; } = "Active";
|
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")]
|
[Table("admin_users")]
|
||||||
public class AdminUser
|
public class AdminUser
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public class AccountEnforcementService : BackgroundService
|
|||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var disabledCount = 0;
|
var disabledCount = 0;
|
||||||
|
var changesMade = false;
|
||||||
|
|
||||||
foreach (var profile in activeProfiles)
|
foreach (var profile in activeProfiles)
|
||||||
{
|
{
|
||||||
@@ -68,12 +69,11 @@ public class AccountEnforcementService : BackgroundService
|
|||||||
reason = "Time expired";
|
reason = "Time expired";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check data cap: sum acctinputoctets + acctoutputoctets from radacct
|
// 2. Check data cap: read from guest_data_usage table
|
||||||
if (reason == null && profile.DataLimitMb > 0)
|
if (reason == null && profile.DataLimitMb > 0)
|
||||||
{
|
{
|
||||||
var totalOctets = await db.RadAcct
|
var usage = await db.GuestDataUsage.FirstOrDefaultAsync(du => du.Username == profile.Username, ct);
|
||||||
.Where(ra => ra.Username == profile.Username)
|
var totalOctets = (usage?.TotalInputOctets ?? 0) + (usage?.TotalOutputOctets ?? 0);
|
||||||
.SumAsync(ra => (ra.AcctInputOctets ?? 0) + (ra.AcctOutputOctets ?? 0), ct);
|
|
||||||
|
|
||||||
long dataLimitBytes = profile.DataLimitMb * 1024 * 1024;
|
long dataLimitBytes = profile.DataLimitMb * 1024 * 1024;
|
||||||
if (totalOctets >= dataLimitBytes)
|
if (totalOctets >= dataLimitBytes)
|
||||||
@@ -102,18 +102,57 @@ public class AccountEnforcementService : BackgroundService
|
|||||||
Value = "Reject"
|
Value = "Reject"
|
||||||
});
|
});
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation("Account '{Username}' disabled. Reason: {Reason}", profile.Username, reason);
|
||||||
"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 => ra.NasIpAddress)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass";
|
||||||
|
|
||||||
|
foreach (var nasIp in activeSessions)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(nasIp)) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var process = new System.Diagnostics.Process
|
||||||
|
{
|
||||||
|
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "sh",
|
||||||
|
Arguments = $"-c \"echo 'User-Name={profile.Username}' | radclient -x {nasIp}:3799 disconnect '{radiusSecret}'\"",
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.Start();
|
||||||
|
await process.WaitForExitAsync(ct);
|
||||||
|
_logger.LogInformation("Sent RADIUS Disconnect-Request to NAS {NasIp} for user {Username}", nasIp, profile.Username);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to send RADIUS Disconnect-Request to NAS {NasIp}", nasIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
disabledCount++;
|
disabledCount++;
|
||||||
|
changesMade = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disabledCount > 0)
|
if (changesMade)
|
||||||
{
|
{
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
_logger.LogInformation("Enforcement check complete. {Count} account(s) disabled.", disabledCount);
|
if (disabledCount > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Enforcement check complete. {Count} account(s) disabled.", disabledCount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,25 +175,27 @@ public class RadiusService : IRadiusService
|
|||||||
.Where(rc => rc.Attribute == "Cleartext-Password")
|
.Where(rc => rc.Attribute == "Cleartext-Password")
|
||||||
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
|
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
|
||||||
|
|
||||||
// Group accounting data by username
|
// Group accounting data by username (for session time and connection status)
|
||||||
var acctStats = await _db.RadAcct
|
var acctStats = await _db.RadAcct
|
||||||
.GroupBy(ra => ra.Username)
|
.GroupBy(ra => ra.Username)
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
Username = g.Key,
|
Username = g.Key,
|
||||||
TotalInput = g.Sum(x => x.AcctInputOctets ?? 0),
|
|
||||||
TotalOutput = g.Sum(x => x.AcctOutputOctets ?? 0),
|
|
||||||
TotalSessionTime = g.Sum(x => x.AcctSessionTime ?? 0),
|
TotalSessionTime = g.Sum(x => x.AcctSessionTime ?? 0),
|
||||||
IsConnected = g.Any(x => x.AcctStopTime == null)
|
IsConnected = g.Any(x => x.AcctStopTime == null)
|
||||||
})
|
})
|
||||||
.ToDictionaryAsync(g => g.Username);
|
.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>();
|
var result = new List<UserUsageDto>();
|
||||||
|
|
||||||
foreach (var p in profiles)
|
foreach (var p in profiles)
|
||||||
{
|
{
|
||||||
passwords.TryGetValue(p.Username, out var pwd);
|
passwords.TryGetValue(p.Username, out var pwd);
|
||||||
acctStats.TryGetValue(p.Username, out var acct);
|
acctStats.TryGetValue(p.Username, out var acct);
|
||||||
|
dataUsage.TryGetValue(p.Username, out var usage);
|
||||||
|
|
||||||
result.Add(new UserUsageDto
|
result.Add(new UserUsageDto
|
||||||
{
|
{
|
||||||
@@ -205,8 +207,8 @@ public class RadiusService : IRadiusService
|
|||||||
SessionTimeMinutes = p.SessionTimeMinutes,
|
SessionTimeMinutes = p.SessionTimeMinutes,
|
||||||
DataLimitMb = p.DataLimitMb,
|
DataLimitMb = p.DataLimitMb,
|
||||||
Status = p.Status,
|
Status = p.Status,
|
||||||
TotalInputOctets = acct?.TotalInput ?? 0,
|
TotalInputOctets = usage?.TotalInputOctets ?? 0,
|
||||||
TotalOutputOctets = acct?.TotalOutput ?? 0,
|
TotalOutputOctets = usage?.TotalOutputOctets ?? 0,
|
||||||
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
|
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
|
||||||
IsCurrentlyConnected = acct?.IsConnected ?? false
|
IsCurrentlyConnected = acct?.IsConnected ?? false
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,3 +131,58 @@ INSERT INTO system_settings (key, value) VALUES ('default_data_limit', '500') ON
|
|||||||
|
|
||||||
-- System settings initialized on startup
|
-- System settings initialized on startup
|
||||||
-- Admin user is created by administrator during first startup via the web interface
|
-- Admin user is created by administrator during first startup via the web interface
|
||||||
|
|
||||||
|
-- Dedicated table for robust, per-user data tracking that survives AP counter resets
|
||||||
|
CREATE TABLE IF NOT EXISTS guest_data_usage (
|
||||||
|
username VARCHAR(64) PRIMARY KEY,
|
||||||
|
total_input_octets BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_output_octets BIGINT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trigger function to safely accumulate data usage from radacct
|
||||||
|
CREATE OR REPLACE FUNCTION update_guest_data_usage()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
delta_input BIGINT := 0;
|
||||||
|
delta_output BIGINT := 0;
|
||||||
|
BEGIN
|
||||||
|
-- Handle INSERT (new session)
|
||||||
|
IF (TG_OP = 'INSERT') THEN
|
||||||
|
delta_input := COALESCE(NEW.acctinputoctets, 0);
|
||||||
|
delta_output := COALESCE(NEW.acctoutputoctets, 0);
|
||||||
|
-- Handle UPDATE (interim updates)
|
||||||
|
ELSIF (TG_OP = 'UPDATE') THEN
|
||||||
|
-- Only add if the new value is greater than the old value (protects against counter resets)
|
||||||
|
IF (COALESCE(NEW.acctinputoctets, 0) > COALESCE(OLD.acctinputoctets, 0)) THEN
|
||||||
|
delta_input := NEW.acctinputoctets - COALESCE(OLD.acctinputoctets, 0);
|
||||||
|
ELSIF (COALESCE(NEW.acctinputoctets, 0) < COALESCE(OLD.acctinputoctets, 0)) THEN
|
||||||
|
-- Counter reset mid-session! Just add the new value as the delta.
|
||||||
|
delta_input := COALESCE(NEW.acctinputoctets, 0);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF (COALESCE(NEW.acctoutputoctets, 0) > COALESCE(OLD.acctoutputoctets, 0)) THEN
|
||||||
|
delta_output := NEW.acctoutputoctets - COALESCE(OLD.acctoutputoctets, 0);
|
||||||
|
ELSIF (COALESCE(NEW.acctoutputoctets, 0) < COALESCE(OLD.acctoutputoctets, 0)) THEN
|
||||||
|
-- Counter reset mid-session!
|
||||||
|
delta_output := COALESCE(NEW.acctoutputoctets, 0);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Upsert the calculated deltas into the usage table per-user
|
||||||
|
IF (delta_input > 0 OR delta_output > 0) THEN
|
||||||
|
INSERT INTO guest_data_usage (username, total_input_octets, total_output_octets)
|
||||||
|
VALUES (NEW.username, delta_input, delta_output)
|
||||||
|
ON CONFLICT (username) DO UPDATE
|
||||||
|
SET total_input_octets = guest_data_usage.total_input_octets + EXCLUDED.total_input_octets,
|
||||||
|
total_output_octets = guest_data_usage.total_output_octets + EXCLUDED.total_output_octets;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Attach trigger to radacct table
|
||||||
|
DROP TRIGGER IF EXISTS trg_update_guest_data_usage ON radacct;
|
||||||
|
CREATE TRIGGER trg_update_guest_data_usage
|
||||||
|
AFTER INSERT OR UPDATE OF acctinputoctets, acctoutputoctets ON radacct
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_guest_data_usage();
|
||||||
|
|||||||
Reference in New Issue
Block a user