Initial commit device-management
tests / ci (push) Has been cancelled

This commit is contained in:
Wian Drs
2026-08-25 09:42:00 +07:00
commit 1e80be180e
1159 changed files with 149545 additions and 0 deletions
@@ -0,0 +1,65 @@
<?php
namespace App\Network\Clients\Hisfocus;
use App\Network\Clients\Hsgq\HsgqCliClient;
use phpseclib3\Net\SSH2;
use RuntimeException;
class HisfocusSshClient implements HsgqCliClient
{
private SSH2 $ssh;
public function __construct(private readonly string $host, private readonly int $port, private readonly int $timeout = 10) {}
public function connect(string $username, string $password): void
{
$this->ssh = new SSH2($this->host, $this->port, $this->timeout);
$this->ssh->setTimeout($this->timeout);
if (! $this->ssh->login($username, $password)) {
throw new RuntimeException('AUTHENTICATION_FAILED: login SSH Hisfocus ditolak.');
}
$this->readPrompt();
}
public function enterEnable(string $password): void
{
$this->ssh->write("enable\n");
$output = $this->ssh->read('/(?:Password\s*:|[#>]\s*$)/i', SSH2::READ_REGEX);
if (preg_match('/Password\s*:/i', (string) $output)) {
$this->ssh->write($password."\n");
$output = $this->readPrompt();
}
if (! str_ends_with(trim((string) $output), '#')) {
throw new RuntimeException('ENABLE_FAILED: gagal masuk privileged mode Hisfocus.');
}
}
public function command(string $command): string
{
$this->ssh->write($command."\n");
$output = $this->readPrompt();
if (preg_match('/(?:%\s*)?(?:Error|Invalid|Incomplete|Ambiguous|unknown command|not found)/i', $output)) {
throw new RuntimeException('COMMAND_REJECTED: Hisfocus menolak perintah read-only.');
}
return $output;
}
public function disconnect(): void
{
if (isset($this->ssh)) {
$this->ssh->disconnect();
}
}
private function readPrompt(): string
{
$output = $this->ssh->read('/(?:\([^\r\n]+\))?[#>]\s*$/', SSH2::READ_REGEX);
if ($output === false || $output === '') {
throw new RuntimeException('CONNECTION_TIMEOUT: prompt CLI Hisfocus tidak diterima.');
}
return $output;
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Network\Clients\Hisfocus;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class HisfocusWebClient
{
public function __construct(private readonly string $host, private readonly int $port = 80, private readonly int $timeout = 10) {}
/** @return array<string, string> */
public function users(string $loginUsername, string $loginPassword): array
{
$match = null;
for ($attempt = 1; $attempt <= 3; $attempt++) {
$body = $this->request($loginUsername, $loginPassword)->get($this->url('/userOverview.asp'))->body();
if (preg_match('/var\s+userList\s*=\s*new\s+Array\((.*?)\);/s', $body, $found)) {
$match = $found;
break;
}
if ($attempt < 3) {
usleep(250_000);
}
}
if (! $match) {
throw new RuntimeException('HISFOCUS_WEB_PARSE_FAILED: daftar user WebGUI tidak dikenali setelah tiga percobaan.');
}
preg_match_all('/"([^"]*)"/', $match[1], $values);
$users = [];
foreach (array_chunk($values[1], 2) as $user) {
if (count($user) === 2) {
$users[$user[0]] = $user[1];
}
}
return $users;
}
public function addUser(string $loginUsername, string $loginPassword, string $username, string $password, string $group): void
{
$response = $this->request($loginUsername, $loginPassword)->asForm()->post($this->url('/goform/setAddUser'), [
'addUserHiddenId' => 0,
'UserName' => $username,
'UserGroup' => $group,
'UserPassword' => $password,
'ComfirmPassword' => $password,
]);
$this->assertAccepted($response->status(), $response->body());
$this->assertUserState($loginUsername, $loginPassword, $username, $group);
}
public function changePassword(string $loginUsername, string $loginPassword, string $username, string $oldPassword, string $newPassword): void
{
$response = $this->request($loginUsername, $loginPassword)->asForm()->post($this->url('/goform/setUserPassword'), [
'HiddenUserName' => $username,
'LYS' => $oldPassword,
'PBT' => $newPassword,
]);
$this->assertAccepted($response->status(), $response->body());
}
public function deleteUser(string $loginUsername, string $loginPassword, string $username): void
{
$response = $this->request($loginUsername, $loginPassword)->asForm()->post($this->url('/goform/setDeleteUser'), [
'user'.$username.'DeleteCheck' => 'on',
]);
$this->assertAccepted($response->status(), $response->body());
$this->assertUserState($loginUsername, $loginPassword, $username, null);
}
public function canLogin(string $username, string $password): bool
{
return $this->request($username, $password)->get($this->url('/'))->successful();
}
private function request(string $username, string $password): PendingRequest
{
return Http::withBasicAuth($username, $password)->timeout($this->timeout)->connectTimeout($this->timeout);
}
private function assertAccepted(int $status, string $body): void
{
if ($status >= 400 || stripos($body, 'Access Denied') !== false) {
throw new RuntimeException('HISFOCUS_WEB_REJECTED: WebGUI menolak operasi user.');
}
}
private function assertUserState(string $loginUsername, string $loginPassword, string $username, ?string $expectedGroup): void
{
for ($attempt = 1; $attempt <= 4; $attempt++) {
$users = $this->users($loginUsername, $loginPassword);
$exists = array_key_exists($username, $users);
if ($expectedGroup === null ? ! $exists : ($exists && strcasecmp($users[$username], $expectedGroup) === 0)) {
return;
}
if ($attempt < 4) {
usleep(250_000);
}
}
throw new RuntimeException($expectedGroup === null
? 'HISFOCUS_WEB_DELETE_FAILED: user masih tercatat di WebGUI.'
: 'HISFOCUS_WEB_ADD_FAILED: user atau role belum tercatat di WebGUI.');
}
private function url(string $path): string
{
return "http://{$this->host}:{$this->port}{$path}";
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Network\Clients\Hsgq;
interface HsgqCliClient
{
public function connect(string $username, string $password): void;
public function command(string $command): string;
public function enterEnable(string $password): void;
public function disconnect(): void;
}
@@ -0,0 +1,65 @@
<?php
namespace App\Network\Clients\Hsgq;
use phpseclib3\Net\SSH2;
use RuntimeException;
class HsgqSshClient implements HsgqCliClient
{
private SSH2 $ssh;
public function __construct(private readonly string $host, private readonly int $port, private readonly int $timeout = 10) {}
public function connect(string $username, string $password): void
{
$this->ssh = new SSH2($this->host, $this->port, $this->timeout);
$this->ssh->setTimeout($this->timeout);
if (! $this->ssh->login($username, $password)) {
throw new RuntimeException('AUTHENTICATION_FAILED: login SSH HSGQ ditolak.');
}
$this->readPrompt();
}
public function command(string $command): string
{
$this->ssh->write($command."\n");
$output = $this->readPrompt();
if (preg_match('/(?:Error|Invalid|Failed|failure|unknown command|Command incomplete|There is no matched command)/i', $output)) {
preg_match('/^(?!.*user\s+(?:add|password)).*(?:Error|Invalid|Failed|failure|unknown command|Command incomplete|There is no matched command).*$/mi', $output, $detail);
throw new RuntimeException('COMMAND_REJECTED: '.str($detail[0] ?? 'HSGQ menolak perintah CLI.')->trim()->limit(180));
}
return $output;
}
public function enterEnable(string $password): void
{
$this->ssh->write("enable\n");
$output = $this->ssh->read('/(?:Password\s*:|#\s*$)/i', SSH2::READ_REGEX);
if (preg_match('/Password\s*:/i', $output)) {
$this->ssh->write($password."\n");
$output = $this->readPrompt();
}
if (! str_ends_with(trim($output), '#')) {
throw new RuntimeException('ENABLE_FAILED: gagal masuk privileged mode HSGQ.');
}
}
public function disconnect(): void
{
if (isset($this->ssh)) {
$this->ssh->disconnect();
}
}
private function readPrompt(): string
{
$output = $this->ssh->read('/(?:\([^\r\n]+\))?[#>]\s*$/', SSH2::READ_REGEX);
if ($output === false || $output === '') {
throw new RuntimeException('CONNECTION_TIMEOUT: prompt CLI HSGQ tidak diterima.');
}
return $output;
}
}
@@ -0,0 +1,132 @@
<?php
namespace App\Network\Clients\Hsgq;
use RuntimeException;
class HsgqTelnetClient implements HsgqCliClient
{
/** @var resource|null */
private $socket;
public function __construct(private readonly string $host, private readonly int $port = 23, private readonly int $timeout = 10) {}
public function connect(string $username, string $password): void
{
$errorNumber = 0;
$errorMessage = '';
$this->socket = @stream_socket_client("tcp://{$this->host}:{$this->port}", $errorNumber, $errorMessage, $this->timeout);
if (! is_resource($this->socket)) {
throw new RuntimeException('TELNET_UNREACHABLE: koneksi Telnet HSGQ port 23 gagal.');
}
stream_set_timeout($this->socket, $this->timeout);
$this->readUntil('/(?:login|username|user\s*name)\s*:\s*$/i');
$this->write($username);
$this->readUntil('/password\s*:\s*$/i');
$this->write($password);
$output = $this->readPrompt();
if (! preg_match('/[>#]\s*$/', trim($output))) {
throw new RuntimeException('AUTHENTICATION_FAILED: login Telnet HSGQ ditolak.');
}
}
public function command(string $command): string
{
$this->write($command);
$output = $this->readPrompt();
$isHelpCommand = in_array(trim($command), ['?', 'help', 'list'], true);
if (! $isHelpCommand && preg_match('/(?:Error|Invalid|Failed|failure|unknown command|Command incomplete|There is no matched command)/i', $output)) {
preg_match('/^(?!.*user\s+(?:add|password)).*(?:Error|Invalid|Failed|failure|unknown command|Command incomplete|There is no matched command).*$/mi', $output, $detail);
throw new RuntimeException('COMMAND_REJECTED: '.str($detail[0] ?? 'HSGQ menolak perintah CLI Telnet.')->trim()->limit(180));
}
return $output;
}
public function enterEnable(string $password): void
{
$this->write('enable');
$output = $this->readUntil('/(?:Password\s*:|#\s*$)/i');
if (preg_match('/Password\s*:/i', $output)) {
$this->write($password);
$output = $this->readPrompt();
}
if (! str_ends_with(trim($output), '#')) {
throw new RuntimeException('ENABLE_FAILED: gagal masuk privileged mode Telnet HSGQ.');
}
}
public function disconnect(): void
{
if (is_resource($this->socket)) {
fclose($this->socket);
}
$this->socket = null;
}
private function write(string $value): void
{
if (! is_resource($this->socket) || fwrite($this->socket, $value."\r\n") === false) {
throw new RuntimeException('CONNECTION_CLOSED: sesi Telnet HSGQ terputus.');
}
}
private function readPrompt(): string
{
return $this->readUntil('/(?:\([^\r\n]+\))?[#>]\s*$/');
}
private function readUntil(string $pattern): string
{
if (! is_resource($this->socket)) {
throw new RuntimeException('CONNECTION_CLOSED: sesi Telnet HSGQ belum tersambung.');
}
$output = '';
$startedAt = microtime(true);
while (microtime(true) - $startedAt < $this->timeout) {
$chunk = fread($this->socket, 4096);
if ($chunk === false || ($chunk === '' && feof($this->socket))) {
throw new RuntimeException('CONNECTION_CLOSED: sesi Telnet HSGQ ditutup perangkat.');
}
if ($chunk !== '') {
$output .= $this->stripNegotiation($chunk);
if (preg_match($pattern, $output)) {
return $output;
}
}
$metadata = stream_get_meta_data($this->socket);
if ($metadata['timed_out']) {
break;
}
}
throw new RuntimeException('CONNECTION_TIMEOUT: prompt Telnet HSGQ tidak diterima.');
}
private function stripNegotiation(string $data): string
{
$clean = '';
$length = strlen($data);
for ($index = 0; $index < $length; $index++) {
if (ord($data[$index]) !== 255) {
$clean .= $data[$index];
continue;
}
if ($index + 2 >= $length) {
break;
}
$command = ord($data[++$index]);
$option = ord($data[++$index]);
if (in_array($command, [251, 252], true)) {
fwrite($this->socket, pack('CCC', 255, 254, $option));
} elseif (in_array($command, [253, 254], true)) {
fwrite($this->socket, pack('CCC', 255, 252, $option));
}
}
return str_replace("\0", '', $clean);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Network\Clients\Hsgq;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class HsgqWebClient
{
private ?string $token = null;
public function __construct(private readonly string $host, private readonly int $timeout = 10) {}
public function login(string $username, string $password): void
{
$response = $this->request()->post($this->url('/userlogin?form=login'), [
'method' => 'set',
'param' => [
'name' => $username,
'key' => md5($username.':'.$password),
'value' => '',
'captcha_v' => '',
'captcha_f' => '',
],
]);
$this->assertSuccess($response->json());
$this->token = $response->header('x-token');
if (! $this->token) {
throw new RuntimeException('HSGQ_WEB_AUTH_FAILED: WebGUI tidak memberikan token sesi.');
}
}
/** @return array<int, array<string, mixed>> */
public function users(): array
{
$response = $this->authenticated()->get($this->url('/usermgmt?form=userlist'));
$this->assertSuccess($response->json());
return $response->json('data', []);
}
public function addUser(string $username, string $password, int $level): void
{
$this->post('/usermgmt?form=userlist', [
'method' => 'add',
'param' => [
'name' => $username,
'key' => md5($username.':'.$password),
'level' => $level,
'reenter' => 4,
'info' => 'Managed by RADIQ NDM',
],
]);
}
public function changePassword(string $username, string $newPassword, string $currentPassword = ''): void
{
$this->post('/usermgmt?form=modifyps', [
'method' => 'set',
'param' => [
'name' => $username,
'key' => $currentPassword === '' ? '' : md5($username.':'.$currentPassword),
'key1' => md5($username.':'.$newPassword),
],
]);
}
public function deleteUser(string $username): void
{
$this->post('/usermgmt?form=userlist', [
'method' => 'delete',
'param' => ['name' => $username],
]);
}
public function hasUser(string $username): bool
{
return collect($this->users())->contains(fn (array $user) => ($user['name'] ?? null) === $username);
}
/** @return array<string, mixed> */
public function boardInfo(): array
{
$response = $this->authenticated()->get($this->url('/board_info'));
$this->assertSuccess($response->json());
return $response->json('data', []);
}
private function post(string $path, array $payload): void
{
$response = $this->authenticated()->post($this->url($path), $payload);
$this->assertSuccess($response->json());
}
private function request(): PendingRequest
{
return Http::acceptJson()->asJson()->timeout($this->timeout)->connectTimeout($this->timeout);
}
private function authenticated(): PendingRequest
{
if (! $this->token) {
throw new RuntimeException('HSGQ_WEB_AUTH_REQUIRED: sesi WebGUI belum dibuat.');
}
return $this->request()->withHeader('x-token', $this->token);
}
private function assertSuccess(?array $payload): void
{
if (($payload['code'] ?? null) !== 1) {
throw new RuntimeException('HSGQ_WEB_REJECTED: '.str($payload['message'] ?? 'WebGUI menolak operasi user.')->limit(160));
}
}
private function url(string $path): string
{
return 'http://'.$this->host.$path;
}
}
@@ -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;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Network\Clients\Zte;
use phpseclib3\Net\SSH2;
use RuntimeException;
class ZteSshClient
{
private SSH2 $ssh;
public function __construct(private readonly string $host, private readonly int $port, private readonly int $timeout = 10) {}
public function connect(string $username, string $password): void
{
$this->ssh = new SSH2($this->host, $this->port, $this->timeout);
$this->ssh->setTimeout($this->timeout);
if (! $this->ssh->login($username, $password)) {
throw new RuntimeException('AUTHENTICATION_FAILED: login SSH ZTE ditolak.');
}
}
public function enterEnable(?string $enablePassword): void
{
$prompt = $this->readPrompt();
if (str_ends_with(trim($prompt), '#')) {
return;
}
$this->ssh->write("enable\n");
$response = $this->ssh->read('/(?:Password\s*:|[#>]\s*$)/i', SSH2::READ_REGEX);
if (preg_match('/Password\s*:/i', $response)) {
if ($enablePassword === null || $enablePassword === '') {
throw new RuntimeException('ENABLE_PASSWORD_REQUIRED: password enable ZTE belum diisi.');
}
$this->ssh->write($enablePassword."\n");
$response = $this->readPrompt();
}
if (! str_ends_with(trim($response), '#')) {
throw new RuntimeException('ENABLE_FAILED: gagal masuk privileged mode ZTE.');
}
}
public function command(string $command): string
{
$this->ssh->write($command."\n");
$output = $this->readPrompt();
if (preg_match('/%(?:Error|Invalid|Incomplete|Ambiguous)/i', $output)) {
preg_match('/%(?:Error|Invalid|Incomplete|Ambiguous)[^\r\n]*/i', $output, $detail);
$safeDetail = str($detail[0] ?? 'ZTE menolak perintah CLI')->limit(180)->toString();
throw new RuntimeException('COMMAND_REJECTED: '.$safeDetail);
}
return $output;
}
public function disconnect(): void
{
if (isset($this->ssh)) {
$this->ssh->disconnect();
}
}
private function readPrompt(): string
{
$output = $this->ssh->read('/(?:\([^\r\n]+\))?[#>]\s*$/', SSH2::READ_REGEX);
if ($output === false || $output === '') {
throw new RuntimeException('CONNECTION_TIMEOUT: prompt CLI ZTE tidak diterima.');
}
return $output;
}
}