Files
Campus-25/server/CampusNet/Services/Connection.cs
T
2026-10-14 06:59:51 +02:00

159 lines
7.3 KiB
C#

using System.Text;
using CampusNet.Net;
using CampusNet.Protocol;
namespace CampusNet.Services;
/// <summary>
/// Per-peer state above the transport: protocol init, the encryption handshake, and dispatch of
/// decoded operation requests to the role (name/master/game) handler. Replies go back plaintext
/// (the client only decrypts messages carrying the 0x80 flag).
/// </summary>
public sealed class Connection
{
public readonly PhotonUdpServer Server;
public readonly PhotonUdpServer.Peer Peer;
public string AppName = "";
public bool InitDone;
public bool Proto18; // client spoke GpBinary 1.8 in its Init (Photon Voice/Chat libs); PUN is 1.6
public PhotonCrypto? Crypto;
public string? UserId; // set by Authenticate
public string NickName = "";
public Room? Room; // game server only
public Actor? Actor;
public bool InLobby;
public readonly DateTime ConnectedAt = DateTime.UtcNow;
public long Sent, Received;
public readonly TokenBucket Ops = new(Guard.OpsPerSec, Guard.OpsBurst);
public readonly TokenBucket EventBytes = new(Guard.EventBytesPerSec, Guard.EventBytesBurst);
/// <summary>
/// Charge one operation (and, for RaiseEvent, its payload) against this connection's budget.
/// False = drop it. A connection that keeps exceeding the budget is kicked and its IP cooled -
/// that is the RPC-spam / "cheer crash" defence: the flood never reaches the other players.
/// </summary>
public bool Charge(int payloadBytes, string what)
{
long now = Environment.TickCount64;
bool ok = Ops.Take(1, now);
if (ok && payloadBytes > 0) ok = EventBytes.Take(payloadBytes, now);
if (ok) return true;
var b = Ops.DroppedSince > 0 ? Ops : EventBytes;
if (b.DroppedSince == 1) Log.Warn($"{Tag}: over budget ({what}) - dropping (ops {Guard.OpsPerSec}/s, event bytes {Guard.EventBytesPerSec}/s)");
if (b.DroppedSince >= Guard.OpKickAfter)
{
Log.Warn($"{Tag}: kicked - {b.Dropped} operations dropped, {b.DroppedSince} in a row ({what})");
Server.Kick(Peer, "operation flood", Guard.OpFloodBanSec); // shorter than the transport ban: a shared NAT is punished less
}
return false;
}
public Connection(PhotonUdpServer server, PhotonUdpServer.Peer peer) { Server = server; Peer = peer; }
public string Tag => $"{Server.Name}:{Peer}{(UserId != null ? " u" + UserId : "")}{(Actor != null ? " a" + Actor.Nr : "")}";
// ---------------------------------------------------------------- sending
public void SendResponse(byte opCode, short rc, string? debug, Dictionary<byte, object?> p, bool reliable = true)
{
var msg = Proto18 ? GpBinary18.OperationResponseMessage(opCode, rc, debug, p) : GpBinary16.OperationResponseMessage(opCode, rc, debug, p);
Send(msg, reliable);
}
public void SendResponse(byte opCode, Dictionary<byte, object?> p) => SendResponse(opCode, Rc.Ok, null, p);
public void SendError(byte opCode, short rc, string debug) => SendResponse(opCode, rc, debug, new());
public void SendEvent(byte code, Dictionary<byte, object?> p, bool reliable = true)
=> Send(Proto18 ? GpBinary18.EventMessage(code, p) : GpBinary16.EventMessage(code, p), reliable);
public void Send(byte[] message, bool reliable = true)
{
Sent++;
if (reliable) Server.SendReliable(Peer, message); else Server.SendUnreliable(Peer, message);
}
public void Disconnect(string why) => Server.Disconnect(Peer, why);
// ---------------------------------------------------------------- receiving
/// <summary>Decode one message; returns the operation to handle, or null when handled here (init / encryption).</summary>
public GpBinary16.OperationRequest? Decode(byte[] message, out bool encryptedRequest)
{
encryptedRequest = false;
Received++;
if (message.Length < 2 || message[0] != GpBinary16.Magic)
{
Log.Warn($"{Tag}: bad message magic {message[0]:x2}"); return null;
}
byte type = message[1];
bool encrypted = (type & GpBinary16.EncryptedFlag) != 0;
type &= 0x7F;
ReadOnlySpan<byte> body = message.AsSpan(2);
if (type == GpBinary16.MsgInit)
{
// protoMajor, protoMinor, 1e 41 06 19 00, app name (32 bytes)
Proto18 = body.Length >= 2 && body[1] >= 8;
AppName = body.Length >= 39 ? Encoding.ASCII.GetString(body.Slice(7, 32)).TrimEnd('\0') : "";
InitDone = true;
Send(GpBinary16.InitResponse());
Log.Info($"{Tag}: init app='{AppName}' protocol 1.{(Proto18 ? 8 : 6)}");
return null;
}
byte[] plain;
if (encrypted)
{
if (Crypto == null) { Log.Warn($"{Tag}: encrypted message before key exchange"); return null; }
var dec = Crypto.TryDecryptAdaptive(body.ToArray(), b => LooksLikeOperation(b));
if (dec == null) { Log.Warn($"{Tag}: could not decrypt message type {type} ({body.Length} bytes) - no candidate key parsed"); return null; }
plain = dec; encryptedRequest = true;
}
else plain = body.ToArray();
if (type == GpBinary16.MsgInternalOperationRequest)
{
var iop = ReadOp(plain);
if (iop.Code == 0) // InitEncryption: param 1 = client's DH public key
{
Crypto = new PhotonCrypto();
if (iop.Params.TryGetValue(1, out var k) && k is byte[] clientKey) Crypto.DeriveSharedKey(clientKey);
var kp = new Dictionary<byte, object?> { [1] = Crypto.PublicKey };
Send(Proto18 ? GpBinary18.OperationResponseMessage(0, 0, null, kp, internalOp: true) : GpBinary16.OperationResponseMessage(0, 0, null, kp, internalOp: true));
Log.Info($"{Tag}: key exchange done");
}
else if (iop.Code == 1) // Ping (internal) - answer with the client's timestamp echoed
{
Send(Proto18 ? GpBinary18.OperationResponseMessage(1, 0, null, iop.Params, internalOp: true) : GpBinary16.OperationResponseMessage(1, 0, null, iop.Params, internalOp: true));
}
else Log.Warn($"{Tag}: internal op {iop.Code} ignored");
return null;
}
if (type == GpBinary16.MsgOperationRequest)
return ReadOp(plain);
Log.Warn($"{Tag}: unexpected message type {type}");
return null;
}
private GpBinary16.OperationRequest ReadOp(byte[] plain)
{
var op = Proto18 ? GpBinary18.ReadOperationRequest(plain) : GpBinary16.ReadOperationRequest(plain);
op.PayloadBytes = plain.Length;
return op;
}
private bool LooksLikeOperation(byte[] b)
{
try { var op = ReadOp(b); return op.Params.Count <= 64; }
catch { return false; }
}
}
public static class Log
{
public static bool Debug = Environment.GetEnvironmentVariable("CAMPUSNET_DEBUG") == "1";
public static void Info(string s) => Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} {s}");
public static void Warn(string s) => Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} WARN {s}");
public static void Dbg(string s) { if (Debug) Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff} dbg {s}"); }
}