@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Clients\RouterOs;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class RouterOsApiClient
|
||||
{
|
||||
/** @var resource|null */
|
||||
private $socket;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $host,
|
||||
private readonly int $port,
|
||||
private readonly string $username,
|
||||
private readonly string $password,
|
||||
private readonly int $timeout = 10,
|
||||
private readonly bool $tls = false,
|
||||
private readonly bool $verifyTls = true,
|
||||
) {}
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
$this->openSocket();
|
||||
try {
|
||||
$this->command(['/login', '=name='.$this->username, '=password='.$this->password]);
|
||||
} catch (RuntimeException $exception) {
|
||||
if (! str_starts_with($exception->getMessage(), 'COMMAND_REJECTED')) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
// RouterOS before 6.43 uses challenge-response authentication.
|
||||
$this->disconnect();
|
||||
$this->openSocket();
|
||||
$challenge = $this->command(['/login'])[0]['ret'] ?? null;
|
||||
if (! is_string($challenge) || ! ctype_xdigit($challenge)) {
|
||||
throw new RuntimeException('AUTHENTICATION_FAILED: login RouterOS ditolak.');
|
||||
}
|
||||
$response = '00'.md5(chr(0).$this->password.pack('H*', $challenge));
|
||||
$this->command(['/login', '=name='.$this->username, '=response='.$response]);
|
||||
}
|
||||
}
|
||||
|
||||
private function openSocket(): void
|
||||
{
|
||||
$transport = $this->tls ? 'tls' : 'tcp';
|
||||
$context = stream_context_create(['ssl' => ['verify_peer' => $this->verifyTls, 'verify_peer_name' => $this->verifyTls, 'SNI_enabled' => true]]);
|
||||
$socket = @stream_socket_client("{$transport}://{$this->host}:{$this->port}", $errorNumber, $errorMessage, $this->timeout, STREAM_CLIENT_CONNECT, $context);
|
||||
if (! is_resource($socket)) {
|
||||
throw new RuntimeException('DEVICE_UNREACHABLE: koneksi RouterOS API gagal.');
|
||||
}
|
||||
$this->socket = $socket;
|
||||
stream_set_timeout($this->socket, $this->timeout);
|
||||
}
|
||||
|
||||
public function disconnect(): void
|
||||
{
|
||||
if (is_resource($this->socket)) {
|
||||
fclose($this->socket);
|
||||
}
|
||||
$this->socket = null;
|
||||
}
|
||||
|
||||
/** @return list<array<string, string>> */
|
||||
public function command(array $words): array
|
||||
{
|
||||
if (! is_resource($this->socket)) {
|
||||
throw new RuntimeException('DRIVER_NOT_CONNECTED');
|
||||
}
|
||||
foreach ($words as $word) {
|
||||
$this->writeWord($word);
|
||||
}
|
||||
$this->writeWord('');
|
||||
|
||||
$rows = [];
|
||||
while (true) {
|
||||
$sentence = $this->readSentence();
|
||||
$type = array_shift($sentence);
|
||||
if ($type === '!trap' || $type === '!fatal') {
|
||||
throw new RuntimeException('COMMAND_REJECTED: RouterOS menolak operasi.');
|
||||
}
|
||||
if ($type === '!re') {
|
||||
$rows[] = $this->attributes($sentence);
|
||||
}
|
||||
if ($type === '!empty') {
|
||||
return $rows;
|
||||
}
|
||||
if ($type === '!done') {
|
||||
$attributes = $this->attributes($sentence);
|
||||
if ($attributes !== []) {
|
||||
$rows[] = $attributes;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function writeWord(string $word): void
|
||||
{
|
||||
$length = strlen($word);
|
||||
$prefix = match (true) {
|
||||
$length < 0x80 => chr($length),
|
||||
$length < 0x4000 => pack('n', $length | 0x8000),
|
||||
$length < 0x200000 => substr(pack('N', $length | 0xC0000000), 1),
|
||||
$length < 0x10000000 => pack('N', $length | 0xE0000000),
|
||||
default => chr(0xF0).pack('N', $length),
|
||||
};
|
||||
$this->writeAll($prefix.$word);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function readSentence(): array
|
||||
{
|
||||
$words = [];
|
||||
while (($word = $this->readWord()) !== '') {
|
||||
$words[] = $word;
|
||||
}
|
||||
|
||||
return $words;
|
||||
}
|
||||
|
||||
private function readWord(): string
|
||||
{
|
||||
$length = $this->readLength();
|
||||
|
||||
return $length === 0 ? '' : $this->readBytes($length);
|
||||
}
|
||||
|
||||
private function readLength(): int
|
||||
{
|
||||
$first = ord($this->readBytes(1));
|
||||
if (($first & 0x80) === 0) {
|
||||
return $first;
|
||||
}
|
||||
if (($first & 0xC0) === 0x80) {
|
||||
return (($first & 0x3F) << 8) + ord($this->readBytes(1));
|
||||
}
|
||||
if (($first & 0xE0) === 0xC0) {
|
||||
$bytes = $this->readBytes(2);
|
||||
|
||||
return (($first & 0x1F) << 16) + (ord($bytes[0]) << 8) + ord($bytes[1]);
|
||||
}
|
||||
if (($first & 0xF0) === 0xE0) {
|
||||
$bytes = $this->readBytes(3);
|
||||
|
||||
return (($first & 0x0F) << 24) + (ord($bytes[0]) << 16) + (ord($bytes[1]) << 8) + ord($bytes[2]);
|
||||
}
|
||||
|
||||
return unpack('N', $this->readBytes(4))[1];
|
||||
}
|
||||
|
||||
private function readBytes(int $length): string
|
||||
{
|
||||
$data = '';
|
||||
while (strlen($data) < $length) {
|
||||
$chunk = fread($this->socket, $length - strlen($data));
|
||||
if ($chunk === false || $chunk === '') {
|
||||
throw new RuntimeException('CONNECTION_TIMEOUT: respons RouterOS tidak lengkap.');
|
||||
}
|
||||
$data .= $chunk;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function writeAll(string $data): void
|
||||
{
|
||||
while ($data !== '') {
|
||||
$written = fwrite($this->socket, $data);
|
||||
if ($written === false || $written === 0) {
|
||||
throw new RuntimeException('CONNECTION_FAILED');
|
||||
}
|
||||
$data = substr($data, $written);
|
||||
}
|
||||
}
|
||||
|
||||
private function attributes(array $words): array
|
||||
{
|
||||
$attributes = [];
|
||||
foreach ($words as $word) {
|
||||
if (str_starts_with($word, '=')) {
|
||||
[, $key, $value] = array_pad(explode('=', $word, 3), 3, '');
|
||||
$attributes[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user