<?php
/**
 * RouterOS API class - PHP 8 safe, RouterOS v6/v7 compatible login.
 * Supports comm('/path', ['name'=>'x', '?name'=>'x', '.id'=>'*1'])
 */
class RouterosAPI {
    public bool $debug = false;
    public int $port = 8728;
    public int $timeout = 6;
    public int $attempts = 1;
    public bool $connected = false;
    public int $delay = 1;
    public $socket = null;
    public array $lastResponse = [];
    public string $lastError = '';

    public function connect(string $ip, string $login, string $password): bool {
        $this->disconnect();
        $this->lastError = '';

        // Allow config style MT_HOST='vpn.example.com:7105' or MT_HOST='vpn.example.com' + MT_PORT=7105.
        // If host contains :port and it is not an IPv6 literal, use that port automatically.
        $host = trim($ip);
        $port = $this->port;
        if (substr_count($host, ':') === 1 && preg_match('/^(.*):(\d+)$/', $host, $m)) {
            $host = $m[1];
            $port = (int)$m[2];
        }

        for ($i = 1; $i <= max(1, $this->attempts); $i++) {
            $errno = 0; $errstr = '';
            $this->socket = @fsockopen($host, $port, $errno, $errstr, $this->timeout);
            if (is_resource($this->socket)) break;
            $this->lastError = trim($errstr ?: ('socket error '.$errno));
            if ($i < $this->attempts) sleep($this->delay);
        }

        if (!is_resource($this->socket)) {
            $this->socket = null;
            return false;
        }
        stream_set_timeout($this->socket, $this->timeout);

        // RouterOS v6.43+ and v7 login method
        $this->write('/login', false);
        $this->write('=name=' . $login, false);
        $this->write('=password=' . $password, true);
        $response = $this->read(false);
        if ($this->hasDone($response) && !$this->hasTrap($response)) {
            $this->connected = true;
            return true;
        }

        // Some old RouterOS versions use challenge/response login.
        $ret = $this->findRet($response);
        if ($ret !== '') {
            $this->write('/login', false);
            $this->write('=name=' . $login, false);
            $this->write('=response=00' . md5(chr(0) . $password . pack('H*', $ret)), true);
            $response = $this->read(false);
            if ($this->hasDone($response) && !$this->hasTrap($response)) {
                $this->connected = true;
                return true;
            }
        }

        $this->lastError = $this->trapMessage($response) ?: 'login failed';
        $this->disconnect();
        return false;
    }

    public function disconnect(): void {
        if (is_resource($this->socket)) {
            @fclose($this->socket);
        }
        $this->socket = null;
        $this->connected = false;
    }

    public function comm(string $command, array $params = []): array {
        if (!is_resource($this->socket)) {
            throw new Exception('RouterOS API is not connected');
        }
        $this->write($command, false);
        foreach ($params as $key => $value) {
            $key = (string)$key;
            if ($key === '') continue;
            if ($key[0] === '?') {
                $this->write($key . '=' . $value, false);       // ?name=user
            } elseif ($key[0] === '=') {
                $this->write($key . $value, false);             // raw =name=value
            } else {
                $this->write('=' . $key . '=' . $value, false); // name=value or .id=*1
            }
        }
        $this->write('', true);
        $words = $this->read(false);
        if ($this->hasTrap($words)) {
            throw new Exception('RouterOS API trap: ' . ($this->trapMessage($words) ?: 'unknown'));
        }
        return $this->parseRecords($words);
    }

    public function write(string $command, bool $last = true): void {
        if (!is_resource($this->socket)) return;
        $parts = explode("\n", $command);
        foreach ($parts as $part) {
            if ($part !== '') $this->writeWord($part);
        }
        if ($last) $this->writeWord('');
    }

    private function writeWord(string $word): void {
        if (!is_resource($this->socket)) return;
        $len = strlen($word);
        if ($len < 0x80) {
            $prefix = chr($len);
        } elseif ($len < 0x4000) {
            $len |= 0x8000;
            $prefix = chr(($len >> 8) & 0xFF) . chr($len & 0xFF);
        } elseif ($len < 0x200000) {
            $len |= 0xC00000;
            $prefix = chr(($len >> 16) & 0xFF) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF);
        } elseif ($len < 0x10000000) {
            $len |= 0xE0000000;
            $prefix = chr(($len >> 24) & 0xFF) . chr(($len >> 16) & 0xFF) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF);
        } else {
            $prefix = chr(0xF0) . chr(($len >> 24) & 0xFF) . chr(($len >> 16) & 0xFF) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF);
        }
        fwrite($this->socket, $prefix . $word);
    }

    public function read(bool $parse = true): array {
        $response = [];
        while (is_resource($this->socket)) {
            $word = $this->readWord();
            if ($word === null) break;
            if ($word === '') {
                // End of one reply sentence. RouterOS replies end with !done.
                if (!empty($response) && in_array('!done', $response, true)) break;
                continue;
            }
            $response[] = $word;
        }
        if ($parse) $this->lastResponse = $this->parseRecords($response);
        return $response;
    }

    private function readWord(): ?string {
        if (!is_resource($this->socket)) return null;
        $b = fread($this->socket, 1);
        if ($b === '' || $b === false) return null;
        $c = ord($b);
        if (($c & 0x80) == 0x00) {
            $len = $c;
        } elseif (($c & 0xC0) == 0x80) {
            $b2 = fread($this->socket, 1); if ($b2 === '' || $b2 === false) return null;
            $len = (($c & ~0xC0) << 8) + ord($b2);
        } elseif (($c & 0xE0) == 0xC0) {
            $b2 = fread($this->socket, 2); if (strlen($b2) < 2) return null;
            $len = (($c & ~0xE0) << 16) + (ord($b2[0]) << 8) + ord($b2[1]);
        } elseif (($c & 0xF0) == 0xE0) {
            $b2 = fread($this->socket, 3); if (strlen($b2) < 3) return null;
            $len = (($c & ~0xF0) << 24) + (ord($b2[0]) << 16) + (ord($b2[1]) << 8) + ord($b2[2]);
        } else {
            $b2 = fread($this->socket, 4); if (strlen($b2) < 4) return null;
            $len = (ord($b2[0]) << 24) + (ord($b2[1]) << 16) + (ord($b2[2]) << 8) + ord($b2[3]);
        }
        if ($len === 0) return '';
        $data = '';
        while (strlen($data) < $len && is_resource($this->socket)) {
            $chunk = fread($this->socket, $len - strlen($data));
            if ($chunk === '' || $chunk === false) return null;
            $data .= $chunk;
        }
        return $data;
    }

    private function parseRecords(array $words): array {
        $records = [];
        $current = null;
        foreach ($words as $word) {
            if ($word === '!re') {
                if ($current !== null) $records[] = $current;
                $current = [];
                continue;
            }
            if ($word === '!done') {
                if ($current !== null) { $records[] = $current; $current = null; }
                continue;
            }
            if (str_starts_with($word, '=')) {
                if ($current === null) $current = [];
                $parts = explode('=', $word, 3);
                if (count($parts) === 3) $current[$parts[1]] = $parts[2];
            }
        }
        return $records;
    }

    private function hasDone(array $words): bool { return in_array('!done', $words, true); }
    private function hasTrap(array $words): bool { return in_array('!trap', $words, true); }

    private function findRet(array $words): string {
        foreach ($words as $word) {
            if (str_starts_with($word, '=ret=')) return substr($word, 5);
        }
        return '';
    }

    private function trapMessage(array $words): string {
        foreach ($words as $word) {
            if (str_starts_with($word, '=message=')) return substr($word, 9);
        }
        return '';
    }
}
?>
