Editor/Core/RelayProtocol.cs
using System;
using System.Buffers.Binary;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace TeamCreate;

// The relay exists because a home connection usually cannot accept an inbound TCP connection: the
// friend's dial to the host's public address is refused unless the router forwards the port, and UPnP is
// best-effort. Both peers instead dial OUT to a relay over a WebSocket, which works through any NAT.
//
// The relay is deliberately dumb. It never sees the session key: everything a guest and the host say
// to each other is the same AES-256-GCM stream the direct route already uses, carried through the
// relay as opaque bytes. What the relay does see is a room id and two proofs, all derived one-way
// from the invite secret, so it can route and gate access but can neither read nor forge session traffic.

/// <summary>Frame kinds on the host's relay connection. A guest's connection carries raw stream bytes only.</summary>
public enum RelayFrameType : byte
{
    Data = 1,
    Open = 2,
    Close = 3
}

/// <summary>One JSON control message, sent as a WebSocket text message before any stream bytes flow.</summary>
public sealed class RelayControl
{
    [JsonPropertyName( "t" )]
    public string Type { get; set; }

    [JsonPropertyName( "v" )]
    public int Version { get; set; }

    [JsonPropertyName( "room" )]
    public string Room { get; set; }

    [JsonPropertyName( "host" )]
    public string HostProof { get; set; }

    [JsonPropertyName( "guest" )]
    public string GuestProof { get; set; }

    [JsonPropertyName( "proof" )]
    public string Proof { get; set; }

    [JsonPropertyName( "key" )]
    public string AccessKey { get; set; }

    [JsonPropertyName( "code" )]
    public string Code { get; set; }

    [JsonPropertyName( "message" )]
    public string Message { get; set; }
}

public static class RelayProtocol
{
    public const int ProtocolVersion = 1;

    public const int RoomBytes = 16;

    public const int ProofBytes = 32;

    public const int HeaderBytes = 5;

    // A WebSocket message is kept far below the engine's per-message ceiling (4 MiB) and the relay's own
    // receive buffer, so a multi-megabyte scene snapshot travels as many small messages rather than one
    // that either side may refuse.
    public const int MaxChunkBytes = 16 * 1024;

    public const int MaxMessageBytes = MaxChunkBytes + HeaderBytes;

    public const int MaxControlChars = 1024;

    public const int MaxAccessKeyChars = 128;

    public const int MaxUrlChars = 200;

    public const string DefaultPath = "/relay";

    // The label a candidate route carries through the resolver, the failure summary and the dock.
    public const string RouteSource = "relay";

    /// <summary>The code recorded for a relay route that was skipped because it names the host's own computer.</summary>
    public const string LoopbackCode = "loopback";

    public const string HostHello = "host";

    public const string GuestHello = "guest";

    public const string Ok = "ok";

    public const string Error = "error";

    // Loopback is the only target the engine's HTTP allow-list lets a script reach by name and port
    // without an opt-in switch, so a local relay for testing is limited to exactly these ports.
    public static readonly int[] LoopbackPorts = { 80, 443, 8080, 8443 };

    /// <summary>
    /// True when the address can be reached by another machine. A relay on localhost or a loopback address exists only on the computer that names it,
    /// so a friend who is handed such an address dials their OWN computer and finds nothing.
    /// </summary>
    public static bool IsPublicRelay( string url )
    {
        if ( !Uri.TryCreate( url?.Trim(), UriKind.Absolute, out var parsed ) || ( parsed.Scheme != "wss" && parsed.Scheme != "ws" ) )
            return false;
        return !parsed.IsLoopback && !string.Equals( parsed.Host, "localhost", StringComparison.OrdinalIgnoreCase )
            && !parsed.Host.EndsWith( ".localhost", StringComparison.OrdinalIgnoreCase );
    }

    /// <summary>The relay address an invite may carry: a public one, or a local one only when the joiner is known to be on this machine (the solo test).</summary>
    public static string InviteRelayFor( string url, bool allowLoopback )
    {
        if ( string.IsNullOrWhiteSpace( url ) )
            return null;
        return allowLoopback || IsPublicRelay( url ) ? url.Trim() : null;
    }

    /// <summary>
    /// The address a socket dials: the relay's address plus the room it wants. A hosted relay has to choose the right room BEFORE the first message
    /// arrives, so the room id, which is one-way derived from the secret and already known to the relay, rides in the address. The secret never does.
    /// </summary>
    public static string ConnectUrl( string url, byte[] secret )
    {
        var trimmed = url.Trim();
        return trimmed + ( trimmed.Contains( '?' ) ? "&" : "?" ) + "room=" + Hex( DeriveRoom( secret ) );
    }

    public static byte[] DeriveRoom( byte[] secret ) => Derive( secret, "room", RoomBytes );

    public static byte[] DeriveHostProof( byte[] secret ) => Derive( secret, "host-proof", ProofBytes );

    public static byte[] DeriveGuestProof( byte[] secret ) => Derive( secret, "guest-proof", ProofBytes );

    // Three independent labels over one secret. HKDF output under one label reveals nothing about
    // another, so knowing a room id or a proof does not help anyone reach the session key
    // (HKDF over the same secret with the project id as its label, in InviteCode.DeriveKey).
    private static byte[] Derive( byte[] secret, string label, int length )
    {
        if ( secret == null || secret.Length != 16 )
            throw new ArgumentException( "A relay room requires the 16-byte invite secret.", nameof( secret ) );
        return InviteCode.HkdfSha256( secret, null, Encoding.ASCII.GetBytes( "TeamCreate/relay/" + label ), length );
    }

    public static string Hex( byte[] bytes ) => Convert.ToHexString( bytes ).ToLowerInvariant();

    public static bool TryParseHex( string text, int expectedBytes, out byte[] bytes )
    {
        bytes = null;
        if ( text == null || text.Length != expectedBytes * 2 )
            return false;
        foreach ( var c in text )
        {
            if ( !( ( c >= '0' && c <= '9' ) || ( c >= 'a' && c <= 'f' ) ) )
                return false;
        }
        bytes = Convert.FromHexString( text );
        return true;
    }

    public static string BuildHostHello( byte[] secret, string accessKey )
    {
        if ( accessKey != null && accessKey.Length > MaxAccessKeyChars )
            throw new ArgumentException( "The relay access key is longer than a relay accepts.", nameof( accessKey ) );
        return JsonSerializer.Serialize( new RelayControl
        {
            Type = HostHello,
            Version = ProtocolVersion,
            Room = Hex( DeriveRoom( secret ) ),
            HostProof = Hex( DeriveHostProof( secret ) ),
            GuestProof = Hex( DeriveGuestProof( secret ) ),
            AccessKey = string.IsNullOrEmpty( accessKey ) ? null : accessKey
        }, ControlOptions );
    }

    public static string BuildGuestHello( byte[] secret )
    {
        return JsonSerializer.Serialize( new RelayControl
        {
            Type = GuestHello,
            Version = ProtocolVersion,
            Room = Hex( DeriveRoom( secret ) ),
            Proof = Hex( DeriveGuestProof( secret ) )
        }, ControlOptions );
    }

    public static string BuildOk() => JsonSerializer.Serialize( new RelayControl { Type = Ok, Version = ProtocolVersion }, ControlOptions );

    public static string BuildError( string code, string message ) =>
        JsonSerializer.Serialize( new RelayControl { Type = Error, Version = ProtocolVersion, Code = code, Message = message }, ControlOptions );

    private static readonly JsonSerializerOptions ControlOptions = new()
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    /// <summary>
    /// Parses one control message. Bounded and strict: a hello is the first thing an unauthenticated
    /// stranger can send a relay, so anything oversized or malformed is refused without allocating for it.
    /// </summary>
    public static bool TryParseControl( string text, out RelayControl control, out string error )
    {
        control = null;
        if ( string.IsNullOrEmpty( text ) )
        {
            error = "The control message is empty.";
            return false;
        }
        if ( text.Length > MaxControlChars )
        {
            error = "The control message is longer than a relay accepts.";
            return false;
        }
        try
        {
            control = JsonSerializer.Deserialize<RelayControl>( text );
        }
        catch ( JsonException )
        {
            error = "The control message is not valid JSON.";
            return false;
        }
        if ( control == null || string.IsNullOrEmpty( control.Type ) )
        {
            control = null;
            error = "The control message has no type.";
            return false;
        }
        error = null;
        return true;
    }

    /// <summary>Validates a hello for shape only; proof comparison is the relay's job.</summary>
    public static string ValidateHello( RelayControl hello, out byte[] room, out byte[] hostProof, out byte[] guestProof, out byte[] proof )
    {
        room = hostProof = guestProof = proof = null;
        if ( hello.Version != ProtocolVersion )
            return "This relay speaks protocol " + ProtocolVersion + "; update the collaboration library.";
        if ( !TryParseHex( hello.Room, RoomBytes, out room ) )
            return "The room identifier is malformed.";
        if ( hello.Type == HostHello )
        {
            if ( !TryParseHex( hello.HostProof, ProofBytes, out hostProof ) || !TryParseHex( hello.GuestProof, ProofBytes, out guestProof ) )
                return "The host proofs are malformed.";
            if ( hello.AccessKey != null && hello.AccessKey.Length > MaxAccessKeyChars )
                return "The relay access key is too long.";
            return null;
        }
        if ( hello.Type == GuestHello )
        {
            return TryParseHex( hello.Proof, ProofBytes, out proof ) ? null : "The guest proof is malformed.";
        }
        return "The first message must be a host or guest hello.";
    }

    public static byte[] BuildFrame( RelayFrameType type, uint channel, ReadOnlySpan<byte> payload )
    {
        var frame = new byte[HeaderBytes + payload.Length];
        frame[0] = (byte)type;
        BinaryPrimitives.WriteUInt32BigEndian( frame.AsSpan( 1 ), channel );
        payload.CopyTo( frame.AsSpan( HeaderBytes ) );
        return frame;
    }

    public static bool TryParseFrame( ReadOnlySpan<byte> message, out RelayFrameType type, out uint channel, out ReadOnlySpan<byte> payload )
    {
        type = 0;
        channel = 0;
        payload = default;
        if ( message.Length < HeaderBytes || message[0] < 1 || message[0] > 3 )
            return false;
        type = (RelayFrameType)message[0];
        channel = BinaryPrimitives.ReadUInt32BigEndian( message.Slice( 1 ) );
        payload = message.Slice( HeaderBytes );
        // Open and Close carry no payload; a frame that does is malformed rather than ignorable.
        return type == RelayFrameType.Data || payload.Length == 0;
    }

    /// <summary>The sentence a person sees for a relay refusal. Every code the relay sends has one.</summary>
    public static string UserMessage( string code, string fallback )
    {
        switch ( code )
        {
            case "no-host":
                return "The host is not connected to the relay. Ask the host to start, or restart, the session and send a new code.";
            case "bad-proof":
                return "The relay refused this invite code. Copy the code again from the host.";
            case "bad-key":
                return "The relay refused this machine's access key. Check .collaboration/relay-access-key.txt.";
            case "room-taken":
                return "Another host is already using this session's relay room.";
            case "full":
                return "The session is full on the relay.";
            case "limit":
                return "The relay is at capacity or is limiting this address. Try again shortly.";
            case "version":
                return fallback ?? "The relay and this library speak different protocol versions.";
            default:
                return string.IsNullOrWhiteSpace( fallback ) ? "The relay refused the connection." : "The relay refused the connection: " + fallback;
        }
    }

    /// <summary>
    /// True when a code means retrying cannot help, so the host should stop instead of reconnecting forever.
    /// </summary>
    public static bool IsTerminal( string code ) => code == "bad-key" || code == "bad-proof" || code == "room-taken" || code == "version" || code == "bad-hello" || code == LoopbackCode;

    /// <summary>
    /// The engine's HTTP client, which every script WebSocket goes through, refuses IP addresses, private
    /// ranges and any loopback port outside a short list. Checking the same rules here turns a confusing
    /// "not allowed" at connect time into a precise message at configuration time. <c>ws://</c> is loopback-only
    /// because the hello carries the room proofs, and those must not cross the internet unencrypted.
    /// </summary>
    public static bool TryValidateUrl( string url, out Uri uri, out string error, bool anyLoopbackPort = false )
    {
        uri = null;
        if ( string.IsNullOrWhiteSpace( url ) )
        {
            error = "No relay address is configured.";
            return false;
        }
        if ( url.Length > MaxUrlChars )
        {
            error = "The relay address is longer than " + MaxUrlChars + " characters.";
            return false;
        }
        if ( !Uri.TryCreate( url.Trim(), UriKind.Absolute, out var parsed ) || ( parsed.Scheme != "wss" && parsed.Scheme != "ws" ) )
        {
            error = "The relay address must be a ws:// or wss:// URL, for example wss://relay.example.com/relay.";
            return false;
        }
        if ( !string.IsNullOrEmpty( parsed.UserInfo ) || !string.IsNullOrEmpty( parsed.Fragment ) )
        {
            error = "The relay address must not contain credentials or a fragment.";
            return false;
        }
        var loopback = parsed.IsLoopback || string.Equals( parsed.Host, "localhost", StringComparison.OrdinalIgnoreCase );
        if ( loopback )
        {
            // anyLoopbackPort exists for the offline tests, which host a relay on an ephemeral port. The engine
            // still applies its own rule when the editor really connects, so this is never a way around it.
            if ( !anyLoopbackPort && Array.IndexOf( LoopbackPorts, parsed.Port ) < 0 )
            {
                error = "A local relay must listen on port 80, 443, 8080 or 8443; the editor refuses other local ports.";
                return false;
            }
        }
        else
        {
            if ( parsed.Scheme != "wss" )
            {
                error = "A relay on another machine must use wss:// so its room proofs are encrypted in transit.";
                return false;
            }
            if ( IPAddress.TryParse( parsed.Host.Trim( '[', ']' ), out _ ) || Uri.CheckHostName( parsed.Host ) != UriHostNameType.Dns )
            {
                error = "The editor refuses IP addresses; give the relay a DNS name.";
                return false;
            }
        }
        uri = parsed;
        error = null;
        return true;
    }
}