@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Contracts;
|
||||
|
||||
interface DeviceDriverInterface
|
||||
{
|
||||
public function testConnection(): array;
|
||||
|
||||
public function getDeviceInfo(): array;
|
||||
|
||||
public function provisionBaseAccess(string $username, string $password): array;
|
||||
|
||||
public function syncUser(string $username, string $password, string $group, bool $enabled): array;
|
||||
|
||||
public function deleteUser(string $username): void;
|
||||
|
||||
public function cleanupLegacyUsers(string $preserveUsername): array;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Drivers\Hisfocus;
|
||||
|
||||
use App\Network\Clients\Hsgq\HsgqCliClient;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
class HisfocusOltDriver
|
||||
{
|
||||
private const ROLES = ['RADIQ-READ' => 'guest', 'RADIQ-WRITE' => 'operator', 'RADIQ-NOC' => 'administrator'];
|
||||
|
||||
public function __construct(private readonly HsgqCliClient $client) {}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function probe(string $username, string $password, ?string $enablePassword): array
|
||||
{
|
||||
$this->client->connect($username, $password);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword ?: $password);
|
||||
$facts = ['management_transport' => str_contains($this->client::class, 'Ssh') ? 'ssh' : 'telnet', 'supported_probes' => []];
|
||||
foreach (['show version', 'show system information', 'show system'] as $command) {
|
||||
try {
|
||||
$output = $this->client->command($command);
|
||||
$facts['supported_probes'][] = $command;
|
||||
$facts += $this->parseFacts($output);
|
||||
} catch (\Throwable) {
|
||||
// Firmware Hisfocus berbeda-beda; satu command unsupported tidak menggagalkan probe lain.
|
||||
}
|
||||
}
|
||||
|
||||
return $facts;
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function provisionBaseAccess(string $loginUsername, string $loginPassword, ?string $enablePassword, string $newUsername, string $newPassword): array
|
||||
{
|
||||
$this->validateAccount($newUsername, $newPassword);
|
||||
$this->client->connect($loginUsername, $loginPassword);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword ?: $loginPassword);
|
||||
$users = $this->client->command('show users');
|
||||
$this->client->command('configure terminal');
|
||||
$this->client->command($this->userExists($users, $newUsername)
|
||||
? "user change {$newUsername} {$newPassword}"
|
||||
: "user create administrator {$newUsername} {$newPassword}");
|
||||
$this->save();
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
$this->testLogin($newUsername, $newPassword, $enablePassword ?: $loginPassword);
|
||||
|
||||
return ['username' => $newUsername, 'role' => 'administrator'];
|
||||
}
|
||||
|
||||
public function testLogin(string $username, string $password, ?string $enablePassword): array
|
||||
{
|
||||
$this->client->connect($username, $password);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword ?: $password);
|
||||
|
||||
return ['users' => $this->client->command('show users')] + $this->probeFacts();
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function syncUser(string $loginUsername, string $loginPassword, ?string $enablePassword, string $username, string $password, string $group, bool $enabled): array
|
||||
{
|
||||
$this->validateAccount($username, $password);
|
||||
$role = self::ROLES[$group] ?? throw new InvalidArgumentException('HISFOCUS_ROLE_INVALID: group RADIQ tidak didukung Hisfocus.');
|
||||
$this->client->connect($loginUsername, $loginPassword);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword ?: $loginPassword);
|
||||
$users = $this->client->command('show users');
|
||||
$exists = $this->userExists($users, $username);
|
||||
$this->client->command('configure terminal');
|
||||
if (! $enabled) {
|
||||
if ($exists) {
|
||||
$this->client->command('user delete '.$username);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
return ['remote_id' => null];
|
||||
}
|
||||
$this->client->command($exists
|
||||
? "user change {$username} {$password}"
|
||||
: "user create {$role} {$username} {$password}");
|
||||
$this->save();
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
$result = $this->testLogin($loginUsername, $loginPassword, $enablePassword);
|
||||
if (! $this->userExists($result['users'], $username)) {
|
||||
throw new RuntimeException('COMMAND_NOT_APPLIED: user tidak ditemukan setelah sinkronisasi Hisfocus.');
|
||||
}
|
||||
|
||||
return ['remote_id' => $username, 'role' => $role];
|
||||
}
|
||||
|
||||
public function deleteUser(string $loginUsername, string $loginPassword, ?string $enablePassword, string $username): void
|
||||
{
|
||||
$this->validateUsername($username);
|
||||
$this->client->connect($loginUsername, $loginPassword);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword ?: $loginPassword);
|
||||
if (! $this->userExists($this->client->command('show users'), $username)) {
|
||||
return;
|
||||
}
|
||||
$this->client->command('configure terminal');
|
||||
$this->client->command('user delete '.$username);
|
||||
$this->save();
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private function probeFacts(): array
|
||||
{
|
||||
foreach (['show system', 'show version'] as $command) {
|
||||
try {
|
||||
return $this->parseFacts($this->client->command($command));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function save(): void
|
||||
{
|
||||
$this->client->command('exit');
|
||||
$this->client->command('write');
|
||||
}
|
||||
|
||||
private function userExists(string $users, string $username): bool
|
||||
{
|
||||
return preg_match('/User\s+\d+\s*:\s*'.preg_quote($username, '/').'\s+/mi', $users) === 1;
|
||||
}
|
||||
|
||||
private function validateAccount(string $username, string $password): void
|
||||
{
|
||||
$this->validateUsername($username);
|
||||
if ($password === '' || strlen($password) > 64 || preg_match('/\s/', $password)) {
|
||||
throw new InvalidArgumentException('HISFOCUS_PASSWORD_INVALID: password wajib diisi, maksimal 64 karakter, dan tanpa spasi.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateUsername(string $username): void
|
||||
{
|
||||
if (! preg_match('/^[A-Za-z][A-Za-z0-9_]{3,15}$/', $username)) {
|
||||
throw new InvalidArgumentException('HISFOCUS_USERNAME_INVALID: username harus 4-16 karakter, diawali huruf, dan hanya alfanumerik/underscore.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function parseFacts(string $output): array
|
||||
{
|
||||
$patterns = [
|
||||
'model' => '/(?:product\s*model|device\s*model|\bmodel)\s*[:=]\s*([^\r\n]+)/i',
|
||||
'serial_number' => '/(?:serial\s*(?:number|no\.?|num)|\bSN)\s*[:=]\s*([^\r\n]+)/i',
|
||||
'firmware_version' => '/(?:firmware|software|system)(?:\s*version)?\s*[:=]\s*([^\r\n]+)/i',
|
||||
'hardware_version' => '/hardware(?:\s*version)?\s*[:=]\s*([^\r\n]+)/i',
|
||||
'mac_address' => '/(?:base\s*)?mac(?:\s*address)?\s*[:=]\s*([0-9a-f:-]{12,17})/i',
|
||||
];
|
||||
$facts = [];
|
||||
foreach ($patterns as $key => $pattern) {
|
||||
if (preg_match($pattern, $output, $match)) {
|
||||
$facts[$key] = trim($match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return $facts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Drivers\Hsgq;
|
||||
|
||||
use App\Network\Clients\Hsgq\HsgqCliClient;
|
||||
use App\Network\Clients\Hsgq\HsgqWebClient;
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class HsgqOltDriver
|
||||
{
|
||||
private const ROLES = ['RADIQ-READ' => 'user', 'RADIQ-WRITE' => 'admin', 'RADIQ-NOC' => 'admin'];
|
||||
|
||||
public function __construct(
|
||||
private HsgqCliClient $client,
|
||||
private readonly ?HsgqCliClient $writeFallback = null,
|
||||
private readonly ?HsgqWebClient $webFallback = null,
|
||||
) {}
|
||||
|
||||
public function testLogin(string $username, string $password, ?string $enablePassword = null): array
|
||||
{
|
||||
$this->client->connect($username, $password);
|
||||
try {
|
||||
$this->prepare($enablePassword ?: $password);
|
||||
|
||||
return ['users' => $this->client->command('show user')];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function rotateRootPassword(string $currentPassword, string $newPassword, ?string $enablePassword = null): void
|
||||
{
|
||||
$this->validatePassword($newPassword);
|
||||
try {
|
||||
$this->withWriteFallback(function () use ($currentPassword, $newPassword, $enablePassword): void {
|
||||
$this->client->connect('root', $currentPassword);
|
||||
try {
|
||||
$this->prepare($enablePassword ?: $currentPassword);
|
||||
try {
|
||||
$this->client->command('user password-self '.$newPassword);
|
||||
} catch (\RuntimeException $exception) {
|
||||
if (! str_starts_with($exception->getMessage(), 'COMMAND_REJECTED')) {
|
||||
throw $exception;
|
||||
}
|
||||
$this->client->command('user password '.$newPassword);
|
||||
}
|
||||
$this->client->command('user save');
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $cliException) {
|
||||
if (! $this->webFallback) {
|
||||
throw $cliException;
|
||||
}
|
||||
$this->webFallback->login('root', $currentPassword);
|
||||
$this->webFallback->changePassword('root', $newPassword, $currentPassword);
|
||||
}
|
||||
$this->testLogin('root', $newPassword, $enablePassword === $currentPassword ? $newPassword : $enablePassword);
|
||||
}
|
||||
|
||||
public function syncUser(string $rootPassword, ?string $enablePassword, string $username, string $password, string $group, bool $enabled): array
|
||||
{
|
||||
$this->validateAccount($username, $password);
|
||||
$role = self::ROLES[$group] ?? throw new InvalidArgumentException('HSGQ_ROLE_INVALID: group RADIQ tidak didukung HSGQ.');
|
||||
|
||||
try {
|
||||
return $this->withWriteFallback(function () use ($rootPassword, $enablePassword, $username, $password, $role, $enabled): array {
|
||||
$this->client->connect('root', $rootPassword);
|
||||
try {
|
||||
$this->prepare($enablePassword ?: $rootPassword);
|
||||
$users = $this->client->command('show user');
|
||||
$exists = $this->userExists($users, $username);
|
||||
if (! $enabled) {
|
||||
if ($exists) {
|
||||
$this->removeUser($username);
|
||||
}
|
||||
|
||||
return ['remote_id' => null];
|
||||
}
|
||||
|
||||
$exists
|
||||
? $this->client->command("user password {$username} {$password}")
|
||||
: $this->client->command("user add {$username} {$password} {$role} reenter 4");
|
||||
$this->client->command('user save');
|
||||
|
||||
if (! $this->userExists($this->client->command('show user'), $username)) {
|
||||
throw new \RuntimeException('COMMAND_NOT_APPLIED: user tidak ditemukan setelah sinkronisasi HSGQ.');
|
||||
}
|
||||
|
||||
return ['remote_id' => $username, 'role' => $role];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $cliException) {
|
||||
return $this->syncUserViaWeb($rootPassword, $username, $password, $group, $enabled, $cliException);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteUser(string $rootPassword, ?string $enablePassword, string $username): void
|
||||
{
|
||||
$this->validateUsername($username);
|
||||
try {
|
||||
$this->withWriteFallback(function () use ($rootPassword, $enablePassword, $username): void {
|
||||
$this->client->connect('root', $rootPassword);
|
||||
try {
|
||||
$this->prepare($enablePassword ?: $rootPassword);
|
||||
if ($this->userExists($this->client->command('show user'), $username)) {
|
||||
$this->removeUser($username);
|
||||
}
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
});
|
||||
} catch (\Throwable $cliException) {
|
||||
if (! $this->webFallback) {
|
||||
throw $cliException;
|
||||
}
|
||||
$this->webFallback->login('root', $rootPassword);
|
||||
if ($this->webFallback->hasUser($username)) {
|
||||
$this->webFallback->deleteUser($username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function removeUser(string $username): void
|
||||
{
|
||||
try {
|
||||
$this->client->command('user offline '.$username);
|
||||
} catch (\RuntimeException $exception) {
|
||||
if (! str_contains($exception->getMessage(), 'This account can not be process')) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
$this->client->command('user delete '.$username);
|
||||
$this->client->command('user save');
|
||||
}
|
||||
|
||||
private function userExists(string $users, string $username): bool
|
||||
{
|
||||
return preg_match('/^\s*'.preg_quote($username, '/').'\s+/mi', $users) === 1;
|
||||
}
|
||||
|
||||
private function withWriteFallback(Closure $operation): mixed
|
||||
{
|
||||
$primary = $this->client;
|
||||
try {
|
||||
return $operation();
|
||||
} catch (\Throwable $primaryException) {
|
||||
if (! $this->writeFallback) {
|
||||
throw $primaryException;
|
||||
}
|
||||
|
||||
$this->client = $this->writeFallback;
|
||||
try {
|
||||
return $operation();
|
||||
} catch (\Throwable $fallbackException) {
|
||||
throw new \RuntimeException('HSGQ_DUAL_CONNECTION_FAILED: SSH gagal menerapkan perubahan dan Telnet gagal: '.str($fallbackException->getMessage())->after(':')->trim()->limit(140), 0, $fallbackException);
|
||||
} finally {
|
||||
$this->client = $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function syncUserViaWeb(string $rootPassword, string $username, string $password, string $group, bool $enabled, \Throwable $cliException): array
|
||||
{
|
||||
if (! $this->webFallback) {
|
||||
throw $cliException;
|
||||
}
|
||||
|
||||
$level = match ($group) {
|
||||
'RADIQ-READ' => 5,
|
||||
'RADIQ-WRITE', 'RADIQ-NOC' => 3,
|
||||
default => throw new InvalidArgumentException('HSGQ_ROLE_INVALID: group RADIQ tidak didukung WebGUI HSGQ.'),
|
||||
};
|
||||
$this->webFallback->login('root', $rootPassword);
|
||||
$exists = $this->webFallback->hasUser($username);
|
||||
if (! $enabled) {
|
||||
if ($exists) {
|
||||
$this->webFallback->deleteUser($username);
|
||||
}
|
||||
|
||||
return ['remote_id' => null];
|
||||
}
|
||||
|
||||
if ($exists) {
|
||||
$this->webFallback->changePassword($username, $password);
|
||||
} else {
|
||||
$this->webFallback->addUser($username, $password, $level);
|
||||
}
|
||||
if (! $this->webFallback->hasUser($username)) {
|
||||
throw new \RuntimeException('COMMAND_NOT_APPLIED: user tidak ditemukan setelah sinkronisasi WebGUI HSGQ.');
|
||||
}
|
||||
|
||||
return ['remote_id' => $username, 'role' => $level, 'transport' => 'webgui'];
|
||||
}
|
||||
|
||||
private function validateAccount(string $username, string $password): void
|
||||
{
|
||||
$this->validateUsername($username);
|
||||
$this->validatePassword($password);
|
||||
}
|
||||
|
||||
private function prepare(string $enablePassword): void
|
||||
{
|
||||
$this->client->enterEnable($enablePassword);
|
||||
$this->client->command('terminal length 0');
|
||||
$this->client->command('configure');
|
||||
}
|
||||
|
||||
private function validateUsername(string $username): void
|
||||
{
|
||||
if ($username === 'root' || ! preg_match('/^[A-Za-z0-9_]{4,16}$/', $username)) {
|
||||
throw new InvalidArgumentException('HSGQ_USERNAME_INVALID: username HSGQ harus 4-16 karakter atau merupakan akun yang dilindungi.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validatePassword(string $password): void
|
||||
{
|
||||
if ($password === '' || strlen($password) > 64 || preg_match('/\s/', $password)) {
|
||||
throw new InvalidArgumentException('HSGQ_PASSWORD_INVALID: password HSGQ maksimal 64 karakter tanpa spasi.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Drivers\Mikrotik;
|
||||
|
||||
use App\Network\Clients\RouterOs\RouterOsApiClient;
|
||||
use App\Network\Contracts\DeviceDriverInterface;
|
||||
use Throwable;
|
||||
|
||||
class MikrotikDriver implements DeviceDriverInterface
|
||||
{
|
||||
private const GROUPS = [
|
||||
'RADIQ-READ' => 'local,ssh,read,test,winbox,api',
|
||||
'RADIQ-WRITE' => 'local,ssh,read,write,test,winbox,password,api',
|
||||
'RADIQ-NOC' => 'local,ssh,read,write,test,winbox,password,api',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RouterOsApiClient $client) {}
|
||||
|
||||
public function testConnection(): array
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
return $this->client->command(['/system/identity/print', '=.proplist=name'])[0] ?? [];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function getDeviceInfo(): array
|
||||
{
|
||||
$identity = $this->safeQuery(['/system/identity/print', '=.proplist=name']);
|
||||
$routerboard = $this->safeQuery(['/system/routerboard/print', '=.proplist=routerboard,model,serial-number,current-firmware,upgrade-firmware']);
|
||||
$resource = $this->safeQuery(['/system/resource/print', '=.proplist=version,board-name,architecture-name,cpu-count,total-memory,free-memory,uptime']);
|
||||
$cpu = $this->safeQuery(['/system/resource/print', '=.proplist=cpu']);
|
||||
|
||||
return ['identity' => $identity['name'] ?? null] + $routerboard + $resource + $cpu;
|
||||
}
|
||||
|
||||
public function provisionBaseAccess(string $username, string $password): array
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
foreach (self::GROUPS as $name => $policies) {
|
||||
$existing = $this->client->command(['/user/group/print', '?name='.$name, '=.proplist=.id']);
|
||||
if ($existing === []) {
|
||||
$this->client->command(['/user/group/add', '=name='.$name, '=policy='.$policies, '=comment=Managed by RADIQ NDM']);
|
||||
} else {
|
||||
$this->client->command(['/user/group/set', '=.id='.$existing[0]['.id'], '=policy='.$policies, '=comment=Managed by RADIQ NDM']);
|
||||
}
|
||||
}
|
||||
|
||||
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
|
||||
if ($users === []) {
|
||||
$this->client->command(['/user/add', '=name='.$username, '=password='.$password, '=group=full', '=disabled=no', '=comment=Managed by RADIQ NDM']);
|
||||
} else {
|
||||
$this->client->command(['/user/set', '=.id='.$users[0]['.id'], '=password='.$password, '=group=full', '=disabled=no', '=comment=Managed by RADIQ NDM']);
|
||||
}
|
||||
|
||||
return ['groups' => array_keys(self::GROUPS), 'username' => $username];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function syncUser(string $username, string $password, string $group, bool $enabled): array
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
|
||||
if ($users === []) {
|
||||
$this->client->command(['/user/add', '=name='.$username, '=password='.$password, '=group='.$group, '=disabled='.($enabled ? 'no' : 'yes'), '=comment=Managed by RADIQ NDM']);
|
||||
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
|
||||
} else {
|
||||
$this->client->command(['/user/set', '=.id='.$users[0]['.id'], '=password='.$password, '=group='.$group, '=disabled='.($enabled ? 'no' : 'yes'), '=comment=Managed by RADIQ NDM']);
|
||||
}
|
||||
|
||||
return ['remote_id' => $users[0]['.id'] ?? null];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteUser(string $username): void
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
|
||||
if ($users !== []) {
|
||||
$this->client->command(['/user/remove', '=.id='.$users[0]['.id']]);
|
||||
}
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function cleanupLegacyUsers(string $preserveUsername): array
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
$users = $this->client->command(['/user/print', '=.proplist=.id,name,group']);
|
||||
$deleted = [];
|
||||
$preserved = [];
|
||||
foreach ($users as $user) {
|
||||
if (($user['name'] ?? '') === $preserveUsername || strtolower($user['group'] ?? '') === 'full') {
|
||||
$preserved[] = $user['name'] ?? '';
|
||||
|
||||
continue;
|
||||
}
|
||||
if (isset($user['.id'])) {
|
||||
$this->client->command(['/user/remove', '=.id='.$user['.id']]);
|
||||
$deleted[] = $user['name'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return ['deleted' => $deleted, 'preserved' => $preserved];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function safeQuery(array $command): array
|
||||
{
|
||||
try {
|
||||
$this->client->connect();
|
||||
|
||||
return $this->client->command($command)[0] ?? [];
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Drivers\Zte;
|
||||
|
||||
use App\Network\Clients\Zte\ZteSshClient;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
class ZteC3xxDriver
|
||||
{
|
||||
private const PRIVILEGES = [
|
||||
'RADIQ-READ' => 1,
|
||||
'RADIQ-WRITE' => 10,
|
||||
'RADIQ-NOC' => 15,
|
||||
];
|
||||
|
||||
public function __construct(private readonly ZteSshClient $client) {}
|
||||
|
||||
public function provisionBaseAccess(string $loginUsername, string $loginPassword, ?string $enablePassword, string $newUsername, string $newPassword): array
|
||||
{
|
||||
$this->validateAccount($newUsername, $newPassword);
|
||||
$this->client->connect($loginUsername, $loginPassword);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword);
|
||||
$this->client->command('configure terminal');
|
||||
$this->setUser($newUsername, $newPassword, 15);
|
||||
$this->client->command('username '.$newUsername.' enable');
|
||||
$this->client->command('end');
|
||||
$this->client->command('write');
|
||||
|
||||
return ['username' => $newUsername, 'privilege' => 15];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function testLogin(string $username, string $password, ?string $enablePassword): void
|
||||
{
|
||||
$this->client->connect($username, $password);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword);
|
||||
$this->client->command('show privilege');
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteUser(string $loginUsername, string $loginPassword, ?string $enablePassword, string $username): void
|
||||
{
|
||||
$this->client->connect($loginUsername, $loginPassword);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword);
|
||||
$this->client->command('configure terminal');
|
||||
$this->client->command('no username '.$username);
|
||||
$this->client->command('end');
|
||||
$this->client->command('write');
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function syncUser(string $loginUsername, string $loginPassword, ?string $enablePassword, string $username, string $password, string $group, bool $enabled): array
|
||||
{
|
||||
$this->validateAccount($username, $password);
|
||||
$privilege = self::PRIVILEGES[$group] ?? throw new InvalidArgumentException('ZTE_PRIVILEGE_INVALID: group RADIQ tidak didukung untuk ZTE.');
|
||||
$this->client->connect($loginUsername, $loginPassword);
|
||||
try {
|
||||
$this->client->enterEnable($enablePassword);
|
||||
$this->client->command('configure terminal');
|
||||
$this->setUser($username, $password, $privilege);
|
||||
$this->client->command('username '.$username.' '.($enabled ? 'enable' : 'disable'));
|
||||
$this->client->command('end');
|
||||
$this->client->command('write');
|
||||
|
||||
return ['remote_id' => $username, 'privilege' => $privilege];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteAccessUser(string $loginUsername, string $loginPassword, ?string $enablePassword, string $username): void
|
||||
{
|
||||
$this->validateUsername($username);
|
||||
$this->deleteUser($loginUsername, $loginPassword, $enablePassword, $username);
|
||||
}
|
||||
|
||||
private function validateAccount(string $username, string $password): void
|
||||
{
|
||||
$this->validateUsername($username);
|
||||
if (strlen($password) < 8 || strlen($password) > 32 || preg_match('/\s/', $password)) {
|
||||
throw new InvalidArgumentException('ZTE_PASSWORD_INVALID: password ZTE harus 8-32 karakter tanpa spasi.');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateUsername(string $username): void
|
||||
{
|
||||
if (! preg_match('/^[A-Za-z0-9_]{1,16}$/', $username)) {
|
||||
throw new InvalidArgumentException('ZTE_USERNAME_INVALID: username ZTE harus 1-16 karakter alfanumerik/underscore.');
|
||||
}
|
||||
}
|
||||
|
||||
private function setUser(string $username, string $password, int $privilege): void
|
||||
{
|
||||
try {
|
||||
$this->client->command("username {$username} password 0 {$password} privilege {$privilege}");
|
||||
} catch (RuntimeException $exception) {
|
||||
if (! str_starts_with($exception->getMessage(), 'COMMAND_REJECTED')) {
|
||||
throw $exception;
|
||||
}
|
||||
// Older C300/C320 firmware omits the explicit clear-text type 0.
|
||||
$this->client->command("username {$username} password {$password} privilege {$privilege}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user