Files
device-management/app/Network/Clients/RouterOs/RouterOsApiClient.php
T
Wian Drs 1e80be180e
tests / ci (push) Has been cancelled
Initial commit device-management
2026-08-25 09:42:00 +07:00

191 lines
5.9 KiB
PHP

<?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;
}
}