<?php
/**
 * seekquarry\atto\TurnSite -- a single-file STUN/TURN server
 * implementing RFC 8489 (STUN) and RFC 8656 (TURN). Supports
 * Allocate, Refresh, CreatePermission, ChannelBind, Send and
 * Data indications, and ChannelData framing. Long-term
 * credentials with realm/nonce per RFC 8489 sec 9.2 are the
 * default authentication mode; an unauthenticated mode is
 * available for plain STUN binding-discovery deployments.
 *
 * Listens on UDP only in this version. TCP and TLS allocations
 * (RFC 6062, RFC 7350) are not implemented, since the common
 * WebRTC and SIP deployments use UDP. IPv4 and IPv6 are both
 * supported on the listener; the relay socket for an
 * allocation is bound on the same family the client used.
 *
 * Copyright (C) 2017-2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license http://www.gnu.org/licenses/ GPL-3.0-or-later
 * @link http://www.seekquarry.com/
 * @copyright 2017-2026
 * @filesource
 */
namespace seekquarry\atto;

/**
 * Abstract long-term-credential authenticator for TURN.
 * Concrete subclasses look up the username in some user
 * store and return the password (or its already-derived
 * MD5 key) so the server can verify MESSAGE-INTEGRITY.
 *
 * RFC 8489 sec 9.2 defines the long-term credential as
 *
 *     key = MD5(username ":" realm ":" password)
 *
 * which is then used as the HMAC-SHA1 key for the
 * MESSAGE-INTEGRITY attribute. Authenticators may return
 * either the cleartext password (the server derives the
 * key) or a precomputed key (so the cleartext password
 * never has to live in memory beyond user creation).
 *
 * Recognized return shapes for lookupUser():
 *
 *   ['password' => string]
 *       Cleartext password. The server computes the MD5
 *       key from username:realm:password.
 *
 *   ['key' => string (16 raw bytes)]
 *       Precomputed long-term-credential key. The server
 *       uses it as-is.
 *
 *   false
 *       No such user; server returns 401.
 */
abstract class TurnAuthenticator
{
    /**
     * @param string $username UTF-8 username from the
     *      USERNAME attribute
     * @return array|false user info, or false if no such user
     */
    abstract public function lookupUser($username);
}
/**
 * In-memory credential store. The constructor takes a map
 * of username => password. Suitable for the demo and small
 * deployments; a production server would back the lookup
 * with a database query.
 */
class StaticTurnAuthenticator extends TurnAuthenticator
{
    /**
     * @param array $users map [username => password, ...]
     */
    public function __construct(protected $users = [])
    {
    }
    /**
     * {@inheritDoc}
     */
    public function lookupUser($username)
    {
        if (!isset($this->users[$username])) {
            return false;
        }
        return ['password' => $this->users[$username]];
    }
}
/**
 * Recognizes a caller by a token rather than by a name written down
 * beforehand. The name a caller gives is the moment the token stops
 * being good, written as a count of seconds, and the secret is a hash
 * over that name made with a key the caller and the relay share. A
 * caller with the key can make itself a token whenever it needs one,
 * and a token stops working on its own.
 *
 * This is the shape RFC 5766 describes for a relay whose users are not
 * known in advance.
 *
 * @author Chris Pollett
 */
class TimeLimitedTurnAuthenticator extends TurnAuthenticator
{
    /**
     * The key the relay and whoever hands out tokens share.
     * @var string
     */
    protected $key;
    /**
     * Builds an authenticator that recognizes tokens made with a key.
     *
     * @param string $key shared with whoever hands out tokens
     */
    public function __construct($key)
    {
        $this->key = (string)$key;
    }
    /**
     * Says whether a name is a token still good, and what secret goes
     * with it. The name is the moment the token stops being good; the
     * secret is that name hashed with the shared key.
     *
     * @param string $username the name a caller gave
     * @return array|bool the password to expect, or false where the
     *      name is not a moment or that moment has passed
     */
    public function lookupUser($username)
    {
        $parts = explode(":", (string)$username, 2);
        $until = (int)$parts[0];
        if ($until <= 0 || $until < time()) {
            return false;
        }
        return ['password' => base64_encode(hash_hmac('sha1',
            (string)$username, $this->key, true))];
    }
}
/**
 * Per-allocation state. One instance per client 5-tuple,
 * created on a successful Allocate request and torn down
 * on Refresh(lifetime=0), expiry, or socket error. RFC
 * 8656 sec 5 calls the underlying object the "allocation
 * data structure".
 */
class TurnAllocation
{
    /**
     * @var string client's address as text ("1.2.3.4" or
     *      "2001:db8::1"); part of the 5-tuple key
     */
    public $client_host = "";
    /**
     * @var int client's UDP port; part of the 5-tuple key
     */
    public $client_port = 0;
    /**
     * @var int 4 or 6 -- address family of the client
     */
    public $family = 4;
    /**
     * @var resource UDP socket bound on the relayed-address
     *      side; the server sends datagrams to peers from
     *      this socket and reads peer-to-client traffic on it
     */
    public $relay_socket = null;
    /**
     * @var string relayed-address as text
     */
    public $relay_host = "";
    /**
     * @var int port the relay_socket is bound to
     */
    public $relay_port = 0;
    /**
     * @var string the canonical username from USERNAME
     *      attribute; logged with each event
     */
    public $username = "";
    /**
     * @var int Unix timestamp at which the allocation
     *      expires unless refreshed. RFC 8656 sec 7.2
     *      default lifetime is 600 seconds.
     */
    public $expires = 0;
    /**
     * @var array permitted peer addresses, indexed by host
     *      string ("1.2.3.4"), value is the Unix timestamp
     *      the permission expires (5 minutes from create
     *      or refresh per RFC 8656 sec 9). The client must
     *      hold a permission for a peer host before it can
     *      Send to or receive Data from that peer.
     */
    public $permissions = [];
    /**
     * @var array channel bindings: [chan_num => [peer_host,
     *      peer_port, expires_ts]]. RFC 8656 sec 12 reserves
     *      0x4000-0x4FFF for client-initiated channels.
     */
    public $channels = [];
    /**
     * @var array reverse map peer_host:peer_port =>
     *      chan_num for fast inbound lookup when a relay
     *      datagram arrives from a peer that has a channel
     */
    public $channels_by_peer = [];
    /**
     * @var string the relayed socket's own bound address as a
     *      host:port key, which is the real local address rather than
     *      the public one handed to the browser. The index that finds an
     *      allocation for an inbound relayed datagram is keyed by this,
     *      so the lookup reads the same address the socket reports.
     */
    public $relay_local_key = "";
}
/**
 * The STUN/TURN server. Accepts datagrams on a UDP listener,
 * parses STUN messages and ChannelData frames, dispatches
 * TURN requests to handler methods, and shuttles relay
 * traffic between clients and their peers.
 *
 * Configuration is via chained setters, mirroring the other
 * atto servers:
 *
 *     $turn = new TurnSite();
 *     $turn->auth(new StaticTurnAuthenticator([
 *             'alice' => 'hunter2']))
 *         ->realm('atto-turn-demo')
 *         ->relayPortRange(60000, 60100)
 *         ->software('atto-turn 1.0');
 *     $turn->listen([
 *         'BIND' => '127.0.0.1',
 *         'TURN_PORT' => 13478,
 *     ]);
 *
 * The listen() loop is non-blocking and uses stream_select
 * across the listener and every active relay socket. There
 * is no per-client TCP control connection in TURN -- the
 * UDP listener is the control channel.
 */
class TurnSite
{
    /*
        --- STUN message-class bits (high two bits of the
            first byte of the message-type field). RFC 8489
            sec 5: a "method" is the lower 12 bits and a
            "class" is two bits split across the type field.
     */
    const CLASS_REQUEST = 0x0000;
    const CLASS_INDICATION = 0x0010;
    const CLASS_SUCCESS = 0x0100;
    const CLASS_ERROR = 0x0110;
    /*
        STUN method numbers. Binding is RFC 8489 core; the
        rest are RFC 8656 TURN extensions. The numbers are
        12-bit identifiers carried in the message-type field
        with the class bits interleaved (encodeMessageType
        and decodeMessageType handle the bit twiddling).
     */
    const METHOD_BINDING = 0x001;
    const METHOD_ALLOCATE = 0x003;
    const METHOD_REFRESH = 0x004;
    const METHOD_SEND = 0x006;
    const METHOD_DATA = 0x007;
    const METHOD_CREATE_PERMISSION = 0x008;
    const METHOD_CHANNEL_BIND = 0x009;
    /*
        STUN attribute types. RFC 8489 / RFC 8656 reserve
        comprehension-required (0x0000-0x7FFF) and
        comprehension-optional (0x8000-0xFFFF) ranges.
     */
    const ATTR_MAPPED_ADDRESS = 0x0001;
    const ATTR_USERNAME = 0x0006;
    const ATTR_MESSAGE_INTEGRITY = 0x0008;
    const ATTR_ERROR_CODE = 0x0009;
    const ATTR_UNKNOWN_ATTRS = 0x000A;
    const ATTR_REALM = 0x0014;
    const ATTR_NONCE = 0x0015;
    const ATTR_XOR_MAPPED_ADDRESS = 0x0020;
    const ATTR_LIFETIME = 0x000D;
    const ATTR_XOR_PEER_ADDRESS = 0x0012;
    const ATTR_DATA = 0x0013;
    const ATTR_XOR_RELAYED_ADDRESS = 0x0016;
    const ATTR_REQUESTED_TRANSPORT = 0x0019;
    const ATTR_REQUESTED_ADDRESS_FAMILY = 0x0017;
    const ATTR_CHANNEL_NUMBER = 0x000C;
    const ATTR_SOFTWARE = 0x8022;
    const ATTR_FINGERPRINT = 0x8028;
    /*
        Magic cookie -- second word of the STUN header,
        always 0x2112A442. The XOR-MAPPED-ADDRESS family
        of attributes XORs the address with this value.
     */
    const MAGIC_COOKIE = 0x2112A442;
    /*
        Default lifetimes per RFC 8656. Allocation default
        is 600 seconds with a max of 3600. Permissions live
        for 300 seconds. Channels for 600 seconds.
     */
    const DEFAULT_ALLOCATION_LIFETIME = 600;
    const MAX_ALLOCATION_LIFETIME = 3600;
    const PERMISSION_LIFETIME = 300;
    const CHANNEL_LIFETIME = 600;
    /*
        Channel number range, RFC 8656 sec 12: 0x4000-0x4FFF
        is the only range a client may use in ChannelBind.
        Other ranges are reserved.
     */
    const MIN_CHANNEL_NUMBER = 0x4000;
    const MAX_CHANNEL_NUMBER = 0x4FFF;
    /**
     * @var TurnAuthenticator|null long-term credential
     *      lookup. If null, the server runs in "STUN-only"
     *      mode -- Allocate is refused with 401 and only
     *      Binding requests are answered.
     */
    protected $authenticator = null;
    /**
     * @var string realm advertised in 401 challenges and
     *      used in long-term key derivation
     */
    protected $realm = "atto-turn";
    /**
     * @var string SOFTWARE attribute value (informational)
     */
    protected $software = "atto-turn";
    /**
     * @var int low end of the relay-port range (inclusive)
     */
    protected $relay_port_low = 60000;
    /**
     * @var int high end of the relay-port range (inclusive)
     */
    protected $relay_port_high = 60100;
    /**
     * The address the relay binds each relayed socket on and hands a
     * browser as the address to reach that socket at. A browser gives
     * this address to the other browser, which then has to reach it, so
     * it has to be an address that other browser can reach: the public
     * address of the machine the relay runs on, not the loopback. Where
     * a caller names none the relay falls back to the loopback, which
     * serves only a browser on the same machine and is why a relay left
     * at the default carried a call nowhere.
     * @var string
     */
    protected $relay_address = "";
    /**
     * @var array configuration from listen(): BIND,
     *      TURN_PORT, IDLE_TIMEOUT
     */
    protected $config = [];
    /**
     * Work to run between rounds of the listening loop, set by
     * betweenRounds(). A caller uses it to notice whatever it watches
     * for: a request to stop, a file that has changed, a count to
     * write down.
     * @var callable|null
     */
    protected $between_rounds = null;
    /**
     * How often the work set by betweenRounds() is run, in seconds. The
     * loop wakes at least this often even where no datagram arrives.
     * @var float
     */
    protected $between_seconds = 1.0;
    /**
     * The moment the between-rounds work last ran, as seconds with a
     * fraction, read and moved by betweenWorkDue() so that work runs at
     * the pace the caller set rather than once per datagram the loop
     * wakes on.
     * @var float
     */
    protected $between_last = 0.0;
    /**
     * A place to send a line about what the relay is doing, set by
     * onLog(). The relay itself keeps no log and names no file, so a
     * caller that wants to see each datagram arrive, each allocation
     * made, and each request turned away hands a function here, and the
     * relay calls it with a plain sentence at each of those moments.
     * Where none is set the relay stays silent, as it did before.
     * @var callable|null
     */
    protected $log_callback = null;
    /**
     * Whether the listening loop should end after this round. stop()
     * sets it, and the work run between rounds is where a caller
     * usually calls stop().
     * @var bool
     */
    protected $stopping = false;
    /**
     * @var resource the UDP listener
     */
    protected $listener = null;
    /**
     * @var array map "client_host:client_port" => TurnAllocation
     */
    protected $allocations = [];
    /**
     * @var array map "host:port" of relay socket =>
     *      TurnAllocation, for reverse lookup when the
     *      stream_select loop wakes on a relay socket
     */
    protected $relay_index = [];
    /**
     * @var array nonces issued recently: nonce => issued_ts.
     *      We accept any nonce that is at most NONCE_TTL
     *      seconds old; older ones get a 438 stale-nonce.
     */
    protected $nonces = [];
    /**
     * Lifetime in seconds for nonces tracked in $nonces; requests
     * arriving with a nonce older than this are rejected with a
     * 438 stale-nonce error.
     */
    const NONCE_TTL = 3600;
    /**
     * Sets the authenticator. Returns $this so calls chain.
     *
     * @param TurnAuthenticator $authenticator credential lookup
     *      implementation used to validate USERNAME / MESSAGE-
     *      INTEGRITY pairs on inbound requests
     * @return $this for fluent chaining
     */
    public function auth($authenticator)
    {
        $this->authenticator = $authenticator;
        return $this;
    }
    /**
     * Sets the realm. Returns $this so calls chain.
     *
     * @param string $realm authentication realm advertised in the
     *      REALM attribute of 401 challenges (RFC 8656 sec 9.1)
     * @return $this for fluent chaining
     */
    public function realm($realm)
    {
        $this->realm = $realm;
        return $this;
    }
    /**
     * Sets the SOFTWARE attribute. Returns $this so calls
     * chain.
     *
     * @param string $software UTF-8 free-form server description
     *      added to outbound responses (RFC 5389 sec 15.10)
     * @return $this for fluent chaining
     */
    public function software($software)
    {
        $this->software = $software;
        return $this;
    }
    /**
     * Sets a piece of work to run between rounds of the listening loop,
     * and how often the loop should wake to run it. A relay otherwise
     * waits for a datagram and does nothing else, so a caller that has
     * to notice something outside the relay has no moment in which to
     * look. The work is run after each round, whether or not anything
     * arrived.
     *
     * @param callable $work run between rounds of the loop
     * @param float $seconds how long the loop waits for a datagram
     *      before running the work anyway
     * @return TurnSite this relay, so calls can be chained
     */
    public function betweenRounds(callable $work, $seconds = 1.0)
    {
        $this->between_rounds = $work;
        $this->between_seconds = (float)$seconds;
        return $this;
    }
    /**
     * Sets where the relay sends a line about what it is doing: a
     * datagram arriving, an allocation made or refreshed, and a request
     * turned away. A relay that only ever writes an "I am up" line
     * cannot show whether a browser reached it at all, so a caller that
     * wants that detail hands a function here and the relay calls it
     * with a plain sentence at each such moment. The relay keeps no log
     * of its own and names no file, so all the function is handed is the
     * words; the caller decides where they go.
     *
     * @param callable $work run with one string argument, the line to
     *      record, each time the relay has something to say
     * @return TurnSite this relay, so calls can be chained
     */
    public function onLog(callable $work)
    {
        $this->log_callback = $work;
        return $this;
    }
    /**
     * betweenWorkDue says whether enough time has passed that the work
     * handed to betweenRounds(), and the sweep for expired allocations,
     * should run again, and remembers the given time when it says yes.
     * The listen loop wakes once per datagram when traffic is flowing,
     * and running that work each wake made the relay do file writes for
     * every packet it carried: the process check behind the work writes
     * files each call, so each relayed packet paid a disk write and the
     * checks a call's connection depends on crawled. The loop asks here
     * instead, so the work runs at the pace the caller set and a packet
     * pays nothing extra.
     *
     * @param float $now the present moment as seconds with a fraction
     * @return bool whether the between-rounds work should run now
     */
    public function betweenWorkDue($now)
    {
        if (($now - $this->between_last) < $this->between_seconds) {
            return false;
        }
        $this->between_last = $now;
        return true;
    }
    /**
     * Sends one line to whatever onLog() was given, or does nothing
     * where a caller set no place to send it. Every place in the relay
     * that has something worth recording calls this rather than writing
     * anywhere itself, so the relay stays a piece of pure logic that
     * names no log file.
     *
     * @param string $line the sentence to record
     * @return void
     */
    protected function logLine($line)
    {
        if ($this->log_callback !== null) {
            call_user_func($this->log_callback, $line);
        }
    }
    /**
     * Asks the listening loop to end after the round it is in. The
     * relay closes the port it took and every port it handed out, and
     * listen() gives back true, so a caller can tell being told to stop
     * from failing to take the port at all.
     *
     * @return TurnSite this relay, so calls can be chained
     */
    /**
     * The address the relay took, as the operating system reports it,
     * or an empty string where it holds none. A caller writes this
     * down rather than repeating what it asked for, since the two can
     * differ: an address of 0.0.0.0 is reported as the port on every
     * address the machine has.
     *
     * @return string the address held, or an empty string
     */
    public function boundAddress()
    {
        if ($this->listener === null) {
            return "";
        }
        return (string)@stream_socket_get_name($this->listener, false);
    }
    /**
     * How many calls the relay is carrying: one for each pair of
     * clients it has handed a port to and has not yet let go.
     *
     * @return int the number of calls being carried
     */
    public function carrying()
    {
        return count($this->allocations);
    }
    /**
     * Asks the listening loop to end after the round it is in. The
     * relay closes the port it took and every port it handed out, and
     * listen() gives back true, so a caller can tell being told to
     * stop from failing to take the port at all.
     *
     * @return TurnSite this relay, so calls can be chained
     */
    public function stop()
    {
        $this->stopping = true;
        return $this;
    }
    /**
     * Sets the relay-port range. Returns $this so calls
     * chain.
     *
     * @param int $low inclusive lower bound of the UDP port range
     *      from which relay sockets are allocated
     * @param int $high inclusive upper bound of the same range
     * @return $this for fluent chaining
     */
    public function relayPortRange($low, $high)
    {
        $this->relay_port_low = (int) $low;
        $this->relay_port_high = (int) $high;
        return $this;
    }
    /**
     * Sets the address the relay binds each relayed socket on and hands
     * a browser as where to reach that socket. The address a browser is
     * given here is passed on to the other browser, which has to reach
     * it for the call to carry, so it has to be the machine's public
     * address rather than the loopback. A caller that runs the relay for
     * people outside the machine names that address here; where none is
     * named the relay keeps to the loopback, reachable only on the
     * machine itself.
     *
     * @param string $host public address of the machine the relay runs
     *      on, the one the two browsers can both reach
     * @return TurnSite this relay, so calls can be chained
     */
    public function relayAddress($host)
    {
        $this->relay_address = (string) $host;
        return $this;
    }
    /*
        --- IPv6 helpers (same pattern as FtpSite) ---
     */
    /**
     * Returns true if the host string contains a colon, i.e.
     * looks like an IPv6 literal.
     *
     * @param string $host host string to test
     * @return bool true when $host appears to be an IPv6 literal
     */
    protected function looksLikeIPv6($host)
    {
        return str_contains($host, ':');
    }
    /**
     * Formats a (host, port) pair as a stream-socket-style
     * udp:// URL, bracket-wrapping IPv6 literals so PHP
     * parses them correctly.
     *
     * @param string $host bind address (may include brackets)
     * @param int $port UDP port number
     * @return string udp:// URL suitable for stream_socket_server
     */
    protected function formatBindAddress($host, $port)
    {
        $host = trim($host, '[]');
        if ($this->looksLikeIPv6($host)) {
            return "udp://[$host]:$port";
        }
        return "udp://$host:$port";
    }
    /**
     * Splits a "host:port" or "[host]:port" string into
     * [host, port]. Returns [false, 0] on parse error.
     *
     * @param string $peer peer address string from a STUN/TURN
     *      message
     * @return array two-element list [string|false $host,
     *      int $port]; [false, 0] when the string can't be parsed
     */
    protected function splitHostPort($peer)
    {
        if ($peer === '' || $peer === false) {
            return [false, 0];
        }
        if ($peer[0] === '[') {
            $end = strpos($peer, ']');
            if ($end === false) {
                return [false, 0];
            }
            $host = substr($peer, 1, $end - 1);
            $port = (int) substr($peer, $end + 2);
            return [$host, $port];
        }
        $colon = strrpos($peer, ':');
        if ($colon === false) {
            return [false, 0];
        }
        return [substr($peer, 0, $colon),
            (int) substr($peer, $colon + 1)];
    }
    /*
        --- Server lifecycle ---
     */
    /**
     * Binds the UDP listener and runs the dispatch loop.
     * Returns when the loop exits (which only happens on
     * fatal listener error).
     *
     * Recognized configuration keys:
     *
     *   BIND          (string) listener bind address; "::"
     *                 binds dual-stack on most platforms,
     *                 "127.0.0.1" or "::1" for loopback only.
     *                 Default "127.0.0.1".
     *
     *   TURN_PORT     (int) UDP port for the listener.
     *                 Default 3478 (the IANA-assigned STUN
     *                 port; binding it requires privilege).
     *
     *   IDLE_TIMEOUT  (int) seconds; allocations idle longer
     *                 than this are torn down even if their
     *                 lifetime has not yet expired. Useful
     *                 for cleaning up after dead clients
     *                 that never send Refresh(lifetime=0).
     *                 Default 900.
     *
     * @param array $config configuration overrides; see method
     *      description above for recognised keys (BIND,
     *      TURN_PORT, IDLE_TIMEOUT)
     * @return void no return; the dispatch loop runs until
     *      either a fatal listener error or the process is
     *      killed
     */
    public function listen($config = [])
    {
        $defaults = [
            'BIND' => '127.0.0.1',
            'TURN_PORT' => 3478,
            'IDLE_TIMEOUT' => 900,
        ];
        $this->config = array_merge($defaults, $config);
        $bind = $this->config['BIND'];
        $port = $this->config['TURN_PORT'];
        $addr = $this->formatBindAddress($bind, $port);
        $errno = 0;
        $errstr = '';
        $listener = @stream_socket_server($addr,
            $errno, $errstr,
            STREAM_SERVER_BIND);
        if (!$listener) {
            echo "Failed to bind TURN $addr: $errstr\n";
            return false;
        }
        stream_set_blocking($listener, 0);
        $this->listener = $listener;
        echo "atto-turn listening: $addr\n";
        $this->loop();
        return true;
    }
    /**
     * Main dispatch loop. select()s across the listener
     * and every relay socket; routes incoming bytes to the
     * right handler depending on which socket woke up. Runs
     * whatever work betweenRounds() was given after each
     * round, and ends where that work has called stop().
     *
     * @return bool true once the loop has been asked to end,
     *      which is the only way it comes back
     */
    protected function loop()
    {
        while (true) {
            $reads = [$this->listener];
            foreach ($this->allocations as $alloc) {
                if ($alloc->relay_socket !== null) {
                    $reads[] = $alloc->relay_socket;
                }
            }
            $writes = null;
            $excepts = null;
            $wait = (int)floor($this->between_seconds);
            $wait_micro = (int)round(($this->between_seconds - $wait) *
                1000000);
            $n = @stream_select($reads, $writes, $excepts,
                $wait, $wait_micro);
            if ($n === false) {
                /*
                    EINTR or similar transient error. Loop
                    body restarts the select.
                 */
                continue;
            }
            if ($n > 0) {
                foreach ($reads as $sock) {
                    if ($sock === $this->listener) {
                        $this->onListenerReadable();
                    } else {
                        $this->onRelayReadable($sock);
                    }
                }
            }
            /* When traffic is flowing the select wakes once per
               datagram, so the sweep and the caller's work are held to
               the pace the caller set rather than run per packet; the
               process check behind that work writes files each call,
               and paying that for every packet slowed the relay path
               enough that a call's connection checks crawled. */
            if ($this->betweenWorkDue(microtime(true))) {
                $this->reapExpired();
                if ($this->between_rounds !== null) {
                    call_user_func($this->between_rounds, $this);
                }
            }
            if ($this->stopping) {
                $this->closeEverything();
                return true;
            }
        }
        return true;
    }
    /**
     * Closes the port the relay took and every port it handed out, so
     * nothing is left holding an address after the loop ends.
     */
    protected function closeEverything()
    {
        foreach ($this->allocations as $alloc) {
            if ($alloc->relay_socket !== null) {
                @fclose($alloc->relay_socket);
                $alloc->relay_socket = null;
            }
        }
        if ($this->listener !== null) {
            @fclose($this->listener);
            $this->listener = null;
        }
    }
    /**
     * Reads one datagram from the control listener and
     * dispatches it. STUN messages start with two zero
     * bits (because the type field's high bits are zero
     * for all defined methods), and ChannelData frames
     * start with bits 01 (the channel number falls in
     * 0x4000-0x7FFF). We discriminate on those two bits.
     */
    protected function onListenerReadable()
    {
        $peer = '';
        $buf = @stream_socket_recvfrom($this->listener,
            65535, 0, $peer);
        if ($buf === false || $buf === '' || $peer === '') {
            return;
        }
        if (strlen($buf) < 4) {
            $this->logLine("datagram from " . $peer . " too short (" .
                strlen($buf) . " bytes), ignored");
            return;
        }
        $first = ord($buf[0]);
        if (($first & 0xC0) === 0x00) {
            $this->logLine("STUN/TURN datagram from " . $peer . " (" .
                strlen($buf) . " bytes)");
            $this->handleStunMessage($buf, $peer);
        } else if (($first & 0xC0) === 0x40) {
            $this->logLine("channel-data datagram from " . $peer . " (" .
                strlen($buf) . " bytes)");
            $this->handleChannelData($buf, $peer);
        } else {
            $this->logLine("datagram from " . $peer .
                " with reserved leading bits, ignored");
        }
        /*
            Other top-bit patterns are reserved by RFC 7983
            for DTLS / RTP demultiplexing on a shared port;
            we ignore them silently.
         */
    }
    /**
     * Reads one datagram from a relay socket. Looks up the
     * owning allocation, then delivers the payload to the
     * client either as a ChannelData frame (if a channel
     * is bound for the peer) or as a STUN Data indication.
     *
     * @param resource $sock UDP relay socket reported readable
     *      by stream_select
     */
    protected function onRelayReadable($sock)
    {
        if (!is_resource($sock)) {
            return;
        }
        $peer = '';
        $buf = @stream_socket_recvfrom($sock, 65535, 0, $peer);
        if ($buf === false || $buf === '' || $peer === '') {
            return;
        }
        $local = stream_socket_get_name($sock, false);
        $key = $this->relayKey($local);
        if (!isset($this->relay_index[$key])) {
            return;
        }
        $alloc = $this->relay_index[$key];
        list($peer_host, $peer_port) =
            $this->splitHostPort($peer);
        if ($peer_host === false) {
            return;
        }
        /*
            Drop datagrams from unauthorized peers. RFC 8656
            sec 11.3: the server MUST silently discard any
            UDP datagram received on the relayed transport
            address whose source IP is not in the
            permissions list.
         */
        if (!isset($alloc->permissions[$peer_host]) ||
            $alloc->permissions[$peer_host] < time()) {
            return;
        }
        $peer_key = $peer_host . ":" . $peer_port;
        if (isset($alloc->channels_by_peer[$peer_key])) {
            $chan = $alloc->channels_by_peer[$peer_key];
            $this->sendChannelData($alloc, $chan, $buf);
        } else {
            $this->sendDataIndication($alloc, $peer_host,
                $peer_port, $buf);
        }
    }
    /**
     * Builds a stable key string from a "host:port" form
     * (with or without IPv6 brackets) so we can index the
     * allocations and relay maps with normalized keys.
     *
     * @param string $endpoint endpoint string as returned by
     *      stream_socket_get_name
     * @return string canonical "host:port" key, or empty string
     *      if $endpoint failed to parse
     */
    protected function relayKey($endpoint)
    {
        list($host, $port) = $this->splitHostPort($endpoint);
        if ($host === false) {
            return '';
        }
        return $host . ':' . $port;
    }
    /**
     * Walks the allocation table and removes anything past
     * its expiry. Also prunes expired permissions and
     * channels in surviving allocations.
     */
    protected function reapExpired()
    {
        $now = time();
        foreach ($this->allocations as $key => $alloc) {
            if ($alloc->expires <= $now) {
                $this->destroyAllocation($alloc);
                unset($this->allocations[$key]);
                continue;
            }
            foreach ($alloc->permissions as $host => $exp) {
                if ($exp <= $now) {
                    unset($alloc->permissions[$host]);
                }
            }
            foreach ($alloc->channels as $chan => $info) {
                list(, , $exp) = $info;
                if ($exp <= $now) {
                    unset($alloc->channels[$chan]);
                    $pkey = $info[0] . ':' . $info[1];
                    unset($alloc->channels_by_peer[$pkey]);
                }
            }
        }
        /* Prune nonces older than NONCE_TTL */
        foreach ($this->nonces as $nonce => $issued) {
            if ($issued + self::NONCE_TTL < $now) {
                unset($this->nonces[$nonce]);
            }
        }
    }
    /**
     * Tears down an allocation: closes the relay socket,
     * unregisters it from relay_index. Allocation removal
     * from the allocations map is the caller's job.
     *
     * @param TurnAllocation $alloc allocation to destroy
     */
    protected function destroyAllocation($alloc)
    {
        if ($alloc->relay_socket !== null) {
            $local = stream_socket_get_name(
                $alloc->relay_socket, false);
            $key = $this->relayKey($local);
            if ($key !== '' &&
                isset($this->relay_index[$key])) {
                unset($this->relay_index[$key]);
            }
            @fclose($alloc->relay_socket);
            $alloc->relay_socket = null;
        }
    }
    /*
        --- STUN message-type encoding (RFC 8489 sec 5) ---

        The 16-bit message type field embeds two 1-bit class
        indicators (C0, C1) and a 12-bit method:

            0..0..M11..M7..C1..M6..M4..C0..M3..M0

        which in plain English means: take the 12-bit method,
        spread bits 4..11 across positions 5..13 with a hole
        at bit 4 (C1) and another at bit 8 (C0); fill C0 and
        C1 from the class. The two top bits are always zero
        for valid STUN messages, which is why ChannelData
        (bits 01) is unambiguously distinguishable.

        Class encoding (high two bits packed into C0 and C1):

            request    = 00     (C1=0, C0=0)
            indication = 01     (C1=0, C0=1)
            success    = 10     (C1=1, C0=0)
            error      = 11     (C1=1, C0=1)
     */
    /**
     * Combines a method (12-bit) and a class (one of the
     * CLASS_* constants) into a 16-bit message-type value.
     *
     * @param int $method 12-bit STUN method number
     * @param int $class one of the CLASS_* constants (request,
     *      indication, success, error)
     * @return int 16-bit message-type field for the STUN header
     */
    protected function encodeMessageType($method, $class)
    {
        $m = $method;
        $low = ($m & 0x000F);
        $mid = ($m & 0x0070) << 1;
        $high = ($m & 0x0F80) << 2;
        $c0 = ($class & 0x0010);
        $c1 = ($class & 0x0100);
        return $low | $mid | $high | $c0 | $c1;
    }
    /**
     * Splits a 16-bit message-type into [method, class].
     *
     * @param int $type 16-bit message-type field as read from
     *      a STUN header
     * @return array two-element list [int $method, int $class]
     */
    protected function decodeMessageType($type)
    {
        $low = ($type & 0x000F);
        $mid = ($type & 0x00E0) >> 1;
        $high = ($type & 0x3E00) >> 2;
        $method = $low | $mid | $high;
        $c0 = ($type & 0x0010);
        $c1 = ($type & 0x0100);
        return [$method, $c0 | $c1];
    }
    /**
     * Parses a STUN message (header + attributes) into a
     * structured array:
     *
     *     [
     *         'method' => int,
     *         'class' => int,
     *         'tid' => string (12 raw bytes),
     *         'attrs' => [ [type => int, value => string], ... ],
     *         'mi_offset' => int|false  (byte offset of the
     *             MESSAGE-INTEGRITY attribute, for HMAC
     *             verification on a substring of the buffer),
     *     ]
     *
     * Returns false on parse failure.
     *
     * @param string $buf raw bytes of a STUN message
     * @return array|false parsed message structure as described
     *      above, or false if the buffer is malformed
     */
    protected function parseStun($buf)
    {
        if (strlen($buf) < 20) {
            return false;
        }
        $hdr = unpack('ntype/nlen/Ncookie', substr($buf, 0, 8));
        if ($hdr['cookie'] !== self::MAGIC_COOKIE) {
            return false;
        }
        $tid = substr($buf, 8, 12);
        $body_len = $hdr['len'];
        if (20 + $body_len > strlen($buf)) {
            return false;
        }
        list($method, $class) =
            $this->decodeMessageType($hdr['type']);
        $attrs = [];
        $off = 20;
        $end = 20 + $body_len;
        $mi_offset = false;
        while ($off + 4 <= $end) {
            $a = unpack('ntype/nlen', substr($buf, $off, 4));
            $atype = $a['type'];
            $alen = $a['len'];
            if ($off + 4 + $alen > $end) {
                return false;
            }
            $aval = substr($buf, $off + 4, $alen);
            if ($atype === self::ATTR_MESSAGE_INTEGRITY &&
                $mi_offset === false) {
                $mi_offset = $off;
            }
            $attrs[] = ['type' => $atype, 'value' => $aval];
            $off += 4 + $alen;
            $pad = (4 - ($alen % 4)) % 4;
            $off += $pad;
        }
        return [
            'method' => $method,
            'class' => $class,
            'tid' => $tid,
            'attrs' => $attrs,
            'mi_offset' => $mi_offset,
        ];
    }
    /**
     * Returns the value of the first attribute matching
     * the given type, or null if not present.
     *
     * @param array $msg parsed STUN message returned by parseStun
     * @param int $type attribute type code (one of ATTR_*)
     * @return string|null raw attribute value bytes, or null if
     *      no matching attribute appears in the message
     */
    protected function findAttr($msg, $type)
    {
        foreach ($msg['attrs'] as $a) {
            if ($a['type'] === $type) {
                return $a['value'];
            }
        }
        return null;
    }
    /**
     * Encodes a single attribute (type, length, value, with
     * 4-byte padding). Returns the attribute bytes.
     *
     * @param int $type attribute type code (one of ATTR_*)
     * @param string $value raw attribute value bytes
     * @return string TLV encoding of the attribute including
     *      trailing zero padding to a 4-byte boundary
     */
    protected function encodeAttr($type, $value)
    {
        $len = strlen($value);
        $out = pack('nn', $type, $len) . $value;
        $pad = (4 - ($len % 4)) % 4;
        if ($pad > 0) {
            $out .= str_repeat("\x00", $pad);
        }
        return $out;
    }
    /**
     * Builds a full STUN message: 20-byte header + attributes.
     * Computes the length field after the attributes are
     * laid out. If $with_mi is non-empty, computes and
     * appends a MESSAGE-INTEGRITY attribute using $with_mi
     * as the HMAC-SHA1 key.
     *
     * @param int $method 12-bit STUN method
     * @param int $class one of the CLASS_* constants
     * @param string $tid 12-byte transaction id
     * @param array $attr_blocks list of pre-encoded attribute
     *      strings (as returned by encodeAttr) to include in
     *      the message body
     * @param string $with_mi HMAC-SHA1 key for the long-term
     *      credential's MESSAGE-INTEGRITY, or empty string to
     *      skip the MI attribute
     * @return string full STUN message bytes ready to send
     */
    protected function buildStun($method, $class, $tid,
        $attr_blocks, $with_mi = '')
    {
        $body = implode('', $attr_blocks);
        if ($with_mi !== '') {
            /*
                Per RFC 8489 sec 14.5: the length field
                in the header counts everything AFTER the
                header up through the MESSAGE-INTEGRITY
                attribute, including its own 24-byte
                framing. Compute the HMAC over header (with
                provisional length set to body+24) plus
                everything before MESSAGE-INTEGRITY.
             */
            $provisional_len = strlen($body) + 24;
            $hdr = pack('nnN',
                $this->encodeMessageType($method, $class),
                $provisional_len, self::MAGIC_COOKIE) . $tid;
            $hmac = hash_hmac('sha1',
                $hdr . $body, $with_mi, true);
            $body .= $this->encodeAttr(
                self::ATTR_MESSAGE_INTEGRITY, $hmac);
        }
        /*
            RFC 8489 sec 14.7: a message may end with a
            FINGERPRINT, whose value is a checksum of
            everything before it with a fixed number mixed
            in. A browser puts one on what it sends and
            drops an answer that comes back without one, so
            every answer carries it. The length in the
            header counts the fingerprint too.
         */
        $with_print = strlen($body) + 8;
        $hdr = pack('nnN',
            $this->encodeMessageType($method, $class),
            $with_print, self::MAGIC_COOKIE) . $tid;
        $sum = (crc32($hdr . $body) ^ 0x5354554e) & 0xFFFFFFFF;
        $body .= $this->encodeAttr(self::ATTR_FINGERPRINT,
            pack('N', $sum));
        return $hdr . $body;
    }
    /**
     * Encodes XOR-MAPPED-ADDRESS or XOR-PEER-ADDRESS or
     * XOR-RELAYED-ADDRESS. RFC 8489 sec 14.2 defines the
     * XOR scheme: port XORed with high half of magic
     * cookie, address XORed with magic cookie (and TID
     * for IPv6).
     *
     * @param string $host IPv4 or IPv6 literal address
     * @param int $port UDP port number to encode
     * @param string $tid 12-byte STUN transaction id (used in
     *      the IPv6 XOR mask)
     * @return string raw attribute-value bytes for the
     *      XOR-*-ADDRESS attribute
     */
    protected function encodeXorAddr($host, $port, $tid)
    {
        $is_v6 = $this->looksLikeIPv6($host);
        $family = $is_v6 ? 0x02 : 0x01;
        $port_x = $port ^ ((self::MAGIC_COOKIE >> 16) & 0xFFFF);
        if ($is_v6) {
            $raw = inet_pton($host);
            $cookie_tid = pack('N', self::MAGIC_COOKIE) . $tid;
            $addr_x = $raw ^ $cookie_tid;
        } else {
            $raw = inet_pton($host);
            $addr_x = $raw ^ pack('N', self::MAGIC_COOKIE);
        }
        return pack('CCn', 0, $family, $port_x) . $addr_x;
    }
    /**
     * Decodes XOR-PEER-ADDRESS or similar. Returns
     * [host_string, port_int] or [false, 0] on parse
     * error.
     *
     * @param string $value raw attribute-value bytes from a
     *      XOR-*-ADDRESS attribute
     * @param string $tid 12-byte STUN transaction id (used to
     *      reverse the IPv6 XOR mask)
     * @return array two-element list [string|false $host,
     *      int $port]; [false, 0] when $value can't be parsed
     */
    protected function decodeXorAddr($value, $tid)
    {
        if (strlen($value) < 8) {
            return [false, 0];
        }
        $hdr = unpack('Czero/Cfamily/nport_x',
            substr($value, 0, 4));
        $port = $hdr['port_x'] ^
            ((self::MAGIC_COOKIE >> 16) & 0xFFFF);
        if ($hdr['family'] === 0x01) {
            if (strlen($value) < 8) {
                return [false, 0];
            }
            $addr_x = substr($value, 4, 4);
            $addr = $addr_x ^ pack('N', self::MAGIC_COOKIE);
            $host = inet_ntop($addr);
            return [$host, $port];
        } else if ($hdr['family'] === 0x02) {
            if (strlen($value) < 20) {
                return [false, 0];
            }
            $addr_x = substr($value, 4, 16);
            $cookie_tid = pack('N', self::MAGIC_COOKIE) . $tid;
            $addr = $addr_x ^ $cookie_tid;
            $host = inet_ntop($addr);
            return [$host, $port];
        }
        return [false, 0];
    }
    /**
     * Builds an ERROR-CODE attribute value. RFC 8489
     * encodes it as: 0x00 0x00 0x?? 0x?? + reason text,
     * where the first two zero bytes are reserved, the
     * third byte is the class (300..699 / 100), and the
     * fourth byte is the number modulo 100.
     *
     * @param int $code STUN error code (e.g. 401, 438, 500)
     * @param string $reason UTF-8 human-readable reason phrase
     * @return string raw attribute-value bytes for the
     *      ERROR-CODE attribute
     */
    protected function encodeErrorCode($code, $reason)
    {
        $cls = intdiv($code, 100);
        $num = $code % 100;
        return chr(0) . chr(0) . chr($cls) . chr($num) .
            $reason;
    }
    /*
        --- STUN message dispatch ---
     */
    /**
     * Handles one parsed STUN message arriving on the
     * listener. Routes by method to a handler method.
     *
     * @param string $buf raw datagram bytes
     * @param string $peer peer address as returned by
     *      stream_socket_recvfrom
     */
    protected function handleStunMessage($buf, $peer)
    {
        $msg = $this->parseStun($buf);
        if ($msg === false) {
            $this->logLine("datagram from " . $peer .
                " was not a STUN message the relay could read");
            return;
        }
        if ($msg['class'] !== self::CLASS_REQUEST &&
            $msg['class'] !== self::CLASS_INDICATION) {
            /*
                Responses arriving on the listener are
                unexpected for a server; we ignore them.
             */
            return;
        }
        $method_names = [
            self::METHOD_BINDING => "Binding",
            self::METHOD_ALLOCATE => "Allocate",
            self::METHOD_REFRESH => "Refresh",
            self::METHOD_CREATE_PERMISSION => "CreatePermission",
            self::METHOD_CHANNEL_BIND => "ChannelBind",
            self::METHOD_SEND => "Send"];
        $method_name = $method_names[$msg['method']] ?? ("method 0x" .
            dechex($msg['method']));
        $this->logLine($method_name . " request from " . $peer);
        switch ($msg['method']) {
            case self::METHOD_BINDING:
                $this->handleBinding($msg, $buf, $peer);
                break;
            case self::METHOD_ALLOCATE:
                $this->handleAllocate($msg, $buf, $peer);
                break;
            case self::METHOD_REFRESH:
                $this->handleRefresh($msg, $buf, $peer);
                break;
            case self::METHOD_CREATE_PERMISSION:
                $this->handleCreatePermission($msg, $buf,
                    $peer);
                break;
            case self::METHOD_CHANNEL_BIND:
                $this->handleChannelBind($msg, $buf, $peer);
                break;
            case self::METHOD_SEND:
                $this->handleSend($msg, $buf, $peer);
                break;
            default:
                /*
                    Unknown method -- per RFC 8489 sec 6.3.4
                    we should reply 420 with UNKNOWN-
                    ATTRIBUTES, but keeping the demo simple
                    a quiet drop is acceptable.
                 */
                break;
        }
    }
    /**
     * Handles a Binding request: returns the client's
     * reflexive address in XOR-MAPPED-ADDRESS. Bare STUN,
     * no authentication required.
     *
     * @param array $msg parsed STUN message from parseStun
     * @param string $buf raw datagram bytes (kept for symmetry
     *      with other handle* methods; not used here)
     * @param string $peer client address that sent the request
     */
    protected function handleBinding($msg, $buf, $peer)
    {
        list($host, $port) = $this->splitHostPort($peer);
        if ($host === false) {
            return;
        }
        $attrs = [
            $this->encodeAttr(self::ATTR_XOR_MAPPED_ADDRESS,
                $this->encodeXorAddr($host, $port,
                $msg['tid'])),
            $this->encodeAttr(self::ATTR_SOFTWARE,
                $this->software),
        ];
        $resp = $this->buildStun(self::METHOD_BINDING,
            self::CLASS_SUCCESS, $msg['tid'], $attrs);
        @stream_socket_sendto($this->listener, $resp,
            0, $peer);
        $this->logLine("told " . $peer . " its address is seen as " .
            $host . ":" . $port);
    }
    /*
        --- Authentication helper ---

        Long-term credential check (RFC 8489 sec 9.2).
        Returns either ['ok' => true, 'username' => ...,
        'key' => ...] on success or ['ok' => false,
        'code' => int, 'reason' => string, 'extra' => array]
        on failure. The caller composes the failure into a
        proper error response.
     */
    /**
     * Verifies the long-term credential on a request that
     * carries USERNAME, REALM, NONCE, and MESSAGE-INTEGRITY.
     * If any are missing or fail, returns the diagnostic
     * needed to send a 401 / 438 / similar.
     *
     * @param array $msg parsed STUN message from parseStun
     * @param string $buf raw datagram bytes (needed because
     *      MESSAGE-INTEGRITY HMAC covers a header-rewritten
     *      prefix of the message)
     * @return array on success ['ok' => true, 'username' =>
     *      string, 'key' => string]; on failure ['ok' => false,
     *      'code' => int, 'reason' => string, 'extra' => array]
     *      where the caller composes 'code' and 'reason' into
     *      an ERROR-CODE attribute and the 'extra' attributes
     *      (REALM, NONCE) into the error response
     */
    protected function checkCredentials($msg, $buf)
    {
        if ($this->authenticator === null) {
            return ['ok' => false, 'code' => 401,
                'reason' => 'Authentication required'];
        }
        $username = $this->findAttr($msg, self::ATTR_USERNAME);
        $realm = $this->findAttr($msg, self::ATTR_REALM);
        $nonce = $this->findAttr($msg, self::ATTR_NONCE);
        $mi = $this->findAttr($msg,
            self::ATTR_MESSAGE_INTEGRITY);
        $extra = [];
        if ($username === null || $realm === null ||
            $nonce === null || $mi === null ||
            $msg['mi_offset'] === false) {
            $nonce_new = $this->mintNonce();
            $extra['REALM'] = $this->realm;
            $extra['NONCE'] = $nonce_new;
            return ['ok' => false, 'code' => 401,
                'reason' => 'Unauthorized',
                'extra' => $extra];
        }
        if (!isset($this->nonces[$nonce])) {
            $nonce_new = $this->mintNonce();
            $extra['REALM'] = $this->realm;
            $extra['NONCE'] = $nonce_new;
            return ['ok' => false, 'code' => 438,
                'reason' => 'Stale nonce',
                'extra' => $extra];
        }
        $info = $this->authenticator->lookupUser($username);
        if ($info === false) {
            $nonce_new = $this->mintNonce();
            $extra['REALM'] = $this->realm;
            $extra['NONCE'] = $nonce_new;
            return ['ok' => false, 'code' => 401,
                'reason' => 'Unknown user',
                'extra' => $extra];
        }
        if (isset($info['key'])) {
            $key = $info['key'];
        } else {
            $key = md5($username . ':' . $realm . ':' .
                $info['password'], true);
        }
        /*
            Recompute MESSAGE-INTEGRITY over the buffer up
            to (but not including) the attribute itself,
            with the header length field rewritten to
            reflect the position of MESSAGE-INTEGRITY.
         */
        $verify_len = $msg['mi_offset'] + 24 - 20;
        $hdr_fixed = substr($buf, 0, 2) .
            pack('n', $verify_len) . substr($buf, 4, 16);
        $body = substr($buf, 20, $msg['mi_offset'] - 20);
        $expected = hash_hmac('sha1', $hdr_fixed . $body,
            $key, true);
        if (!hash_equals($expected, $mi)) {
            $nonce_new = $this->mintNonce();
            $extra['REALM'] = $this->realm;
            $extra['NONCE'] = $nonce_new;
            return ['ok' => false, 'code' => 401,
                'reason' => 'Bad MESSAGE-INTEGRITY',
                'extra' => $extra];
        }
        return ['ok' => true,
            'username' => $username, 'key' => $key];
    }
    /**
     * Issues a fresh nonce, records its mint time, and
     * returns it. RFC 8489 sec 9.2 only requires the nonce
     * be unique and unguessable; we use 16 hex chars from
     * random_bytes which is more than sufficient.
     *
     * @return string 16-character hex nonce, already inserted
     *      into the $nonces map so a follow-up request can be
     *      validated against NONCE_TTL
     */
    protected function mintNonce()
    {
        $n = bin2hex(random_bytes(8));
        $this->nonces[$n] = time();
        return $n;
    }
    /**
     * Sends a STUN error response over the listener. The
     * 'extra' hash adds REALM/NONCE/USERNAME/etc.
     *
     * @param int $method STUN method of the request being
     *      replied to
     * @param array $msg parsed STUN message of the request
     * @param string $peer client address to send the response to
     * @param int $code STUN error code (e.g. 401, 438, 500)
     * @param string $reason UTF-8 reason phrase
     * @param array $extra optional extra attribute values keyed
     *      by attribute name (REALM, NONCE, USERNAME, ...)
     * @param string $key HMAC-SHA1 key for MESSAGE-INTEGRITY on
     *      the response, or empty string to omit MI
     */
    protected function sendError($method, $msg, $peer, $code,
        $reason, $extra = [], $key = '')
    {
        $blocks = [
            $this->encodeAttr(self::ATTR_ERROR_CODE,
                $this->encodeErrorCode($code, $reason)),
        ];
        foreach ($extra as $k => $v) {
            switch ($k) {
                case 'REALM':
                    $blocks[] = $this->encodeAttr(
                        self::ATTR_REALM, $v);
                    break;
                case 'NONCE':
                    $blocks[] = $this->encodeAttr(
                        self::ATTR_NONCE, $v);
                    break;
            }
        }
        $blocks[] = $this->encodeAttr(self::ATTR_SOFTWARE,
            $this->software);
        $resp = $this->buildStun($method,
            self::CLASS_ERROR, $msg['tid'], $blocks, $key);
        @stream_socket_sendto($this->listener, $resp, 0,
            $peer);
        $this->logLine("answered " . $peer . " with " . $code . " " .
            $reason);
    }
    /*
        --- TURN Allocate / Refresh ---
     */
    /**
     * Handles an Allocate request. RFC 8656 sec 7.
     * Authenticates with long-term credentials, validates
     * REQUESTED-TRANSPORT (must be UDP), allocates a relay
     * port on the same family the client used, and replies
     * with XOR-RELAYED-ADDRESS, XOR-MAPPED-ADDRESS, and
     * LIFETIME.
     *
     * @param array $msg parsed STUN message from parseStun
     * @param string $buf raw datagram bytes
     * @param string $peer client address that sent the request
     */
    protected function handleAllocate($msg, $buf, $peer)
    {
        list($host, $port) = $this->splitHostPort($peer);
        if ($host === false) {
            return;
        }
        $key5 = $host . ':' . $port;
        $cred = $this->checkCredentials($msg, $buf);
        if (!$cred['ok']) {
            $this->sendError(self::METHOD_ALLOCATE, $msg,
                $peer, $cred['code'], $cred['reason'],
                $cred['extra']);
            return;
        }
        if (isset($this->allocations[$key5])) {
            /*
                RFC 8656 sec 7.2: a 437 Allocation Mismatch
                response is the right answer when an
                allocation already exists for this 5-tuple
                and the new request does not match it. For
                the demo we keep this simple: reject.
             */
            $this->sendError(self::METHOD_ALLOCATE, $msg,
                $peer, 437, 'Allocation Mismatch', [],
                $cred['key']);
            return;
        }
        $rt = $this->findAttr($msg,
            self::ATTR_REQUESTED_TRANSPORT);
        if ($rt === null || strlen($rt) < 4) {
            $this->sendError(self::METHOD_ALLOCATE, $msg,
                $peer, 400, 'Bad Request', [], $cred['key']);
            return;
        }
        $proto = ord($rt[0]);
        if ($proto !== 17) {
            /*
                RFC 8656 sec 7.2 only requires UDP support;
                17 is the IANA UDP protocol number. TCP
                allocations are RFC 6062.
             */
            $this->sendError(self::METHOD_ALLOCATE, $msg,
                $peer, 442, 'Unsupported Transport Protocol',
                [], $cred['key']);
            return;
        }
        list($relay_sock, $relay_host, $relay_port) =
            $this->bindRelaySocket($host);
        if ($relay_sock === false) {
            $this->sendError(self::METHOD_ALLOCATE, $msg,
                $peer, 508, 'Insufficient Capacity', [],
                $cred['key']);
            return;
        }
        $lifetime = self::DEFAULT_ALLOCATION_LIFETIME;
        $req_lt = $this->findAttr($msg, self::ATTR_LIFETIME);
        if ($req_lt !== null && strlen($req_lt) === 4) {
            $u = unpack('Nv', $req_lt);
            $req_seconds = $u['v'];
            if ($req_seconds > 0) {
                $lifetime = min($req_seconds,
                    self::MAX_ALLOCATION_LIFETIME);
            }
        }
        $alloc = new TurnAllocation();
        $alloc->client_host = $host;
        $alloc->client_port = $port;
        $alloc->family = $this->looksLikeIPv6($host) ? 6 : 4;
        $alloc->relay_socket = $relay_sock;
        $alloc->relay_host = $relay_host;
        $alloc->relay_port = $relay_port;
        $alloc->username = $cred['username'];
        $alloc->expires = time() + $lifetime;
        $this->allocations[$key5] = $alloc;
        /*
            The reverse lookup when a datagram arrives on the relayed
            socket reads that socket's own bound address, which is the
            real local one and not the public address handed to the
            browser; key the index by that same local address so an
            inbound datagram finds its allocation. Where the two are the
            same, as on the loopback, this is the address either way.
         */
        $relay_local = stream_socket_get_name($relay_sock, false);
        $alloc->relay_local_key = $this->relayKey($relay_local);
        $this->relay_index[$alloc->relay_local_key] = $alloc;
        $blocks = [
            $this->encodeAttr(
                self::ATTR_XOR_RELAYED_ADDRESS,
                $this->encodeXorAddr($relay_host,
                $relay_port, $msg['tid'])),
            $this->encodeAttr(
                self::ATTR_XOR_MAPPED_ADDRESS,
                $this->encodeXorAddr($host, $port,
                $msg['tid'])),
            $this->encodeAttr(self::ATTR_LIFETIME,
                pack('N', $lifetime)),
            $this->encodeAttr(self::ATTR_SOFTWARE,
                $this->software),
        ];
        $resp = $this->buildStun(self::METHOD_ALLOCATE,
            self::CLASS_SUCCESS, $msg['tid'], $blocks,
            $cred['key']);
        @stream_socket_sendto($this->listener, $resp, 0,
            $peer);
        $this->logLine("gave " . $peer . " a relay address of " .
            $relay_host . ":" . $relay_port . " for " . $lifetime .
            "s");
    }
    /**
     * Picks a free port in the configured relay range and
     * binds a UDP socket on the same family as the client.
     * Returns [socket, host, port] on success or
     * [false, '', 0] on failure.
     *
     * @param string $client_host client IP literal, used to pick
     *      a relay-side bind address on the same address family
     * @return array three-element list [resource|false $socket,
     *      string $host, int $port]; [false, '', 0] when no port
     *      could be allocated in the configured range
     */
    protected function bindRelaySocket($client_host)
    {
        $is_v6 = $this->looksLikeIPv6($client_host);
        /*
            Where a public address was named, bind the relayed socket on
            every address of that family so a peer out on the internet
            can reach it, and hand the browser the named public address
            to reach it at. A browser passes that address to the other
            browser, so it has to be one the other browser can reach.
            Where none was named, keep to the loopback as before, which
            serves only a browser on the same machine.
         */
        $advertised = $this->relay_address;
        if ($advertised !== "") {
            $bind_host = $is_v6 ? '::' : '0.0.0.0';
            $report_host = $advertised;
        } else {
            $bind_host = $is_v6 ? '::1' : '127.0.0.1';
            $report_host = $bind_host;
        }
        $tries = 0;
        $low = $this->relay_port_low;
        $high = $this->relay_port_high;
        $max_tries = max(8, ($high - $low + 1));
        while ($tries < $max_tries) {
            $port = random_int($low, $high);
            $addr = $this->formatBindAddress($bind_host,
                $port);
            $sock = @stream_socket_server($addr,
                $errno, $errstr, STREAM_SERVER_BIND);
            if ($sock !== false) {
                stream_set_blocking($sock, 0);
                return [$sock, $report_host, $port];
            }
            $tries++;
        }
        return [false, '', 0];
    }
    /**
     * Handles a Refresh request. RFC 8656 sec 8. With
     * lifetime>0 it extends the allocation; with lifetime=0
     * it tears the allocation down.
     *
     * @param array $msg parsed STUN message from parseStun
     * @param string $buf raw datagram bytes
     * @param string $peer client address that sent the request
     */
    protected function handleRefresh($msg, $buf, $peer)
    {
        list($host, $port) = $this->splitHostPort($peer);
        if ($host === false) {
            return;
        }
        $key5 = $host . ':' . $port;
        $cred = $this->checkCredentials($msg, $buf);
        if (!$cred['ok']) {
            $this->sendError(self::METHOD_REFRESH, $msg,
                $peer, $cred['code'], $cred['reason'],
                $cred['extra']);
            return;
        }
        if (!isset($this->allocations[$key5])) {
            $this->sendError(self::METHOD_REFRESH, $msg,
                $peer, 437, 'Allocation Mismatch', [],
                $cred['key']);
            return;
        }
        $alloc = $this->allocations[$key5];
        $lifetime = self::DEFAULT_ALLOCATION_LIFETIME;
        $req_lt = $this->findAttr($msg, self::ATTR_LIFETIME);
        if ($req_lt !== null && strlen($req_lt) === 4) {
            $u = unpack('Nv', $req_lt);
            $req_seconds = $u['v'];
            $lifetime = min($req_seconds,
                self::MAX_ALLOCATION_LIFETIME);
        }
        if ($lifetime === 0) {
            $this->destroyAllocation($alloc);
            unset($this->allocations[$key5]);
            $blocks = [
                $this->encodeAttr(self::ATTR_LIFETIME,
                    pack('N', 0)),
                $this->encodeAttr(self::ATTR_SOFTWARE,
                    $this->software),
            ];
            $resp = $this->buildStun(self::METHOD_REFRESH,
                self::CLASS_SUCCESS, $msg['tid'], $blocks,
                $cred['key']);
            @stream_socket_sendto($this->listener, $resp,
                0, $peer);
            return;
        }
        $alloc->expires = time() + $lifetime;
        $blocks = [
            $this->encodeAttr(self::ATTR_LIFETIME,
                pack('N', $lifetime)),
            $this->encodeAttr(self::ATTR_SOFTWARE,
                $this->software),
        ];
        $resp = $this->buildStun(self::METHOD_REFRESH,
            self::CLASS_SUCCESS, $msg['tid'], $blocks,
            $cred['key']);
        @stream_socket_sendto($this->listener, $resp, 0,
            $peer);
    }
    /*
        --- TURN CreatePermission / ChannelBind / Send ---
     */
    /**
     * Handles a CreatePermission request. RFC 8656 sec 9.
     * Adds (or refreshes) one or more peer-IP entries in
     * the allocation's permission table.
     *
     * @param array $msg parsed STUN message from parseStun
     * @param string $buf raw datagram bytes
     * @param string $peer client address that sent the request
     */
    protected function handleCreatePermission($msg, $buf,
        $peer)
    {
        list($host, $port) = $this->splitHostPort($peer);
        if ($host === false) {
            return;
        }
        $key5 = $host . ':' . $port;
        $cred = $this->checkCredentials($msg, $buf);
        if (!$cred['ok']) {
            $this->sendError(self::METHOD_CREATE_PERMISSION,
                $msg, $peer, $cred['code'], $cred['reason'],
                $cred['extra']);
            return;
        }
        if (!isset($this->allocations[$key5])) {
            $this->sendError(self::METHOD_CREATE_PERMISSION,
                $msg, $peer, 437, 'Allocation Mismatch', [],
                $cred['key']);
            return;
        }
        $alloc = $this->allocations[$key5];
        $any = false;
        foreach ($msg['attrs'] as $a) {
            if ($a['type'] !== self::ATTR_XOR_PEER_ADDRESS) {
                continue;
            }
            list($ph, $pp) = $this->decodeXorAddr(
                $a['value'], $msg['tid']);
            if ($ph === false) {
                continue;
            }
            $alloc->permissions[$ph] = time() +
                self::PERMISSION_LIFETIME;
            $any = true;
        }
        if (!$any) {
            $this->sendError(self::METHOD_CREATE_PERMISSION,
                $msg, $peer, 400, 'Bad Request', [],
                $cred['key']);
            return;
        }
        $blocks = [
            $this->encodeAttr(self::ATTR_SOFTWARE,
                $this->software),
        ];
        $resp = $this->buildStun(
            self::METHOD_CREATE_PERMISSION,
            self::CLASS_SUCCESS, $msg['tid'], $blocks,
            $cred['key']);
        @stream_socket_sendto($this->listener, $resp, 0,
            $peer);
    }
    /**
     * Handles a ChannelBind request. RFC 8656 sec 12.
     * Binds a channel number (0x4000-0x4FFF) to a peer
     * (host, port) pair. Implicitly creates a permission
     * for the peer host. Refreshes the binding if it
     * already exists.
     *
     * @param array $msg parsed STUN message from parseStun
     * @param string $buf raw datagram bytes
     * @param string $peer client address that sent the request
     */
    protected function handleChannelBind($msg, $buf, $peer)
    {
        list($host, $port) = $this->splitHostPort($peer);
        if ($host === false) {
            return;
        }
        $key5 = $host . ':' . $port;
        $cred = $this->checkCredentials($msg, $buf);
        if (!$cred['ok']) {
            $this->sendError(self::METHOD_CHANNEL_BIND, $msg,
                $peer, $cred['code'], $cred['reason'],
                $cred['extra']);
            return;
        }
        if (!isset($this->allocations[$key5])) {
            $this->sendError(self::METHOD_CHANNEL_BIND, $msg,
                $peer, 437, 'Allocation Mismatch', [],
                $cred['key']);
            return;
        }
        $alloc = $this->allocations[$key5];
        $cn_attr = $this->findAttr($msg,
            self::ATTR_CHANNEL_NUMBER);
        $pa_attr = $this->findAttr($msg,
            self::ATTR_XOR_PEER_ADDRESS);
        if ($cn_attr === null || strlen($cn_attr) < 4 ||
            $pa_attr === null) {
            $this->sendError(self::METHOD_CHANNEL_BIND, $msg,
                $peer, 400, 'Bad Request', [],
                $cred['key']);
            return;
        }
        $cn = unpack('nchan', $cn_attr);
        $chan_num = $cn['chan'];
        if ($chan_num < self::MIN_CHANNEL_NUMBER ||
            $chan_num > self::MAX_CHANNEL_NUMBER) {
            $this->sendError(self::METHOD_CHANNEL_BIND, $msg,
                $peer, 400, 'Channel out of range', [],
                $cred['key']);
            return;
        }
        list($ph, $pp) = $this->decodeXorAddr($pa_attr,
            $msg['tid']);
        if ($ph === false) {
            $this->sendError(self::METHOD_CHANNEL_BIND, $msg,
                $peer, 400, 'Bad peer address', [],
                $cred['key']);
            return;
        }
        $peer_key = $ph . ':' . $pp;
        /*
            Per RFC 8656 sec 12: a channel number can be
            bound to at most one peer, and a peer can be
            bound to at most one channel. Reject conflicts
            with 400.
         */
        foreach ($alloc->channels as $other_chan => $info) {
            if ($other_chan === $chan_num) {
                continue;
            }
            if ($info[0] === $ph && $info[1] === $pp) {
                $this->sendError(
                    self::METHOD_CHANNEL_BIND, $msg, $peer,
                    400, 'Peer already bound', [],
                    $cred['key']);
                return;
            }
        }
        if (isset($alloc->channels[$chan_num])) {
            $existing = $alloc->channels[$chan_num];
            if ($existing[0] !== $ph || $existing[1] !== $pp) {
                $this->sendError(
                    self::METHOD_CHANNEL_BIND, $msg, $peer,
                    400, 'Channel already bound', [],
                    $cred['key']);
                return;
            }
        }
        $exp = time() + self::CHANNEL_LIFETIME;
        $alloc->channels[$chan_num] = [$ph, $pp, $exp];
        $alloc->channels_by_peer[$peer_key] = $chan_num;
        /*
            ChannelBind implicitly creates a permission for
            the peer's IP per RFC 8656 sec 12.2.
         */
        $alloc->permissions[$ph] = time() +
            self::PERMISSION_LIFETIME;
        $blocks = [
            $this->encodeAttr(self::ATTR_SOFTWARE,
                $this->software),
        ];
        $resp = $this->buildStun(self::METHOD_CHANNEL_BIND,
            self::CLASS_SUCCESS, $msg['tid'], $blocks,
            $cred['key']);
        @stream_socket_sendto($this->listener, $resp, 0,
            $peer);
    }
    /**
     * Handles a Send indication. Indications carry no
     * MESSAGE-INTEGRITY (RFC 8656 sec 11.1) -- the
     * allocation itself is the credential. Forwards the
     * data to the peer over the relay socket if a
     * permission is held.
     *
     * @param array $msg parsed STUN message from parseStun
     * @param string $buf raw datagram bytes
     * @param string $peer client address that sent the request
     */
    protected function handleSend($msg, $buf, $peer)
    {
        list($host, $port) = $this->splitHostPort($peer);
        if ($host === false) {
            return;
        }
        $key5 = $host . ':' . $port;
        if (!isset($this->allocations[$key5])) {
            return;
        }
        $alloc = $this->allocations[$key5];
        $pa_attr = $this->findAttr($msg,
            self::ATTR_XOR_PEER_ADDRESS);
        $data_attr = $this->findAttr($msg,
            self::ATTR_DATA);
        if ($pa_attr === null || $data_attr === null) {
            return;
        }
        list($ph, $pp) = $this->decodeXorAddr($pa_attr,
            $msg['tid']);
        if ($ph === false) {
            return;
        }
        if (!isset($alloc->permissions[$ph]) ||
            $alloc->permissions[$ph] < time()) {
            /*
                RFC 8656 sec 11.3: silently discard if no
                permission is held for the peer.
             */
            return;
        }
        $dest = $this->formatBindAddress($ph, $pp);
        /*
            stream_socket_sendto for UDP wants "host:port"
            without the udp:// scheme; use the bracket-
            wrapped form for v6 addresses.
         */
        $sendto = $this->looksLikeIPv6($ph) ?
            "[$ph]:$pp" : "$ph:$pp";
        if (!is_resource($alloc->relay_socket)) {
            return;
        }
        @stream_socket_sendto($alloc->relay_socket,
            $data_attr, 0, $sendto);
    }
    /**
     * Handles a ChannelData frame. The first 4 bytes are
     * a channel number (0x4000-0x4FFF) and a 16-bit
     * length; the rest is the payload. Looks up the
     * channel and forwards to the bound peer.
     *
     * @param string $buf raw ChannelData frame bytes
     * @param string $peer client address that sent the frame
     */
    protected function handleChannelData($buf, $peer)
    {
        if (strlen($buf) < 4) {
            return;
        }
        $hdr = unpack('nchan/nlen', substr($buf, 0, 4));
        $chan_num = $hdr['chan'];
        $len = $hdr['len'];
        if ($chan_num < self::MIN_CHANNEL_NUMBER ||
            $chan_num > self::MAX_CHANNEL_NUMBER) {
            return;
        }
        if (strlen($buf) < 4 + $len) {
            return;
        }
        $payload = substr($buf, 4, $len);
        list($host, $port) = $this->splitHostPort($peer);
        if ($host === false) {
            return;
        }
        $key5 = $host . ':' . $port;
        if (!isset($this->allocations[$key5])) {
            return;
        }
        $alloc = $this->allocations[$key5];
        if (!isset($alloc->channels[$chan_num])) {
            return;
        }
        list($ph, $pp, ) = $alloc->channels[$chan_num];
        if (!isset($alloc->permissions[$ph]) ||
            $alloc->permissions[$ph] < time()) {
            return;
        }
        $sendto = $this->looksLikeIPv6($ph) ?
            "[$ph]:$pp" : "$ph:$pp";
        if (!is_resource($alloc->relay_socket)) {
            return;
        }
        @stream_socket_sendto($alloc->relay_socket,
            $payload, 0, $sendto);
    }
    /**
     * Sends a Data indication: wraps a peer-to-client
     * datagram in a STUN message with XOR-PEER-ADDRESS
     * and DATA attributes.
     *
     * @param TurnAllocation $alloc allocation whose client
     *      should receive the indication
     * @param string $peer_host source peer IP literal
     * @param int $peer_port source peer UDP port
     * @param string $payload bytes to relay to the client
     */
    protected function sendDataIndication($alloc,
        $peer_host, $peer_port, $payload)
    {
        $tid = random_bytes(12);
        $blocks = [
            $this->encodeAttr(self::ATTR_XOR_PEER_ADDRESS,
                $this->encodeXorAddr($peer_host, $peer_port,
                $tid)),
            $this->encodeAttr(self::ATTR_DATA, $payload),
        ];
        $resp = $this->buildStun(self::METHOD_DATA,
            self::CLASS_INDICATION, $tid, $blocks);
        $sendto = $this->looksLikeIPv6($alloc->client_host) ?
            "[" . $alloc->client_host . "]:" .
            $alloc->client_port :
            $alloc->client_host . ":" . $alloc->client_port;
        @stream_socket_sendto($this->listener, $resp, 0,
            $sendto);
    }
    /**
     * Sends a ChannelData frame: 4-byte header + payload,
     * padded to 4 bytes for UDP (per RFC 8656 sec 12.4
     * the padding is required only for TCP/TLS but adding
     * it on UDP is harmless and clients tolerate it).
     *
     * @param TurnAllocation $alloc allocation whose client
     *      should receive the frame
     * @param int $chan_num channel number bound by the client
     *      (0x4000..0x4FFF)
     * @param string $payload bytes to wrap in the ChannelData
     *      frame
     */
    protected function sendChannelData($alloc, $chan_num,
        $payload)
    {
        $frame = pack('nn', $chan_num, strlen($payload)) .
            $payload;
        $sendto = $this->looksLikeIPv6($alloc->client_host) ?
            "[" . $alloc->client_host . "]:" .
            $alloc->client_port :
            $alloc->client_host . ":" . $alloc->client_port;
        @stream_socket_sendto($this->listener, $frame, 0,
            $sendto);
    }
}
