the files

This commit is contained in:
niko
2026-10-14 06:59:51 +02:00
parent f6175893fc
commit 2040419525
1855 changed files with 6735668 additions and 0 deletions
Binary file not shown.
BIN
View File
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
# Game server API (whole API over a valid cert), PLUS the admin panel.
#
# Also serves the Photon custom-auth endpoint at
# https://gameauth.ddns.net/photon/auth
# Everything - including /admin - now reverse-proxies to the local server on :8080.
#
# SECURITY: /admin used to be blocked here (respond "Not found" 404) so the panel
# was only reachable via SSH tunnel. It's now PUBLIC. The only thing standing between
# the internet and full server control is the admin username+password+2FA in
# AdminController. If you re-enable 2FA on all admin accounts you're fine; without it,
# a weak password on any admin account = whole game server compromised. Rotate any
# default admin credentials and consider putting the panel behind Cloudflare Access
# or a Caddy basic_auth if the site becomes high-profile.
gameauth.ddns.net {
reverse_proxy localhost:8080
}
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>CampusNet</RootNamespace>
<AssemblyName>campusnet</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>
</Project>
+125
View File
@@ -0,0 +1,125 @@
using System.Collections.Concurrent;
using System.Net;
using CampusNet.Services;
namespace CampusNet.Net;
/// <summary>
/// Abuse limits for the whole stack. Every number has an environment override (CAMPUSNET_*) so a
/// limit can be tuned on the box without a rebuild. The numbers are sized so a real client never
/// meets them: the Rec Room client's steady state is a few dozen events a second and well under
/// 100 KB/s, and a burst of RPCs on a scene change is hundreds, not thousands.
///
/// 2026-09-17: the op/byte budgets were raised ~10x. The maker pen's Clone and Split send one
/// RPC per object in a single burst (a big selection is thousands of ops in a second), and the
/// 200 ops/s bucket was silently dropping the tail of that burst ("over budget (op 253)") -
/// the other clients never saw the clones and the pen looked broken. A budget must sit far
/// above anything a builder can do; these only stop a flood, not a build session.
///
/// Transport (per peer / per IP, PhotonUdpServer)
/// MaxPeersPerIp simultaneous connections from one address (a household behind NAT)
/// ConnectsPerIpPer10s new CONNECTs per address per 10 s (a bot spinning up peers)
/// InBytesPerSec / InBytesBurst inbound bytes per peer, token bucket - excess packets are
/// dropped before decryption/decoding; a peer over it for
/// FloodKickAfterSec straight is dropped and its IP cooled for IpBanSec
/// MaxFragmentTotal largest reassembled message (the fragment header's "total" is
/// client-supplied - unchecked it is an allocation of any size)
/// MaxFragmentSets concurrent reassemblies per peer
/// MaxPendingReliable out-of-order reliable commands buffered per channel
/// Game (per connection, GameService)
/// OpsPerSec / OpsBurst operations (RaiseEvent, SetProperties, ...) per connection
/// EventBytesPerSec / EventBytesBurst RaiseEvent payload bytes per connection
/// MaxEventBytes one event's payload
/// MaxRoomCache / MaxActorCache cached events per room / per actor
/// OpKickAfter dropped operations in a row before the connection is closed and
/// its IP cooled for IpBanSec
/// </summary>
public static class Guard
{
public static readonly int MaxPeersPerIp = Env("CAMPUSNET_MAX_PEERS_PER_IP", 16);
public static readonly int ConnectsPerIpPer10s = Env("CAMPUSNET_CONNECTS_PER_IP_10S", 30);
public static readonly int InBytesPerSec = Env("CAMPUSNET_IN_BYTES_PER_SEC", 2048 * 1024);
public static readonly int InBytesBurst = Env("CAMPUSNET_IN_BYTES_BURST", 8192 * 1024);
public static readonly int FloodKickAfterSec = Env("CAMPUSNET_FLOOD_KICK_AFTER_SEC", 5);
public static readonly int MaxFragmentTotal = Env("CAMPUSNET_MAX_FRAGMENT_TOTAL", 8 * 1024 * 1024);
public static readonly int MaxFragmentSets = Env("CAMPUSNET_MAX_FRAGMENT_SETS", 16);
public static readonly int MaxPendingReliable = Env("CAMPUSNET_MAX_PENDING_RELIABLE", 4096);
public static readonly int IpBanSec = Env("CAMPUSNET_IP_BAN_SEC", 300);
public static readonly int OpsPerSec = Env("CAMPUSNET_OPS_PER_SEC", 2000);
public static readonly int OpsBurst = Env("CAMPUSNET_OPS_BURST", 10000);
public static readonly int EventBytesPerSec = Env("CAMPUSNET_EVENT_BYTES_PER_SEC", 2048 * 1024);
public static readonly int EventBytesBurst = Env("CAMPUSNET_EVENT_BYTES_BURST", 8192 * 1024);
public static readonly int MaxEventBytes = Env("CAMPUSNET_MAX_EVENT_BYTES", 4096 * 1024);
public static readonly int MaxRoomCache = Env("CAMPUSNET_MAX_ROOM_CACHE", 100000);
public static readonly int MaxActorCache = Env("CAMPUSNET_MAX_ACTOR_CACHE", 8192);
public static readonly int OpKickAfter = Env("CAMPUSNET_OP_KICK_AFTER", 1500);
public static readonly int OpFloodBanSec = Env("CAMPUSNET_OP_FLOOD_BAN_SEC", 60);
private static int Env(string name, int dflt) => int.TryParse(Environment.GetEnvironmentVariable(name), out int v) ? v : dflt;
// ---------------------------------------------------------------- ip cooldown
private static readonly ConcurrentDictionary<string, long> _banned = new();
private static readonly ConcurrentDictionary<string, (int count, long windowStart)> _connects = new();
public static string IpOf(EndPoint ep) => ep is IPEndPoint ip ? ip.Address.ToString() : ep.ToString() ?? "";
public static void BanIp(string ip, string why, int? seconds = null)
{
long until = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + (seconds ?? IpBanSec);
_banned.AddOrUpdate(ip, until, (_, cur) => Math.Max(cur, until));
Log.Warn($"guard: ip {ip} cooled for {(seconds ?? IpBanSec)}s - {why}");
}
public static bool IsBanned(string ip)
{
if (!_banned.TryGetValue(ip, out long until)) return false;
if (until > DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return true;
_banned.TryRemove(ip, out _);
return false;
}
/// <summary>Count a CONNECT from this IP; false when it is over ConnectsPerIpPer10s.</summary>
public static bool AllowConnect(string ip)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var e = _connects.AddOrUpdate(ip, _ => (1, now), (_, cur) => now - cur.windowStart >= 10 ? (1, now) : (cur.count + 1, cur.windowStart));
if (_connects.Count > 10000) foreach (var kv in _connects) if (now - kv.Value.windowStart > 60) _connects.TryRemove(kv.Key, out _);
return e.count <= ConnectsPerIpPer10s;
}
}
/// <summary>Token bucket: <c>rate</c> tokens/s refilled continuously, at most <c>burst</c> banked.</summary>
public sealed class TokenBucket
{
private readonly double _rate, _burst;
private double _tokens;
private long _last;
public long Dropped; // total refused
public long DroppedSince; // refusals in the current violation run
public long ViolatingSinceMs; // when the current run started, 0 = clean
private long _lastDropMs = long.MinValue;
private const int RunGapMs = 2000; // a run ends after this long without a refusal
public TokenBucket(int ratePerSec, int burst) { _rate = ratePerSec; _burst = burst; _tokens = burst; _last = long.MinValue; }
/// <param name="nowMs">any monotonic millisecond clock - the bucket adopts the first one it sees</param>
public bool Take(double n, long nowMs)
{
if (_last == long.MinValue) _last = nowMs;
double dt = (nowMs - _last) / 1000.0;
if (dt > 0) { _tokens = Math.Min(_burst, _tokens + dt * _rate); _last = nowMs; }
if (_tokens >= n)
{
_tokens -= n;
if (ViolatingSinceMs != 0 && nowMs - _lastDropMs > RunGapMs) { DroppedSince = 0; ViolatingSinceMs = 0; }
return true;
}
// A run of refusals: a client that stays over budget keeps the run alive even though
// the trickle of refilled tokens lets the odd operation through in between.
if (ViolatingSinceMs == 0 || nowMs - _lastDropMs > RunGapMs) { DroppedSince = 0; ViolatingSinceMs = nowMs; }
Dropped++; DroppedSince++; _lastDropMs = nowMs;
return false;
}
}
+469
View File
@@ -0,0 +1,469 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
namespace CampusNet.Net;
/// <summary>
/// Photon's ENet-style reliable UDP (wire details verified from a capture, docs/photon-wire-findings.md):
/// 12-byte packet header + N commands, per-channel reliable sequencing with acks and retransmission,
/// unreliable sequencing, fragmentation above the MTU, ping/keep-alive, server-time via ack timestamp.
/// One instance per listening port; the service on top receives whole messages per peer.
/// </summary>
public sealed class PhotonUdpServer
{
public const int Mtu = 1200;
private const int PacketHeader = 12, CommandHeader = 12;
private const byte CmdAck = 1, CmdConnect = 2, CmdVerifyConnect = 3, CmdDisconnect = 4, CmdPing = 5,
CmdReliable = 6, CmdUnreliable = 7, CmdFragment = 8, CmdServerTime = 12;
private const byte FlagReliable = 1, FlagUnsequenced = 2;
private const byte ChannelSystem = 255;
private const int PeerTimeoutMs = 12000, PingIntervalMs = 1000, MaxRetries = 8;
public readonly int Port;
public readonly string Name;
private readonly Socket _sock;
private readonly ConcurrentDictionary<EndPoint, Peer> _peers = new();
private readonly System.Diagnostics.Stopwatch _clock = System.Diagnostics.Stopwatch.StartNew();
private readonly Random _rng = new();
private int ServerTime => (int)_clock.ElapsedMilliseconds;
public event Action<Peer, byte[], bool>? OnMessage; // a complete message (0xF3 ...), reliable?
public event Action<Peer>? OnDisconnect;
public event Action<Peer>? OnConnect;
public PhotonUdpServer(string name, int port)
{
Name = name; Port = port;
_sock = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
try { _sock.IOControl(-1744830452, new byte[] { 0 }, null); } catch { } // SIO_UDP_CONNRESET off (Windows)
_sock.Bind(new IPEndPoint(IPAddress.Any, port));
}
public int PeerCount => _peers.Count;
public IEnumerable<Peer> Peers => _peers.Values;
// ------------------------------------------------------------ peer
public sealed class Peer
{
public readonly EndPoint Remote;
public readonly short PeerId;
public int Challenge;
public bool Connected;
public long LastRecv, LastPing, LastSend;
public int Rtt = 200;
public object? Session; // the service's per-connection state
internal readonly Dictionary<byte, ChannelState> Channels = new();
internal readonly List<PendingAck> AckQueue = new();
internal readonly List<OutCommand> OutQueue = new();
internal readonly Dictionary<long, OutCommand> UnackedBySeq = new();
internal readonly Dictionary<long, Fragmenter> Fragments = new();
internal readonly HashSet<byte> UnknownTypesLogged = new();
internal int UnreliableOutSeq;
internal bool Disconnecting;
internal readonly TokenBucket InBytes = new(Guard.InBytesPerSec, Guard.InBytesBurst);
public readonly string Ip;
public Peer(EndPoint remote, short id) { Remote = remote; PeerId = id; Ip = Guard.IpOf(remote); }
public override string ToString() => $"{Remote}#{PeerId}";
}
internal sealed class ChannelState
{
public int ReliableOut; // last reliable seq sent
public int ReliableInExpected = 1;
public int UnreliableIn;
public readonly SortedDictionary<int, byte[]> Pending = new(); // out-of-order reliable commands (raw command bytes)
}
internal struct PendingAck { public byte Channel; public int Seq; public int SentTime; }
internal sealed class OutCommand
{
public byte Type, Channel, Flags; public int Seq; public byte[] Payload = Array.Empty<byte>();
public long FirstSent, LastSent; public int Retries; public bool Reliable => (Flags & FlagReliable) != 0;
public int Length => CommandHeader + Payload.Length;
}
internal sealed class Fragmenter { public int Count, Total; public readonly SortedDictionary<int, byte[]> Parts = new(); }
// ------------------------------------------------------------ run loop
public async Task RunAsync(CancellationToken ct)
{
Console.WriteLine($"[{Name}] listening on udp/{Port}");
var buf = new byte[65536];
var tick = Task.Run(() => TickLoop(ct), ct);
while (!ct.IsCancellationRequested)
{
SocketReceiveFromResult r;
try { r = await _sock.ReceiveFromAsync(buf, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), ct); }
catch (OperationCanceledException) { break; }
catch (SocketException) { continue; }
catch (Exception ex) { Console.WriteLine($"[{Name}] recv error: {ex.Message}"); continue; }
try { HandlePacket(r.RemoteEndPoint, buf.AsSpan(0, r.ReceivedBytes)); }
catch (Exception ex) { Console.WriteLine($"[{Name}] packet from {r.RemoteEndPoint} failed: {ex}"); }
}
await tick;
}
private async Task TickLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await Task.Delay(20, ct); } catch { break; }
long now = _clock.ElapsedMilliseconds;
foreach (var p in _peers.Values)
{
try
{
lock (p)
{
if (now - p.LastRecv > PeerTimeoutMs) { DropPeer(p, "timeout"); continue; }
if (p.Connected && now - p.LastPing > PingIntervalMs)
{
p.LastPing = now;
Enqueue(p, new OutCommand { Type = CmdPing, Channel = ChannelSystem, Flags = FlagReliable });
}
// Retransmit unacked reliable commands.
foreach (var c in p.UnackedBySeq.Values.ToList())
{
int rto = Math.Clamp(p.Rtt * 2 + 50, 200, 2000) << Math.Min(c.Retries, 3);
if (now - c.LastSent < rto) continue;
if (c.Retries++ >= MaxRetries) { DropPeer(p, "unacked command"); break; }
p.OutQueue.Add(c);
}
Flush(p);
}
}
catch (Exception ex) { Console.WriteLine($"[{Name}] tick {p}: {ex.Message}"); }
}
}
}
// ------------------------------------------------------------ receive
private void HandlePacket(EndPoint from, ReadOnlySpan<byte> pkt)
{
if (pkt.Length < PacketHeader) return;
short peerId = BinaryPrimitives.ReadInt16BigEndian(pkt);
int count = pkt[3];
int sentTime = BinaryPrimitives.ReadInt32BigEndian(pkt.Slice(4));
int challenge = BinaryPrimitives.ReadInt32BigEndian(pkt.Slice(8));
if (!_peers.TryGetValue(from, out var peer))
{
// Only a CONNECT may create a peer.
if (pkt.Length < PacketHeader + CommandHeader || pkt[PacketHeader] != CmdConnect) return;
string ip = Guard.IpOf(from);
if (Guard.IsBanned(ip)) return;
if (!Guard.AllowConnect(ip)) { Guard.BanIp(ip, $"more than {Guard.ConnectsPerIpPer10s} connects in 10s on {Name}"); return; }
if (_peers.Values.Count(p => p.Ip == ip) >= Guard.MaxPeersPerIp)
{
Console.WriteLine($"[{Name}] connect from {from} refused: {Guard.MaxPeersPerIp} peers already from {ip}");
return;
}
short id;
do { id = (short)_rng.Next(1, short.MaxValue); } while (_peers.Values.Any(p => p.PeerId == id));
peer = new Peer(from, id) { Challenge = challenge };
_peers[from] = peer;
Console.WriteLine($"[{Name}] connect from {from} -> peer {id}");
}
lock (peer)
{
long nowMs = _clock.ElapsedMilliseconds;
peer.LastRecv = nowMs;
// Inbound flood: over the byte budget the packet is dropped before any decode work;
// a peer that stays over it is closed and its address cooled.
if (!peer.InBytes.Take(pkt.Length, nowMs))
{
if (peer.InBytes.DroppedSince == 1) Console.WriteLine($"[{Name}] {peer} over {Guard.InBytesPerSec} B/s inbound - dropping packets");
if (nowMs - peer.InBytes.ViolatingSinceMs > Guard.FloodKickAfterSec * 1000L)
{
Guard.BanIp(peer.Ip, $"inbound flood on {Name} ({peer.InBytes.Dropped} packets dropped)");
DropPeer(peer, "inbound flood");
}
return;
}
int pos = PacketHeader;
for (int i = 0; i < count && pos + CommandHeader <= pkt.Length; i++)
{
byte type = pkt[pos], channel = pkt[pos + 1], flags = pkt[pos + 2];
int len = BinaryPrimitives.ReadInt32BigEndian(pkt.Slice(pos + 4));
int seq = BinaryPrimitives.ReadInt32BigEndian(pkt.Slice(pos + 8));
if (len < CommandHeader || pos + len > pkt.Length) break;
var body = pkt.Slice(pos + CommandHeader, len - CommandHeader);
HandleCommand(peer, type, channel, flags, seq, sentTime, body);
pos += len;
}
Flush(peer);
}
}
private void HandleCommand(Peer peer, byte type, byte channel, byte flags, int seq, int sentTime, ReadOnlySpan<byte> body)
{
if ((flags & FlagReliable) != 0 && type != CmdAck)
peer.AckQueue.Add(new PendingAck { Channel = channel, Seq = seq, SentTime = sentTime });
switch (type)
{
case CmdAck:
{
if (body.Length < 8) return;
int ackedSeq = BinaryPrimitives.ReadInt32BigEndian(body);
int ackedSent = BinaryPrimitives.ReadInt32BigEndian(body.Slice(4));
if (peer.UnackedBySeq.Remove(Key(channel, ackedSeq), out var cmd))
{
int rtt = ServerTime - ackedSent;
if (rtt >= 0 && rtt < 5000) peer.Rtt = (peer.Rtt * 7 + rtt) / 8;
}
return;
}
case CmdConnect:
{
// Answer VerifyConnect with the peer id we assigned; the rest echoes the client's connect block.
var payload = new byte[32];
if (body.Length >= 32) body.Slice(0, 32).CopyTo(payload);
BinaryPrimitives.WriteInt16BigEndian(payload, peer.PeerId);
for (int i = 12; i < 32; i++) payload[i] = 0;
if (!peer.Connected)
{
peer.Connected = true;
peer.LastPing = _clock.ElapsedMilliseconds;
Enqueue(peer, new OutCommand { Type = CmdVerifyConnect, Channel = ChannelSystem, Flags = FlagReliable, Payload = payload });
OnConnect?.Invoke(peer);
}
return;
}
case CmdDisconnect:
DropPeer(peer, "client disconnect");
return;
case CmdPing:
case CmdServerTime:
return; // the ack (with our header sentTime) is the whole answer
case CmdReliable:
DeliverReliable(peer, channel, seq, body.ToArray());
return;
case CmdUnreliable:
{
if (body.Length < 4) return;
int useq = BinaryPrimitives.ReadInt32BigEndian(body);
var ch = Channel(peer, channel);
if (useq <= ch.UnreliableIn) return; // late/duplicate
ch.UnreliableIn = useq;
Dispatch(peer, body.Slice(4).ToArray(), false);
return;
}
case CmdFragment:
{
if (body.Length < 20) return;
int start = BinaryPrimitives.ReadInt32BigEndian(body);
int fcount = BinaryPrimitives.ReadInt32BigEndian(body.Slice(4));
int total = BinaryPrimitives.ReadInt32BigEndian(body.Slice(12));
int offset = BinaryPrimitives.ReadInt32BigEndian(body.Slice(16));
long key = Key(channel, start);
// The fragment header is client-supplied: bound the reassembly buffer and the number
// of half-built messages before either becomes an allocation the client controls.
if (total <= 0 || total > Guard.MaxFragmentTotal || fcount <= 0 || fcount > Guard.MaxFragmentTotal / 64 || offset < 0 || offset >= total)
{
Guard.BanIp(peer.Ip, $"bad fragment header on {Name} (total {total}, count {fcount}, offset {offset})");
DropPeer(peer, "bad fragment");
return;
}
if (!peer.Fragments.TryGetValue(key, out var f))
{
if (peer.Fragments.Count >= Guard.MaxFragmentSets) { DropPeer(peer, "too many partial fragments"); return; }
peer.Fragments[key] = f = new Fragmenter { Count = fcount, Total = total };
}
f.Parts[offset] = body.Slice(20).ToArray();
if (f.Parts.Count < f.Count) return;
peer.Fragments.Remove(key);
var whole = new byte[f.Total]; int p = 0;
foreach (var part in f.Parts.Values) { Buffer.BlockCopy(part, 0, whole, p, Math.Min(part.Length, whole.Length - p)); p += part.Length; }
// Fragments occupy reliable seqs start..start+count-1; deliver in order at `start`.
DeliverReliable(peer, channel, start, whole, fcount);
return;
}
default:
// Unknown command type: say so once per (peer, type) - a missing delivery mode
// (unsequenced variants are 11/14/15/16) shows up here instead of vanishing.
if (peer.UnknownTypesLogged.Add(type))
Console.WriteLine($"[{Name}] {peer} sent command type {type} (channel {channel}, flags {flags}, {body.Length} bytes) - not handled, dropped");
return;
}
}
private static long Key(byte channel, int seq) => ((long)channel << 32) | (uint)seq;
private ChannelState Channel(Peer p, byte c) { if (!p.Channels.TryGetValue(c, out var s)) p.Channels[c] = s = new ChannelState(); return s; }
private void DeliverReliable(Peer peer, byte channel, int seq, byte[] message, int span = 1)
{
var ch = Channel(peer, channel);
if (seq < ch.ReliableInExpected) return; // duplicate (already acked above)
if (seq > ch.ReliableInExpected)
{
if (ch.Pending.Count >= Guard.MaxPendingReliable) { DropPeer(peer, "reliable window overflow"); return; }
ch.Pending[seq] = message; return;
}
ch.ReliableInExpected = seq + span;
Dispatch(peer, message, true);
while (ch.Pending.Count > 0)
{
var first = ch.Pending.First();
if (first.Key != ch.ReliableInExpected) break;
ch.Pending.Remove(first.Key);
ch.ReliableInExpected++;
Dispatch(peer, first.Value, true);
}
}
private void Dispatch(Peer peer, byte[] message, bool reliable)
{
if (message.Length == 0) return;
try { OnMessage?.Invoke(peer, message, reliable); }
catch (Exception ex) { Console.WriteLine($"[{Name}] handler error for {peer}: {ex}"); }
}
// ------------------------------------------------------------ send
public void SendReliable(Peer peer, byte[] message, byte channel = 0)
{
lock (peer)
{
if (peer.Disconnecting) return;
int maxBody = Mtu - PacketHeader - CommandHeader;
if (message.Length <= maxBody)
{
Enqueue(peer, new OutCommand { Type = CmdReliable, Channel = channel, Flags = FlagReliable, Payload = message });
return;
}
int chunk = maxBody - 20;
int count = (message.Length + chunk - 1) / chunk;
var ch = Channel(peer, channel);
int start = ch.ReliableOut + 1;
for (int i = 0; i < count; i++)
{
int off = i * chunk, len = Math.Min(chunk, message.Length - off);
var payload = new byte[20 + len];
BinaryPrimitives.WriteInt32BigEndian(payload, start);
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(4), count);
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(8), i);
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(12), message.Length);
BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(16), off);
Buffer.BlockCopy(message, off, payload, 20, len);
Enqueue(peer, new OutCommand { Type = CmdFragment, Channel = channel, Flags = FlagReliable, Payload = payload });
}
}
}
public void SendUnreliable(Peer peer, byte[] message, byte channel = 0)
{
lock (peer)
{
if (peer.Disconnecting) return;
if (message.Length > Mtu - PacketHeader - CommandHeader - 4) { SendReliable(peer, message, channel); return; }
var payload = new byte[4 + message.Length];
BinaryPrimitives.WriteInt32BigEndian(payload, ++peer.UnreliableOutSeq);
Buffer.BlockCopy(message, 0, payload, 4, message.Length);
Enqueue(peer, new OutCommand { Type = CmdUnreliable, Channel = channel, Flags = 0, Payload = payload });
}
}
public void Disconnect(Peer peer, string why)
{
lock (peer)
{
if (peer.Disconnecting) return;
Enqueue(peer, new OutCommand { Type = CmdDisconnect, Channel = ChannelSystem, Flags = FlagUnsequenced });
Flush(peer);
DropPeer(peer, why);
}
}
private void Enqueue(Peer peer, OutCommand c)
{
var ch = Channel(peer, c.Channel);
if (c.Reliable)
{
c.Seq = ++ch.ReliableOut;
peer.UnackedBySeq[Key(c.Channel, c.Seq)] = c;
}
else
{
// ENet sequences unreliable commands AGAINST the channel's reliable stream: the header's
// sequence field must carry the last reliable seq sent on the channel. The receiver
// holds an unreliable command until it has that reliable command, and DISCARDS one whose
// reliable seq is older than what it already has. Stamping 0 here meant every unreliable
// event the server relayed - all the avatar movement - was thrown away as stale the
// moment the client had received its first reliable message (the join response).
c.Seq = ch.ReliableOut;
}
peer.OutQueue.Add(c);
}
/// <summary>Pack queued acks + commands into MTU-sized packets and send them.</summary>
private void Flush(Peer peer)
{
if (peer.AckQueue.Count == 0 && peer.OutQueue.Count == 0) return;
long now = _clock.ElapsedMilliseconds;
var pkt = new byte[Mtu];
int pos = PacketHeader, n = 0;
void Send()
{
if (n == 0) return;
BinaryPrimitives.WriteInt16BigEndian(pkt, 0);
pkt[2] = 0; pkt[3] = (byte)n;
BinaryPrimitives.WriteInt32BigEndian(pkt.AsSpan(4), ServerTime);
BinaryPrimitives.WriteInt32BigEndian(pkt.AsSpan(8), peer.Challenge);
try { _sock.SendTo(pkt, 0, pos, SocketFlags.None, peer.Remote); } catch (Exception ex) { Console.WriteLine($"[{Name}] send to {peer} failed: {ex.Message}"); }
pos = PacketHeader; n = 0;
}
void Put(byte type, byte channel, byte flags, int seq, ReadOnlySpan<byte> payload)
{
int len = CommandHeader + payload.Length;
if (pos + len > Mtu) Send();
pkt[pos] = type; pkt[pos + 1] = channel; pkt[pos + 2] = flags; pkt[pos + 3] = 4;
BinaryPrimitives.WriteInt32BigEndian(pkt.AsSpan(pos + 4), len);
BinaryPrimitives.WriteInt32BigEndian(pkt.AsSpan(pos + 8), seq);
payload.CopyTo(pkt.AsSpan(pos + CommandHeader));
pos += len; n++;
if (n == 255) Send();
}
foreach (var a in peer.AckQueue)
{
Span<byte> ap = stackalloc byte[8];
BinaryPrimitives.WriteInt32BigEndian(ap, a.Seq);
BinaryPrimitives.WriteInt32BigEndian(ap.Slice(4), a.SentTime);
Put(CmdAck, a.Channel, 0, 0, ap);
}
peer.AckQueue.Clear();
foreach (var c in peer.OutQueue)
{
if (c.Reliable) { if (c.FirstSent == 0) c.FirstSent = now; c.LastSent = now; }
Put(c.Type, c.Channel, c.Flags, c.Seq, c.Payload);
}
peer.OutQueue.Clear();
Send();
peer.LastSend = now;
}
/// <summary>Close a peer for abuse and cool its address (Guard.IpBanSec).</summary>
public void Kick(Peer peer, string why, int? banSeconds = null)
{
Guard.BanIp(peer.Ip, $"{why} on {Name}", banSeconds);
lock (peer) Disconnect(peer, why);
}
private void DropPeer(Peer peer, string why)
{
if (peer.Disconnecting) return;
peer.Disconnecting = true;
_peers.TryRemove(peer.Remote, out _);
Console.WriteLine($"[{Name}] {peer} gone ({why})");
try { OnDisconnect?.Invoke(peer); } catch (Exception ex) { Console.WriteLine($"[{Name}] disconnect handler: {ex}"); }
}
}
+40
View File
@@ -0,0 +1,40 @@
using CampusNet.Net;
using CampusNet.Services;
// CampusNet - a Photon-protocol (LoadBalancing over ENet-style UDP, GpBinary 1.6) game server for the
// Rec Room client. Runs the name server, master and game roles in one process on the standard ports.
//
// CAMPUSNET_PUBLIC_HOST the IP/host clients reach this machine on (what the name server hands out)
// CAMPUSNET_NS_PORT / CAMPUSNET_MASTER_PORT / CAMPUSNET_GAME_PORT default 5058 / 5055 / 5056
// CAMPUSNET_JWT_KEY the Campus server's HS256 key (validates the photonAuthToken)
// CAMPUSNET_ALLOW_UNSIGNED=1 accept a plain UserId when no token is presented (testing only)
// CAMPUSNET_DEBUG=1 log every operation
string publicHost = Environment.GetEnvironmentVariable("CAMPUSNET_PUBLIC_HOST") ?? "127.0.0.1";
int nsPort = int.Parse(Environment.GetEnvironmentVariable("CAMPUSNET_NS_PORT") ?? "5058");
int masterPort = int.Parse(Environment.GetEnvironmentVariable("CAMPUSNET_MASTER_PORT") ?? "5055");
int gamePort = int.Parse(Environment.GetEnvironmentVariable("CAMPUSNET_GAME_PORT") ?? "5056");
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel();
var ns = new PhotonUdpServer("name", nsPort);
var master = new PhotonUdpServer("master", masterPort);
var game = new PhotonUdpServer("game", gamePort);
_ = new LoadBalancingApp(Role.NameServer, ns, publicHost, masterPort, gamePort);
_ = new LoadBalancingApp(Role.Master, master, publicHost, masterPort, gamePort);
_ = new LoadBalancingApp(Role.Game, game, publicHost, masterPort, gamePort);
Log.Info($"CampusNet starting: public host {publicHost}, name/{nsPort} master/{masterPort} game/{gamePort}, DH generator {CampusNet.Protocol.PhotonCrypto.Generator}");
var sweeper = Task.Run(async () =>
{
while (!cts.IsCancellationRequested)
{
try { await Task.Delay(1000, cts.Token); } catch { break; }
try { GameService.Sweep(); } catch (Exception ex) { Log.Warn($"sweep: {ex.Message}"); }
}
});
await Task.WhenAll(ns.RunAsync(cts.Token), master.RunAsync(cts.Token), game.RunAsync(cts.Token), sweeper);
+289
View File
@@ -0,0 +1,289 @@
using System.Buffers.Binary;
using System.Collections;
using System.Text;
namespace CampusNet.Protocol;
/// <summary>
/// A value the client sent as a custom type ('c'): PUN registers Vector3/Quaternion/Vector2/Player
/// etc. under one-letter codes. The server never interprets them - it stores and re-emits the bytes.
/// </summary>
public sealed record CustomValue(byte Code, byte[] Data);
/// <summary>An array the client sent with an explicit element type ('y'): re-emitted the same way.</summary>
public sealed record TypedArray(byte ElemType, object?[] Items, byte CustomCode = 0);
/// <summary>A Dictionary sent with typed keys/values ('D'); key/value type 0 = per-entry typed.</summary>
public sealed class TypedDictionary
{
public byte KeyType, ValueType;
public readonly List<KeyValuePair<object?, object?>> Entries = new();
}
/// <summary>
/// Photon GpBinary 1.6 - the wire format PUN uses on every Realtime connection (verified from a live
/// capture, see docs/photon-wire-findings.md). Big-endian throughout. Hashtables are the workhorse:
/// keys are typed values (bytes for system props, strings for custom props).
/// </summary>
public static class GpBinary16
{
public const byte Magic = 0xF3;
// Message types (the byte after 0xF3; bit 0x80 = encrypted body).
public const byte MsgInit = 0, MsgInitResponse = 1, MsgOperationRequest = 2, MsgOperationResponse = 3,
MsgEvent = 4, MsgInternalOperationRequest = 6, MsgInternalOperationResponse = 7,
MsgMessage = 8, MsgRawMessage = 9;
public const byte EncryptedFlag = 0x80;
// ---------------------------------------------------------------- reading
public ref struct Reader
{
private readonly ReadOnlySpan<byte> _b;
public int Pos;
public Reader(ReadOnlySpan<byte> b, int pos = 0) { _b = b; Pos = pos; }
public int Remaining => _b.Length - Pos;
public byte U8() => _b[Pos++];
public short I16() { var v = BinaryPrimitives.ReadInt16BigEndian(_b.Slice(Pos)); Pos += 2; return v; }
public int I32() { var v = BinaryPrimitives.ReadInt32BigEndian(_b.Slice(Pos)); Pos += 4; return v; }
public long I64() { var v = BinaryPrimitives.ReadInt64BigEndian(_b.Slice(Pos)); Pos += 8; return v; }
public float F32() { var v = BinaryPrimitives.ReadSingleBigEndian(_b.Slice(Pos)); Pos += 4; return v; }
public double F64() { var v = BinaryPrimitives.ReadDoubleBigEndian(_b.Slice(Pos)); Pos += 8; return v; }
public byte[] Bytes(int n) { var v = _b.Slice(Pos, n).ToArray(); Pos += n; return v; }
public string Str() { int n = I16(); var s = Encoding.UTF8.GetString(_b.Slice(Pos, n)); Pos += n; return s; }
public object? Value() => Value(U8());
public object? Value(byte t)
{
switch ((char)t)
{
case '*': return null;
case 'b': return U8();
case 'o': return U8() != 0;
case 'k': return I16();
case 'i': return I32();
case 'l': return I64();
case 'f': return F32();
case 'd': return F64();
case 's': return Str();
case 'x': { int n = I32(); return Bytes(n); }
case 'n': { int n = I32(); var a = new int[n]; for (int i = 0; i < n; i++) a[i] = I32(); return a; }
case 'a': { int n = I16(); var a = new string[n]; for (int i = 0; i < n; i++) a[i] = Str(); return a; }
case 'h':
{
int n = I16(); var h = new Hashtable(n);
for (int i = 0; i < n; i++) { var k = Value(); var v = Value(); if (k != null) h[k] = v; }
return h;
}
case 'D':
{
var d = new TypedDictionary { KeyType = U8(), ValueType = U8() };
int n = I16();
for (int i = 0; i < n; i++)
{
var k = d.KeyType == 0 ? Value() : Value(d.KeyType);
var v = d.ValueType == 0 ? Value() : Value(d.ValueType);
d.Entries.Add(new(k, v));
}
return d;
}
case 'z': { int n = I16(); var a = new object?[n]; for (int i = 0; i < n; i++) a[i] = Value(); return a; }
case 'y':
{
int n = I16(); byte et = U8();
if ((char)et == 'c')
{
byte code = U8(); var items = new object?[n];
for (int i = 0; i < n; i++) { int len = I16(); items[i] = new CustomValue(code, Bytes(len)); }
return new TypedArray(et, items, code);
}
var arr = new object?[n];
for (int i = 0; i < n; i++) arr[i] = Value(et);
return new TypedArray(et, arr);
}
case 'c': { byte code = U8(); int len = I16(); return new CustomValue(code, Bytes(len)); }
default: throw new InvalidDataException($"GpBinary16: unknown type 0x{t:x2} at {Pos - 1}");
}
}
public Dictionary<byte, object?> Params()
{
int n = I16(); var d = new Dictionary<byte, object?>(n);
for (int i = 0; i < n; i++) { byte k = U8(); d[k] = Value(); }
return d;
}
}
public sealed class OperationRequest { public byte Code; public Dictionary<byte, object?> Params = new(); public int PayloadBytes; /* wire size of the whole message, for budgets */ }
public sealed class OperationResponse { public byte Code; public short ReturnCode; public string? Debug; public Dictionary<byte, object?> Params = new(); }
public sealed class EventData { public byte Code; public Dictionary<byte, object?> Params = new(); }
public static OperationRequest ReadOperationRequest(ReadOnlySpan<byte> body)
{
var r = new Reader(body);
var op = new OperationRequest { Code = r.U8() };
op.Params = r.Params();
return op;
}
public static OperationResponse ReadOperationResponse(ReadOnlySpan<byte> body)
{
var r = new Reader(body);
var op = new OperationResponse { Code = r.U8(), ReturnCode = r.I16() };
op.Debug = r.Value() as string;
op.Params = r.Params();
return op;
}
public static EventData ReadEvent(ReadOnlySpan<byte> body)
{
var r = new Reader(body);
var ev = new EventData { Code = r.U8() };
ev.Params = r.Params();
return ev;
}
// ---------------------------------------------------------------- writing
public sealed class Writer
{
private byte[] _b = new byte[512];
public int Length;
public Writer(int cap = 512) { _b = new byte[cap]; }
private void Ensure(int n) { if (Length + n > _b.Length) Array.Resize(ref _b, Math.Max(_b.Length * 2, Length + n)); }
public void U8(byte v) { Ensure(1); _b[Length++] = v; }
public void I16(short v) { Ensure(2); BinaryPrimitives.WriteInt16BigEndian(_b.AsSpan(Length), v); Length += 2; }
public void I32(int v) { Ensure(4); BinaryPrimitives.WriteInt32BigEndian(_b.AsSpan(Length), v); Length += 4; }
public void I64(long v) { Ensure(8); BinaryPrimitives.WriteInt64BigEndian(_b.AsSpan(Length), v); Length += 8; }
public void F32(float v) { Ensure(4); BinaryPrimitives.WriteSingleBigEndian(_b.AsSpan(Length), v); Length += 4; }
public void F64(double v) { Ensure(8); BinaryPrimitives.WriteDoubleBigEndian(_b.AsSpan(Length), v); Length += 8; }
public void Raw(ReadOnlySpan<byte> s) { Ensure(s.Length); s.CopyTo(_b.AsSpan(Length)); Length += s.Length; }
public void Str(string s) { var n = Encoding.UTF8.GetByteCount(s); I16((short)n); Ensure(n); Encoding.UTF8.GetBytes(s, _b.AsSpan(Length)); Length += n; }
public byte[] ToArray() => _b.AsSpan(0, Length).ToArray();
public ReadOnlySpan<byte> Span => _b.AsSpan(0, Length);
/// <summary>Write a typed value (type byte + data).</summary>
public void Value(object? v)
{
switch (v)
{
case null: U8((byte)'*'); break;
case byte b: U8((byte)'b'); U8(b); break;
case bool o: U8((byte)'o'); U8(o ? (byte)1 : (byte)0); break;
case short k: U8((byte)'k'); I16(k); break;
case int i: U8((byte)'i'); I32(i); break;
case long l: U8((byte)'l'); I64(l); break;
case float f: U8((byte)'f'); F32(f); break;
case double d: U8((byte)'d'); F64(d); break;
case string s: U8((byte)'s'); Str(s); break;
case byte[] x: U8((byte)'x'); I32(x.Length); Raw(x); break;
case int[] n: U8((byte)'n'); I32(n.Length); foreach (var e in n) I32(e); break;
case string[] a: U8((byte)'a'); I16((short)a.Length); foreach (var e in a) Str(e); break;
case Hashtable h:
U8((byte)'h'); I16((short)h.Count);
foreach (DictionaryEntry e in h) { Value(e.Key); Value(e.Value); }
break;
case TypedDictionary td:
U8((byte)'D'); U8(td.KeyType); U8(td.ValueType); I16((short)td.Entries.Count);
foreach (var e in td.Entries)
{
if (td.KeyType == 0) Value(e.Key); else ValueBody(e.Key, td.KeyType);
if (td.ValueType == 0) Value(e.Value); else ValueBody(e.Value, td.ValueType);
}
break;
case TypedArray ta:
U8((byte)'y'); I16((short)ta.Items.Length); U8(ta.ElemType);
if ((char)ta.ElemType == 'c')
{
U8(ta.CustomCode);
foreach (var e in ta.Items) { var c = (CustomValue)e!; I16((short)c.Data.Length); Raw(c.Data); }
}
else foreach (var e in ta.Items) ValueBody(e, ta.ElemType);
break;
case object?[] z: U8((byte)'z'); I16((short)z.Length); foreach (var e in z) Value(e); break;
case CustomValue c: U8((byte)'c'); U8(c.Code); I16((short)c.Data.Length); Raw(c.Data); break;
case Dictionary<byte, object?> dbo: // convenience: byte-keyed dictionary as Hashtable
U8((byte)'h'); I16((short)dbo.Count);
foreach (var e in dbo) { Value(e.Key); Value(e.Value); }
break;
case Dictionary<int, object?> dio: // actorNr -> props as Hashtable with int keys
U8((byte)'h'); I16((short)dio.Count);
foreach (var e in dio) { Value(e.Key); Value(e.Value); }
break;
case Dictionary<object, object?> doo:
U8((byte)'h'); I16((short)doo.Count);
foreach (var e in doo) { Value(e.Key); Value(e.Value); }
break;
default: throw new NotSupportedException($"GpBinary16: cannot serialize {v.GetType()}");
}
}
/// <summary>Write only the data of a value whose type byte was already written (typed arrays/dictionaries).</summary>
private void ValueBody(object? v, byte t)
{
// Re-use Value() and drop the leading type byte it wrote.
int start = Length;
Value(v);
if (_b[start] != t)
{
// Element type mismatch (e.g. int in a 'k' array) - fix up the type byte position.
throw new InvalidDataException($"GpBinary16: element type {(char)_b[start]} does not match array type {(char)t}");
}
Buffer.BlockCopy(_b, start + 1, _b, start, Length - start - 1);
Length--;
}
public void Params(IReadOnlyDictionary<byte, object?> p)
{
I16((short)p.Count);
foreach (var e in p) { U8(e.Key); Value(e.Value); }
}
}
public static byte[] OperationResponseMessage(byte opCode, short returnCode, string? debug, IReadOnlyDictionary<byte, object?> p, bool internalOp = false)
{
var w = new Writer();
w.U8(Magic); w.U8(internalOp ? MsgInternalOperationResponse : MsgOperationResponse);
w.U8(opCode); w.I16(returnCode); w.Value(debug); w.Params(p);
return w.ToArray();
}
public static byte[] EventMessage(byte eventCode, IReadOnlyDictionary<byte, object?> p)
{
var w = new Writer();
w.U8(Magic); w.U8(MsgEvent);
w.U8(eventCode); w.Params(p);
return w.ToArray();
}
/// <summary>Body (after the 0xF3 + type header) of an operation response - what gets encrypted when a reply must be.</summary>
public static byte[] OperationResponseBody(byte opCode, short returnCode, string? debug, IReadOnlyDictionary<byte, object?> p)
{
var w = new Writer();
w.U8(opCode); w.I16(returnCode); w.Value(debug); w.Params(p);
return w.ToArray();
}
public static byte[] InitResponse() => new byte[] { Magic, MsgInitResponse, 0 };
public static string Describe(object? v, int depth = 0)
{
switch (v)
{
case null: return "null";
case string s: return $"\"{s}\"";
case byte[] x: return $"byte[{x.Length}]";
case int[] n: return "[" + string.Join(",", n) + "]";
case string[] a: return "[" + string.Join(",", a.Select(s => "\"" + s + "\"")) + "]";
case Hashtable h:
if (depth > 2) return $"{{…{h.Count}}}";
return "{" + string.Join(", ", h.Cast<DictionaryEntry>().Select(e => Describe(e.Key, depth + 1) + ":" + Describe(e.Value, depth + 1))) + "}";
case TypedDictionary d: return $"D[{d.Entries.Count}]";
case TypedArray ta: return $"{(char)ta.ElemType}[{ta.Items.Length}]";
case object?[] z: return "(" + string.Join(", ", z.Select(e => Describe(e, depth + 1))) + ")";
case CustomValue c: return $"custom'{(char)c.Code}'[{c.Data.Length}]";
default: return v.ToString() ?? "?";
}
}
}
+175
View File
@@ -0,0 +1,175 @@
using System.Buffers.Binary;
using System.Collections;
using System.Text;
namespace CampusNet.Protocol;
/// <summary>
/// Photon GpBinary 1.8 - what the Photon Voice / newer client libraries speak (the PUN connections
/// use 1.6). Compact: counts are varints, ints are zig-zag varints, type codes are numeric.
/// Enough of it to run the name-server hop for the voice client and to hand it addresses.
/// </summary>
public static class GpBinary18
{
// GpType
private const byte TUnknown = 0, TBoolean = 2, TByte = 3, TShort = 4, TFloat = 5, TDouble = 6, TString = 7, TNull = 8,
TCompressedInt = 9, TCompressedLong = 10, TInt1 = 11, TInt1_ = 12, TInt2 = 13, TInt2_ = 14, TL1 = 15, TL1_ = 16,
TL2 = 17, TL2_ = 18, TCustom = 19, TDictionary = 20, THashtable = 21, TObjectArray = 23, TOperationRequest = 24,
TOperationResponse = 25, TEventData = 26, TBooleanFalse = 27, TBooleanTrue = 28, TShortZero = 29, TIntZero = 30,
TLongZero = 31, TFloatZero = 32, TDoubleZero = 33, TByteZero = 34, TArray = 64, TByteArray = 67, TCustomSlim = 128;
public ref struct Reader
{
private readonly ReadOnlySpan<byte> _b; public int Pos;
public Reader(ReadOnlySpan<byte> b, int pos = 0) { _b = b; Pos = pos; }
public byte U8() => _b[Pos++];
public uint VarUInt() { uint v = 0; int shift = 0; while (true) { byte b = U8(); v |= (uint)(b & 0x7F) << shift; if ((b & 0x80) == 0) return v; shift += 7; } }
public ulong VarULong() { ulong v = 0; int shift = 0; while (true) { byte b = U8(); v |= (ulong)(b & 0x7F) << shift; if ((b & 0x80) == 0) return v; shift += 7; } }
public int ZigInt() { uint u = VarUInt(); return (int)(u >> 1) ^ -(int)(u & 1); }
public long ZigLong() { ulong u = VarULong(); return (long)(u >> 1) ^ -(long)(u & 1); }
public short I16() { var v = BinaryPrimitives.ReadInt16BigEndian(_b.Slice(Pos)); Pos += 2; return v; }
public float F32() { var v = BinaryPrimitives.ReadSingleBigEndian(_b.Slice(Pos)); Pos += 4; return v; }
public double F64() { var v = BinaryPrimitives.ReadDoubleBigEndian(_b.Slice(Pos)); Pos += 8; return v; }
public byte[] Bytes(int n) { var v = _b.Slice(Pos, n).ToArray(); Pos += n; return v; }
public string Str() { int n = (int)VarUInt(); var s = Encoding.UTF8.GetString(_b.Slice(Pos, n)); Pos += n; return s; }
public object? Value() => Value(U8());
public object? Value(byte t)
{
switch (t)
{
case TNull: return null;
case TBoolean: return U8() != 0;
case TBooleanFalse: return false;
case TBooleanTrue: return true;
case TByte: return U8();
case TByteZero: return (byte)0;
case TShort: return I16();
case TShortZero: return (short)0;
case TFloat: return F32();
case TFloatZero: return 0f;
case TDouble: return F64();
case TDoubleZero: return 0d;
case TString: return Str();
case TCompressedInt: return ZigInt();
case TIntZero: return 0;
case TInt1: return (int)U8();
case TInt1_: return -(int)U8();
case TInt2: return (int)(ushort)I16();
case TInt2_: return -(int)(ushort)I16();
case TCompressedLong: return ZigLong();
case TLongZero: return 0L;
case TL1: return (long)U8();
case TL1_: return -(long)U8();
case TL2: return (long)(ushort)I16();
case TL2_: return -(long)(ushort)I16();
case TByteArray: { int n = (int)VarUInt(); return Bytes(n); }
case THashtable:
{
int n = (int)VarUInt(); var h = new Hashtable(n);
for (int i = 0; i < n; i++) { var k = Value(); var v = Value(); if (k != null) h[k] = v; }
return h;
}
case TDictionary:
{
byte kt = U8(), vt = U8(); int n = (int)VarUInt();
var d = new TypedDictionary { KeyType = kt, ValueType = vt };
for (int i = 0; i < n; i++) { var k = kt == TUnknown ? Value() : Value(kt); var v = vt == TUnknown ? Value() : Value(vt); d.Entries.Add(new(k, v)); }
return d;
}
case TObjectArray: { int n = (int)VarUInt(); var a = new object?[n]; for (int i = 0; i < n; i++) a[i] = Value(); return a; }
case TCustom: { byte code = U8(); int n = (int)VarUInt(); return new CustomValue(code, Bytes(n)); }
default:
if ((t & TCustomSlim) != 0) { byte code = (byte)(t & 0x7F); int n = (int)VarUInt(); return new CustomValue(code, Bytes(n)); }
if ((t & TArray) != 0)
{
int n = (int)VarUInt(); byte et = (byte)(t & ~TArray);
if (et == TString) { var a = new string[n]; for (int i = 0; i < n; i++) a[i] = Str(); return a; }
if (et == TCompressedInt) { var a = new int[n]; for (int i = 0; i < n; i++) a[i] = ZigInt(); return a; }
var arr = new object?[n];
for (int i = 0; i < n; i++) arr[i] = Value(et);
return new TypedArray(et, arr);
}
throw new InvalidDataException($"GpBinary18: unknown type {t} at {Pos - 1}");
}
}
public Dictionary<byte, object?> Params()
{
int n = U8(); var d = new Dictionary<byte, object?>(n);
for (int i = 0; i < n; i++) { byte k = U8(); d[k] = Value(); }
return d;
}
}
public static GpBinary16.OperationRequest ReadOperationRequest(ReadOnlySpan<byte> body)
{
var r = new Reader(body);
var op = new GpBinary16.OperationRequest { Code = r.U8() };
op.Params = r.Params();
return op;
}
public sealed class Writer
{
private byte[] _b = new byte[256]; public int Length;
private void Ensure(int n) { if (Length + n > _b.Length) Array.Resize(ref _b, Math.Max(_b.Length * 2, Length + n)); }
public void U8(byte v) { Ensure(1); _b[Length++] = v; }
public void Raw(ReadOnlySpan<byte> s) { Ensure(s.Length); s.CopyTo(_b.AsSpan(Length)); Length += s.Length; }
public void VarUInt(uint v) { while (v >= 0x80) { U8((byte)(v | 0x80)); v >>= 7; } U8((byte)v); }
public void I16(short v) { Ensure(2); BinaryPrimitives.WriteInt16BigEndian(_b.AsSpan(Length), v); Length += 2; }
public void Str(string s) { var n = Encoding.UTF8.GetByteCount(s); VarUInt((uint)n); Ensure(n); Encoding.UTF8.GetBytes(s, _b.AsSpan(Length)); Length += n; }
public byte[] ToArray() => _b.AsSpan(0, Length).ToArray();
public void Value(object? v)
{
switch (v)
{
case null: U8(TNull); break;
case bool o: U8(o ? TBooleanTrue : TBooleanFalse); break;
case byte b: if (b == 0) U8(TByteZero); else { U8(TByte); U8(b); } break;
case short k: if (k == 0) U8(TShortZero); else { U8(TShort); I16(k); } break;
case int i:
if (i == 0) U8(TIntZero);
else { U8(TCompressedInt); uint z = (uint)((i << 1) ^ (i >> 31)); VarUInt(z); }
break;
case long l:
if (l == 0) U8(TLongZero);
else { U8(TCompressedLong); ulong z = (ulong)((l << 1) ^ (l >> 63)); while (z >= 0x80) { U8((byte)(z | 0x80)); z >>= 7; } U8((byte)z); }
break;
case float f: if (f == 0) U8(TFloatZero); else { U8(TFloat); Ensure(4); BinaryPrimitives.WriteSingleBigEndian(_b.AsSpan(Length), f); Length += 4; } break;
case double d: if (d == 0) U8(TDoubleZero); else { U8(TDouble); Ensure(8); BinaryPrimitives.WriteDoubleBigEndian(_b.AsSpan(Length), d); Length += 8; } break;
case string s: U8(TString); Str(s); break;
case byte[] x: U8(TByteArray); VarUInt((uint)x.Length); Raw(x); break;
case string[] a: U8(TArray | TString); VarUInt((uint)a.Length); foreach (var e in a) Str(e); break;
case int[] n: U8(TArray | TCompressedInt); VarUInt((uint)n.Length); foreach (var e in n) { uint z = (uint)((e << 1) ^ (e >> 31)); VarUInt(z); } break;
case Hashtable h: U8(THashtable); VarUInt((uint)h.Count); foreach (DictionaryEntry e in h) { Value(e.Key); Value(e.Value); } break;
case object?[] z: U8(TObjectArray); VarUInt((uint)z.Length); foreach (var e in z) Value(e); break;
case CustomValue c: U8(TCustom); U8(c.Code); VarUInt((uint)c.Data.Length); Raw(c.Data); break;
default: throw new NotSupportedException($"GpBinary18: cannot serialize {v.GetType()}");
}
}
public void Params(IReadOnlyDictionary<byte, object?> p)
{
U8((byte)p.Count);
foreach (var e in p) { U8(e.Key); Value(e.Value); }
}
}
public static byte[] OperationResponseMessage(byte opCode, short returnCode, string? debug, IReadOnlyDictionary<byte, object?> p, bool internalOp = false)
{
var w = new Writer();
w.U8(GpBinary16.Magic); w.U8(internalOp ? GpBinary16.MsgInternalOperationResponse : GpBinary16.MsgOperationResponse);
w.U8(opCode); w.I16(returnCode); w.Value(debug); w.Params(p);
return w.ToArray();
}
public static byte[] EventMessage(byte eventCode, IReadOnlyDictionary<byte, object?> p)
{
var w = new Writer();
w.U8(GpBinary16.Magic); w.U8(GpBinary16.MsgEvent);
w.U8(eventCode); w.Params(p);
return w.ToArray();
}
}
+97
View File
@@ -0,0 +1,97 @@
using System.Globalization;
using System.Numerics;
using System.Security.Cryptography;
namespace CampusNet.Protocol;
/// <summary>
/// The client's InitEncryption handshake: Diffie-Hellman over the 768-bit Oakley group (the client's
/// public key is 96 bytes - see the capture), shared secret hashed with SHA-256 into an AES-256-CBC
/// key (zero IV, PKCS7). The exact byte order Photon feeds SHA-256 isn't verifiable from a capture, so
/// the first encrypted message is tried against every candidate key and the one that yields a
/// parseable body is kept (see TryDecryptAdaptive).
/// </summary>
public sealed class PhotonCrypto
{
// RFC 2409 Oakley group 1 (768-bit).
private static readonly BigInteger Prime = BigInteger.Parse(
"0FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DD" +
"EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A63A3620FFFFFFFFFFFFFFFF",
NumberStyles.HexNumber);
public static int Generator = int.TryParse(Environment.GetEnvironmentVariable("CAMPUSNET_DH_GENERATOR"), out var g) ? g : 22;
private readonly BigInteger _priv;
public readonly byte[] PublicKey; // 96 bytes, big-endian unsigned
private BigInteger _shared;
private byte[]? _aesKey; // locked in once a decrypt succeeds
private List<byte[]>? _candidates;
public PhotonCrypto()
{
var rnd = RandomNumberGenerator.GetBytes(64);
_priv = new BigInteger(rnd, isUnsigned: true, isBigEndian: true) % (Prime - 2) + 1;
var pub = BigInteger.ModPow(Generator, _priv, Prime);
PublicKey = ToFixed(pub, 96);
}
public bool Ready => _aesKey != null || _candidates != null;
/// <summary>Derive the candidate AES keys from the client's public key.</summary>
public void DeriveSharedKey(byte[] clientPublicKey)
{
var a = new BigInteger(clientPublicKey, isUnsigned: true, isBigEndian: true);
_shared = BigInteger.ModPow(a, _priv, Prime);
var be = _shared.ToByteArray(isUnsigned: true, isBigEndian: true);
var le = _shared.ToByteArray(isUnsigned: true, isBigEndian: false);
var leSigned = _shared.ToByteArray(isUnsigned: false, isBigEndian: false);
var be96 = ToFixed(_shared, 96);
_candidates = new List<byte[]>();
foreach (var bytes in new[] { be, be96, le, leSigned })
{
var k = SHA256.HashData(bytes);
if (!_candidates.Any(c => c.AsSpan().SequenceEqual(k))) _candidates.Add(k);
}
_aesKey = null;
}
private static byte[] ToFixed(BigInteger v, int len)
{
var raw = v.ToByteArray(isUnsigned: true, isBigEndian: true);
if (raw.Length == len) return raw;
var outp = new byte[len];
if (raw.Length > len) Buffer.BlockCopy(raw, raw.Length - len, outp, 0, len);
else Buffer.BlockCopy(raw, 0, outp, len - raw.Length, raw.Length);
return outp;
}
private static byte[] AesRun(byte[] key, byte[] data, bool encrypt)
{
using var aes = Aes.Create();
aes.Key = key; aes.IV = new byte[16]; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7;
using var t = encrypt ? aes.CreateEncryptor() : aes.CreateDecryptor();
return t.TransformFinalBlock(data, 0, data.Length);
}
/// <summary>Decrypt an encrypted message body; on first use, pick the candidate key that yields a body `validate` accepts.</summary>
public byte[]? TryDecryptAdaptive(byte[] cipher, Func<byte[], bool> validate)
{
if (_aesKey != null)
{
try { return AesRun(_aesKey, cipher, false); } catch { return null; }
}
if (_candidates == null) return null;
foreach (var k in _candidates)
{
byte[] plain;
try { plain = AesRun(k, cipher, false); } catch { continue; }
if (validate(plain)) { _aesKey = k; return plain; }
}
return null;
}
public byte[]? Encrypt(byte[] plain)
{
var k = _aesKey ?? _candidates?.FirstOrDefault();
return k == null ? null : AesRun(k, plain, true);
}
}
+66
View File
@@ -0,0 +1,66 @@
namespace CampusNet.Services;
/// <summary>Photon LoadBalancing operation / parameter / event codes (standard values; the ones in the capture are marked).</summary>
public static class Op
{
public const byte GetRegions = 220, Authenticate = 230, AuthenticateOnce = 231, JoinLobby = 229, LeaveLobby = 228,
CreateGame = 227, JoinGame = 226, JoinRandomGame = 225, Leave = 254, RaiseEvent = 253, SetProperties = 252,
GetProperties = 251, ChangeGroups = 248, FindFriends = 222, GetLobbyStats = 221, WebRpc = 219,
ServerSettings = 218, GetGameList = 217;
}
public static class P
{
public const byte RoomName = 255, ActorNr = 254, TargetActorNr = 253, ActorList = 252, Properties = 251,
Broadcast = 250, PlayerProperties = 249, GameProperties = 248, Cache = 247, ReceiverGroup = 246,
Data = 245, Code = 244, CleanupCacheOnLeave = 241, Group = 240, Remove = 239, Add = 238,
PublishUserId = 239, EmptyRoomTtl = 236, PlayerTtl = 235, IsInactive = 233, CheckUserOnJoin = 232,
ExpectedValues = 231, Address = 230, PeerCount = 229, GameCount = 228, MasterPeerCount = 227,
UserId = 225, ApplicationId = 224, MatchMakingType = 223, GameList = 222, Secret = 221,
AppVersion = 220, AzureNodeInfo = 219, AzureLocalNodeId = 218, ClientAuthenticationType = 217,
ClientAuthenticationData = 216, JoinMode = 215, ClientAuthenticationParams = 214, LobbyName = 213,
LobbyType = 212, LobbyStats = 211, Region = 210, UriPath = 209, RpcCallParams = 208,
RpcCallRetCode = 207, RpcCallRetMessage = 206, CacheSliceIndex = 205, Plugins = 204,
MasterClientId = 203, RoomOptionFlags = 191;
}
public static class Ev
{
public const byte Join = 255, Leave = 254, PropertiesChanged = 253, ErrorInfo = 251, CacheSliceChanged = 224,
AppStats = 226, GameList = 230, GameListUpdate = 229;
}
/// <summary>Well-known game (room) property keys.</summary>
public static class GameProp
{
public const byte MaxPlayers = 255, IsVisible = 254, IsOpen = 253, PlayerCount = 252, Removed = 251,
PropsListedInLobby = 250, CleanupCacheOnLeave = 249, MasterClientId = 248, ExpectedUsers = 247,
PlayerTtl = 246, EmptyRoomTtl = 245;
}
/// <summary>Well-known actor (player) property keys.</summary>
public static class PlayerProp
{
public const byte NickName = 255, IsInactive = 254, UserId = 253;
}
public static class Rc
{
public const short Ok = 0, OperationNotAllowedInCurrentState = -3, InvalidOperation = -2, InternalServerError = -1,
InvalidAuthentication = 32767, GameIdAlreadyExists = 32766, GameFull = 32765, GameClosed = 32764,
NoRandomMatchFound = 32760, GameDoesNotExist = 32758, MaxCcuReached = 32757, InvalidRegion = 32756,
CustomAuthenticationFailed = 32755, AuthenticationTicketExpired = 32753, PluginReportedError = 32752,
PluginMismatch = 32751, JoinFailedPeerAlreadyJoined = 32750, JoinFailedFoundInactiveJoiner = 32749,
JoinFailedWithRejoinerNotFound = 32748, JoinFailedFoundExcludedUserId = 32747,
JoinFailedFoundActiveJoiner = 32746, HttpLimitReached = 32745, ExternalHttpCallFailed = 32744,
SlotError = 32742, InvalidEncryptionParameters = 32741;
}
public static class Cache
{
public const byte DoNotCache = 0, MergeCache = 1, ReplaceCache = 2, RemoveCache = 3, AddToRoomCache = 4,
AddToRoomCacheGlobal = 5, RemoveFromRoomCache = 6, RemoveFromRoomCacheForActorsLeft = 7,
SliceIncreaseIndex = 10, SliceSetIndex = 11, SlicePurgeIndex = 12, SlicePurgeUpToIndex = 13;
}
public static class Receivers { public const byte Others = 0, All = 1, MasterClient = 2; }
+158
View File
@@ -0,0 +1,158 @@
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}"); }
}
+510
View File
@@ -0,0 +1,510 @@
using System.Collections;
using CampusNet.Net;
using CampusNet.Protocol;
namespace CampusNet.Services;
/// <summary>The game-server side: rooms, actors, events (with PUN's event cache), properties, groups.</summary>
public static class GameService
{
public static void Handle(Connection c, GpBinary16.OperationRequest op, bool reliable)
{
int payload = op.Code == Op.RaiseEvent ? op.PayloadBytes : 0;
if (payload > Guard.MaxEventBytes)
{
Log.Warn($"{c.Tag}: event of {payload} bytes refused (cap {Guard.MaxEventBytes})");
c.SendError(op.Code, Rc.InvalidOperation, "Event too large");
return;
}
if (!c.Charge(payload, $"op {op.Code}")) return;
switch (op.Code)
{
case Op.CreateGame:
case Op.JoinGame: Join(c, op); return;
case Op.Leave: Leave(c, op); return;
case Op.RaiseEvent: RaiseEvent(c, op, reliable); return;
case Op.SetProperties: SetProperties(c, op); return;
case Op.GetProperties: GetProperties(c, op); return;
case Op.ChangeGroups: ChangeGroups(c, op); return;
case Op.ServerSettings: c.SendResponse(op.Code, new()); return;
default:
Log.Warn($"{c.Tag}: game op {op.Code} not implemented {LoadBalancingApp.DescribeParams(op.Params)}");
c.SendError(op.Code, Rc.InvalidOperation, "Operation not supported");
return;
}
}
// ---------------------------------------------------------------- join / create
private static void Join(Connection c, GpBinary16.OperationRequest op)
{
if (c.Room != null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Already in a game"); return; }
string name = op.Params.GetValueOrDefault(P.RoomName) as string ?? "";
if (name.Length == 0) { c.SendError(op.Code, Rc.InvalidOperation, "Missing game id"); return; }
int joinMode = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.JoinMode)); // 0 default, 1 CreateIfNotExists, 2 JoinOrRejoin, 3 RejoinOnly
Room room;
bool created;
if (op.Code == Op.CreateGame) room = Rooms.GetOrCreate(name, out created);
else
{
var existing = Rooms.Get(name);
if (existing == null && joinMode != 1) { c.SendError(op.Code, Rc.GameDoesNotExist, "Game does not exist"); return; }
room = existing ?? Rooms.GetOrCreate(name, out _);
created = existing == null;
}
Actor actor;
var joinerProps = op.Params.GetValueOrDefault(P.PlayerProperties) as Hashtable ?? new Hashtable();
var cachedEvents = new List<CachedEvent>();
var others = new List<Connection>();
lock (room.Sync)
{
if (created || room.Actors.Count == 0 && room.Props.Count == 0)
{
if (op.Params.GetValueOrDefault(P.GameProperties) is Hashtable gp) Room.Merge(room.Props, gp);
room.PlayerTtl = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.PlayerTtl));
room.EmptyRoomTtl = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.EmptyRoomTtl));
if (room.Props[GameProp.PlayerTtl] is int pt) room.PlayerTtl = pt;
if (room.Props[GameProp.EmptyRoomTtl] is int et) room.EmptyRoomTtl = et;
Log.Info($"{c.Tag}: created room '{name}' props={GpBinary16.Describe(room.Props)}");
}
var rejoin = room.FindByUserId(c.UserId!);
if (rejoin != null)
{
if (!rejoin.Inactive && rejoin.Conn != null && rejoin.Conn != c)
{
// Same user connected twice: the newer connection wins (the old one is stale).
Log.Warn($"{c.Tag}: user already in '{name}' as actor {rejoin.Nr} - replacing the old connection");
var old = rejoin.Conn; old.Room = null; old.Actor = null;
try { old.Disconnect("replaced by a new connection"); } catch { }
}
actor = rejoin;
actor.Inactive = false; actor.Conn = c;
Room.Merge(actor.Props, joinerProps);
}
else
{
if (!room.IsOpen && joinMode != 3) { c.SendError(op.Code, Rc.GameClosed, "Game closed"); return; }
if (room.MaxPlayers > 0 && room.ActiveCount >= room.MaxPlayers) { c.SendError(op.Code, Rc.GameFull, "Game full"); return; }
actor = new Actor { Nr = room.NextActorNr++, UserId = c.UserId!, Conn = c };
Room.Merge(actor.Props, joinerProps);
if (!actor.Props.ContainsKey(PlayerProp.NickName) && c.NickName.Length > 0) actor.Props[PlayerProp.NickName] = c.NickName;
room.Actors[actor.Nr] = actor;
}
c.Room = room; c.Actor = actor;
foreach (var a in room.Actors.Values)
if (a.Conn != null && a.Conn != c && !a.Inactive) others.Add(a.Conn);
cachedEvents.AddRange(room.Cache);
foreach (var a in room.Actors.Values) cachedEvents.AddRange(a.OwnCache);
// Response: my actor number, everyone's properties, the room's, and the active actor list.
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[P.ActorNr] = actor.Nr,
[P.ActorList] = room.ActorList(),
[P.PlayerProperties] = room.AllPlayerProps(),
[P.GameProperties] = room.GamePropsForClient(),
[P.RoomName] = name
});
// Join event to everyone including the joiner (PUN sets up its own player from it).
var joinEv = new Dictionary<byte, object?>
{
[P.ActorNr] = actor.Nr,
[P.PlayerProperties] = Room.PlayerPropsForClient(actor),
[P.ActorList] = room.ActorList()
};
c.SendEvent(Ev.Join, joinEv);
foreach (var o in others) o.SendEvent(Ev.Join, joinEv);
}
// Replay the cache so the late joiner sees every instantiate / cached RPC in order.
foreach (var ev in cachedEvents)
c.SendEvent(ev.Code, EventParams(ev.ActorNr, ev.Data));
Log.Info($"{c.Tag}: joined '{name}' as actor {actor.Nr} ({room.ActiveCount} active, {cachedEvents.Count} cached events replayed)");
}
private static Dictionary<byte, object?> EventParams(int senderActor, object? data)
{
var p = new Dictionary<byte, object?> { [P.ActorNr] = senderActor };
if (data != null) p[P.Data] = data;
return p;
}
// ---------------------------------------------------------------- leave
private static void Leave(Connection c, GpBinary16.OperationRequest op)
{
bool inactive = op.Params.GetValueOrDefault(P.IsInactive) is bool b && b;
RemoveFromRoom(c, inactive, "leave");
c.SendResponse(op.Code, new());
}
public static void OnConnectionLost(Connection c) => RemoveFromRoom(c, becomeInactive: true, why: "disconnect");
private static void RemoveFromRoom(Connection c, bool becomeInactive, string why)
{
var room = c.Room; var actor = c.Actor;
if (room == null || actor == null) return;
c.Room = null; c.Actor = null;
List<Connection> others;
int master; bool removed;
lock (room.Sync)
{
if (actor.Conn != c) return; // already superseded
actor.Conn = null;
bool keep = becomeInactive && room.PlayerTtl != 0;
if (keep) { actor.Inactive = true; actor.InactiveSince = DateTime.UtcNow; removed = false; }
else
{
room.Actors.Remove(actor.Nr); removed = true;
// Cached events by this actor go unless they were cached "global".
room.Cache.RemoveAll(e => e.ActorNr == actor.Nr);
}
master = room.MasterClientId;
others = room.Actors.Values.Where(a => a.Conn != null && !a.Inactive).Select(a => a.Conn!).ToList();
if (room.ActiveCount == 0) room.EmptySince = DateTime.UtcNow;
}
var ev = new Dictionary<byte, object?>
{
[P.ActorNr] = actor.Nr,
[P.IsInactive] = !removed,
[P.MasterClientId] = master
};
foreach (var o in others) o.SendEvent(Ev.Leave, ev);
Log.Info($"{c.Tag}: left '{room.Name}' ({why}, {(removed ? "removed" : "inactive")}), master now {master}");
MaybeRemoveRoom(room);
}
private static void MaybeRemoveRoom(Room room)
{
lock (room.Sync)
{
if (room.Actors.Values.Any(a => !a.Inactive)) return;
if (room.EmptyRoomTtl > 0 && (DateTime.UtcNow - room.EmptySince).TotalMilliseconds < room.EmptyRoomTtl) return;
if (room.Actors.Values.Any(a => a.Inactive) && room.PlayerTtl < 0) return; // PlayerTtl -1 = keep forever
}
Rooms.Remove(room.Name);
Log.Info($"room '{room.Name}' removed (empty)");
}
private static DateTime _lastStats = DateTime.MinValue;
/// <summary>Periodic: expire inactive actors past PlayerTtl and empty rooms past EmptyRoomTtl.</summary>
public static void Sweep()
{
bool stats = (DateTime.UtcNow - _lastStats).TotalSeconds >= 15;
if (stats) _lastStats = DateTime.UtcNow;
foreach (var room in Rooms.All())
{
// Every 15 s, for rooms with company: who raised what, and where it went. This is the
// trace for "the other player doesn't move" - it shows whether the mover's events
// arrive at all, whether they are unreliable, which interest group they carry, and
// whether anyone was subscribed to receive them.
if (stats)
{
lock (room.Sync)
{
if (room.ActiveCount >= 2)
{
foreach (var a in room.Actors.Values)
{
if (a.Raised == 0 && a.Conn == null) continue;
string codes = string.Join(" ", a.RaisedByCode.OrderByDescending(kv => kv.Value).Take(8).Select(kv => $"{kv.Key}:{kv.Value}"));
string groups = a.RaisedByGroup.Count == 0 ? "-" : string.Join(" ", a.RaisedByGroup.OrderByDescending(kv => kv.Value).Take(6).Select(kv => $"g{kv.Key}:{kv.Value}"));
string subs = a.Groups.Count == 0 ? "none" : (a.Groups.Count > 12 ? $"{a.Groups.Count} groups" : string.Join(",", a.Groups.OrderBy(g => g)));
Log.Info($"stats '{room.Name}' actor {a.Nr} u{a.UserId}{(a.Inactive ? " (inactive)" : "")}: raised {a.Raised} ({a.RaisedUnreliable} unreliable), delivered {a.Delivered}, no-recipient {a.DroppedNoRecipient}, codes [{codes}], sent-in-groups [{groups}], subscribed [{subs}], recv {a.Conn?.Sent ?? 0} msgs, rtt {a.Conn?.Peer.Rtt ?? 0}ms");
a.Raised = a.RaisedUnreliable = a.Delivered = a.DroppedNoRecipient = 0;
a.RaisedByCode.Clear(); a.RaisedByGroup.Clear();
}
}
}
}
List<Connection> others = new(); List<int> expired = new(); int master;
lock (room.Sync)
{
foreach (var a in room.Actors.Values.Where(a => a.Inactive).ToList())
{
if (room.PlayerTtl > 0 && (DateTime.UtcNow - a.InactiveSince).TotalMilliseconds > room.PlayerTtl)
{
room.Actors.Remove(a.Nr); room.Cache.RemoveAll(e => e.ActorNr == a.Nr); expired.Add(a.Nr);
}
}
master = room.MasterClientId;
if (expired.Count > 0) others = room.Actors.Values.Where(a => a.Conn != null && !a.Inactive).Select(a => a.Conn!).ToList();
}
foreach (var nr in expired)
foreach (var o in others)
o.SendEvent(Ev.Leave, new Dictionary<byte, object?> { [P.ActorNr] = nr, [P.IsInactive] = false, [P.MasterClientId] = master });
MaybeRemoveRoom(room);
}
}
// ---------------------------------------------------------------- events
private static readonly string[] TraceRooms = (Environment.GetEnvironmentVariable("CAMPUSNET_TRACE_ROOM") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static readonly HashSet<string> TraceUsers = new((Environment.GetEnvironmentVariable("CAMPUSNET_TRACE_USER") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
private static readonly string TraceRoom = (TraceRooms.Length > 0 || TraceUsers.Count > 0) ? "on" : "";
private static bool Traced(Room room) => TraceRooms.Any(t => room.Name.Contains(t, StringComparison.OrdinalIgnoreCase));
// A traced user's rooms are traced too (private instances carry no room id in their name).
private static bool Traced(Room room, Actor me) => Traced(room) || TraceUsers.Contains(me.UserId ?? "") || room.Actors.Values.Any(a => TraceUsers.Contains(a.UserId ?? ""));
/// <summary>The actor numbers in a RaiseEvent's target list, whatever container they arrived in.</summary>
private static int[]? ActorTargets(object? v)
{
switch (v)
{
case null: return null;
case int[] n: return n;
case byte[] b: return b.Select(x => (int)x).ToArray();
case short[] sh: return sh.Select(x => (int)x).ToArray();
case TypedArray ta: return ta.Items.Select(LoadBalancingApp.ToInt).ToArray();
case object?[] oa: return oa.Select(LoadBalancingApp.ToInt).ToArray();
default:
Log.Warn($"RaiseEvent: unrecognised target-actor list type {v.GetType().Name} - treated as none");
return null;
}
}
private static void RaiseEvent(Connection c, GpBinary16.OperationRequest op, bool reliable)
{
var room = c.Room; var me = c.Actor;
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
byte code = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Code));
var data = op.Params.GetValueOrDefault(P.Data);
byte cache = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Cache));
byte receivers = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.ReceiverGroup));
byte group = (byte)LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.Group));
// Target actors (252). PUN spells this int[] ('n'), which is all that was matched before;
// a typed array of ints / an object[] / a byte[] decodes to something else and used to be
// silently treated as "no targets" - the event then went by receiver group instead.
var targets = ActorTargets(op.Params.GetValueOrDefault(P.ActorList));
var recipients = new List<Connection>();
lock (room.Sync)
{
bool isMaster = me.Nr == room.MasterClientId;
switch (cache)
{
case Cache.AddToRoomCache:
case Cache.AddToRoomCacheGlobal:
if (room.Cache.Count >= Guard.MaxRoomCache)
{
if (room.Cache.Count == Guard.MaxRoomCache) Log.Warn($"{c.Tag}: room '{room.Name}' cache full ({Guard.MaxRoomCache}) - new cached events are delivered but not kept");
break;
}
room.Cache.Add(new CachedEvent { ActorNr = cache == Cache.AddToRoomCacheGlobal ? 0 : me.Nr, Code = code, Data = data, Group = group }); break;
case Cache.RemoveFromRoomCache:
// Only the master client may purge OTHER actors' cached events (PUN's DestroyAll /
// scene-object cleanup); everyone else can only take back their own, so one client
// cannot wipe everybody's instantiations out from under late joiners.
room.Cache.RemoveAll(e => (isMaster || e.ActorNr == me.Nr) && CacheFilterMatch(e, targets, op.Params.ContainsKey(P.Code) ? code : null, data as Hashtable)); break;
case Cache.RemoveFromRoomCacheForActorsLeft:
room.Cache.RemoveAll(e => e.ActorNr != 0 && !room.Actors.ContainsKey(e.ActorNr)); break;
case Cache.MergeCache:
{
var existing = me.OwnCache.FirstOrDefault(e => e.Code == code);
if (existing != null && existing.Data is Hashtable eh && data is Hashtable dh) Room.Merge(eh, dh);
else if (existing != null) existing.Data = data;
else if (me.OwnCache.Count < Guard.MaxActorCache) me.OwnCache.Add(new CachedEvent { ActorNr = me.Nr, Code = code, Data = data, Group = group });
break;
}
case Cache.ReplaceCache:
me.OwnCache.RemoveAll(e => e.Code == code);
if (data != null && me.OwnCache.Count < Guard.MaxActorCache) me.OwnCache.Add(new CachedEvent { ActorNr = me.Nr, Code = code, Data = data, Group = group });
break;
case Cache.RemoveCache:
me.OwnCache.RemoveAll(e => e.Code == code); break;
}
if (targets != null)
{
foreach (var nr in targets)
if (room.Actors.TryGetValue(nr, out var a) && a.Conn != null && !a.Inactive) recipients.Add(a.Conn);
}
else if (receivers == Receivers.MasterClient)
{
if (room.Actors.TryGetValue(room.MasterClientId, out var m) && m.Conn != null) recipients.Add(m.Conn);
}
else
{
foreach (var a in room.Actors.Values)
{
if (a.Conn == null || a.Inactive) continue;
if (a == me && receivers != Receivers.All) continue;
if (group != 0 && !a.Groups.Contains(group)) continue;
recipients.Add(a.Conn);
}
}
}
// A pure cache-manipulation call (no code / no data) has nothing to deliver.
bool deliver = op.Params.ContainsKey(P.Code) && !(cache >= Cache.RemoveFromRoomCache && cache <= Cache.RemoveFromRoomCacheForActorsLeft && data == null);
// Room trace (CAMPUSNET_TRACE_ROOM=<substring of the room name>): every non-transform event
// with its size, routing and - for RPC-shaped payloads - the readable strings inside (RR RPC
// method names are plain text on the wire). Used to see what a "Rooms 2.0" authority
// handshake actually sends, without the firehose of CAMPUSNET_DEBUG.
if (TraceRoom.Length > 0 && code != 28 && Traced(room, me))
{
string strs = "";
try
{
var found = new List<string>();
void Scan(object? o, int depth)
{
if (o == null || depth > 4 || found.Count >= 6) return;
if (o is string str) { if (str.Length >= 3 && str.Length <= 80) found.Add(str); return; }
if (o is byte[] bytes)
{
var sb = new System.Text.StringBuilder();
foreach (var bt in bytes)
{
if (bt >= 32 && bt < 127) sb.Append((char)bt);
else { if (sb.Length >= 5 && found.Count < 6) found.Add(sb.ToString()); sb.Clear(); }
}
if (sb.Length >= 5 && found.Count < 6) found.Add(sb.ToString());
return;
}
if (o is Hashtable ht) { foreach (DictionaryEntry e in ht) { Scan(e.Key, depth + 1); Scan(e.Value, depth + 1); } return; }
if (o is System.Collections.IEnumerable en) { int n = 0; foreach (var x in en) { if (n++ > 64) break; Scan(x, depth + 1); } }
}
Scan(data, 0);
if (found.Count > 0) strs = " strs=[" + string.Join(" | ", found) + "]";
}
catch { }
// Every op parameter with its decoded CLR type, so a receiver-group / target-list field
// that arrives in an unexpected encoding is visible instead of silently ignored.
string keys = "[" + string.Join(",", op.Params.Select(kv => $"{kv.Key}:{(kv.Value == null ? "null" : kv.Value is TypedArray ta ? $"TypedArray<{(char)ta.ElemType}>" : kv.Value.GetType().Name)}")) + "]";
Log.Info($"trace '{room.Name}' a{me.Nr} u{me.UserId}: ev {code} {op.PayloadBytes}B cache={cache} recv={receivers} group={group} targets=[{(targets == null ? "-" : string.Join(",", targets))}] -> {recipients.Count} recipient(s){(deliver ? "" : " (cache-only)")} keys={keys}{strs}");
}
if (deliver)
{
var msg = GpBinary16.EventMessage(code, EventParams(me.Nr, data));
foreach (var r in recipients) r.Send(msg, reliable);
}
lock (room.Sync)
{
me.Raised++; if (!reliable) me.RaisedUnreliable++;
me.RaisedByCode[code] = me.RaisedByCode.GetValueOrDefault(code) + 1;
if (group != 0) me.RaisedByGroup[group] = me.RaisedByGroup.GetValueOrDefault(group) + 1;
if (deliver) { me.Delivered += recipients.Count; if (recipients.Count == 0) me.DroppedNoRecipient++; }
}
if (reliable) c.SendResponse(op.Code, new()); // PUN expects the ack'd response for reliable raises
}
private static bool CacheFilterMatch(CachedEvent e, int[]? actors, byte? code, Hashtable? dataFilter)
{
if (actors != null && actors.Length > 0 && !actors.Contains(e.ActorNr)) return false;
if (code != null && e.Code != code) return false;
if (dataFilter != null)
{
if (e.Data is not Hashtable h) return false;
foreach (DictionaryEntry f in dataFilter)
if (!h.ContainsKey(f.Key) || !Room.ValuesEqual(h[f.Key], f.Value)) return false;
}
return true;
}
// ---------------------------------------------------------------- properties
private static void SetProperties(Connection c, GpBinary16.OperationRequest op)
{
var room = c.Room; var me = c.Actor;
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
var props = op.Params.GetValueOrDefault(P.Properties) as Hashtable ?? new Hashtable();
var expected = op.Params.GetValueOrDefault(P.ExpectedValues) as Hashtable;
bool broadcast = op.Params.GetValueOrDefault(P.Broadcast) is bool b && b;
int target = LoadBalancingApp.ToInt(op.Params.GetValueOrDefault(P.ActorNr)); // 0 = room properties
Hashtable changed; List<Connection> recipients = new();
lock (room.Sync)
{
Hashtable store;
if (target == 0) store = room.Props;
else
{
if (!room.Actors.TryGetValue(target, out var a)) { c.SendError(op.Code, Rc.InvalidOperation, "No such actor"); return; }
// A client writes its own player properties; only the master client may write
// another actor's (PUN never does this from a plain client, a modified one would -
// it is how a "bot" renames or re-flags other players).
if (target != me.Nr && me.Nr != room.MasterClientId)
{
Log.Warn($"{c.Tag}: tried to set actor {target}'s properties - refused");
c.SendError(op.Code, Rc.InvalidOperation, "Not your actor");
return;
}
store = a.Props;
}
if (!Room.CasMatches(store, expected))
{
c.SendError(op.Code, Rc.InvalidOperation, "CAS failed"); // PUN treats this rc as the CAS miss
return;
}
changed = Room.Merge(store, props);
if (target == 0 && changed.ContainsKey(GameProp.PlayerTtl) && changed[GameProp.PlayerTtl] is int pt) room.PlayerTtl = pt;
if (target == 0 && changed.ContainsKey(GameProp.EmptyRoomTtl) && changed[GameProp.EmptyRoomTtl] is int et) room.EmptyRoomTtl = et;
foreach (var a in room.Actors.Values)
if (a.Conn != null && !a.Inactive && (broadcast || a.Conn != c)) recipients.Add(a.Conn);
}
c.SendResponse(op.Code, new());
// Player-property writes are rare (join, avatar/state changes) - log the keys and sizes so a
// "others don't see my new outfit" report can be matched against what actually crossed the wire.
if (target != 0 || (TraceRoom.Length > 0 && Traced(room, me)))
{
var desc = string.Join(", ", changed.Keys.Cast<object>().Select(k => $"{k}={(changed[k] is byte[] ba ? ba.Length + "B" : changed[k] is string str ? str.Length + "ch" : changed[k] is Hashtable ? "table" : changed[k]?.ToString() ?? "null")}"));
Log.Info($"{c.Tag}: {(target == 0 ? "room" : "player")} props on {(target == 0 ? "room" : "actor " + target)} [{desc}] cas={(expected != null)} -> {recipients.Count} recipient(s){(broadcast ? " (broadcast)" : "")}");
}
if (broadcast || recipients.Count > 0)
{
var ev = new Dictionary<byte, object?> { [P.TargetActorNr] = target, [P.Properties] = changed, [P.ActorNr] = me.Nr };
var msg = GpBinary16.EventMessage(Ev.PropertiesChanged, ev);
foreach (var r in recipients) if (broadcast || r != c) r.Send(msg);
}
}
private static void GetProperties(Connection c, GpBinary16.OperationRequest op)
{
var room = c.Room;
if (room == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
var resp = new Dictionary<byte, object?>();
lock (room.Sync)
{
resp[P.GameProperties] = room.GamePropsForClient();
resp[P.PlayerProperties] = room.AllPlayerProps();
}
c.SendResponse(op.Code, resp);
}
private static void ChangeGroups(Connection c, GpBinary16.OperationRequest op)
{
var me = c.Actor; var room = c.Room;
if (room == null || me == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not in a game"); return; }
lock (room.Sync)
{
// null = all groups; empty array = none
if (op.Params.TryGetValue(P.Remove, out var rem))
{
if (rem == null) me.Groups.Clear();
else foreach (var g in AsBytes(rem)) me.Groups.Remove(g);
}
if (op.Params.TryGetValue(P.Add, out var add))
{
if (add == null) for (int g = 1; g < 256; g++) me.Groups.Add((byte)g);
else foreach (var g in AsBytes(add)) me.Groups.Add(g);
}
Log.Info($"{c.Tag}: groups now [{(me.Groups.Count > 12 ? me.Groups.Count + " groups" : string.Join(",", me.Groups.OrderBy(g => g)))}] (remove={(op.Params.TryGetValue(P.Remove, out var rr) ? (rr == null ? "all" : GpBinary16.Describe(rr)) : "-")} add={(op.Params.TryGetValue(P.Add, out var aa) ? (aa == null ? "all" : GpBinary16.Describe(aa)) : "-")})");
}
c.SendResponse(op.Code, new());
}
private static IEnumerable<byte> AsBytes(object v) => v switch
{
byte[] b => b,
TypedArray ta => ta.Items.Select(x => (byte)LoadBalancingApp.ToInt(x)),
int[] n => n.Select(x => (byte)x),
_ => Array.Empty<byte>()
};
}
+297
View File
@@ -0,0 +1,297 @@
using System.Collections;
using CampusNet.Net;
using CampusNet.Protocol;
namespace CampusNet.Services;
public enum Role { NameServer, Master, Game }
/// <summary>
/// The Photon LoadBalancing application: one instance per role/port. The client walks
/// NameServer (GetRegions/Authenticate → master address) → Master (Authenticate, JoinGame → game
/// address) → Game (Authenticate, JoinGame, RaiseEvent/SetProperties/Leave). All three run in this
/// process and share the room registry, so "addresses" are just this host's ports.
/// </summary>
public sealed class LoadBalancingApp
{
public readonly Role Role;
public readonly PhotonUdpServer Server;
public readonly string PublicHost;
public readonly int MasterPort, GamePort;
private static readonly object AppStatsSync = new();
public LoadBalancingApp(Role role, PhotonUdpServer server, string publicHost, int masterPort, int gamePort)
{
Role = role; Server = server; PublicHost = publicHost; MasterPort = masterPort; GamePort = gamePort;
server.OnConnect += p => p.Session = new Connection(server, p);
server.OnMessage += OnMessage;
server.OnDisconnect += OnDisconnect;
}
private static Connection C(PhotonUdpServer.Peer p) => (Connection)(p.Session ??= null!);
private void OnMessage(PhotonUdpServer.Peer peer, byte[] message, bool reliable)
{
if (peer.Session is not Connection c) { c = new Connection(Server, peer); peer.Session = c; }
GpBinary16.OperationRequest? op;
try { op = c.Decode(message, out _); }
catch (Exception ex) { Log.Warn($"{c.Tag}: decode failed: {ex.Message} [{Convert.ToHexString(message.AsSpan(0, Math.Min(48, message.Length)))}]"); return; }
if (op == null) return;
if (Log.Debug) Log.Dbg($"{c.Tag} <- op {op.Code} {DescribeParams(op.Params)}");
try { Handle(c, op, reliable); }
catch (Exception ex)
{
Log.Warn($"{c.Tag}: op {op.Code} failed: {ex}");
c.SendError(op.Code, Rc.InternalServerError, ex.Message);
}
}
private void OnDisconnect(PhotonUdpServer.Peer peer)
{
if (peer.Session is not Connection c) return;
if (Role == Role.Game && c.Room != null) GameService.OnConnectionLost(c);
}
private void Handle(Connection c, GpBinary16.OperationRequest op, bool reliable)
{
switch (op.Code)
{
case Op.GetRegions: HandleGetRegions(c, op); return;
case Op.Authenticate:
case Op.AuthenticateOnce: HandleAuthenticate(c, op); return;
}
if (c.UserId == null) { c.SendError(op.Code, Rc.OperationNotAllowedInCurrentState, "Not authenticated"); return; }
if (Role != Role.Game && !c.Charge(0, $"op {op.Code}")) return;
switch (Role)
{
case Role.Master: HandleMaster(c, op); return;
case Role.Game: GameService.Handle(c, op, reliable); return;
default: c.SendError(op.Code, Rc.InvalidOperation, "Not on this server"); return;
}
}
// ---------------------------------------------------------------- name server
private void HandleGetRegions(Connection c, GpBinary16.OperationRequest op)
{
c.SendResponse(Op.GetRegions, new Dictionary<byte, object?>
{
[P.Region] = new[] { "us" },
[P.Address] = new[] { $"{PublicHost}:{MasterPort}" }
});
}
private void HandleAuthenticate(Connection c, GpBinary16.OperationRequest op)
{
string? userId = null, nick = "";
if (Role == Role.NameServer)
{
// Custom auth: the Campus photonAuthToken rides in ClientAuthenticationData (216); a
// few builds put it in the params string (214) instead. Fall back to the UserId (225).
// Rec Room sends the JWT as the BYTES of ClientAuthenticationParams (214), not a string.
string? token = null;
foreach (var key in new[] { P.ClientAuthenticationData, P.ClientAuthenticationParams })
{
var raw = op.Params.GetValueOrDefault(key);
var str = raw as string ?? (raw is byte[] rb ? System.Text.Encoding.UTF8.GetString(rb) : null);
if (string.IsNullOrEmpty(str)) continue;
if (str.StartsWith("token=")) str = str[6..];
// Rec Room's auth data is JSON: {"accountId":"6","accessToken":"<the game session JWT>"}.
if (str.StartsWith("{"))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(str);
if (doc.RootElement.TryGetProperty("accessToken", out var at)) str = at.GetString() ?? str;
}
catch { }
}
if (Tokens.ValidateCampusJwt(str) != null) { token = str; break; }
token ??= str;
}
var v = Tokens.ValidateCampusJwt(token);
if (v != null) userId = v.Value.userId;
else if (op.Params.GetValueOrDefault(P.UserId) is string uid && uid.Length > 0 && AllowUnsignedUserId) userId = uid;
if (userId == null)
{
Log.Warn($"{c.Tag}: authentication rejected (token {(token == null ? "absent" : "invalid")}) params={DescribeParams(op.Params)} token='{token}'");
if (op.Params.GetValueOrDefault(P.ClientAuthenticationParams) is byte[] rawp) Log.Warn($"{c.Tag}: 214 hex={Convert.ToHexString(rawp.AsSpan(0, Math.Min(64, rawp.Length)))}");
c.SendError(op.Code, Rc.CustomAuthenticationFailed, "Invalid token");
return;
}
nick = op.Params.GetValueOrDefault(P.UriPath) as string ?? "";
}
else
{
var s = Tokens.ValidateSecret(op.Params.GetValueOrDefault(P.Secret) as string);
if (s == null)
{
Log.Warn($"{c.Tag}: bad secret on {Role}");
c.SendError(op.Code, Rc.InvalidAuthentication, "Invalid secret");
return;
}
userId = s.Value.userId; nick = s.Value.nick;
}
c.UserId = userId; c.NickName = nick ?? "";
string secret = Tokens.MintSecret(userId, c.NickName);
var resp = new Dictionary<byte, object?> { [P.Secret] = secret, [P.UserId] = userId };
if (Role == Role.NameServer) resp[P.Address] = $"{PublicHost}:{MasterPort}";
c.SendResponse(op.Code, resp);
Log.Info($"{c.Tag}: authenticated on {Role} (app '{c.AppName}')");
if (Role == Role.Master) SendAppStats(c);
}
public static bool AllowUnsignedUserId = Environment.GetEnvironmentVariable("CAMPUSNET_ALLOW_UNSIGNED") == "1";
// ---------------------------------------------------------------- master
private void SendAppStats(Connection c)
{
int peers = Server.PeerCount;
c.SendEvent(Ev.AppStats, new Dictionary<byte, object?>
{
[P.MasterPeerCount] = peers,
[P.PeerCount] = Rooms.All().Sum(r => r.ActiveCount),
[P.GameCount] = Rooms.Count
}, reliable: false);
}
private void HandleMaster(Connection c, GpBinary16.OperationRequest op)
{
switch (op.Code)
{
case Op.JoinLobby:
c.InLobby = true;
c.SendResponse(op.Code, new());
c.SendEvent(Ev.GameList, new Dictionary<byte, object?> { [P.GameList] = GameListHashtable() });
return;
case Op.LeaveLobby:
c.InLobby = false;
c.SendResponse(op.Code, new());
return;
case Op.CreateGame:
case Op.JoinGame:
{
string name = op.Params.GetValueOrDefault(P.RoomName) as string ?? "";
if (name.Length == 0) name = Guid.NewGuid().ToString("N");
int joinMode = ToInt(op.Params.GetValueOrDefault(P.JoinMode));
var room = Rooms.Get(name);
if (op.Code == Op.JoinGame && room == null && joinMode == 0)
{
c.SendError(op.Code, Rc.GameDoesNotExist, "Game does not exist"); return;
}
if (op.Code == Op.CreateGame && room != null && joinMode == 0)
{
c.SendError(op.Code, Rc.GameIdAlreadyExists, "A game with the specified id already exist"); return;
}
if (room != null)
{
lock (room.Sync)
{
bool rejoin = room.FindByUserId(c.UserId!) != null;
if (!rejoin && !room.IsOpen) { c.SendError(op.Code, Rc.GameClosed, "Game closed"); return; }
if (!rejoin && room.MaxPlayers > 0 && room.ActiveCount >= room.MaxPlayers) { c.SendError(op.Code, Rc.GameFull, "Game full"); return; }
}
}
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[P.Address] = $"{PublicHost}:{GamePort}",
[P.Secret] = Tokens.MintSecret(c.UserId!, c.NickName),
[P.RoomName] = name
});
Log.Info($"{c.Tag}: {(op.Code == Op.CreateGame ? "create" : "join")} '{name}' -> game server");
return;
}
case Op.JoinRandomGame:
{
var expected = op.Params.GetValueOrDefault(P.GameProperties) as Hashtable;
Room? pick = null;
foreach (var r in Rooms.All())
{
lock (r.Sync)
{
if (!r.IsOpen || !r.IsVisible) continue;
if (r.MaxPlayers > 0 && r.ActiveCount >= r.MaxPlayers) continue;
if (expected != null && !Room.CasMatches(r.Props, expected)) continue;
pick = r; break;
}
}
if (pick == null) { c.SendError(op.Code, Rc.NoRandomMatchFound, "No match found"); return; }
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[P.Address] = $"{PublicHost}:{GamePort}",
[P.Secret] = Tokens.MintSecret(c.UserId!, c.NickName),
[P.RoomName] = pick.Name
});
return;
}
case Op.GetGameList:
c.SendResponse(op.Code, new Dictionary<byte, object?> { [P.GameList] = GameListHashtable() });
return;
case Op.FindFriends:
{
var ids = op.Params.GetValueOrDefault((byte)1) as string[] ?? Array.Empty<string>();
var online = new bool[ids.Length]; var rooms = new string[ids.Length];
for (int i = 0; i < ids.Length; i++)
{
rooms[i] = "";
foreach (var r in Rooms.All())
{
Actor? a; lock (r.Sync) a = r.FindByUserId(ids[i]);
if (a != null && !a.Inactive) { online[i] = true; rooms[i] = r.Name; break; }
}
}
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[1] = new TypedArray((byte)'o', online.Cast<object?>().ToArray()),
[2] = rooms
});
return;
}
case Op.GetLobbyStats:
c.SendResponse(op.Code, new Dictionary<byte, object?>
{
[P.LobbyName] = new[] { "" }, [P.LobbyType] = new TypedArray((byte)'b', new object?[] { (byte)0 }),
[P.PeerCount] = new[] { Server.PeerCount }, [P.GameCount] = new[] { Rooms.Count }
});
return;
case Op.SetProperties:
case Op.RaiseEvent:
c.SendResponse(op.Code, new()); // harmless on the master
return;
case Op.ServerSettings:
c.SendResponse(op.Code, new());
return;
default:
Log.Warn($"{c.Tag}: master op {op.Code} not implemented {DescribeParams(op.Params)}");
c.SendError(op.Code, Rc.InvalidOperation, "Operation not supported");
return;
}
}
private static Hashtable GameListHashtable()
{
var h = new Hashtable();
foreach (var r in Rooms.All())
{
lock (r.Sync)
{
if (!r.IsVisible) continue;
var props = new Hashtable();
if (r.Props[GameProp.PropsListedInLobby] is string[] listed)
foreach (var k in listed) if (r.Props.ContainsKey(k)) props[k] = r.Props[k];
props[GameProp.PlayerCount] = (byte)r.ActiveCount;
props[GameProp.MaxPlayers] = (byte)Math.Clamp(r.MaxPlayers, 0, 255);
props[GameProp.IsOpen] = r.IsOpen;
props[GameProp.IsVisible] = r.IsVisible;
h[r.Name] = props;
}
}
return h;
}
public static int ToInt(object? v) => v switch { null => 0, byte b => b, short s => s, int i => i, long l => (int)l, bool o => o ? 1 : 0, _ => 0 };
public static string DescribeParams(IReadOnlyDictionary<byte, object?> p)
=> "{" + string.Join(", ", p.Select(e => $"{e.Key}={GpBinary16.Describe(e.Value)}")) + "}";
}
+142
View File
@@ -0,0 +1,142 @@
using System.Collections;
using CampusNet.Protocol;
namespace CampusNet.Services;
public sealed class Actor
{
public int Nr;
public string UserId = "";
public Connection? Conn; // null while inactive (disconnected with PlayerTtl)
public readonly Hashtable Props = new();
public bool Inactive;
public DateTime InactiveSince;
public readonly HashSet<byte> Groups = new(); // interest groups (0 = always)
public readonly List<CachedEvent> OwnCache = new(); // MergeCache / ReplaceCache per actor (legacy)
// Traffic counters for the periodic room summary (see GameService.Sweep): what this actor
// raised, how much of it was unreliable, per event code, and how many recipients it reached.
public long Raised, RaisedUnreliable, Delivered, DroppedNoRecipient;
public readonly Dictionary<byte, int> RaisedByCode = new();
public readonly Dictionary<byte, int> RaisedByGroup = new();
}
public sealed class CachedEvent
{
public int ActorNr; public byte Code; public object? Data; public byte Group;
}
/// <summary>A game room: actors, properties, and the event cache PUN relies on for late joiners.</summary>
public sealed class Room
{
public readonly string Name;
public readonly Hashtable Props = new(); // byte system keys + custom keys
public readonly SortedDictionary<int, Actor> Actors = new();
public readonly List<CachedEvent> Cache = new(); // AddToRoomCache / AddToRoomCacheGlobal, in order
public int NextActorNr = 1;
public int PlayerTtl, EmptyRoomTtl;
public DateTime EmptySince = DateTime.UtcNow;
public readonly object Sync = new();
public readonly DateTime CreatedAt = DateTime.UtcNow;
public Room(string name) { Name = name; }
public int MaxPlayers => Props[GameProp.MaxPlayers] is byte b ? b : (Props[GameProp.MaxPlayers] is int i ? i : 0);
public bool IsOpen => Props[GameProp.IsOpen] is bool o ? o : true;
public bool IsVisible => Props[GameProp.IsVisible] is bool v ? v : true;
public int ActiveCount => Actors.Values.Count(a => !a.Inactive);
public int MasterClientId => Actors.Values.Where(a => !a.Inactive).Select(a => a.Nr).DefaultIfEmpty(0).Min();
public Actor? FindByUserId(string userId) => Actors.Values.FirstOrDefault(a => a.UserId == userId);
/// <summary>Game properties as sent to clients: stored props plus the live system values.</summary>
public Hashtable GamePropsForClient()
{
var h = new Hashtable();
foreach (DictionaryEntry e in Props) h[e.Key] = e.Value;
h[GameProp.MasterClientId] = MasterClientId;
h[GameProp.PlayerCount] = (byte)ActiveCount;
if (!h.ContainsKey(GameProp.IsOpen)) h[GameProp.IsOpen] = true;
if (!h.ContainsKey(GameProp.IsVisible)) h[GameProp.IsVisible] = true;
return h;
}
/// <summary>All players' properties keyed by actor number (what the join response carries).</summary>
public Hashtable AllPlayerProps()
{
var h = new Hashtable();
foreach (var a in Actors.Values) h[a.Nr] = PlayerPropsForClient(a);
return h;
}
public static Hashtable PlayerPropsForClient(Actor a)
{
var h = new Hashtable();
foreach (DictionaryEntry e in a.Props) h[e.Key] = e.Value;
h[PlayerProp.UserId] = a.UserId;
h[PlayerProp.IsInactive] = a.Inactive;
if (!h.ContainsKey(PlayerProp.NickName)) h[PlayerProp.NickName] = "";
return h;
}
public int[] ActorList() => Actors.Values.Where(a => !a.Inactive).Select(a => a.Nr).ToArray();
/// <summary>Copy props from a client hashtable into a store, honouring the "null removes" rule; returns the changed subset.</summary>
public static Hashtable Merge(Hashtable target, Hashtable incoming)
{
var changed = new Hashtable();
foreach (DictionaryEntry e in incoming)
{
if (e.Value == null) { if (target.ContainsKey(e.Key)) { target.Remove(e.Key); changed[e.Key] = null; } }
else { target[e.Key] = e.Value; changed[e.Key] = e.Value; }
}
return changed;
}
/// <summary>Check-and-set: every expected value must match the current one (both absent counts as match).</summary>
public static bool CasMatches(Hashtable target, Hashtable? expected)
{
if (expected == null) return true;
foreach (DictionaryEntry e in expected)
{
var cur = target[e.Key];
if (!ValuesEqual(cur, e.Value)) return false;
}
return true;
}
public static bool ValuesEqual(object? a, object? b)
{
if (a == null || b == null) return a == null && b == null;
if (a is byte[] ab && b is byte[] bb) return ab.AsSpan().SequenceEqual(bb);
if (a is Array aa && b is Array ba)
{
if (aa.Length != ba.Length) return false;
for (int i = 0; i < aa.Length; i++) if (!ValuesEqual(aa.GetValue(i), ba.GetValue(i))) return false;
return true;
}
if (a is CustomValue ca && b is CustomValue cb) return ca.Code == cb.Code && ca.Data.AsSpan().SequenceEqual(cb.Data);
try { return Convert.ToDouble(a) == Convert.ToDouble(b); } catch { }
return a.Equals(b);
}
}
/// <summary>Every room this process hosts (master and game share it - one process).</summary>
public static class Rooms
{
private static readonly Dictionary<string, Room> _rooms = new();
public static readonly object Sync = new();
public static Room? Get(string name) { lock (Sync) return _rooms.TryGetValue(name, out var r) ? r : null; }
public static Room GetOrCreate(string name, out bool created)
{
lock (Sync)
{
if (_rooms.TryGetValue(name, out var r)) { created = false; return r; }
r = new Room(name); _rooms[name] = r; created = true; return r;
}
}
public static void Remove(string name) { lock (Sync) _rooms.Remove(name); }
public static List<Room> All() { lock (Sync) return _rooms.Values.ToList(); }
public static int Count { get { lock (Sync) return _rooms.Count; } }
}
+70
View File
@@ -0,0 +1,70 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace CampusNet.Services;
/// <summary>
/// Two tokens live here. (1) The Campus server's photonAuthToken - an HS256 JWT (sub = account id,
/// aud = photon room id) the client presents as custom-auth data on the name server; validated with the
/// shared key. (2) The "secret" this server mints for the master/game hops (Photon hands the client an
/// opaque secret after the first Authenticate and the client presents it on the next server).
/// </summary>
public static class Tokens
{
public static string Key = Environment.GetEnvironmentVariable("CAMPUSNET_JWT_KEY")
?? Environment.GetEnvironmentVariable("CAMPUS_KEY")
?? "fI0wlalapwWIAKW0PJFOlwaalwWOFjNWmnemNWIwal";
private static byte[] Hmac(string data) => HMACSHA256.HashData(Encoding.UTF8.GetBytes(Key), Encoding.UTF8.GetBytes(data));
private static string B64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] FromB64Url(string s)
{
s = s.Replace('-', '+').Replace('_', '/');
while (s.Length % 4 != 0) s += "=";
return Convert.FromBase64String(s);
}
/// <summary>Validate the Campus JWT; returns (accountId, audience/room) or null.</summary>
public static (string userId, string? aud)? ValidateCampusJwt(string? jwt)
{
if (string.IsNullOrEmpty(jwt)) return null;
var parts = jwt.Split('.');
if (parts.Length != 3) return null;
try
{
var expected = B64Url(Hmac(parts[0] + "." + parts[1]));
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(parts[2]))) return null;
using var doc = JsonDocument.Parse(FromB64Url(parts[1]));
var root = doc.RootElement;
string? sub = root.TryGetProperty("sub", out var s) ? s.ToString() : null;
string? aud = root.TryGetProperty("aud", out var a) ? a.ToString() : null;
return sub == null ? null : (sub, aud);
}
catch { return null; }
}
/// <summary>Mint the master/game "secret": userId|nick|expiry, HMAC-signed.</summary>
public static string MintSecret(string userId, string nick)
{
long exp = DateTimeOffset.UtcNow.AddHours(12).ToUnixTimeSeconds();
string body = $"{userId}|{nick}|{exp}";
return B64Url(Encoding.UTF8.GetBytes(body)) + "." + B64Url(Hmac(body));
}
public static (string userId, string nick)? ValidateSecret(string? secret)
{
if (string.IsNullOrEmpty(secret)) return null;
var i = secret.IndexOf('.');
if (i <= 0) return null;
try
{
string body = Encoding.UTF8.GetString(FromB64Url(secret[..i]));
if (B64Url(Hmac(body)) != secret[(i + 1)..]) return null;
var f = body.Split('|');
if (f.Length != 3 || !long.TryParse(f[2], out var exp) || exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds()) return null;
return (f[0], f[1]);
}
catch { return null; }
}
}
@@ -0,0 +1,23 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v8.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v8.0": {
"campusnet/1.0.0": {
"runtime": {
"campusnet.dll": {}
}
}
}
},
"libraries": {
"campusnet/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,15 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
"configProperties": {
"System.GC.Server": false,
"System.Globalization.Invariant": true,
"System.Globalization.PredefinedCulturesOnly": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}
@@ -0,0 +1,23 @@
{
"runtimeTarget": {
"name": ".NETCoreApp,Version=v8.0",
"signature": ""
},
"compilationOptions": {},
"targets": {
".NETCoreApp,Version=v8.0": {
"campusnet/1.0.0": {
"runtime": {
"campusnet.dll": {}
}
}
}
},
"libraries": {
"campusnet/1.0.0": {
"type": "project",
"serviceable": false,
"sha512": ""
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"framework": {
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
"configProperties": {
"System.GC.Server": false,
"System.Globalization.Invariant": true,
"System.Globalization.PredefinedCulturesOnly": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

Some files were not shown because too many files have changed in this diff Show More