the files
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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 = Campus_live_rewrite.api.server.ConfigCampus.AdminIpAllowlistEnabled ? CurrentAllowlist() : null;
|
||||
return allow != null && allow.Contains(ip.Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Could this IP reach the admin panel at all: loopback, a listed IP, or any IP while the
|
||||
/// allowlist is off. The website's quick-admin popup (SiteAdminController) uses the same rule,
|
||||
/// so it never works from somewhere the panel itself would answer 404.
|
||||
/// </summary>
|
||||
public static bool AdminReachableFrom(string? ip) => !Campus_live_rewrite.api.server.ConfigCampus.AdminIpAllowlistEnabled || IsAllowlistedIp(ip) || CurrentAllowlist() == null;
|
||||
|
||||
/// <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 = Campus_live_rewrite.api.server.ConfigCampus.AdminIpAllowlistEnabled ? CurrentAllowlist() : null;
|
||||
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,45 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Campus_live_rewrite.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// The player's real address, now that Cloudflare sits in front of Caddy.
|
||||
///
|
||||
/// Every per-IP defence in this server (TrafficGuard, LoginGuard, the website sign-up limiter, bans, the IP allowlist) keys on the LAST
|
||||
/// X-Forwarded-For entry, which used to be the player because Caddy appended the connecting peer. Behind Cloudflare the connecting peer is
|
||||
/// a Cloudflare edge address, so every player on that edge would share one address: one abuser rate-limits, locks out or IP-bans thousands.
|
||||
///
|
||||
/// Caddy (Deploy/harden-vps.sh, "cloudflare" mode) works the real address out itself - it only believes Cloudflare's CF-Connecting-IP
|
||||
/// when the connection really comes from a Cloudflare range - and hands it to this server in <c>X-Campus-Client-IP</c>, REPLACING any value a
|
||||
/// client sent. This middleware, first in the pipeline, copies that value into X-Forwarded-For (and CF-Connecting-IP, which some older code
|
||||
/// reads first), so every existing check sees the real player without being touched.
|
||||
///
|
||||
/// Trust: only a request whose socket peer is this machine (the proxy) is believed, and the header is only meaningful because Caddy
|
||||
/// overwrites it on every request it forwards. Without that Caddy configuration the header is absent and nothing changes.
|
||||
/// </summary>
|
||||
public class ClientIpMiddleware
|
||||
{
|
||||
public const string Header = "X-Campus-Client-IP";
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public ClientIpMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public Task Invoke(HttpContext context)
|
||||
{
|
||||
var remote = context.Connection.RemoteIpAddress;
|
||||
bool fromProxy = remote == null || IPAddress.IsLoopback(remote)
|
||||
|| (context.Connection.LocalIpAddress != null && remote.Equals(context.Connection.LocalIpAddress));
|
||||
if (fromProxy && context.Request.Headers.TryGetValue(Header, out var v))
|
||||
{
|
||||
string ip = v.ToString().Trim();
|
||||
if (IPAddress.TryParse(ip, out var parsed))
|
||||
{
|
||||
ip = parsed.ToString();
|
||||
context.Request.Headers["X-Forwarded-For"] = ip;
|
||||
context.Request.Headers["CF-Connecting-IP"] = ip;
|
||||
}
|
||||
}
|
||||
return _next(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Campus_live_rewrite.api.server;
|
||||
|
||||
namespace Campus_live_rewrite.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Which host name may show what. Every Campus host name reaches the SAME app (Caddy just proxies it), so without this the
|
||||
/// website, the admin panel and the game API would all be visible on every one of them.
|
||||
///
|
||||
/// camprec.net the website, and the game API (older launchers / patches / Quest builds still use this host for the API)
|
||||
/// admin.camprec.net the admin panel and the game API it calls for its own data - no website
|
||||
/// api.camprec.net the game API (what /ns hands the game) - no website, no admin
|
||||
/// ns.camprec.net the name-server answer (/ and /ns) only
|
||||
/// cdn.camprec.net /cdn only
|
||||
/// gameauth.ddns.net the old host: the game API for builds installed before the swap, the college portfolio (/collegeportfolio)
|
||||
/// (and gameauthcdn...) and the sign-in callbacks registered with Discord / Spotify / the social apps. No website, no admin.
|
||||
///
|
||||
/// A website page asked for on the wrong host is sent to camprec.net (a link or a bookmark still lands somewhere);
|
||||
/// the admin panel on the wrong host is a plain 404 (apart from a GET of /admin itself, which points at admin.camprec.net).
|
||||
/// localhost / 127.0.0.1 (the SSH tunnel, a developer's own copy) is never restricted: that is how the panel is reached
|
||||
/// without the internet. ConfigCampus.HostRouting = false switches all of this off.
|
||||
/// </summary>
|
||||
public class HostRoutingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
public const string SiteHost = "camprec.net";
|
||||
public const string AdminHost = "admin.camprec.net";
|
||||
public const string ApiHost = "api.camprec.net";
|
||||
public const string NsHost = "ns.camprec.net";
|
||||
public const string CdnHost = "cdn.camprec.net";
|
||||
|
||||
private enum HostKind { Local, Site, Admin, Api, Ns, Cdn, Legacy, Other }
|
||||
private enum PathKind { Admin, AdminShared, Site, SiteApi, Callback, Portfolio, Root, Cdn, Game }
|
||||
|
||||
// Pages / files that make up the website (WebController + the Website folder). Matched exactly or by prefix at the ROOT only.
|
||||
private static readonly string[] SiteExact =
|
||||
{
|
||||
"/home", "/site", "/profile", "/news", "/rooms", "/patch-notes", "/support", "/weekly", "/challenges", "/settings",
|
||||
"/stats", "/login", "/download", "/accessibility", "/signup", "/campus.exe", "/messages",
|
||||
};
|
||||
private static readonly string[] SitePrefix = { "/user/", "/room/", "/event/", "/image/", "/support/" };
|
||||
// The names inside the Website folder (app.js, style.css, assets/ ...): served at the root, so they are website too.
|
||||
private static readonly HashSet<string> SiteFiles = LoadSiteFiles();
|
||||
|
||||
private static HashSet<string> LoadSiteFiles()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
string dir = Path.Combine(Environment.CurrentDirectory, "Website");
|
||||
if (Directory.Exists(dir))
|
||||
foreach (var e in Directory.EnumerateFileSystemEntries(dir)) set.Add(Path.GetFileName(e));
|
||||
}
|
||||
catch { }
|
||||
return set;
|
||||
}
|
||||
|
||||
public HostRoutingMiddleware(RequestDelegate next) { _next = next; }
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
if (!ConfigCampus.HostRouting) { await _next(ctx); return; }
|
||||
|
||||
var kind = KindOf(ctx);
|
||||
if (kind == HostKind.Local) { await _next(ctx); return; }
|
||||
|
||||
string path = ctx.Request.Path.Value ?? "/";
|
||||
var pk = Classify(path);
|
||||
bool get = HttpMethods.IsGet(ctx.Request.Method) || HttpMethods.IsHead(ctx.Request.Method);
|
||||
|
||||
// "/" is the name server for the game and the home page for a browser
|
||||
if (pk == PathKind.Root)
|
||||
{
|
||||
if (WantsHtml(ctx.Request))
|
||||
{
|
||||
if (kind == HostKind.Site) { await _next(ctx); return; }
|
||||
ctx.Response.Redirect(kind == HostKind.Admin ? "/admin" : "https://" + SiteHost + "/", permanent: false);
|
||||
return;
|
||||
}
|
||||
if (kind == HostKind.Cdn) { NotFound(ctx); return; }
|
||||
await _next(ctx); // the game asking for the name server
|
||||
return;
|
||||
}
|
||||
|
||||
bool allowed = Allowed(kind, pk, path);
|
||||
if (allowed) { await _next(ctx); return; }
|
||||
|
||||
// a website page asked for on the wrong host: point at the real one
|
||||
if (get && (pk == PathKind.Site || pk == PathKind.Portfolio) && kind != HostKind.Ns && kind != HostKind.Cdn)
|
||||
{
|
||||
ctx.Response.Redirect("https://" + SiteHost + path + ctx.Request.QueryString, permanent: false);
|
||||
return;
|
||||
}
|
||||
// a bare GET of the panel's own address on the wrong host
|
||||
if (get && pk == PathKind.Admin && path.TrimEnd('/').Equals("/admin", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ctx.Response.Redirect("https://" + AdminHost + "/admin", permanent: false);
|
||||
return;
|
||||
}
|
||||
NotFound(ctx);
|
||||
}
|
||||
|
||||
private static readonly bool Debug = Environment.GetEnvironmentVariable("CAMPUS_HOSTROUTING_DEBUG") == "1"; // tests: say which 404s are ours
|
||||
|
||||
private static void NotFound(HttpContext ctx)
|
||||
{
|
||||
if (Debug) ctx.Response.Headers["X-Host-Routing"] = "blocked";
|
||||
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
}
|
||||
|
||||
private static bool Allowed(HostKind host, PathKind pk, string path)
|
||||
{
|
||||
switch (host)
|
||||
{
|
||||
case HostKind.Site:
|
||||
return pk != PathKind.Admin; // everything but the admin panel (its assets are shared with the website's own images)
|
||||
case HostKind.Admin:
|
||||
// the panel itself, and the game API it calls for its own data (/Accounts/account/me, rooms, images ...): everything except the
|
||||
// website's pages and API, the college portfolio and the sign-in callbacks
|
||||
return pk == PathKind.Admin || pk == PathKind.AdminShared || pk == PathKind.Game || pk == PathKind.Cdn
|
||||
|| path.StartsWith("/api/auth/", StringComparison.OrdinalIgnoreCase); // the Discord / password sign-in the panel uses
|
||||
case HostKind.Ns:
|
||||
return false; // only "/" (handled above) and /ns
|
||||
case HostKind.Cdn:
|
||||
return pk == PathKind.Cdn;
|
||||
case HostKind.Legacy:
|
||||
return pk == PathKind.Game || pk == PathKind.Cdn || pk == PathKind.AdminShared || pk == PathKind.Callback || pk == PathKind.Portfolio;
|
||||
default: // api.camprec.net and any other name that reaches us
|
||||
return pk == PathKind.Game || pk == PathKind.Cdn || pk == PathKind.AdminShared;
|
||||
}
|
||||
}
|
||||
|
||||
private static PathKind Classify(string p)
|
||||
{
|
||||
if (p == "/" || p.Length == 0) return PathKind.Root;
|
||||
if (p.Equals("/ns", StringComparison.OrdinalIgnoreCase)) return PathKind.Root; // the name server: handled like "/"
|
||||
if (p.StartsWith("/admin-assets", StringComparison.OrdinalIgnoreCase)) return PathKind.Admin; // the panel's own branding images
|
||||
if (p.Equals("/admin", StringComparison.OrdinalIgnoreCase) || p.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.StartsWith("/api/web/admin", StringComparison.OrdinalIgnoreCase)) return PathKind.Admin;
|
||||
if (p.StartsWith("/collegeportfolio", StringComparison.OrdinalIgnoreCase)) return PathKind.Portfolio;
|
||||
// sign-in callbacks registered with Discord / Spotify / the social apps (they land on gameauth.ddns.net)
|
||||
if (p.StartsWith("/api/auth/discord/", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Equals("/api/site/spotify/callback", StringComparison.OrdinalIgnoreCase)
|
||||
|| (p.StartsWith("/api/site/social/", StringComparison.OrdinalIgnoreCase) && p.EndsWith("/callback", StringComparison.OrdinalIgnoreCase)))
|
||||
return PathKind.Callback;
|
||||
if (p.StartsWith("/api/site/", StringComparison.OrdinalIgnoreCase) || p.StartsWith("/api/search/", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Equals("/api/profile-image", StringComparison.OrdinalIgnoreCase)) return PathKind.SiteApi;
|
||||
if (p.StartsWith("/img/", StringComparison.OrdinalIgnoreCase) || p.Equals("/favicon.ico", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Equals("/robots.txt", StringComparison.OrdinalIgnoreCase)) return PathKind.AdminShared;
|
||||
if (p.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase) || p.Equals("/cdn", StringComparison.OrdinalIgnoreCase)) return PathKind.Cdn;
|
||||
foreach (var e in SiteExact) if (p.Equals(e, StringComparison.OrdinalIgnoreCase)) return PathKind.Site;
|
||||
foreach (var e in SitePrefix) if (p.StartsWith(e, StringComparison.OrdinalIgnoreCase)) return PathKind.Site;
|
||||
if (p.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && p.IndexOf('/', 1) < 0) return PathKind.Site; // /{page}.html
|
||||
int slash = p.IndexOf('/', 1);
|
||||
string first = slash < 0 ? p.Substring(1) : p.Substring(1, slash - 1);
|
||||
if (SiteFiles.Contains(first)) return PathKind.Site; // app.js, style.css, assets/...
|
||||
return PathKind.Game;
|
||||
}
|
||||
|
||||
private static HostKind KindOf(HttpContext ctx)
|
||||
{
|
||||
string host = ctx.Request.Host.Host.ToLowerInvariant();
|
||||
// the SSH tunnel / a developer's own copy: a localhost Host that did NOT come through Caddy (Caddy adds X-Forwarded-For)
|
||||
if ((host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]")
|
||||
&& !ctx.Request.Headers.ContainsKey("X-Forwarded-For")) return HostKind.Local;
|
||||
switch (host)
|
||||
{
|
||||
case SiteHost: case "www." + SiteHost: return HostKind.Site;
|
||||
case AdminHost: return HostKind.Admin;
|
||||
case ApiHost: return HostKind.Api;
|
||||
case NsHost: return HostKind.Ns;
|
||||
case CdnHost: return HostKind.Cdn;
|
||||
case "gameauth.ddns.net": case "gameauthcdn.ddns.net": return HostKind.Legacy;
|
||||
default: return HostKind.Other;
|
||||
}
|
||||
}
|
||||
|
||||
// same test WebController.NsRoot uses: a browser, not the game
|
||||
private static bool WantsHtml(HttpRequest req)
|
||||
{
|
||||
if (!HttpMethods.IsGet(req.Method)) return false;
|
||||
if (req.Headers.UserAgent.ToString().Contains("BestHTTP", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
return req.Headers.Accept.ToString().Contains("text/html", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Discord admin sign-in lands on gameauth.ddns.net (the address registered with Discord), but the panel lives on
|
||||
/// admin.camprec.net, and a cookie set on one host never reaches the other. So the callback leaves the signed token here
|
||||
/// under a one-time ticket (60 s) and sends the browser to admin.camprec.net/admin/discord-finish?t=..., which takes it
|
||||
/// and sets the cookie on the panel's own host.
|
||||
/// </summary>
|
||||
public static class AdminHandoff
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, (string jwt, DateTime until)> _t = new();
|
||||
|
||||
public static string Create(string jwt)
|
||||
{
|
||||
foreach (var kv in _t) if (kv.Value.until < DateTime.UtcNow) _t.TryRemove(kv.Key, out _);
|
||||
string id = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(24)).ToLowerInvariant();
|
||||
_t[id] = (jwt, DateTime.UtcNow.AddSeconds(60));
|
||||
return id;
|
||||
}
|
||||
|
||||
public static string? Take(string? id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || !_t.TryRemove(id, out var v)) return null;
|
||||
return v.until >= DateTime.UtcNow ? v.jwt : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,40 @@
|
||||
using System.Net;
|
||||
using Campus_live_rewrite.api.server;
|
||||
|
||||
namespace Campus_live_rewrite.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Only the reverse proxy may talk to this server.
|
||||
///
|
||||
/// Every real request reaches Kestrel through Caddy, so its socket peer is this machine (127.0.0.1). The app nevertheless listens on
|
||||
/// 0.0.0.0:8080 (the systemd unit's ASPNETCORE_URLS), which made http://<vps ip>:8080/ answer from the open internet:
|
||||
/// * it bypassed Caddy entirely (TLS, its timeouts, anything added there later), and
|
||||
/// * every per-IP defence here (TrafficGuard, bans, the IP allowlist, login lockout) keys on the LAST X-Forwarded-For entry, which Caddy
|
||||
/// appends - a client talking to :8080 directly can send any X-Forwarded-For it likes and walk straight past all of them.
|
||||
/// A connection from anywhere else is dropped without an answer. A peer that is this machine's own address (a local service reaching the
|
||||
/// app by the public IP) is allowed as well. CAMPUS_ORIGIN_OPEN=1 (or ConfigCampus.OriginLock = false) turns the lock off, for running the
|
||||
/// server on a LAN without a proxy.
|
||||
/// </summary>
|
||||
public class OriginLockMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private static readonly bool EnvOpen = Environment.GetEnvironmentVariable("CAMPUS_ORIGIN_OPEN") == "1";
|
||||
private static long _dropped;
|
||||
|
||||
public OriginLockMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
if (!ConfigCampus.OriginLock || EnvOpen) { await _next(context); return; }
|
||||
var remote = context.Connection.RemoteIpAddress;
|
||||
if (remote == null || IPAddress.IsLoopback(remote) || (context.Connection.LocalIpAddress != null && remote.Equals(context.Connection.LocalIpAddress)))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
long n = Interlocked.Increment(ref _dropped);
|
||||
if (n <= 3 || n % 1000 == 0) Console.WriteLine($"[originlock] dropped a direct connection from {remote} ({n} so far) - only the proxy may reach this port");
|
||||
context.Abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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.StartsWith("/api/patch/proof", StringComparison.OrdinalIgnoreCase) // the login gate's proof (PatchProofs)
|
||||
|| 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("/challenges", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Equals("/settings", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Equals("/stats", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.Equals("/messages", 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,164 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Campus_live_rewrite.api.server;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Campus_live_rewrite.Middleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Serves Quest clients the Android builds of first-party clothing, the way RecFlare does.
|
||||
///
|
||||
/// The first-party bundles (FirstPartyUGC.json saves: UnityAsset = meshes, UnityAsset2 =
|
||||
/// textures/material) are Windows builds. RecFlare converted every one for Android and hosts
|
||||
/// the result under avatar/quest/<same name>; its API hands a client that sends
|
||||
/// unityAssetTarget=2 (Android/Quest) every bundle name as "quest/<name>", and any other
|
||||
/// target the plain name (verified against api.recflare.net 2026-09-26). The HASHES must change
|
||||
/// too: UnityAssetHash / UnityAsset2Hash are the sha256 of the file and the client rejects a
|
||||
/// download that doesn't match (seen live: each quest/ bundle fetched 3x, then dropped), so
|
||||
/// each gets the quest file's hash (BundleHashes.OfQuest). The files are mirrored to Data/CDN/AvatarBlobs/quest/ (CDNController fetches
|
||||
/// a missing one from cdn.recflare.net/avatar/quest/ on demand).
|
||||
///
|
||||
/// Only some calls carry unityAssetTarget in the query (owned, search); the busiest one,
|
||||
/// POST /api/customAvatarItems/v1/bulk, does not - and it is the FIRST clothing call after a
|
||||
/// login (the worn outfit). So the target is remembered per account AND login platform (the
|
||||
/// token's "platform" claim: Steam 0, Oculus 1...), and until that device session has sent
|
||||
/// one, the platform decides: Oculus = Quest bundles, anything else = PC. Keyed per account
|
||||
/// alone, a player who switched PC -> Quest got their worn items as PC bundles until they
|
||||
/// logged in again (2026-09-26).
|
||||
/// </summary>
|
||||
public class QuestBundleMiddleware
|
||||
{
|
||||
public const int QuestTarget = 2;
|
||||
|
||||
private static readonly string[] Paths =
|
||||
{
|
||||
"/api/customAvatarItems", "/econ/customAvatarItems", "/api/items/bulkpurchase", "/outfits",
|
||||
};
|
||||
|
||||
private static readonly Regex BundleName = new(
|
||||
@"""(UnityAsset2?)""\s*:\s*""(?!quest/)([^""/]+\.assetbundle)""", RegexOptions.Compiled);
|
||||
|
||||
private const int OculusPlatform = 1; // Platforms.Oculus
|
||||
|
||||
private static readonly ConcurrentDictionary<(ulong account, int platform), int> _targetBySession = new();
|
||||
|
||||
/// <summary>The login platform the token was issued for (its "platform" claim), -1 if unreadable.
|
||||
/// Read without re-verifying: AuthHelper.ValidateToken has already checked the signature.</summary>
|
||||
private static int TokenPlatform(HttpContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
string token = ctx.Request.Headers["Authorization"].ToString();
|
||||
if (string.IsNullOrEmpty(token)) token = ctx.Request.Cookies["Authorization"] ?? "";
|
||||
token = token.Replace("Bearer ", "").Trim();
|
||||
// Our game tokens have more than the usual three segments, so find the one whose
|
||||
// JSON carries the claim instead of assuming it is the second.
|
||||
foreach (var part in token.Split('.'))
|
||||
{
|
||||
JObject payload;
|
||||
try
|
||||
{
|
||||
string b64 = part.Replace('-', '+').Replace('_', '/');
|
||||
b64 = b64.PadRight(b64.Length + (4 - b64.Length % 4) % 4, '=');
|
||||
payload = JObject.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(b64)));
|
||||
}
|
||||
catch { continue; }
|
||||
var pl = payload["platform"];
|
||||
if (pl == null) continue;
|
||||
if (pl.Type == JTokenType.Integer) return (int)pl;
|
||||
return Enum.TryParse<Campus_live_rewrite.api.server.Classes.db_class.Platforms>((string?)pl, true, out var e) ? (int)e : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
public QuestBundleMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
/// <summary>Point every save at the quest/ conversion, with that file's hash.</summary>
|
||||
private static string RewriteJson(string body)
|
||||
{
|
||||
var root = JToken.Parse(body);
|
||||
var objects = root is JContainer c ? c.DescendantsAndSelf().OfType<JObject>().ToList() : new List<JObject>();
|
||||
foreach (var o in objects)
|
||||
{
|
||||
Swap(o, "UnityAsset", "UnityAssetHash");
|
||||
Swap(o, "UnityAsset2", "UnityAsset2Hash");
|
||||
}
|
||||
return root.ToString(Newtonsoft.Json.Formatting.None);
|
||||
}
|
||||
|
||||
private static void Swap(JObject save, string nameKey, string hashKey)
|
||||
{
|
||||
if (save[nameKey] is not JValue { Type: JTokenType.String } v) return;
|
||||
string name = (string)v!;
|
||||
if (!name.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase) || name.Contains('/')) return;
|
||||
save[nameKey] = "quest/" + name;
|
||||
var h = BundleHashes.OfQuest(name);
|
||||
if (h != null) save[hashKey] = h;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
string path = ctx.Request.Path.Value ?? "";
|
||||
if (!Paths.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await _next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
int? target = int.TryParse(ctx.Request.Query["unityAssetTarget"].ToString(), out var t) ? t : null;
|
||||
ulong? account = null;
|
||||
try { var (acc, err) = AuthHelper.ValidateToken(ctx); if (err == null && acc != null) account = acc.Id; }
|
||||
catch { }
|
||||
int platform = account.HasValue ? TokenPlatform(ctx) : -1;
|
||||
if (account is ulong id && target is int tt)
|
||||
{
|
||||
var key = (id, platform);
|
||||
int prev = _targetBySession.TryGetValue(key, out var p) ? p : (platform == OculusPlatform ? QuestTarget : 0);
|
||||
_targetBySession[key] = tt;
|
||||
if (prev != tt)
|
||||
Console.WriteLine($"[quest-bundles] account {id} (login platform {platform}) unityAssetTarget={tt} - {(tt == QuestTarget ? "serving Quest (Android) clothing bundles" : "serving PC bundles")}");
|
||||
}
|
||||
bool quest;
|
||||
if (target is int q) quest = q == QuestTarget;
|
||||
else if (account is ulong a && _targetBySession.TryGetValue((a, platform), out var r)) quest = r == QuestTarget;
|
||||
else quest = platform == OculusPlatform; // nothing sent yet this device: the login platform decides
|
||||
if (!quest)
|
||||
{
|
||||
await _next(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
var original = ctx.Response.Body;
|
||||
using var buffer = new MemoryStream();
|
||||
ctx.Response.Body = buffer;
|
||||
try
|
||||
{
|
||||
await _next(ctx);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctx.Response.Body = original;
|
||||
}
|
||||
buffer.Position = 0;
|
||||
string ct = ctx.Response.ContentType ?? "";
|
||||
if (ct.Contains("json", StringComparison.OrdinalIgnoreCase) || ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string body = await new StreamReader(buffer, Encoding.UTF8).ReadToEndAsync();
|
||||
string rewritten;
|
||||
try { rewritten = RewriteJson(body); }
|
||||
catch { rewritten = BundleName.Replace(body, "\"$1\":\"quest/$2\""); }
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(rewritten);
|
||||
ctx.Response.ContentLength = bytes.Length;
|
||||
await original.WriteAsync(bytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
await buffer.CopyToAsync(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Campus_live_rewrite.api.dbs;
|
||||
using Campus_live_rewrite.api.server;
|
||||
using Campus_live_rewrite.api.server.Classes.db_class;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
/// <summary>
|
||||
/// A banned account can still sign in to the WEBSITE (every page shows the ban banner with the
|
||||
/// time left), but it can't change anything. This refuses every write a banned website session
|
||||
/// makes - profile picture, banner, bio, email, photos, friends, reports, social links - with a
|
||||
/// 403 the site shows as a message. One gate for all of them, so a route added later is covered
|
||||
/// without anyone remembering to add a check.
|
||||
///
|
||||
/// Still allowed while banned: signing in and out, registering, the support desk (tickets are
|
||||
/// how a player appeals) and dismissing notifications. Reads (GET) are never touched.
|
||||
/// </summary>
|
||||
public class SiteBanGateMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
public SiteBanGateMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
private const string SiteCookie = "campus_site";
|
||||
|
||||
private static readonly string[] AllowedWhileBanned =
|
||||
{
|
||||
"/api/site/login", "/api/site/logout", "/api/site/register",
|
||||
"/api/site/support/", "/api/site/notifications/",
|
||||
};
|
||||
|
||||
private static bool IsWrite(string method) =>
|
||||
HttpMethods.IsPost(method) || HttpMethods.IsPut(method) || HttpMethods.IsDelete(method) || HttpMethods.IsPatch(method);
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
string path = context.Request.Path.Value ?? "";
|
||||
bool siteWrite = IsWrite(context.Request.Method)
|
||||
&& (path.StartsWith("/api/site/", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.Equals("/api/profile-image", StringComparison.OrdinalIgnoreCase));
|
||||
if (!siteWrite || AllowedWhileBanned.Any(a => path.StartsWith(a, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
ulong accountId = 0;
|
||||
string token = context.Request.Cookies[SiteCookie] ?? "";
|
||||
if (token.Length > 0)
|
||||
{
|
||||
string data = ClientSecurity.DecodeToken(token, requireSurface: "website");
|
||||
if (!string.IsNullOrEmpty(data) && !data.StartsWith("error_"))
|
||||
{
|
||||
try { accountId = JsonConvert.DeserializeObject<player_info>(data)?.Id ?? 0; } catch { }
|
||||
}
|
||||
}
|
||||
var ban = accountId == 0 ? null : PlayerDB.GetSiteBan(accountId);
|
||||
if (ban == null)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
string left = ban.RemainingSeconds == null ? "permanently" : "for " + Describe(ban.RemainingSeconds.Value);
|
||||
Console.WriteLine($"[site-ban] refused {context.Request.Method} {path} from banned account {accountId}");
|
||||
context.Response.StatusCode = 403;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsync(JsonConvert.SerializeObject(new
|
||||
{
|
||||
ok = false,
|
||||
banned = true,
|
||||
error = $"This account is banned {left}. You can't change anything until the ban ends."
|
||||
}));
|
||||
}
|
||||
|
||||
public static string Describe(int seconds)
|
||||
{
|
||||
var t = TimeSpan.FromSeconds(seconds);
|
||||
if (t.TotalDays >= 1) return $"{(int)t.TotalDays}d {t.Hours}h";
|
||||
if (t.TotalHours >= 1) return $"{(int)t.TotalHours}h {t.Minutes}m";
|
||||
if (t.TotalMinutes >= 1) return $"{(int)t.TotalMinutes}m";
|
||||
return $"{t.Seconds}s";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
/// <summary>
|
||||
/// The website takes PLAIN TEXT only (2026-09-27 request): no "fancy font" letters (𝓯𝓸𝓷𝓽,
|
||||
/// 𝕗𝕠𝕟𝕥, font - Unicode look-alikes that dodge the word filter and impersonate names), no
|
||||
/// zalgo marks, no invisible characters, no emoji. One gate over every JSON write to /api/site/*,
|
||||
/// so a text field added later is covered too - names, bio, comments, photo captions, reports,
|
||||
/// support tickets, friend nicknames, email.
|
||||
///
|
||||
/// What a phone keyboard inserts on its own is converted, not refused: curly quotes, dashes,
|
||||
/// the ellipsis and non-breaking spaces become their plain ASCII forms. Anything else outside
|
||||
/// printable ASCII (plus new lines and tabs) refuses the request with a 400 the site shows.
|
||||
/// Passwords are never inspected. File uploads (multipart) are not JSON and pass untouched.
|
||||
/// </summary>
|
||||
public class SitePlainTextMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
public SitePlainTextMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public const string Message = "Only plain text is allowed - no special fonts, symbols or emoji.";
|
||||
|
||||
private static readonly HashSet<string> SkipKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"password", "confirm_password", "current_password", "new_password", "old_password",
|
||||
};
|
||||
|
||||
private static readonly Dictionary<char, string> Convert = new()
|
||||
{
|
||||
['‘'] = "'", ['’'] = "'", ['‚'] = "'", ['′'] = "'",
|
||||
['“'] = "\"", ['”'] = "\"", ['„'] = "\"", ['″'] = "\"",
|
||||
['–'] = "-", ['—'] = "-", ['−'] = "-",
|
||||
['…'] = "...",
|
||||
[' '] = " ", [' '] = " ",
|
||||
};
|
||||
|
||||
public static bool IsPlainChar(char c) => (c >= ' ' && c <= '~') || c == '\n' || c == '\r' || c == '\t';
|
||||
|
||||
/// <summary>The text with keyboard punctuation made plain, or null when something else is left.</summary>
|
||||
public static string? Plain(string s)
|
||||
{
|
||||
StringBuilder? sb = null;
|
||||
for (int i = 0; i < s.Length; i++)
|
||||
{
|
||||
char c = s[i];
|
||||
if (IsPlainChar(c)) { sb?.Append(c); continue; }
|
||||
if (!Convert.TryGetValue(c, out var rep)) return null;
|
||||
sb ??= new StringBuilder(s, 0, i, s.Length + 8);
|
||||
sb.Append(rep);
|
||||
}
|
||||
return sb?.ToString() ?? s;
|
||||
}
|
||||
|
||||
public async Task Invoke(HttpContext context)
|
||||
{
|
||||
var req = context.Request;
|
||||
string path = req.Path.Value ?? "";
|
||||
bool write = HttpMethods.IsPost(req.Method) || HttpMethods.IsPut(req.Method) || HttpMethods.IsPatch(req.Method);
|
||||
// Any body that isn't a file upload or a form: some handlers read the raw body whatever the
|
||||
// Content-Type says, so a JSON body labelled text/plain must not slip past.
|
||||
string ct = req.ContentType ?? "";
|
||||
bool json = !ct.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !ct.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase);
|
||||
if (!write || !json || !path.StartsWith("/api/site/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
string body;
|
||||
using (var reader = new StreamReader(req.Body, Encoding.UTF8))
|
||||
body = await reader.ReadToEndAsync();
|
||||
|
||||
JsonNode? root = null;
|
||||
try { if (!string.IsNullOrWhiteSpace(body)) root = JsonNode.Parse(body); } catch { }
|
||||
|
||||
if (root != null)
|
||||
{
|
||||
bool changed = false;
|
||||
if (!Clean(root, null, ref changed))
|
||||
{
|
||||
Console.WriteLine($"[plaintext] refused {req.Method} {path}: non-plain characters");
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = Message });
|
||||
return;
|
||||
}
|
||||
if (changed) body = root.ToJsonString();
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
req.Body = new MemoryStream(bytes);
|
||||
req.ContentLength = bytes.Length;
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
// Walks every string value (not property names); false = a value has a non-plain character.
|
||||
private static bool Clean(JsonNode node, string? key, ref bool changed)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject o:
|
||||
foreach (var kv in o.ToList())
|
||||
{
|
||||
if (kv.Value == null || SkipKeys.Contains(kv.Key)) continue;
|
||||
if (kv.Value is JsonValue v && v.TryGetValue<string>(out var s))
|
||||
{
|
||||
var p = Plain(s);
|
||||
if (p == null) return false;
|
||||
if (!ReferenceEquals(p, s)) { o[kv.Key] = p; changed = true; }
|
||||
}
|
||||
else if (!Clean(kv.Value, kv.Key, ref changed)) return false;
|
||||
}
|
||||
return true;
|
||||
case JsonArray a:
|
||||
for (int i = 0; i < a.Count; i++)
|
||||
{
|
||||
var item = a[i];
|
||||
if (item == null) continue;
|
||||
if (item is JsonValue v && v.TryGetValue<string>(out var s))
|
||||
{
|
||||
var p = Plain(s);
|
||||
if (p == null) return false;
|
||||
if (!ReferenceEquals(p, s)) { a[i] = p; changed = true; }
|
||||
}
|
||||
else if (!Clean(item, key, ref changed)) return false;
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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;
|
||||
|
||||
// Escalation: address -> (rejections in the current window, window start); address -> blocked-until (unix seconds).
|
||||
private static readonly ConcurrentDictionary<string, (int count, long windowStart)> _strikes = new();
|
||||
private static readonly ConcurrentDictionary<string, long> _blocked = new();
|
||||
private static readonly ConcurrentDictionary<string, int> _inflight = new();
|
||||
private static readonly ConcurrentDictionary<string, long> _lastLog = new();
|
||||
|
||||
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);
|
||||
|
||||
// Cut off for a while after sustained abuse: answered with the cheapest possible 429 - no logging, no routing, nothing else.
|
||||
if (_blocked.TryGetValue(ip, out long until))
|
||||
{
|
||||
if (now < until)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
context.Response.Headers["Retry-After"] = Math.Max(1, until - now).ToString();
|
||||
return;
|
||||
}
|
||||
_blocked.TryRemove(ip, out _);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Too many requests parked at once from one address (slow-read / slow-response hoarding). Long-lived WebSocket requests are not counted.
|
||||
bool counted = !context.WebSockets.IsWebSocketRequest;
|
||||
if (counted)
|
||||
{
|
||||
int now_in = _inflight.AddOrUpdate(ip, 1, (_, v) => v + 1);
|
||||
if (now_in > ConfigCampus.MaxInflightPerIp)
|
||||
{
|
||||
_inflight.AddOrUpdate(ip, 0, (_, v) => Math.Max(0, v - 1));
|
||||
await Reject(context, 5, path, ip, "inflight");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try { await _next(context); }
|
||||
finally { if (counted) _inflight.AddOrUpdate(ip, 0, (_, v) => Math.Max(0, v - 1)); }
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
// One log line per address per minute: under a flood a line per refusal would fill the journal and the disk.
|
||||
if (now - _lastLog.GetOrAdd(ip, 0) >= 60)
|
||||
{
|
||||
_lastLog[ip] = now;
|
||||
Console.WriteLine($"[trafficguard] 429 ({kind}) {context.Request.Method} {path} from {ip} - retry in {retryAfter}s");
|
||||
}
|
||||
|
||||
// Strikes: a client that backs off after a 429 never accumulates these; a bot hammering through them gets cut off.
|
||||
var s = _strikes.AddOrUpdate(ip, _ => (1, now),
|
||||
(_, cur) => (now - cur.windowStart >= ConfigCampus.AbuseStrikeWindowSeconds) ? (1, now) : (cur.count + 1, cur.windowStart));
|
||||
if (s.count >= ConfigCampus.AbuseStrikesToBlock)
|
||||
{
|
||||
_blocked[ip] = now + ConfigCampus.AbuseBlockMinutes * 60L;
|
||||
_strikes.TryRemove(ip, out _);
|
||||
Console.WriteLine($"[trafficguard] {ip} kept going after {s.count} refusals - cut off for {ConfigCampus.AbuseBlockMinutes} min");
|
||||
}
|
||||
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 _);
|
||||
foreach (var kv in _strikes)
|
||||
if (now - kv.Value.windowStart > ConfigCampus.AbuseStrikeWindowSeconds + 60)
|
||||
_strikes.TryRemove(kv.Key, out _);
|
||||
foreach (var kv in _blocked)
|
||||
if (now >= kv.Value) _blocked.TryRemove(kv.Key, out _);
|
||||
foreach (var kv in _lastLog)
|
||||
if (now - kv.Value > 300) _lastLog.TryRemove(kv.Key, out _);
|
||||
foreach (var kv in _inflight)
|
||||
if (kv.Value <= 0) _inflight.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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user