the files
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}"); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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() ?? "?";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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}"); }
|
||||
}
|
||||
@@ -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>()
|
||||
};
|
||||
}
|
||||
@@ -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)}")) + "}";
|
||||
}
|
||||
@@ -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; } }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user