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,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;
}
}
+227
View File
@@ -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();
}
}
}
+114
View File
@@ -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}");
}
}
}