the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
@@ -0,0 +1,176 @@
using System.Collections.Concurrent;
// Hardening for the ADMIN surface (/admin/* and /api/web/admin/*) on top of the auth the other
// layers already enforce (dev token + admin session cookie). The admin endpoints are the most
// dangerous surface on the server - they mutate players, economy, rooms, bans - so they get:
//
// * Security headers on every response: no MIME sniffing, no framing (clickjacking), no
// referrer leakage, no indexing, no caching, a locked-down Permissions-Policy, and a CSP on
// the admin HTML pages.
// * A per-IP rate limit: a generous window for normal panel use (which polls the log/players),
// and a tight window on the dev-login endpoint to blunt brute force.
//
// Runs after BannedIpMiddleware and PatchGateMiddleware, so a banned IP never reaches it and the
// login page stays reachable to be throttled.
public class AdminProtectionMiddleware
{
private readonly RequestDelegate _next;
public AdminProtectionMiddleware(RequestDelegate next) => _next = next;
// Fixed-window per-IP counters: key -> (count, window-start unix seconds).
private static readonly ConcurrentDictionary<string, (int count, long windowStart)> _hits = new();
// Master switch, in case the throttle ever needs disabling without a redeploy of logic.
public static bool RateLimitEnabled = true;
private const int WindowSeconds = 15;
private const int MaxPerWindow = 300; // generous: the Log tab polls every 3s, lists burst on load
private const int LoginWindowSeconds = 60;
private const int MaxLoginPerWindow = 10; // brute-force guard on /api/web/admin/login
// ADMIN-ONLY IP allowlist (Data/admin_allowlist.txt). If the file exists AND lists at least one
// IP, the admin surface is reachable ONLY from those IPs (plus loopback, so the SSH tunnel /
// localhost can never lock the operator out); everyone else gets 404 - the panel simply looks
// like it isn't there. Empty/missing file = OFF (admin still gated by token + dev login). This
// is scoped to /admin + /api/web/admin ONLY, so it never affects game clients. One IP per line;
// blank lines and lines starting with '#' are ignored, re-read every few seconds.
private const int AllowlistCacheSeconds = 5;
private static readonly object _allowLock = new();
private static HashSet<string>? _allow;
private static long _allowLoadedAt;
private static HashSet<string>? CurrentAllowlist()
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
lock (_allowLock)
{
if (_allow != null && now - _allowLoadedAt < AllowlistCacheSeconds) return _allow.Count == 0 ? null : _allow;
_allowLoadedAt = now;
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
var path = Path.Combine(Environment.CurrentDirectory, "Data", "admin_allowlist.txt");
if (File.Exists(path))
foreach (var raw in File.ReadAllLines(path))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
set.Add(line);
}
}
catch { /* unreadable file -> treat as OFF, never lock out on an IO hiccup */ }
_allow = set;
return set.Count == 0 ? null : set;
}
}
/// <summary>Is this IP in Data/admin_allowlist.txt (or loopback)?</summary>
public static bool IsAllowlistedIp(string? ip)
{
if (string.IsNullOrWhiteSpace(ip)) return false;
if (ip == "127.0.0.1" || ip == "::1" || ip.StartsWith("::ffff:127.")) return true;
var allow = CurrentAllowlist();
return allow != null && allow.Contains(ip.Trim());
}
/// <summary>An allowlisted admin IP that is exempt from the anti-abuse limits (ConfigCampus.AdminRateLimitEnabled = false).</summary>
public static bool IsLimitExempt(string? ip) =>
!Campus_live_rewrite.api.server.ConfigCampus.AdminRateLimitEnabled && IsAllowlistedIp(ip);
private static bool IsAdmin(string p) =>
p.StartsWith("/admin", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/api/web/admin", StringComparison.OrdinalIgnoreCase);
private static bool IsLogin(string p) =>
p.StartsWith("/api/web/admin/login", StringComparison.OrdinalIgnoreCase);
private static bool IsApi(string p) =>
p.StartsWith("/admin/api", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/api/web/admin", StringComparison.OrdinalIgnoreCase);
public async Task Invoke(HttpContext context)
{
string path = context.Request.Path.HasValue ? context.Request.Path.Value! : "/";
if (!IsAdmin(path))
{
await _next(context);
return;
}
// Admin-only IP allowlist: if configured, non-listed IPs get 404 (panel "doesn't exist").
// Loopback is always allowed so the operator can't be locked out over the SSH tunnel.
var allow = CurrentAllowlist();
if (allow != null)
{
string aip = PatchGateMiddleware.ClientIp(context);
bool loop = System.Net.IPAddress.TryParse(aip, out var pip) && System.Net.IPAddress.IsLoopback(pip);
if (!loop && !allow.Contains(aip))
{
Console.WriteLine($"[adminprotect] IP {aip} not on admin allowlist -> 404 {path}");
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsync("");
return;
}
}
// Stamp the security headers just before the response flushes, so they win over anything a
// controller/static-file handler set.
context.Response.OnStarting(() =>
{
ApplySecurityHeaders(context.Response.Headers, path);
return Task.CompletedTask;
});
if (RateLimitEnabled)
{
bool login = IsLogin(path);
string ip = PatchGateMiddleware.ClientIp(context);
string key = (login ? "L:" : "A:") + ip;
int cap = login ? MaxLoginPerWindow : MaxPerWindow;
int win = login ? LoginWindowSeconds : WindowSeconds;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var entry = _hits.AddOrUpdate(
key,
_ => (1, now),
(_, cur) => (now - cur.windowStart >= win) ? (1, now) : (cur.count + 1, cur.windowStart));
if (entry.count > cap)
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers["Retry-After"] = win.ToString();
Console.WriteLine($"[adminprotect] 429 {context.Request.Method} {path} from {ip} ({entry.count} in {win}s)");
await context.Response.WriteAsync("Too many requests.");
return;
}
}
await _next(context);
}
private static void ApplySecurityHeaders(IHeaderDictionary h, string path)
{
h["X-Content-Type-Options"] = "nosniff";
h["X-Frame-Options"] = "DENY";
h["Referrer-Policy"] = "no-referrer";
h["X-Robots-Tag"] = "noindex, nofollow";
h["Cache-Control"] = "no-store, no-cache, must-revalidate";
h["Pragma"] = "no-cache";
h["Permissions-Policy"] = "geolocation=(), microphone=(), camera=(), payment=()";
// CSP only on the admin HTML PAGES (not the JSON APIs). The panel uses inline scripts/styles,
// Font Awesome + Google Fonts over CDN, /img thumbnails, and same-origin fetch; block the rest
// and forbid being framed anywhere.
if (!IsApi(path))
{
h["Content-Security-Policy"] =
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; " +
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com; " +
"font-src 'self' https://cdnjs.cloudflare.com https://fonts.gstatic.com data:; " +
"img-src 'self' data: https:; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
}
}
}
@@ -0,0 +1,209 @@
using System.Collections.Concurrent;
using System.Net;
using Campus_live_rewrite.api.dbs;
/// <summary>
/// Refuses EVERY request from a banned IP with 502, not just the login call.
///
/// The IP ban itself is enforced in AuthController, but that only covers
/// /Auth/connect/token. A banned address could still reach every other endpoint -
/// images, rooms, chat, the admin login page - and anything holding a valid token kept
/// working until it expired. This closes the whole domain.
///
/// 502 is deliberate (Kyle's call): to the client it looks like the site is down rather
/// than like a ban, so there is nothing to probe at.
/// </summary>
public class BannedIpMiddleware
{
private readonly RequestDelegate _next;
// BanDB.GetIpBan does an AES encrypt plus a LiteDB lookup. Doing that on every single
// request (images alone are hundreds per session) would be wasteful, so decisions are
// cached briefly. A ban therefore takes up to CacheSeconds to bite, and an unban up to
// CacheSeconds to release - acceptable for a moderation action, and it keeps the hot
// path to a dictionary probe.
private const int CacheSeconds = 20;
private static readonly ConcurrentDictionary<string, (bool Banned, DateTime Checked)> _cache = new();
public BannedIpMiddleware(RequestDelegate next) => _next = next;
/// <summary>Drops a cached decision so a ban/unban applies immediately.</summary>
public static void Forget(string? ip)
{
if (!string.IsNullOrWhiteSpace(ip)) _cache.TryRemove(ip!, out _);
}
public static void ForgetAll() => _cache.Clear();
public async Task Invoke(HttpContext context)
{
string? ip = ClientIp(context);
// Never block loopback. Caddy proxies from the same box, and the admin panel is
// reached over an SSH tunnel to localhost - banning a shared/CGNAT address must not
// be able to lock the operator out of their own server.
if (string.IsNullOrWhiteSpace(ip) || IsLoopback(ip))
{
await _next(context);
return;
}
// ALLOWLIST MODE (private-server lockdown).
//
// When Data/allowlist.txt exists AND has at least one address, ONLY those addresses
// (plus loopback, exempted above) may reach the server - everything else gets 502,
// so the whole domain looks down to anyone not on the list. This is how the server is
// pinned to the operator's own IP.
//
// Safe by construction:
// - no file, or an empty file -> mode is OFF, server behaves normally. Deleting or
// blanking the file instantly reopens the server, no rebuild.
// - loopback is always allowed (checked above), so the admin panel over the SSH
// tunnel keeps working even when your own public IP is not listed.
// - the file is re-read when it changes, so editing it takes effect within seconds.
//
// One IP per line; blank lines and lines starting with '#' are ignored.
if (!IsAllowed(ip!))
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync("502 Bad Gateway");
return;
}
if (IsBanned(ip!))
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync("502 Bad Gateway");
return;
}
await _next(context);
}
private static readonly string AllowlistPath =
Path.Combine(Environment.CurrentDirectory, "Data", "allowlist.txt");
private static HashSet<string>? _allow;
private static DateTime _allowLoadedAt = DateTime.MinValue;
private static DateTime _allowFileStamp = DateTime.MinValue;
private static readonly object _allowLock = new();
/// <summary>
/// True unless allowlist mode is active and this IP is not on the list. An empty or
/// missing list means "mode off" - allow everyone.
/// </summary>
private static bool IsAllowed(string ip)
{
var list = LoadAllowlist();
if (list.Count == 0) return true; // mode off
return list.Contains(ip);
}
private static HashSet<string> LoadAllowlist()
{
// Cheap re-check: only touch the disk every few seconds, and only reparse when the
// file's timestamp actually moved.
var now = DateTime.UtcNow;
if (_allow != null && (now - _allowLoadedAt).TotalSeconds < 5)
return _allow;
lock (_allowLock)
{
if (_allow != null && (now - _allowLoadedAt).TotalSeconds < 5)
return _allow;
_allowLoadedAt = now;
try
{
if (!File.Exists(AllowlistPath))
{
_allow = new HashSet<string>();
return _allow;
}
var stamp = File.GetLastWriteTimeUtc(AllowlistPath);
if (_allow != null && stamp == _allowFileStamp)
return _allow;
_allowFileStamp = stamp;
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in File.ReadAllLines(AllowlistPath))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
set.Add(line);
}
if (set.Count > 0)
Console.WriteLine($"[allowlist] active - {set.Count} address(es) may reach the server; all others get 502");
else
Console.WriteLine("[allowlist] file present but empty - allowlist mode OFF");
_allow = set;
}
catch (Exception ex)
{
// Never lock everyone out over a read error - fail OPEN.
Console.WriteLine($"[allowlist] could not read {AllowlistPath} ({ex.Message}) - allowing all");
_allow = new HashSet<string>();
}
return _allow;
}
}
private static bool IsBanned(string ip)
{
var now = DateTime.UtcNow;
if (_cache.TryGetValue(ip, out var hit) && (now - hit.Checked).TotalSeconds < CacheSeconds)
return hit.Banned;
bool banned;
try
{
banned = BanDB.IsIpBanned(ip);
}
catch (Exception ex)
{
// Never take the server down over a ban lookup - fail open.
Console.WriteLine($"[banip] lookup failed for {ip}: {ex.Message}");
banned = false;
}
_cache[ip] = (banned, now);
if (banned)
Console.WriteLine($"[banip] 502 for banned IP {ip}");
return banned;
}
private static string? ClientIp(HttpContext context)
{
// Same order the rest of the server uses: Cloudflare, then a generic proxy header,
// then the socket. X-Forwarded-For can be a list - the client is the first entry.
string? ip = context.Request.Headers["CF-Connecting-IP"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(ip))
{
string? xff = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(xff))
ip = xff.Split(',')[0].Trim();
}
if (string.IsNullOrWhiteSpace(ip))
ip = context.Connection.RemoteIpAddress?.ToString();
return string.IsNullOrWhiteSpace(ip) ? null : ip.Trim();
}
private static bool IsLoopback(string ip)
{
if (ip == "::1" || ip.StartsWith("127.")) return true;
return IPAddress.TryParse(ip, out var parsed) && IPAddress.IsLoopback(parsed);
}
}
@@ -0,0 +1,82 @@
using Campus_live_rewrite.api.server;
public class IPAllowMiddleware
{
private readonly RequestDelegate _next;
private static readonly HashSet<string> _allowedIPs = new()
{
"47.198.33.69",
"50.114.179.17"
//"96.27.234.110"
};
// Disabled: public Rec Room game server, not a staging box. Reference server's whitelist
// was E12354's own dev IP (47.198.33.69). Leave the plumbing so a future operator can
// toggle back on + populate _allowedIPs, but OFF by default so every player can reach us.
public static bool IPAllowMiddleware_enable = false;
public IPAllowMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
if (!IPAllowMiddleware_enable)
{
await _next(context);
return;
}
string? remoteIpString = context.Request.Headers["CF-Connecting-IP"].FirstOrDefault() ?? context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString();
string? useragent = context.Request.Headers["User-Agent"].FirstOrDefault();
if (string.IsNullOrEmpty(remoteIpString))
{
Deny(context, remoteIpString, useragent);
return;
}
if (!System.Net.IPAddress.TryParse(remoteIpString, out var remoteIp))
{
Deny(context, remoteIpString, useragent);
return;
}
if (System.Net.IPAddress.IsLoopback(remoteIp))
{
await _next(context);
return;
}
if (remoteIp.IsIPv4MappedToIPv6)
{
remoteIp = remoteIp.MapToIPv4();
}
if (!_allowedIPs.Contains(remoteIp.ToString()))
{
await Deny(context, remoteIp.ToString(), useragent);
return;
}
await _next(context);
}
private static async Task Deny(HttpContext context, string? ip, string? useragent)
{
string msg = $"Forbidden request from IP: {ip} and user agent {useragent}";
Console.WriteLine($"[IPAllowMiddleware] {msg}");
Webhook.Send(1, "gaylog", "uh oh!", msg, 1);
if (!context.Response.HasStarted)
{
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync(
""
);
}
}
}
@@ -0,0 +1,30 @@
public class IPBlockMiddleware
{
private readonly RequestDelegate _next;
private static readonly HashSet<string> _blockedIPs = new() // this can be ipv6 or ipv4
{
"37.203.37.86",
"173.2.187.39"
};
public IPBlockMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
string? remoteIp = context.Request.Headers["CF-Connecting-IP"].FirstOrDefault()
?? context.Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? context.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(remoteIp) && _blockedIPs.Contains(remoteIp))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;// this do kick people //Status401Unauthorized;
await context.Response.WriteAsync("");
return;
}
await _next(context);
}
}
@@ -0,0 +1,133 @@
using System.Collections.Concurrent;
using System.Text;
// Lightweight per-request access log for the admin "Log" tab (/admin/api/apilog).
//
// WHY THIS WAS REWRITTEN: the old version did File.AppendAllTextAsync on EVERY request -
// an open/write/close each time. Under any concurrency two requests race for the same file,
// AppendAllText throws "file in use", and the catch swallowed it - so lines were dropped
// silently. It was therefore gated behind ConfigCampus.VerboseRequestLogging (default false)
// and the admin Log tab, which tails Data/APILog.txt, showed nothing = "the logs tool doesn't
// work".
//
// Now: the authoritative source for the admin tab is an in-memory RING BUFFER (last N lines) -
// zero file contention, always fresh, survives a read-only disk. The file append is kept as a
// best-effort secondary (history + something to read offline) behind a single shared writer so
// concurrent requests never collide. This middleware is cheap enough to run UNCONDITIONALLY
// (see Program.cs), independent of the heavy VerboseRequestLogging body-logging path.
public class Log
{
private readonly RequestDelegate _next;
private readonly string _logFilePath;
// Ring buffer of recent request lines. Bounded so memory can't grow without limit.
private const int Capacity = 5000;
private static readonly ConcurrentQueue<string> _recent = new();
// One shared writer, guarded, so we never open/close the file per request or race on it.
private static readonly object _fileLock = new();
private static StreamWriter? _writer;
private static string? _writerPath;
public Log(RequestDelegate next, string logFilePath)
{
_next = next;
_logFilePath = logFilePath;
try
{
var dir = Path.GetDirectoryName(_logFilePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
}
catch
{
// even startup logging must never crash
}
}
// Which headers does the real client actually send? Every DISTINCT header name is logged once
// per process, so a request-signature header (X-RNSIG and friends) would name itself here
// instead of being guessed at. Costs one set lookup per request after the first few.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _seenHeaders = new();
public async Task InvokeAsync(HttpContext context)
{
if (Campus_live_rewrite.api.server.ConfigCampus.LogNewRequestHeaders)
{
foreach (var h in context.Request.Headers)
{
if (!_seenHeaders.TryAdd(h.Key, 0)) continue;
string v = h.Value.ToString();
if (string.Equals(h.Key, "Authorization", StringComparison.OrdinalIgnoreCase) || string.Equals(h.Key, "Cookie", StringComparison.OrdinalIgnoreCase))
v = "<redacted>";
Console.WriteLine($"[headers] first sighting: '{h.Key}: {(v.Length > 120 ? v.Substring(0, 120) + "…" : v)}' on {context.Request.Method} {context.Request.Path}");
}
}
await _next(context);
try
{
string line =
$"{DateTime.UtcNow:o} {context.Request.Method} {context.Request.Path}{context.Request.QueryString} -> {context.Response.StatusCode}";
// In-memory first - this is what the admin tab reads, so it works even if the disk
// write below fails.
_recent.Enqueue(line);
while (_recent.Count > Capacity && _recent.TryDequeue(out _)) { }
AppendToFile(line);
}
catch
{
// NEVER let logging crash the request.
}
}
private void AppendToFile(string line)
{
try
{
lock (_fileLock)
{
// (Re)open the shared writer if this is the first line or the path changed.
// FileShare.ReadWrite so /admin/api/apilog (and anyone tailing) can read while
// we hold it open. AutoFlush so the admin tab sees lines immediately.
if (_writer == null || _writerPath != _logFilePath)
{
_writer?.Dispose();
var fs = new FileStream(_logFilePath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
_writer = new StreamWriter(fs, new UTF8Encoding(false)) { AutoFlush = true };
_writerPath = _logFilePath;
}
_writer.WriteLine(line);
}
}
catch
{
// Disk write is best-effort; the ring buffer already has the line.
}
}
/// <summary>
/// The most recent request lines for the admin Log tab, oldest-first, optionally filtered
/// to those containing <paramref name="find"/>. Reads the in-memory ring buffer, so it is
/// instant and never blocked by disk state. Empty until the first request is logged.
/// </summary>
public static IReadOnlyList<string> Recent(int lines, string? find = null)
{
if (lines < 1) lines = 1;
IEnumerable<string> rows = _recent.ToArray();
if (!string.IsNullOrEmpty(find))
rows = rows.Where(l => l.Contains(find, StringComparison.OrdinalIgnoreCase));
var arr = rows.ToArray();
if (arr.Length <= lines) return arr;
return arr[^lines..];
}
/// <summary>True once at least one request has been logged this process.</summary>
public static bool HasBufferedLines => !_recent.IsEmpty;
}
@@ -0,0 +1,130 @@
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
public class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger<RequestResponseLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task Invoke(HttpContext context)
{
// Fast path. Everything below buffers the whole response in memory and spams
// a globally-locked Console, which serialises concurrent requests and is what
// makes chat and general play feel delayed. Off by default; see
// ConfigCampus.VerboseRequestLogging. Skipping it changes no response bytes.
if (!Campus_live_rewrite.api.server.ConfigCampus.VerboseRequestLogging)
{
await _next(context);
return;
}
if (context.Request.Path.Equals("/api/images/v4/uploadsaved") || (context.Request.Path.Equals("/Matchmaking/heartbeat") || context.Request.Path.Equals("/api/gameconfigs/v1/all") || context.Request.Path.StartsWithSegments("/Room_server/rooms/visitedby/me") || context.Request.Path.Equals("/api/avatar/v4/items") || context.Request.Path.Equals("/api/avatar/v1/defaultunlocked") || context.Request.Path.Equals("/api/equipment/v2/getUnlocked") || context.Request.Path.Equals("/admin/api/players") || context.Request.Path.Equals("/admin/api/rooms") || context.Request.Path.StartsWithSegments("/Room_server/rooms/hot") || context.Request.Path.StartsWithSegments("/Room_server/rooms/") || context.Request.Path.StartsWithSegments("/Room_server/rooms/search") || context.Request.Path.StartsWithSegments("/api/images/v5") || context.Request.Path.StartsWithSegments("/cdn/room") || context.Request.Path.StartsWithSegments("/cdn/") || context.Request.Path.Equals("/api/consumables/v2/getUnlocked")))
{
await _next(context);
return;
}
string requestUrl = $"{context.Request.Method} {context.Request.Scheme}://{context.Request.Host}{context.Request.Path}{context.Request.QueryString}";
string contentType = context.Request.ContentType ?? string.Empty;
bool isBinaryRequest = IsBinaryContentType(contentType);
string cfConnectingIp = context.Request.Headers["CF-Connecting-IP"].FirstOrDefault() ?? "[NO CF-CONNECTING-IP]";
string auth = context.Request.Headers["Authorization"].FirstOrDefault()
?? context.Request.Cookies["Authorization"];
string requestBody = (context.Request.Method == "POST" || context.Request.Method == "PUT") && !isBinaryRequest
? await ReadRequestBody(context)
: "[NO REQUEST BODY]";
Console.WriteLine($"[REQUEST] {requestUrl}");
Console.WriteLine($"[CF-CONNECTING-IP] {cfConnectingIp}");
Console.WriteLine($"[AUTH] {auth}");
_logger.LogInformation($"Request: {requestUrl} - CF-Connecting-IP: {cfConnectingIp}");
if (!isBinaryRequest)
{
Console.WriteLine($"[REQUEST BODY] {requestBody}");
_logger.LogInformation($"Request Body: {requestBody}");
}
else
{
Console.WriteLine("[REQUEST BODY] [BINARY CONTENT SKIPPED]");
}
var originalResponseBodyStream = context.Response.Body;
using (var responseBodyStream = new MemoryStream())
{
context.Response.Body = responseBodyStream;
await _next(context);
string responseContentType = context.Response.ContentType ?? string.Empty;
bool isBinaryResponse = IsBinaryContentType(responseContentType);
string responseBody = !isBinaryResponse ? await ReadResponseBody(responseBodyStream) : "[BINARY CONTENT SKIPPED]";
Console.WriteLine($"[RESPONSE] Status: {context.Response.StatusCode}");
if (!isBinaryResponse)
{
Console.WriteLine($"[RESPONSE BODY] {responseBody}");
_logger.LogInformation($"Response: {context.Response.StatusCode} - Body: {responseBody}");
}
else
{
Console.WriteLine("[RESPONSE BODY] [BINARY CONTENT SKIPPED]");
}
responseBodyStream.Seek(0, SeekOrigin.Begin);
await responseBodyStream.CopyToAsync(originalResponseBodyStream);
}
}
private async Task<string> ReadRequestBody(HttpContext context)
{
context.Request.EnableBuffering();
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
string body = await reader.ReadToEndAsync();
context.Request.Body.Position = 0;
return string.IsNullOrWhiteSpace(body) ? "[EMPTY REQUEST BODY]" : body;
}
private async Task<string> ReadResponseBody(Stream responseBodyStream)
{
responseBodyStream.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(responseBodyStream, Encoding.UTF8, leaveOpen: true);
string body = await reader.ReadToEndAsync();
responseBodyStream.Seek(0, SeekOrigin.Begin);
return string.IsNullOrWhiteSpace(body) ? "[EMPTY RESPONSE BODY]" : body;
}
private bool IsBinaryContentType(string contentType)
{
if (string.IsNullOrWhiteSpace(contentType))
return false;
contentType = contentType.ToLowerInvariant();
return contentType.StartsWith("image/") ||
contentType.StartsWith("audio/") ||
contentType.StartsWith("video/") ||
contentType == "application/octet-stream" ||
contentType.Contains("application/pdf") ||
contentType.Contains("application/zip") ||
contentType.Contains("application/x-binary");
}
}
@@ -0,0 +1,264 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using Campus_live_rewrite.api.server;
// Two gates in one place - the server is the gatekeeper for BOTH ways in:
//
// 1. GAME clients (the DLL): the patch stamps the shared-secret header X-Campus-Patch on EVERY
// request (its SendRequest hook). The server checks that header on EVERY request here - a
// match passes, anything else is 403'd. There is NO whitelist and NO time window: an old/no/
// tampered patch that doesn't stamp the exact secret is denied on every single call, every
// time, so there's nothing to "ride" after validating once.
//
// 2. The ADMIN panel (a browser, no DLL): can't stamp the header, so it is gated by a short-lived
// WEB SESSION TOKEN instead. You do NOT type it - a correct username/password login MINTS a
// signed, self-expiring token (AdminGate) into a cookie. The rest of the admin surface (home +
// /admin/api/*) requires a live token; when it expires you're bounced back to the login to
// mint a fresh one. The login page/API stay reachable so you can log in.
// The admin web session token. You do NOT type this - it is MINTED when you log in (correct
// username/password) and expires after a short period, like a bearer token. It's a signed,
// self-expiring value: "<expiryUnix>.<HMAC(expiryUnix)>", keyed by ConfigCampus.AdminWebToken.
// The server can validate it (signature + not-expired) without storing any session state, and
// once it lapses the browser is bounced back to the login to mint a fresh one.
public static class AdminGate
{
public const string CookieName = "admin_session";
// Short lifetime - a stale tab can't sit logged into admin indefinitely, and the whole admin
// web session resets every hour (re-login required). Change here if needed.
public static readonly TimeSpan Ttl = TimeSpan.FromHours(1);
private static string Sign(string payload)
{
var key = Encoding.UTF8.GetBytes(ConfigCampus.AdminWebToken ?? "");
using var h = new HMACSHA256(key);
return Convert.ToBase64String(h.ComputeHash(Encoding.UTF8.GetBytes(payload)));
}
// Mint a fresh token at login.
public static string Issue()
{
string payload = DateTimeOffset.UtcNow.Add(Ttl).ToUnixTimeSeconds().ToString();
return payload + "." + Sign(payload);
}
public static bool IsValid(HttpContext ctx)
{
string? c = ctx.Request.Cookies[CookieName];
if (string.IsNullOrEmpty(c) || string.IsNullOrEmpty(ConfigCampus.AdminWebToken)) return false;
int dot = c.IndexOf('.');
if (dot <= 0 || dot >= c.Length - 1) return false;
string payload = c.Substring(0, dot);
string sig = c.Substring(dot + 1);
// Signature must match (constant-time) ...
var a = Encoding.UTF8.GetBytes(sig);
var b = Encoding.UTF8.GetBytes(Sign(payload));
if (!CryptographicOperations.FixedTimeEquals(a, b)) return false;
// ... and the embedded expiry must still be in the future.
if (!long.TryParse(payload, out long exp)) return false;
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() < exp;
}
}
public class PatchGateMiddleware
{
private readonly RequestDelegate _next;
// Master switch for the GAME (patch) gate. The admin web-token gate is always on.
//
// TURNED OFF 2026-09-01, and it must stay off until the clients stamp a header again.
//
// Both patches used to call BestHTTP HTTPRequest.SetHeader on every request to stamp
// X-Campus-Patch. That stamping has been removed from the PC patch (SendRequest.h) and
// the Quest patch (campus_hook.cpp), so NO client sends the header any more. With
// Enable = true, this middleware would 403 every single game call and nobody could log in.
//
// WHAT THIS COSTS: the server can no longer distinguish a patched client from an old,
// absent or tampered one. The game API is open to anything that can reach it. IP bans
// (BannedIpMiddleware) and per-endpoint JWT validation still apply - this only removes
// the "must be running our DLL" check.
//
// The startup check (POST /api/patch/verify) still works and is still enforced client-side:
// both patches build that request with their own HTTP client and write the header by hand,
// which needs no game offsets. It is a startup gate, not per-request enforcement.
public static bool Enable = false;
// Header the patch stamps on every request (matches CampusPatch SendRequest.h).
public const string PatchHeaderName = "X-Campus-Patch";
public PatchGateMiddleware(RequestDelegate next) => _next = next;
// The admin surface that the web token protects: the panel, and its dev-login API.
private static bool IsAdminSurface(string p) =>
p.StartsWith("/admin", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/api/web/admin", StringComparison.OrdinalIgnoreCase);
// The login entry point: the login PAGE (where the token box lives) and the login API (which
// checks the token itself). These must be reachable WITHOUT the gate cookie - otherwise there'd
// be nowhere to enter the token. Bare /admin roots just redirect to the login, so allow them
// too. Everything else on the admin surface requires the gate cookie a good login stamps.
private static bool IsAdminLoginEntry(string p) =>
p.StartsWith("/api/web/admin/login", StringComparison.OrdinalIgnoreCase)
|| p.EndsWith("/login", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/admin", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/admin/", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/admin/home", StringComparison.OrdinalIgnoreCase);
// Reachable with NO patch header at all - the public browser surface. This is the WEBSITE
// base (landing page + the Campus_Net site and its login), which is meant for everyone, not
// just the game client. IP bans still apply to all of it because BannedIpMiddleware runs
// BEFORE this. Only the actual game API (everything not listed here or under the admin gate)
// requires the patch header.
private static bool IsOpen(string p) =>
p.StartsWith("/api/patch/verify", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/", StringComparison.OrdinalIgnoreCase) // website base / landing page
|| p.Equals("/index.html", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/img", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/cdn", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/Web", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/favicon.png", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/DefaultProfileImage.jpg", StringComparison.OrdinalIgnoreCase)
// ---- Campus website (ContentRoot/Website), served by Kestrel since the Flask
// backend was retired. A browser cannot stamp X-Campus-Patch, so every asset and
// every site API call has to be open or the whole site 403s. This is the PUBLIC
// site only: /api/site/* is account signup/login/me for the website and reads the
// same PlayerDB the game does, but it exposes nothing the game API does.
|| IsSiteAsset(p)
|| p.StartsWith("/api/site/", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/api/search/", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/api/health", StringComparison.OrdinalIgnoreCase)
// Tachyon voice server's room lookup (GET /tachyon?id=<AccountId>). Server-to-server,
// no patch header - must stay reachable even if the patch gate is switched on.
|| p.Equals("/tachyon", StringComparison.OrdinalIgnoreCase)
// Public website clean URLs (WebController) + the launcher download. Browser pages, no
// patch header - keep them open like the rest of the site.
|| p.Equals("/campus.exe", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/profile", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/news", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/rooms", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/patch-notes", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/weekly", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/settings", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/stats", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/download", StringComparison.OrdinalIgnoreCase)
|| p.Equals("/accessibility", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/user/", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/room/", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/image/", StringComparison.OrdinalIgnoreCase);
// Static files that make up the website. Matched by extension rather than by listing every
// page, so adding a page to Website/ does not silently 403 until someone remembers to come
// back here. Extensions are deliberately narrow - no .py/.bat/.json - so a stray file
// dropped in the web root is not served just because it is in the folder.
private static bool IsSiteAsset(string p)
{
// IsOpen is evaluated BEFORE IsAdminSurface in Invoke, so a bare extension match here
// would hand out the admin panel to anyone who asked for /admin/<something>.html.
// The admin surface is never a public site asset.
if (IsAdminSurface(p)) return false;
if (p.StartsWith("/assets/", StringComparison.OrdinalIgnoreCase)) return true;
foreach (var ext in SiteAssetExtensions)
if (p.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
private static readonly string[] SiteAssetExtensions =
{ ".html", ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".woff", ".woff2" };
public static string ClientIp(HttpContext ctx) =>
ctx.Request.Headers["CF-Connecting-IP"].FirstOrDefault()
?? ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? ctx.Connection.RemoteIpAddress?.ToString()
?? "";
public async Task Invoke(HttpContext context)
{
string path = context.Request.Path.HasValue ? context.Request.Path.Value! : "/";
if (IsOpen(path))
{
await _next(context);
return;
}
// ---- Admin surface: gated by the WEB TOKEN (browser, no DLL) ----
if (IsAdminSurface(path))
{
// The login page/API is where the token is entered, so it's always reachable.
if (IsAdminLoginEntry(path))
{
await _next(context);
return;
}
// Rest of the panel (home + /admin/api/*) needs a live session token from login.
if (AdminGate.IsValid(context))
{
await _next(context);
return;
}
// No gate cookie: APIs get a clean 403; pages fall back to the login screen.
if (path.StartsWith("/api/web/admin", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/admin/api", StringComparison.OrdinalIgnoreCase))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("");
}
else
{
context.Response.Redirect("/admin/v4/login");
}
return;
}
// ---- Everything else: the GAME patch gate (per-request header) ----
if (!Enable)
{
await _next(context);
return;
}
// Accept EITHER form of auth on a game endpoint:
//
// (a) IN-GAME auth: the patch signature header, stamped by the DLL on every request.
// (b) WEB auth: a live admin session token. The admin panel is a browser (no DLL, so no
// patch header), but its dashboard legitimately calls game endpoints like
// /Accounts/account/me. A valid admin session proves an authenticated operator, so
// let those calls through too - otherwise the panel 403s on its own data.
//
// So: game client OR logged-in admin passes; a random browser with neither is denied.
string sent = context.Request.Headers[PatchHeaderName].FirstOrDefault() ?? "";
string expected = ConfigCampus.PatchSignature ?? "";
bool inGameAuth = !string.IsNullOrEmpty(expected)
&& CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(sent), Encoding.UTF8.GetBytes(expected));
if (inGameAuth || AdminGate.IsValid(context))
{
await _next(context);
return;
}
// Genuine server-internal calls (health checks, loopback) don't come through the proxy and
// carry no forwarded IP, so they can't stamp the header - let those through. Real clients
// always arrive with a forwarded IP, so this never opens the gate for them.
string clientIp = ClientIp(context);
if (System.Net.IPAddress.TryParse(clientIp, out var parsed) && System.Net.IPAddress.IsLoopback(parsed))
{
await _next(context);
return;
}
var ua = context.Request.Headers["User-Agent"].FirstOrDefault();
Console.WriteLine($"[patchgate] DENIED {context.Request.Method} {path} from {clientIp} - patch header {(string.IsNullOrEmpty(sent) ? "absent" : "mismatch")} (UA={ua})");
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("");
}
}
@@ -0,0 +1,159 @@
using System.Collections.Concurrent;
using System.Net;
using Campus_live_rewrite.api.server;
// Per-IP flood / brute-force guard. Sits EARLY in the pipeline (right after the banned-IP
// check) so a flood is dropped before it touches routing, controllers, LiteDB or the access
// log - the cheapest possible rejection.
//
// Two fixed-window buckets, both keyed on the TRUSTWORTHY client IP:
//
// * GENERAL: a high ceiling that only an actual request flood crosses (see
// ConfigCampus.FloodMaxPerWindow). This is the anti-DoS ceiling - it keeps one address
// from driving the box to max memory by hammering it.
// * AUTH: a tight ceiling on the credential endpoints (/Auth/connect/token, /api/site/login).
// This is the brute-force / token-farming guard the operator asked for: you cannot sit and
// hammer the token endpoint guessing passwords or minting bearer tokens.
//
// WHY the IP must be the LAST X-Forwarded-For entry: behind Caddy every request's socket peer
// is 127.0.0.1, so RemoteIpAddress is useless for per-IP limiting, and the FIRST XFF entry is
// whatever the client sent - a spoof. Caddy APPENDS the real peer as the last entry, so that
// entry is authoritative and cannot be rotated by a client sending its own XFF. Same rule the
// website signup limiter uses. CF-Connecting-IP is deliberately NOT trusted (no Cloudflare in
// front - it would be a free spoof).
public class TrafficGuardMiddleware
{
private readonly RequestDelegate _next;
public TrafficGuardMiddleware(RequestDelegate next) => _next = next;
// key -> (count, window-start unix seconds). Separate namespaces ("G:" / "A:") per bucket.
private static readonly ConcurrentDictionary<string, (int count, long windowStart)> _hits = new();
// The dictionary would otherwise grow one entry per (bucket, IP) forever. Sweep stale
// entries occasionally - cheap, amortised, and bounded.
private static long _lastSweep;
private const int SweepEverySeconds = 120;
private static bool IsAuthPath(string p) =>
p.StartsWith("/Auth/connect/token", StringComparison.OrdinalIgnoreCase)
|| p.StartsWith("/api/site/login", StringComparison.OrdinalIgnoreCase);
public async Task Invoke(HttpContext context)
{
if (!ConfigCampus.TrafficGuardEnabled)
{
await _next(context);
return;
}
string ip = ClientIp(context);
// Never throttle loopback: Caddy, health checks and the admin SSH tunnel all originate
// there, and an empty/unparseable IP is treated as loopback so we never lock everyone
// out over a missing header.
if (string.IsNullOrWhiteSpace(ip) || IsLoopback(ip) || AdminProtectionMiddleware.IsLimitExempt(ip))
{
await _next(context);
return;
}
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
MaybeSweep(now);
string path = context.Request.Path.HasValue ? context.Request.Path.Value! : "/";
bool auth = IsAuthPath(path);
// Brute-force lockout: an IP that failed login too many times is blocked from the credential
// endpoints for a few minutes (see LoginGuard). Checked BEFORE the rate limiter and the
// controller, so a locked-out address can't keep guessing.
if (auth && LoginGuard.IsLockedOut(ip, out int lockRetry))
{
await Reject(context, lockRetry, path, ip, "login-lockout");
return;
}
// Auth endpoints are checked against BOTH ceilings; everything else only the general one.
if (auth && Throttled("A:" + ip, ConfigCampus.AuthMaxPerWindow, ConfigCampus.AuthWindowSeconds, now, out int aRetry))
{
await Reject(context, aRetry, path, ip, "auth");
return;
}
if (Throttled("G:" + ip, ConfigCampus.FloodMaxPerWindow, ConfigCampus.FloodWindowSeconds, now, out int gRetry))
{
await Reject(context, gRetry, path, ip, "flood");
return;
}
// An address that posted uploads the game didn't make is held to a much lower ceiling on
// EVERYTHING for the strike window (UploadGuard) - the "everything rate-limits them" half
// of the upload rule, on top of the ban that lands at the strike count.
if (UploadGuard.IsPenalisedIp(ip)
&& Throttled("P:" + ip, ConfigCampus.UploadPenaltyMaxPerWindow, ConfigCampus.FloodWindowSeconds, now, out int pRetry))
{
await Reject(context, pRetry, path, ip, "upload-penalty");
return;
}
await _next(context);
}
// Fixed-window counter. Returns true (and a Retry-After) once the window count exceeds cap.
//
// A REJECTED request no longer counts: the client retries /connect/token every few seconds
// after a 429, and with every retry counted the window never drained - 317 refusals in one
// hour from one address, a player who could only get in by changing IP (a VPN). Now the
// window fills, refuses until it ends, and then admits again.
private static bool Throttled(string key, int cap, int windowSeconds, long now, out int retryAfter)
{
var entry = _hits.AddOrUpdate(
key,
_ => (1, now),
(_, cur) => (now - cur.windowStart >= windowSeconds) ? (1, now)
: (cur.count > cap ? cur.count : cur.count + 1, cur.windowStart));
if (entry.count > cap)
{
retryAfter = (int)Math.Max(1, windowSeconds - (now - entry.windowStart));
return true;
}
retryAfter = 0;
return false;
}
private static async Task Reject(HttpContext context, int retryAfter, string path, string ip, string kind)
{
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.Response.Headers["Retry-After"] = retryAfter.ToString();
Console.WriteLine($"[trafficguard] 429 ({kind}) {context.Request.Method} {path} from {ip} - retry in {retryAfter}s");
await context.Response.WriteAsync("Too many requests.");
}
private static void MaybeSweep(long now)
{
if (now - Interlocked.Read(ref _lastSweep) < SweepEverySeconds) return;
Interlocked.Exchange(ref _lastSweep, now);
// Drop any entry whose window is well past (older than the widest window we use). Cheap
// enough to do inline every couple of minutes.
long maxWindow = Math.Max(ConfigCampus.FloodWindowSeconds, ConfigCampus.AuthWindowSeconds);
foreach (var kv in _hits)
if (now - kv.Value.windowStart > maxWindow + 60)
_hits.TryRemove(kv.Key, out _);
}
// Trustworthy client IP = the LAST X-Forwarded-For entry (Caddy-appended), else the socket.
private static string ClientIp(HttpContext ctx)
{
string xff = ctx.Request.Headers["X-Forwarded-For"].ToString();
if (!string.IsNullOrWhiteSpace(xff))
{
var parts = xff.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length > 0) return parts[parts.Length - 1];
}
return ctx.Connection.RemoteIpAddress?.ToString() ?? "";
}
private static bool IsLoopback(string ip)
{
if (ip == "::1" || ip.StartsWith("127.")) return true;
return IPAddress.TryParse(ip, out var parsed) && IPAddress.IsLoopback(parsed);
}
}
@@ -0,0 +1,156 @@
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
public class VpnBlockMiddleware
{
private static readonly HttpClient Http = new HttpClient
{
Timeout = TimeSpan.FromSeconds(5)
};
private static readonly string DataPath = Path.Combine(Environment.CurrentDirectory, "Data");
private static readonly string CacheFile = Path.Combine(DataPath, "Ips.json");
private static readonly List<IpCacheEntry> IpCache;
private readonly RequestDelegate _next;
static VpnBlockMiddleware()
{
Http.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
// Ensure Data folder exists
if (!Directory.Exists(DataPath))
Directory.CreateDirectory(DataPath);
// Load or initialize cache
if (File.Exists(CacheFile))
{
var json = File.ReadAllText(CacheFile);
try
{
IpCache = JsonSerializer.Deserialize<List<IpCacheEntry>>(json) ?? new List<IpCacheEntry>();
}
catch
{
IpCache = new List<IpCacheEntry>();
}
}
else
{
IpCache = new List<IpCacheEntry>();
}
}
public VpnBlockMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
string? ip = context.Request.Headers["CF-Connecting-IP"].FirstOrDefault()
?? context.Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? context.Connection.RemoteIpAddress?.ToString();
if (ip == null)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("???");
return;
}
string ipStr = ip.ToString();
// ---------- VPN CHECK START ----------
// Check cache first
var cached = IpCache.Find(x => x.Ip == ipStr);
if (cached != null)
{
if (cached.IsVpn)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("You have been blocked due to being suspicious.");
return;
}
else
{
await _next(context);
return;
}
}
// Not in cache fetch from web
bool isVpn = true;
try
{
var url = $"https://whatismyipaddress.com/ip/{ipStr}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
// Set all headers to mimic real browser
request.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7");
request.Headers.Add("Accept-Encoding", "gzip, deflate, br, zstd");
request.Headers.Add("Accept-Language", "en-US,en;q=0.9");
request.Headers.Add("Cache-Control", "no-cache");
request.Headers.Add("Cookie", "_omappvp=gVm5QIhYs7ZPribnnBc5i0hcLYpksUDiKmaUjnC3eJDngY1GqHDK9X9VZ6X6YjdGJOIEopJi4PrOdRMf9FdpCTgsbQBQd8NA; usprivacy=1N--; pt=3f72ce96ed0863ed93f8e4f35cd5c051; cf_clearance=E.lrd8juQIWkUqgGOEu9IK1D7hj4VjU9MrKMu3ndw.Q-1768983651-1.2.1.1-yatNw.jMPyw7hUdlaVTRBwLPNS3zrTyJYuJP7WZ5cMC_JU1QcPOxxCSj.BIc6n9gYQlDTuu3MIeDohOS.dF.GzEY6SroaJJRS3Kqz.bSs2HmGA8WiDdMZ585PG47Sngr2MFufhyaQUejsCs62g3Xn5TNQW6ScnFH.SwZ_4_ltZrdvu7pYtu3SrGQC8rWvhyHgR_6DJJ8BM64SeF8zIL9ghgA4e0cSmp3q63JijsJb_I; _awl=2.1768987486.5-5aef88a3ad28e1513eae2c6b9b05ea1e-6763652d75732d7765737431-1");
request.Headers.Add("Pragma", "no-cache");
request.Headers.Add("Priority", "u=0, i");
request.Headers.Add("Sec-CH-UA", "\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"");
request.Headers.Add("Sec-CH-UA-Mobile", "?0");
request.Headers.Add("Sec-CH-UA-Platform", "\"Windows\"");
request.Headers.Add("Sec-Fetch-Dest", "document");
request.Headers.Add("Sec-Fetch-Mode", "navigate");
request.Headers.Add("Sec-Fetch-Site", "none");
request.Headers.Add("Sec-Fetch-User", "?1");
request.Headers.Add("Upgrade-Insecure-Requests", "1");
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36");
var response = await Http.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"[VPNBlockMiddleware] Failed to fetch IP info {ipStr}, status {response.StatusCode}");
isVpn = true; // fail-closed
}
else
{
var html = await response.Content.ReadAsStringAsync();
isVpn = !html.Contains("Services:</span><span>None detected", StringComparison.OrdinalIgnoreCase);
}
}
catch (Exception ex)
{
Console.WriteLine($"[VPNBlockMiddleware] Error checking IP {ipStr}: {ex.Message}");
isVpn = false; // fail-closed
}
// Save to cache
IpCache.Add(new IpCacheEntry { Ip = ipStr, IsVpn = isVpn });
File.WriteAllText(CacheFile, JsonSerializer.Serialize(IpCache, new JsonSerializerOptions { WriteIndented = false }));
if (isVpn)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("You have been blocked due to being suspicious.");
return;
}
// ---------- VPN CHECK END ----------
await _next(context);
}
private class IpCacheEntry
{
public string Ip { get; set; } = "";
public bool IsVpn { get; set; }
}
}