@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,70 @@
|
||||
APP_NAME="RADIQ NDM"
|
||||
|
||||
# control_plane, managed_cloud, or self_hosted
|
||||
DEPLOYMENT_MODE=managed_cloud
|
||||
RADIQ_LICENSE_SERVER_URL=
|
||||
RADIQ_LICENSE_PUBLIC_KEY=
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
APP_LOCALE=id
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=id_ID
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
CHANGELOG.md export-ignore
|
||||
README.md export-ignore
|
||||
.github/workflows/browser-tests.yml export-ignore
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -0,0 +1,38 @@
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup Application
|
||||
run: composer setup
|
||||
|
||||
- name: Run CI Checks
|
||||
run: composer ci:check
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/resources/js/actions
|
||||
/resources/js/routes
|
||||
/resources/js/wayfinder
|
||||
/vendor
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
@@ -0,0 +1,2 @@
|
||||
resources/js/components/ui/*
|
||||
resources/views/mail/*
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"singleAttributePerLine": false,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"printWidth": 80,
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"tailwindFunctions": [
|
||||
"clsx",
|
||||
"cn",
|
||||
"cva"
|
||||
],
|
||||
"tailwindStylesheet": "resources/css/app.css",
|
||||
"tabWidth": 4,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "**/*.yml",
|
||||
"options": {
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# RADIQ NDM
|
||||
|
||||
RADIQ NDM (Network Device Management) is a centralized, multi-tenant platform for managing ISP and network-device access.
|
||||
|
||||
## Phase 1 architecture
|
||||
|
||||
- PHP 8.3 and Laravel 13.
|
||||
- React 19, TypeScript, Inertia 3, Tailwind CSS 4, and shadcn/ui.
|
||||
- Laravel Fortify/session authentication with registration disabled. Users are provisioned by authorized administrators.
|
||||
- Spatie Laravel Permission 8 with team mode mapped to `tenant_id`.
|
||||
- Laravel Sanctum for `/api/v1` token authentication.
|
||||
- Database-backed session, cache, and queue for the foundation. Redis is deferred until operational load justifies it.
|
||||
- Tenant context is established server-side for every web/API request. Tenant-owned Eloquent models use a reusable global scope.
|
||||
- Platform access requires the explicit platform-admin marker and cross-tenant permission; normal administrators remain tenant-bound.
|
||||
|
||||
## Phase 1 database
|
||||
|
||||
- `tenants`: UUID public identifier, unique slug, active state.
|
||||
- `users`: UUID, nullable tenant for platform identities, hashed application password, active/platform flags.
|
||||
- `roles`, `permissions`, and tenant-aware model pivots supplied by Spatie.
|
||||
- Authentication support tables for sessions, resets, passkeys, 2FA, and API tokens.
|
||||
- Database queue/cache foundation tables.
|
||||
|
||||
Secrets belong only in `.env`. The committed `.env.example` intentionally contains blank database credentials.
|
||||
|
||||
## Development commands (Windows)
|
||||
|
||||
Ensure PHP 8.3 is first in the process path because the machine also contains an older XAMPP PHP:
|
||||
|
||||
```powershell
|
||||
$env:Path = 'C:\php83;' + $env:Path
|
||||
C:\php83\php.exe artisan about
|
||||
C:\php83\php.exe artisan migrate:status
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Run tests with the available SQLite DLLs loaded for the test process:
|
||||
|
||||
```powershell
|
||||
C:\php83\php.exe -d "extension=C:\php83\ext\php_pdo_sqlite.dll" -d "extension=C:\php83\ext\php_sqlite3.dll" vendor\bin\phpunit
|
||||
npm run types:check
|
||||
npm run lint:check
|
||||
```
|
||||
|
||||
## Current scope
|
||||
|
||||
Phase 1 foundation is ready for user acceptance testing. Authentication, tenant context, RBAC schema, base API, Master Admin bootstrap, platform dashboard, Tenant + Tenant Admin provisioning, tenant user management, password reset, and account activation are present. Device/driver functionality intentionally has not started pending acceptance of Phase 1.
|
||||
|
||||
Phase 2 device inventory is now available: global vendor/type/model catalogs, tenant-scoped device CRUD, encrypted and masked device credentials, server-side search/filter/pagination, and configurable Tenant User create/update-own/delete-own policy. Network connection testing remains disabled until the Phase 3 driver engine is implemented.
|
||||
|
||||
MikroTik provisioning is available through the native RouterOS API client. Tenant Admin configures an encrypted Base User template, then explicitly activates a MikroTik device after storing its bootstrap credential. Activation assigns the Base User to RouterOS' built-in `full` group and synchronizes `RADIQ-READ`, `RADIQ-WRITE`, and `RADIQ-NOC` without RouterOS `policy`.
|
||||
|
||||
Centralized Device Users are managed independently from application users. One encrypted credential can target many devices, while each assignment tracks queued/pending/processing/synced/failed/unsupported state. Queue jobs contain only tenant and assignment IDs. Pending offline devices are re-queued by the scheduler every five minutes.
|
||||
|
||||
Roles and permissions are fixed by the application and have no management UI. The only roles are `MASTER ADMIN`, `TENANT ADMIN`, and `TENANT USER`. A Tenant Admin can only create and manage Tenant User accounts in its own tenant. Device permissions for Tenant User are governed by the fixed tenant policy and will be refined with the device module.
|
||||
|
||||
## Deployment and licensing boundary
|
||||
|
||||
- `control_plane`: RADIQ-owned licensing/master administration. Stores customer tenants, installations, license lifecycle, limits, and renewal history.
|
||||
- `managed_cloud`: RADIQ-hosted multi-tenant NDM. Platform Master Admin exists here; each ISP remains a tenant.
|
||||
- `self_hosted`: installed on an ISP/customer server. The customer is still a tenant and receives Tenant Owner access, never Platform Master Admin. Its signed license is issued and renewed by the RADIQ control plane.
|
||||
|
||||
Raw license and instance secrets are never stored. Only SHA-256 hashes are persisted. The schema supports signed license payloads so a self-hosted installation can validate a license with a public key; the signing private key must exist only on the RADIQ control plane. Runtime activation/heartbeat and enforcement will be implemented as a dedicated licensing increment before production distribution.
|
||||
|
||||
Create the first Platform Super Admin interactively (the password is never passed as a command option):
|
||||
|
||||
```powershell
|
||||
C:\php83\php.exe artisan app:create-platform-admin
|
||||
```
|
||||
|
||||
Public registration is intentionally disabled. Subsequent tenant users will be created from the permission-protected Administration UI.
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
...$this->profileRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => $input['password'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use App\Services\Encryption\TenantEnvelopeEncryption;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use RuntimeException;
|
||||
|
||||
class TenantEncrypted implements CastsAttributes
|
||||
{
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$tenantId = (int) ($attributes['tenant_id'] ?? 0);
|
||||
if ($tenantId < 1) {
|
||||
throw new RuntimeException('TENANT_CONTEXT_REQUIRED_FOR_DECRYPTION');
|
||||
}
|
||||
|
||||
return app(TenantEnvelopeEncryption::class)->decrypt($tenantId, $value);
|
||||
}
|
||||
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$tenantId = (int) ($attributes['tenant_id'] ?? $model->getAttribute('tenant_id'));
|
||||
if ($tenantId < 1) {
|
||||
throw new RuntimeException('TENANT_CONTEXT_REQUIRED_FOR_ENCRYPTION');
|
||||
}
|
||||
|
||||
return app(TenantEnvelopeEncryption::class)->encrypt($tenantId, $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Password|ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate the current password.
|
||||
*
|
||||
* @return array<int, Password|ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function currentPasswordRules(): array
|
||||
{
|
||||
return ['required', 'string', 'current_password'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait ProfileValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate user profiles.
|
||||
*
|
||||
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
|
||||
*/
|
||||
protected function profileRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->nameRules(),
|
||||
'email' => $this->emailRules($userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user names.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function nameRules(): array
|
||||
{
|
||||
return ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user emails.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function emailRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
$userId === null
|
||||
? Rule::unique(User::class)
|
||||
: Rule::unique(User::class)->ignore($userId),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
#[Signature('app:create-platform-admin {--name=} {--email=}')]
|
||||
#[Description('Create the first platform-level super administrator')]
|
||||
class CreatePlatformAdmin extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
if (config('deployment.mode') === 'self_hosted') {
|
||||
$this->error('Platform Super Admin hanya dibuat pada RADIQ control plane atau managed cloud.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$name = $this->option('name') ?: $this->ask('Full name');
|
||||
$email = $this->option('email') ?: $this->ask('Email address');
|
||||
$password = $this->secret('Password (minimum 12 characters)');
|
||||
$confirmation = $this->secret('Confirm password');
|
||||
|
||||
$validator = Validator::make(
|
||||
compact('name', 'email', 'password') + ['password_confirmation' => $confirmation],
|
||||
[
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
],
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$admin = new User;
|
||||
$admin->forceFill([
|
||||
'tenant_id' => null,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'email_verified_at' => now(),
|
||||
'password' => $password,
|
||||
'is_platform_admin' => true,
|
||||
'is_active' => true,
|
||||
])->save();
|
||||
|
||||
$this->info('Platform Master Admin created successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\TenantProvisioningService;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
#[Signature('app:install-tenant {--name=} {--slug=} {--admin-name=} {--admin-email=} {--domain=}')]
|
||||
#[Description('Provision the only Tenant Admin account for a self-hosted installation')]
|
||||
class InstallTenant extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(TenantProvisioningService $service): int
|
||||
{
|
||||
if (config('deployment.mode') !== 'self_hosted') {
|
||||
$this->error('Command ini hanya boleh digunakan saat DEPLOYMENT_MODE=self_hosted.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (Tenant::exists()) {
|
||||
$this->error('Instalasi self-hosted ini sudah memiliki tenant.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$data = [
|
||||
'name' => $this->option('name') ?: $this->ask('Nama tenant/ISP'),
|
||||
'slug' => $this->option('slug') ?: $this->ask('Slug tenant'),
|
||||
'owner_name' => $this->option('admin-name') ?: $this->ask('Nama Tenant Admin'),
|
||||
'owner_email' => $this->option('admin-email') ?: $this->ask('Email Tenant Admin'),
|
||||
'owner_password' => $this->secret('Password Tenant Admin (minimum 12 karakter)'),
|
||||
'deployment_type' => 'self_hosted', 'deployment_domain' => $this->option('domain'),
|
||||
];
|
||||
$confirmation = $this->secret('Konfirmasi password Tenant Admin');
|
||||
$validator = Validator::make($data + ['owner_password_confirmation' => $confirmation], [
|
||||
'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'alpha_dash', 'max:100'],
|
||||
'owner_name' => ['required', 'string', 'max:255'], 'owner_email' => ['required', 'email'],
|
||||
'owner_password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$service->createWithOwner($data);
|
||||
$this->info('RADIQ NDM self-hosted berhasil dipasang. Hanya akun Tenant Admin yang dibuat.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DeploymentMode: string
|
||||
{
|
||||
case ControlPlane = 'control_plane';
|
||||
case ManagedCloud = 'managed_cloud';
|
||||
case SelfHosted = 'self_hosted';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum LicenseStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Active = 'active';
|
||||
case Grace = 'grace';
|
||||
case Expired = 'expired';
|
||||
case Suspended = 'suspended';
|
||||
case Revoked = 'revoked';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SystemRole: string
|
||||
{
|
||||
case MasterAdmin = 'MASTER ADMIN';
|
||||
case TenantAdmin = 'TENANT ADMIN';
|
||||
case TenantUser = 'TENANT USER';
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administration;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Administration\StoreTenantRequest;
|
||||
use App\Http\Requests\Administration\UpdateTenantRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\TenantProvisioningService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$this->authorize('viewAny', Tenant::class);
|
||||
|
||||
$tenants = Tenant::query()
|
||||
->withCount('users')
|
||||
->with('installations:id,tenant_id,deployment_type,domain,status,last_seen_at')
|
||||
->orderBy('name')
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('administration/tenants/index', ['tenants' => $tenants]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
$this->authorize('create', Tenant::class);
|
||||
|
||||
return Inertia::render('administration/tenants/create');
|
||||
}
|
||||
|
||||
public function store(StoreTenantRequest $request, TenantProvisioningService $service): RedirectResponse
|
||||
{
|
||||
$tenant = $service->createWithOwner($request->validated());
|
||||
|
||||
return to_route('administration.tenants.edit', $tenant)
|
||||
->with('success', 'Tenant dan Tenant Owner berhasil dibuat.');
|
||||
}
|
||||
|
||||
public function edit(Tenant $tenant): Response
|
||||
{
|
||||
$this->authorize('update', $tenant);
|
||||
|
||||
$users = User::query()
|
||||
->withoutGlobalScope('tenant')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->orderBy('name')
|
||||
->get(['uuid', 'name', 'email', 'is_active', 'created_at']);
|
||||
|
||||
return Inertia::render('administration/tenants/edit', [
|
||||
'tenant' => $tenant,
|
||||
'users' => $users,
|
||||
'installation' => $tenant->installations()->first(['id', 'deployment_type', 'domain', 'status', 'last_seen_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateTenantRequest $request, Tenant $tenant): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $tenant);
|
||||
$data = $request->validated();
|
||||
$tenant->update(collect($data)->only(['name', 'slug', 'is_active'])->all());
|
||||
if (isset($data['deployment_type'])) {
|
||||
$tenant->installations()->first()?->update([
|
||||
'deployment_type' => $data['deployment_type'],
|
||||
'domain' => $data['deployment_domain'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Tenant berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $tenant);
|
||||
abort_if($tenant->users()->exists(), 422, 'Tenant yang masih memiliki user tidak dapat dihapus. Nonaktifkan tenant sebagai gantinya.');
|
||||
$tenant->delete();
|
||||
|
||||
return to_route('administration.tenants.index')->with('success', 'Tenant berhasil dihapus.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administration;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Administration\ResetUserPasswordRequest;
|
||||
use App\Http\Requests\Administration\StoreUserRequest;
|
||||
use App\Http\Requests\Administration\UpdateUserRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): Response
|
||||
{
|
||||
abort_unless(request()->user()->can('user.view'), 403);
|
||||
|
||||
$users = User::query()
|
||||
->with('roles:id,name')
|
||||
->when(! request()->user()->is_platform_admin, fn ($query) => $query->role(SystemRole::TenantUser->value))
|
||||
->orderBy('name')
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('administration/users/index', compact('tenant', 'users'));
|
||||
}
|
||||
|
||||
public function create(Tenant $tenant): Response
|
||||
{
|
||||
abort_unless(request()->user()->can('user.create'), 403);
|
||||
|
||||
return Inertia::render('administration/users/create', ['tenant' => $tenant]);
|
||||
}
|
||||
|
||||
public function store(StoreUserRequest $request, Tenant $tenant): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$user = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
]);
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
$user->assignRole(SystemRole::TenantUser->value);
|
||||
|
||||
return to_route('administration.users.index', $tenant)->with('success', 'User berhasil dibuat.');
|
||||
}
|
||||
|
||||
public function edit(Tenant $tenant, User $user): Response
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
abort_unless(request()->user()->can('user.update'), 403);
|
||||
|
||||
return Inertia::render('administration/users/edit', [
|
||||
'tenant' => $tenant,
|
||||
'managedUser' => $user->load('roles:id,name'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateUserRequest $request, Tenant $tenant, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
$data = $request->validated();
|
||||
$user->update(['name' => $data['name'], 'email' => $data['email']]);
|
||||
|
||||
return back()->with('success', 'User berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function resetPassword(ResetUserPasswordRequest $request, Tenant $tenant, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
$user->update(['password' => $request->validated('password')]);
|
||||
$user->tokens()->delete();
|
||||
|
||||
return back()->with('success', 'Password user berhasil direset dan token API dicabut.');
|
||||
}
|
||||
|
||||
public function toggleActive(Tenant $tenant, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
abort_unless(request()->user()->can('user.update'), 403);
|
||||
abort_if(request()->user()->is($user), 422, 'Anda tidak dapat menonaktifkan akun sendiri.');
|
||||
abort_if($user->is_active && $user->hasRole(SystemRole::TenantAdmin->value), 422, 'Akun Tenant Admin utama tidak dapat dinonaktifkan dari pengelolaan user tenant.');
|
||||
$user->update(['is_active' => ! $user->is_active]);
|
||||
$user->tokens()->delete();
|
||||
|
||||
return back()->with('success', $user->is_active ? 'User diaktifkan.' : 'User dinonaktifkan.');
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
abort_unless(request()->user()->can('user.delete'), 403);
|
||||
abort_if(request()->user()->is($user), 422, 'Anda tidak dapat menghapus akun sendiri.');
|
||||
abort_if($user->hasRole(SystemRole::TenantAdmin->value), 422, 'Akun Tenant Admin utama tidak dapat dihapus dari pengelolaan user tenant.');
|
||||
$user->delete();
|
||||
|
||||
return to_route('administration.users.index', $tenant)->with('success', 'User berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function ensureManageableUser(Tenant $tenant, User $user): void
|
||||
{
|
||||
abort_unless($user->tenant_id === $tenant->id && ! $user->is_platform_admin, 404);
|
||||
abort_if(
|
||||
! request()->user()->is_platform_admin && ! $user->hasRole(SystemRole::TenantUser->value),
|
||||
403,
|
||||
'Tenant Admin hanya dapat mengelola Tenant User.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreTenantRequest;
|
||||
use App\Http\Requests\UpdateTenantRequest;
|
||||
use App\Http\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Tenant::class);
|
||||
$user = request()->user();
|
||||
$query = Tenant::query()->orderBy('name');
|
||||
|
||||
if (! ($user->is_platform_admin && $user->can('tenant.access-any'))) {
|
||||
$query->whereKey($user->tenant_id);
|
||||
}
|
||||
|
||||
return TenantResource::collection($query->paginate());
|
||||
}
|
||||
|
||||
public function store(StoreTenantRequest $request): JsonResponse
|
||||
{
|
||||
$tenant = Tenant::create($request->validated());
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Tenant created successfully', 'data' => new TenantResource($tenant)], 201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $tenant);
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Tenant retrieved successfully', 'data' => new TenantResource($tenant)]);
|
||||
}
|
||||
|
||||
public function update(UpdateTenantRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $tenant);
|
||||
$tenant->update($request->validated());
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Tenant updated successfully', 'data' => new TenantResource($tenant)]);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $tenant);
|
||||
$tenant->delete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Tenant deleted successfully', 'data' => null]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\LicenseStatus;
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\DeploymentInstallation;
|
||||
use App\Models\Device;
|
||||
use App\Models\License;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): Response
|
||||
{
|
||||
$user = request()->user();
|
||||
|
||||
if ($user->is_platform_admin) {
|
||||
return Inertia::render('dashboard', [
|
||||
'role' => SystemRole::MasterAdmin->value,
|
||||
'tenant' => null,
|
||||
'stats' => [
|
||||
'tenants' => Tenant::count(),
|
||||
'activeTenants' => Tenant::where('is_active', true)->count(),
|
||||
'users' => User::withoutGlobalScope('tenant')->where('is_platform_admin', false)->count(),
|
||||
'activeLicenses' => License::withoutGlobalScope('tenant')->where('status', LicenseStatus::Active->value)->count(),
|
||||
'installations' => DeploymentInstallation::withoutGlobalScope('tenant')->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$role = $user->hasRole(SystemRole::TenantAdmin->value)
|
||||
? SystemRole::TenantAdmin->value
|
||||
: SystemRole::TenantUser->value;
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'role' => $role,
|
||||
'tenant' => $user->tenant?->only(['id', 'name', 'slug', 'is_active']),
|
||||
'stats' => [
|
||||
'users' => User::count(),
|
||||
'activeUsers' => User::where('is_active', true)->count(),
|
||||
'devices' => Device::count(),
|
||||
'canAddDevice' => $user->can('device.create'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreDeviceAccessUserRequest;
|
||||
use App\Http\Requests\UpdateDeviceAccessUserRequest;
|
||||
use App\Jobs\SyncDeviceUser;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceAccessUser;
|
||||
use App\Models\DeviceUserAssignment;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DeviceAccessUserController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->can('device.user.view'), 403);
|
||||
$users = DeviceAccessUser::query()->with(['assignments' => fn ($query) => $query->with('device:id,name')])
|
||||
->when($request->integer('device_id'), fn ($query, $deviceId) => $query->whereHas('assignments', fn ($assignment) => $assignment->where('device_id', $deviceId)))
|
||||
->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where(fn ($nested) => $nested->where('display_name', 'ilike', '%'.$request->string('search').'%')->orWhere('username', 'ilike', '%'.$request->string('search').'%')))
|
||||
->orderBy('display_name')->paginate(15)->withQueryString();
|
||||
|
||||
return Inertia::render('device-users/index', ['deviceUsers' => $users, 'devices' => Device::orderBy('name')->get(['id', 'name']), 'filters' => $request->only('search', 'device_id')]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
abort_unless(request()->user()->can('device.user.create'), 403);
|
||||
|
||||
return Inertia::render('device-users/form', ['deviceUser' => null, 'devices' => $this->devices()]);
|
||||
}
|
||||
|
||||
public function store(StoreDeviceAccessUserRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$assignments = DB::transaction(function () use ($request, $data) {
|
||||
$user = DeviceAccessUser::create(['tenant_id' => $request->user()->tenant_id, 'display_name' => $data['display_name'], 'username' => $data['username'], 'password' => $data['password'], 'is_enabled' => $data['is_enabled'] ?? true, 'notes' => $data['notes'] ?? null, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
|
||||
|
||||
return collect($data['device_ids'])->map(fn ($deviceId) => DeviceUserAssignment::create(['tenant_id' => $request->user()->tenant_id, 'device_access_user_id' => $user->id, 'device_id' => $deviceId, 'group_name' => $data['group_name'], 'remote_username' => $user->username, 'sync_status' => 'queued']));
|
||||
});
|
||||
$this->syncAssignments($assignments, $request->user()->tenant_id);
|
||||
|
||||
return to_route('device-users.index')->with('success', $this->syncResultMessage($assignments, 'User perangkat berhasil dibuat.'));
|
||||
}
|
||||
|
||||
public function edit(DeviceAccessUser $deviceAccessUser): Response
|
||||
{
|
||||
$this->ensureOwned($deviceAccessUser);
|
||||
abort_unless(request()->user()->can('device.user.update'), 403);
|
||||
|
||||
return Inertia::render('device-users/form', ['deviceUser' => $deviceAccessUser->load('assignments:id,device_access_user_id,device_id,group_name'), 'devices' => $this->devices()]);
|
||||
}
|
||||
|
||||
public function update(UpdateDeviceAccessUserRequest $request, DeviceAccessUser $deviceAccessUser): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($deviceAccessUser);
|
||||
$data = $request->validated();
|
||||
$assignments = DB::transaction(function () use ($request, $data, $deviceAccessUser) {
|
||||
$oldUsername = $deviceAccessUser->username;
|
||||
$attributes = ['display_name' => $data['display_name'], 'username' => $data['username'], 'is_enabled' => $data['is_enabled'] ?? false, 'notes' => $data['notes'] ?? null, 'updated_by' => $request->user()->id];
|
||||
if (filled($data['password'] ?? null)) {
|
||||
$attributes['password'] = $data['password'];
|
||||
}
|
||||
$deviceAccessUser->update($attributes);
|
||||
$deviceAccessUser->assignments()->whereNull('remote_username')->update(['remote_username' => $oldUsername]);
|
||||
$selected = collect($data['device_ids'])->map(fn ($id) => (int) $id);
|
||||
$deviceAccessUser->assignments()->whereNotIn('device_id', $selected)->update(['desired_operation' => 'delete', 'sync_status' => 'queued']);
|
||||
foreach ($selected as $deviceId) {
|
||||
$deviceAccessUser->assignments()->updateOrCreate(['device_id' => $deviceId], ['tenant_id' => $request->user()->tenant_id, 'group_name' => $data['group_name'], 'desired_operation' => 'upsert', 'sync_status' => 'queued', 'error_code' => null]);
|
||||
}
|
||||
|
||||
return $deviceAccessUser->assignments()->where('sync_status', 'queued')->get();
|
||||
});
|
||||
$this->syncAssignments($assignments, $request->user()->tenant_id);
|
||||
|
||||
return to_route('device-users.index')->with('success', $this->syncResultMessage($assignments, 'Perubahan user berhasil disimpan.'));
|
||||
}
|
||||
|
||||
public function destroy(DeviceAccessUser $deviceAccessUser): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($deviceAccessUser);
|
||||
abort_unless(request()->user()->can('device.user.delete'), 403);
|
||||
$assignments = DB::transaction(function () use ($deviceAccessUser) {
|
||||
$deviceAccessUser->assignments()->update(['desired_operation' => 'delete', 'sync_status' => 'queued']);
|
||||
$items = $deviceAccessUser->assignments()->get();
|
||||
$deviceAccessUser->delete();
|
||||
|
||||
return $items;
|
||||
});
|
||||
$this->syncAssignments($assignments, request()->user()->tenant_id);
|
||||
|
||||
return back()->with('success', $this->syncResultMessage($assignments, 'Penghapusan user perangkat diproses.'));
|
||||
}
|
||||
|
||||
private function devices()
|
||||
{
|
||||
return Device::with('vendor:id,name')->where('is_active', true)->orderBy('name')->get(['id', 'name', 'device_vendor_id', 'status', 'activation_status']);
|
||||
}
|
||||
|
||||
private function ensureOwned(DeviceAccessUser $user): void
|
||||
{
|
||||
abort_unless(! request()->user()->is_platform_admin && $user->tenant_id === request()->user()->tenant_id, 404);
|
||||
}
|
||||
|
||||
private function syncResultMessage($assignments, string $prefix): string
|
||||
{
|
||||
$remaining = DeviceUserAssignment::withoutGlobalScope('tenant')->whereIn('id', $assignments->pluck('id'))->get()->keyBy('id');
|
||||
$statuses = $assignments->map(function ($assignment) use ($remaining) {
|
||||
return $remaining->get($assignment->id)?->sync_status
|
||||
?? ($assignment->desired_operation === 'delete' ? 'synced' : 'failed');
|
||||
});
|
||||
$pending = $statuses->filter(fn ($status) => in_array($status, ['pending', 'queued'], true))->count();
|
||||
$synced = $statuses->where('synced')->count();
|
||||
$failed = $statuses->filter(fn ($status) => in_array($status, ['failed', 'unsupported'], true))->count();
|
||||
|
||||
return $prefix." {$synced} perangkat langsung tersinkron"
|
||||
.($pending ? ", {$pending} perangkat pending dan akan dicoba scheduler" : '')
|
||||
.($failed ? ", {$failed} perangkat memerlukan pemeriksaan" : '').'.';
|
||||
}
|
||||
|
||||
private function syncAssignments($assignments, int $tenantId): void
|
||||
{
|
||||
$assignments->each(function (DeviceUserAssignment $assignment) use ($tenantId): void {
|
||||
$isSshOlt = in_array($assignment->device()->with('vendor:id,slug')->first()?->vendor?->slug, ['zte', 'hsgq'], true);
|
||||
$isSshOlt
|
||||
? SyncDeviceUser::dispatch($tenantId, $assignment->id)
|
||||
: SyncDeviceUser::dispatchSync($tenantId, $assignment->id);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Jobs\SyncDeviceBaseCredential;
|
||||
use App\Models\Device;
|
||||
use App\Models\TenantDevicePolicy;
|
||||
use App\Services\Devices\MikrotikActivationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Throwable;
|
||||
|
||||
class DeviceActivationController extends Controller
|
||||
{
|
||||
public function __invoke(Device $device, MikrotikActivationService $service): RedirectResponse
|
||||
{
|
||||
$user = request()->user();
|
||||
abort_if($user->is_platform_admin || $device->tenant_id !== $user->tenant_id, 404);
|
||||
abort_unless($user->can('device.connect'), 403);
|
||||
if ($user->hasRole(SystemRole::TenantUser->value)) {
|
||||
$policy = TenantDevicePolicy::where('tenant_id', $user->tenant_id)->first();
|
||||
abort_if(! $policy?->tenant_user_can_update_own || $device->created_by !== $user->id, 403);
|
||||
}
|
||||
|
||||
$device->update(['activation_status' => 'processing', 'activation_message' => null]);
|
||||
try {
|
||||
$queuedOlt = ($device->vendor?->slug === 'zte' && $device->hasConnection('ssh'))
|
||||
|| ($device->vendor?->slug === 'hsgq' && ($device->hasConnection('ssh') || $device->hasConnection('telnet')))
|
||||
|| ($device->vendor?->slug === 'hisfocus' && ($device->hasConnection('ssh') || $device->hasConnection('telnet')));
|
||||
if ($queuedOlt) {
|
||||
$device->update(['base_sync_status' => 'queued', 'base_sync_error_code' => null, 'base_sync_message' => 'Aktivasi OLT menunggu queue worker.']);
|
||||
SyncDeviceBaseCredential::dispatch($device->tenant_id, $device->id);
|
||||
|
||||
return back()->with('success', 'Aktivasi OLT dimasukkan ke queue. Status akan diperbarui setelah proses SSH selesai.');
|
||||
}
|
||||
|
||||
$result = $service->activate($device->load('vendor'));
|
||||
$info = $result['device_info'];
|
||||
$deleted = count($result['cleanup']['deleted']);
|
||||
$device->update([
|
||||
'hostname' => $info['identity'] ?? $device->hostname,
|
||||
'serial_number' => $info['serial-number'] ?? $device->serial_number,
|
||||
'firmware_version' => $info['current-firmware'] ?? $device->firmware_version,
|
||||
'software_version' => $info['version'] ?? $device->software_version,
|
||||
'device_facts' => $info, 'status' => 'online', 'last_seen_at' => now(),
|
||||
'activation_status' => 'active', 'activated_at' => now(),
|
||||
'activation_message' => 'Base User RADIQ aktif'.($deleted > 0 ? "; {$deleted} user lama dihapus." : '.'),
|
||||
'base_sync_status' => 'synced', 'base_sync_error_code' => null,
|
||||
'base_sync_message' => 'Base User berhasil diverifikasi saat aktivasi.', 'base_synced_at' => now(),
|
||||
]);
|
||||
|
||||
return back()->with('success', 'Perangkat aktif dan akun management RADIQ berhasil dibuat.');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$message = str($exception->getMessage())->before(':')->limit(80)->toString();
|
||||
$device->update(['activation_status' => 'failed', 'activation_message' => $message]);
|
||||
|
||||
return back()->withErrors(['activation' => 'Aktivasi gagal: '.$message]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Http\Requests\StoreDeviceRequest;
|
||||
use App\Http\Requests\UpdateDeviceRequest;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceCredential;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\DeviceVendor;
|
||||
use App\Models\TenantDevicePolicy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->ensureTenantAccount();
|
||||
abort_unless($request->user()->can('device.view'), 403);
|
||||
$devices = Device::query()->with(['vendor:id,name', 'type:id,name', 'model:id,name', 'creator:id,name'])->withCount('userAssignments')
|
||||
->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where(fn ($nested) => $nested
|
||||
->where('name', 'ilike', '%'.$request->string('search').'%')
|
||||
->orWhere('management_address', 'ilike', '%'.$request->string('search').'%')
|
||||
->orWhere('location', 'ilike', '%'.$request->string('search').'%')))
|
||||
->when($request->integer('vendor'), fn ($query, $vendor) => $query->where('device_vendor_id', $vendor))
|
||||
->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')))
|
||||
->latest()->paginate(15)->withQueryString();
|
||||
|
||||
return Inertia::render('devices/index', ['devices' => $devices, 'vendors' => DeviceVendor::where('is_active', true)->get(['id', 'name']), 'filters' => $request->only('search', 'vendor', 'status'), 'policy' => $this->policy(), 'isTenantAdmin' => $request->user()->hasRole(SystemRole::TenantAdmin->value)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create(): Response
|
||||
{
|
||||
$this->authorizeCreate();
|
||||
|
||||
return Inertia::render('devices/form', $this->catalog() + ['device' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(StoreDeviceRequest $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeCreate();
|
||||
$this->validateModelCombination($request);
|
||||
$data = $request->validated();
|
||||
$username = $data['initial_username'];
|
||||
$password = $data['initial_password'] ?? '';
|
||||
$enablePassword = $data['initial_enable_password'] ?? null;
|
||||
unset($data['initial_username'], $data['initial_password'], $data['initial_enable_password']);
|
||||
|
||||
DB::transaction(function () use ($data, $username, $password, $enablePassword, $request): void {
|
||||
$device = Device::create($data + ['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
|
||||
DeviceCredential::create([
|
||||
'tenant_id' => $request->user()->tenant_id, 'device_id' => $device->id,
|
||||
'name' => 'Login Awal', 'username' => $username, 'password' => $password,
|
||||
'enable_password' => $enablePassword,
|
||||
'connection_type' => $device->connection_type, 'privilege_type' => 'master',
|
||||
'is_master' => true, 'is_active' => true,
|
||||
'created_by' => $request->user()->id, 'updated_by' => $request->user()->id,
|
||||
]);
|
||||
});
|
||||
|
||||
return to_route('devices.index')->with('success', 'Perangkat berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Device $device): Response
|
||||
{
|
||||
$this->ensureOwnedDevice($device);
|
||||
abort_unless(request()->user()->can('device.view'), 403);
|
||||
|
||||
return Inertia::render('devices/show', [
|
||||
'device' => $device->load(['vendor:id,name,slug', 'type:id,name', 'model:id,name', 'creator:id,name']),
|
||||
'credentials' => request()->user()->can('device.credential.view')
|
||||
? $device->credentials()->get(['id', 'name', 'username', 'connection_type', 'privilege_type', 'is_master', 'is_active', 'last_verified_at'])
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(Device $device): Response
|
||||
{
|
||||
$this->ensureOwnedDevice($device);
|
||||
$this->authorizeUpdate($device);
|
||||
|
||||
return Inertia::render('devices/form', $this->catalog() + ['device' => $device]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(UpdateDeviceRequest $request, Device $device): RedirectResponse
|
||||
{
|
||||
$this->ensureOwnedDevice($device);
|
||||
$this->authorizeUpdate($device);
|
||||
$this->validateModelCombination($request);
|
||||
$device->update($request->validated() + ['updated_by' => $request->user()->id]);
|
||||
|
||||
return to_route('devices.show', $device)->with('success', 'Perangkat berhasil diperbarui.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Device $device): RedirectResponse
|
||||
{
|
||||
$this->ensureOwnedDevice($device);
|
||||
abort_unless(request()->user()->can('device.delete'), 403);
|
||||
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && (! $this->policy()->tenant_user_can_delete_own || $device->created_by !== request()->user()->id), 403);
|
||||
$device->delete();
|
||||
|
||||
return to_route('devices.index')->with('success', 'Perangkat berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function catalog(): array
|
||||
{
|
||||
return ['vendors' => DeviceVendor::where('is_active', true)->get(['id', 'name', 'slug']), 'types' => DeviceType::where('is_active', true)->get(['id', 'name']), 'models' => DeviceModel::where('is_active', true)->get(['id', 'name', 'device_vendor_id', 'device_type_id'])];
|
||||
}
|
||||
|
||||
private function policy(): TenantDevicePolicy
|
||||
{
|
||||
return TenantDevicePolicy::firstOrCreate(['tenant_id' => request()->user()->tenant_id]);
|
||||
}
|
||||
|
||||
private function ensureTenantAccount(): void
|
||||
{
|
||||
abort_if(request()->user()->is_platform_admin || ! request()->user()->tenant_id, 403);
|
||||
}
|
||||
|
||||
private function ensureOwnedDevice(Device $device): void
|
||||
{
|
||||
$this->ensureTenantAccount();
|
||||
abort_unless($device->tenant_id === request()->user()->tenant_id, 404);
|
||||
}
|
||||
|
||||
private function authorizeCreate(): void
|
||||
{
|
||||
$this->ensureTenantAccount();
|
||||
abort_unless(request()->user()->can('device.create'), 403);
|
||||
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && ! $this->policy()->tenant_user_can_create, 403);
|
||||
}
|
||||
|
||||
private function authorizeUpdate(Device $device): void
|
||||
{
|
||||
abort_unless(request()->user()->can('device.update'), 403);
|
||||
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && (! $this->policy()->tenant_user_can_update_own || $device->created_by !== request()->user()->id), 403);
|
||||
}
|
||||
|
||||
private function validateModelCombination(Request $request): void
|
||||
{
|
||||
if ($request->filled('device_model_id')) {
|
||||
abort_unless(DeviceModel::whereKey($request->integer('device_model_id'))->where('device_vendor_id', $request->integer('device_vendor_id'))->where('device_type_id', $request->integer('device_type_id'))->exists(), 422, 'Model tidak sesuai dengan vendor dan tipe perangkat.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreDeviceCredentialRequest;
|
||||
use App\Http\Requests\UpdateDeviceCredentialRequest;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceCredential;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class DeviceCredentialController extends Controller
|
||||
{
|
||||
public function store(StoreDeviceCredentialRequest $request, Device $device): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($device);
|
||||
$data = $request->validated();
|
||||
$data['is_master'] = $data['privilege_type'] === 'master' || ($data['is_master'] ?? false);
|
||||
abort_if(($data['is_master'] ?? false) && ! $request->user()->can('device.master_account.manage'), 403);
|
||||
DeviceCredential::create($data + ['tenant_id' => $request->user()->tenant_id, 'device_id' => $device->id, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
|
||||
|
||||
return back()->with('success', 'Credential perangkat berhasil disimpan secara terenkripsi.');
|
||||
}
|
||||
|
||||
public function update(UpdateDeviceCredentialRequest $request, Device $device, DeviceCredential $credential): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($device, $credential);
|
||||
abort_if($credential->is_master && ! $request->user()->can('device.master_account.manage'), 403);
|
||||
$data = $request->validated();
|
||||
$data['is_master'] = $data['privilege_type'] === 'master' || ($data['is_master'] ?? false);
|
||||
if (blank($data['password'] ?? null)) {
|
||||
unset($data['password']);
|
||||
}
|
||||
if (blank($data['enable_password'] ?? null)) {
|
||||
unset($data['enable_password']);
|
||||
}
|
||||
$credential->update($data + ['updated_by' => $request->user()->id]);
|
||||
|
||||
return back()->with('success', 'Credential perangkat berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Device $device, DeviceCredential $credential): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($device, $credential);
|
||||
abort_unless(request()->user()->can('device.credential.delete'), 403);
|
||||
abort_if($credential->is_master && ! request()->user()->can('device.master_account.manage'), 403);
|
||||
$credential->delete();
|
||||
|
||||
return back()->with('success', 'Credential perangkat berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function ensureOwned(Device $device, ?DeviceCredential $credential = null): void
|
||||
{
|
||||
abort_if(request()->user()->is_platform_admin || $device->tenant_id !== request()->user()->tenant_id, 404);
|
||||
abort_if($credential && ($credential->tenant_id !== $device->tenant_id || $credential->device_id !== $device->id), 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\SyncDeviceUser;
|
||||
use App\Models\DeviceUserAssignment;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class DeviceUserSyncController extends Controller
|
||||
{
|
||||
public function __invoke(DeviceUserAssignment $assignment): RedirectResponse
|
||||
{
|
||||
abort_unless(request()->user()->can('device.user.update'), 403);
|
||||
abort_unless($assignment->tenant_id === request()->user()->tenant_id, 404);
|
||||
$assignment->update(['sync_status' => 'queued', 'error_code' => null, 'message' => null]);
|
||||
$isSshOlt = in_array($assignment->device()->with('vendor:id,slug')->first()?->vendor?->slug, ['zte', 'hsgq'], true);
|
||||
$isSshOlt
|
||||
? SyncDeviceUser::dispatch(request()->user()->tenant_id, $assignment->id)
|
||||
: SyncDeviceUser::dispatchSync(request()->user()->tenant_id, $assignment->id);
|
||||
|
||||
return back()->with('success', 'Sinkronisasi ulang langsung dijalankan. Jika perangkat offline, proses akan dicoba kembali oleh scheduler.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\TenantDeviceSetting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RevealTenantBasePasswordController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$data = $request->validate(['current_password' => ['required', 'string']]);
|
||||
if (! Hash::check($data['current_password'], $request->user()->password)) {
|
||||
throw ValidationException::withMessages(['current_password' => 'Password login tidak sesuai.']);
|
||||
}
|
||||
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $request->user()->tenant_id)->firstOrFail();
|
||||
Log::notice('Tenant Admin revealed the base device password.', [
|
||||
'tenant_id' => $request->user()->tenant_id,
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return response()->json(['password' => $setting->base_password])
|
||||
->header('Cache-Control', 'no-store, private')
|
||||
->header('Pragma', 'no-cache');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's profile settings page.
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
return Inertia::render('settings/profile', [
|
||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
|
||||
|
||||
return to_route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's profile.
|
||||
*/
|
||||
public function destroy(ProfileDeleteRequest $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\PasswordUpdateRequest;
|
||||
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
class SecurityController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's security settings page.
|
||||
*/
|
||||
public function edit(TwoFactorAuthenticationRequest $request): Response
|
||||
{
|
||||
$props = [
|
||||
'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(),
|
||||
'canManagePasskeys' => Features::canManagePasskeys(),
|
||||
'passkeys' => Features::canManagePasskeys()
|
||||
? $request->user()
|
||||
->passkeys()
|
||||
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn ($passkey) => [
|
||||
'id' => $passkey->id,
|
||||
'name' => $passkey->name,
|
||||
'authenticator' => $passkey->authenticator,
|
||||
'created_at_diff' => $passkey->created_at->diffForHumans(),
|
||||
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
|
||||
])
|
||||
->values()
|
||||
->all()
|
||||
: [],
|
||||
'passwordRules' => Password::defaults()->toPasswordRulesString(),
|
||||
];
|
||||
|
||||
if (Features::canManageTwoFactorAuthentication()) {
|
||||
$request->ensureStateIsValid();
|
||||
|
||||
$props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication();
|
||||
$props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
|
||||
}
|
||||
|
||||
return Inertia::render('settings/security', $props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(PasswordUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->update([
|
||||
'password' => $request->password,
|
||||
]);
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\TenantDevicePolicy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TenantDevicePolicyController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$data = $request->validate([
|
||||
'tenant_user_can_create' => ['required', 'boolean'],
|
||||
'tenant_user_can_update_own' => ['required', 'boolean'],
|
||||
'tenant_user_can_delete_own' => ['required', 'boolean'],
|
||||
]);
|
||||
TenantDevicePolicy::updateOrCreate(['tenant_id' => $request->user()->tenant_id], $data);
|
||||
|
||||
return back()->with('success', 'Aturan perangkat Tenant User berhasil diperbarui.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Jobs\SyncDeviceBaseCredential;
|
||||
use App\Models\Device;
|
||||
use App\Models\TenantDeviceSetting;
|
||||
use App\Models\TenantEncryptionKey;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TenantDeviceSettingController extends Controller
|
||||
{
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $request->user()->tenant_id)->first();
|
||||
|
||||
$key = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $request->user()->tenant_id)->where('is_active', true)->first();
|
||||
|
||||
return Inertia::render('devices/settings', [
|
||||
'setting' => $setting?->only(['base_username', 'use_tls', 'verify_tls', 'connection_timeout']),
|
||||
'hasPassword' => (bool) $setting,
|
||||
'encryptionKey' => $key?->only(['version', 'source', 'created_at']),
|
||||
'devices' => Device::with('vendor:id,name')->orderBy('name')->get(['id', 'name', 'device_vendor_id', 'base_sync_status', 'base_sync_error_code', 'base_sync_message', 'base_synced_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $request->user()->tenant_id)->first();
|
||||
$data = $request->validate([
|
||||
'base_username' => ['required', 'regex:/^[A-Za-z0-9][A-Za-z0-9_.@#-]*[A-Za-z0-9]$/', 'max:64'],
|
||||
'base_password' => [$setting ? 'nullable' : 'required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
'use_tls' => ['required', 'boolean'], 'verify_tls' => ['required', 'boolean'],
|
||||
'connection_timeout' => ['required', 'integer', 'between:3,30'],
|
||||
]);
|
||||
if (blank($data['base_password'] ?? null)) {
|
||||
unset($data['base_password']);
|
||||
}
|
||||
$oldUsername = $setting?->base_username;
|
||||
$credentialChanged = ! $setting || $oldUsername !== $data['base_username'] || array_key_exists('base_password', $data);
|
||||
TenantDeviceSetting::updateOrCreate(['tenant_id' => $request->user()->tenant_id], $data);
|
||||
if ($credentialChanged) {
|
||||
Device::where('tenant_id', $request->user()->tenant_id)->where('is_active', true)->get(['id', 'tenant_id'])->each(function (Device $device) use ($oldUsername): void {
|
||||
$device->update(['base_sync_status' => 'queued', 'base_sync_error_code' => null, 'base_sync_message' => 'Perubahan Base User menunggu sinkronisasi.']);
|
||||
SyncDeviceBaseCredential::dispatch($device->tenant_id, $device->id, $oldUsername);
|
||||
});
|
||||
}
|
||||
|
||||
return back()->with('success', $credentialChanged ? 'Base User tersimpan terenkripsi dan rotasi seluruh perangkat tenant dimasukkan ke queue.' : 'Pengaturan koneksi perangkat berhasil disimpan.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Services\Encryption\TenantEnvelopeEncryption;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class TenantEncryptionController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, TenantEnvelopeEncryption $encryption): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$data = $request->validate([
|
||||
'mode' => ['required', 'in:generated,manual'],
|
||||
'manual_key' => ['nullable', 'required_if:mode,manual', 'string', 'min:32', 'max:4096', 'confirmed'],
|
||||
'current_password' => ['required', 'string'],
|
||||
]);
|
||||
abort_unless(Hash::check($data['current_password'], $request->user()->password), 422, 'Password akun Tenant Admin tidak valid.');
|
||||
$key = $encryption->rotate($request->user()->tenant_id, $data['mode'] === 'manual' ? $data['manual_key'] : null, $request->user()->id);
|
||||
|
||||
return back()->with('success', "Encryption key tenant berhasil dirotasi ke versi {$key->version}. Semua secret database telah dienkripsi ulang.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureActiveUser
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->user() && ! $request->user()->is_active) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors(['email' => 'Akun Anda sedang dinonaktifkan.']);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HandleAppearance
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
/**
|
||||
* The root template that's loaded on the first page visit.
|
||||
*
|
||||
* @see https://inertiajs.com/server-side-setup#root-template
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rootView = 'app';
|
||||
|
||||
/**
|
||||
* Determines the current asset version.
|
||||
*
|
||||
* @see https://inertiajs.com/asset-versioning
|
||||
*/
|
||||
public function version(Request $request): ?string
|
||||
{
|
||||
return parent::version($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the props that are shared by default.
|
||||
*
|
||||
* @see https://inertiajs.com/shared-data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
'permissions' => fn () => $request->user()?->getAllPermissions()->pluck('name')->values() ?? [],
|
||||
],
|
||||
'flash' => [
|
||||
'success' => fn () => $request->session()->get('success'),
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\TenantContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SelectAdministrationTenant
|
||||
{
|
||||
public function __construct(private readonly TenantContext $context) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$tenant = $request->route('tenant');
|
||||
abort_unless($tenant instanceof Tenant, 404);
|
||||
|
||||
$user = $request->user();
|
||||
abort_unless($user?->is_platform_admin || $user?->tenant_id === $tenant->id, 403);
|
||||
|
||||
$previousTenantId = $this->context->id();
|
||||
$this->context->set($tenant->id);
|
||||
setPermissionsTeamId($tenant->id);
|
||||
|
||||
try {
|
||||
return $next($request);
|
||||
} finally {
|
||||
$this->context->set($previousTenantId);
|
||||
setPermissionsTeamId($previousTenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\TenantContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetTenantContext
|
||||
{
|
||||
public function __construct(private readonly TenantContext $context) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$requestedTenantId = $request->header('X-Tenant-ID');
|
||||
|
||||
if ($user?->is_platform_admin && $requestedTenantId !== null) {
|
||||
abort_unless($user->can('tenant.access-any'), 403);
|
||||
$this->context->set((int) $requestedTenantId);
|
||||
} else {
|
||||
$this->context->set($user?->tenant_id);
|
||||
}
|
||||
|
||||
setPermissionsTeamId($this->context->id());
|
||||
|
||||
try {
|
||||
return $next($request);
|
||||
} finally {
|
||||
$this->context->clear();
|
||||
setPermissionsTeamId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class ResetUserPasswordRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('user.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class StoreTenantRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('tenant.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'owner_name' => ['required', 'string', 'max:255'],
|
||||
'owner_email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'owner_password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
'deployment_type' => ['sometimes', 'in:managed_cloud,self_hosted'],
|
||||
'deployment_domain' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class StoreUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('user.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('tenant.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug,'.$this->route('tenant')->id],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
'deployment_type' => ['sometimes', 'in:managed_cloud,self_hosted'],
|
||||
'deployment_domain' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('user.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->route('user')->id)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PasswordUpdateRequest extends FormRequest
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_password' => $this->currentPasswordRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ProfileDeleteRequest extends FormRequest
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'password' => $this->currentPasswordRules(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
use ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return $this->profileRules($this->user()->id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Laravel\Fortify\InteractsWithTwoFactorState;
|
||||
|
||||
class TwoFactorAuthenticationRequest extends FormRequest
|
||||
{
|
||||
use InteractsWithTwoFactorState;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Device;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreDeviceAccessUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.user.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return $this->rulesForUser();
|
||||
}
|
||||
|
||||
protected function rulesForUser(?int $ignoreId = null): array
|
||||
{
|
||||
$tenantId = $this->user()->tenant_id;
|
||||
|
||||
return [
|
||||
'display_name' => ['required', 'string', 'max:255'],
|
||||
'username' => ['required', 'regex:/^[A-Za-z0-9][A-Za-z0-9_.@#-]*[A-Za-z0-9]$/', 'max:64', Rule::unique('device_access_users')->where('tenant_id', $tenantId)->ignore($ignoreId)],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
'group_name' => ['required', 'in:RADIQ-READ,RADIQ-WRITE,RADIQ-NOC'],
|
||||
'device_ids' => ['required', 'array', 'min:1'],
|
||||
'device_ids.*' => ['integer', Rule::exists('devices', 'id')->where('tenant_id', $tenantId)],
|
||||
'is_enabled' => ['sometimes', 'boolean'], 'notes' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
$hasZte = Device::whereIn('id', $this->input('device_ids', []))->whereHas('vendor', fn ($query) => $query->where('slug', 'zte'))->exists();
|
||||
if ($hasZte && ! preg_match('/^[A-Za-z0-9_]{1,16}$/', (string) $this->input('username'))) {
|
||||
$validator->errors()->add('username', 'Untuk ZTE C300/C320, username harus 1-16 karakter alfanumerik atau underscore.');
|
||||
}
|
||||
$password = (string) $this->input('password');
|
||||
if ($hasZte && $password !== '' && (strlen($password) > 32 || preg_match('/\s/', $password))) {
|
||||
$validator->errors()->add('password', 'Untuk ZTE C300/C320, password maksimal 32 karakter dan tidak boleh mengandung spasi.');
|
||||
}
|
||||
|
||||
$hasHsgq = Device::whereIn('id', $this->input('device_ids', []))->whereHas('vendor', fn ($query) => $query->where('slug', 'hsgq'))->exists();
|
||||
if ($hasHsgq && ! preg_match('/^[A-Za-z0-9_]{4,16}$/', (string) $this->input('username'))) {
|
||||
$validator->errors()->add('username', 'Untuk HSGQ, username harus 4-16 karakter alfanumerik atau underscore.');
|
||||
}
|
||||
if ($hasHsgq && $password !== '' && (strlen($password) > 64 || preg_match('/\s/', $password))) {
|
||||
$validator->errors()->add('password', 'Untuk HSGQ, password maksimal 64 karakter dan tidak boleh mengandung spasi.');
|
||||
}
|
||||
$hasHisfocus = Device::whereIn('id', $this->input('device_ids', []))->whereHas('vendor', fn ($query) => $query->where('slug', 'hisfocus'))->exists();
|
||||
if ($hasHisfocus && ! preg_match('/^[A-Za-z][A-Za-z0-9_]{3,15}$/', (string) $this->input('username'))) {
|
||||
$validator->errors()->add('username', 'Untuk Hisfocus, username harus 4-16 karakter, diawali huruf, dan hanya alfanumerik atau underscore.');
|
||||
}
|
||||
if ($hasHisfocus && $password !== '' && (strlen($password) > 64 || preg_match('/\s/', $password))) {
|
||||
$validator->errors()->add('password', 'Untuk Hisfocus, password maksimal 64 karakter dan tidak boleh mengandung spasi.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreDeviceCredentialRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.credential.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return self::deviceCredentialRules();
|
||||
}
|
||||
|
||||
public static function deviceCredentialRules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'], 'username' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'string', 'max:4096'],
|
||||
'enable_password' => ['nullable', 'string', 'max:4096'],
|
||||
'connection_type' => ['required', 'in:routeros_api,ssh,telnet,snmp,vendor_api'],
|
||||
'privilege_type' => ['required', 'in:master,noc,technician,monitoring,custom'],
|
||||
'is_master' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\DeviceVendor;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class StoreDeviceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return self::deviceRules() + [
|
||||
'initial_username' => ['required', 'string', 'max:255'],
|
||||
'initial_password' => ['nullable', 'string', 'max:1000'],
|
||||
'initial_enable_password' => ['nullable', 'string', 'max:1000'],
|
||||
'remove_legacy_users_on_activation' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function deviceRules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'device_vendor_id' => ['required', 'exists:device_vendors,id'], 'device_type_id' => ['required', 'exists:device_types,id'],
|
||||
'device_model_id' => ['nullable', 'exists:device_models,id'],
|
||||
'management_address' => ['required', 'string', 'max:255'],
|
||||
'connection_type' => ['required', 'in:routeros_api,ssh,telnet,web_http,snmp,vendor_api'],
|
||||
'management_port' => ['required', 'integer', 'between:1,65535'],
|
||||
'connection_ports' => ['required', 'array'],
|
||||
'connection_ports.routeros_api' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'connection_ports.ssh' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'connection_ports.telnet' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'connection_ports.web_http' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'connection_ports.snmp' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'connection_ports.vendor_api' => ['nullable', 'integer', 'between:1,65535'],
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'], 'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$ports = collect($this->input('connection_ports', []))
|
||||
->map(fn ($port) => $port === '' || $port === null ? null : (int) $port)
|
||||
->filter()
|
||||
->all();
|
||||
$slug = DeviceVendor::find($this->input('device_vendor_id'))?->slug;
|
||||
$order = match ($slug) {
|
||||
'mikrotik' => ['routeros_api'],
|
||||
'zte' => ['ssh'],
|
||||
'hsgq' => ['ssh', 'telnet'],
|
||||
'hisfocus' => ['telnet', 'web_http'],
|
||||
default => ['ssh', 'telnet', 'routeros_api', 'web_http', 'snmp', 'vendor_api'],
|
||||
};
|
||||
$primary = collect($order)->first(fn ($protocol) => isset($ports[$protocol]));
|
||||
$this->merge([
|
||||
'connection_ports' => $ports,
|
||||
'connection_type' => $primary ?? 'vendor_api',
|
||||
'management_port' => $primary ? $ports[$primary] : 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function after(): array
|
||||
{
|
||||
return [function (Validator $validator): void {
|
||||
$slug = DeviceVendor::find($this->input('device_vendor_id'))?->slug;
|
||||
$ports = $this->input('connection_ports', []);
|
||||
$valid = match ($slug) {
|
||||
'mikrotik' => isset($ports['routeros_api']),
|
||||
'zte' => isset($ports['ssh']),
|
||||
'hsgq' => isset($ports['ssh']) || isset($ports['telnet']),
|
||||
'hisfocus' => isset($ports['telnet']) || isset($ports['web_http']),
|
||||
default => count($ports) > 0,
|
||||
};
|
||||
if (! $valid) {
|
||||
$validator->errors()->add('connection_ports', 'Isi minimal satu port protokol yang didukung vendor.');
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreTenantRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('tenant.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\DeviceAccessUser;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UpdateDeviceAccessUserRequest extends StoreDeviceAccessUserRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.user.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var DeviceAccessUser $deviceAccessUser */
|
||||
$deviceAccessUser = $this->route('deviceAccessUser');
|
||||
$rules = $this->rulesForUser($deviceAccessUser->id);
|
||||
$rules['password'] = ['nullable', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateDeviceCredentialRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.credential.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = StoreDeviceCredentialRequest::deviceCredentialRules();
|
||||
$rules['password'] = ['nullable', 'string', 'max:4096'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateDeviceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return StoreDeviceRequest::deviceRules();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('tenant.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'required', 'alpha_dash', 'max:100', 'unique:tenants,slug,'.$this->route('tenant')->id],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TenantResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'is_active' => $this->is_active,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\TenantDeviceSetting;
|
||||
use App\Network\Clients\Hisfocus\HisfocusSshClient;
|
||||
use App\Network\Clients\Hisfocus\HisfocusWebClient;
|
||||
use App\Network\Clients\Hsgq\HsgqSshClient;
|
||||
use App\Network\Clients\Hsgq\HsgqTelnetClient;
|
||||
use App\Network\Clients\Hsgq\HsgqWebClient;
|
||||
use App\Network\Clients\RouterOs\RouterOsApiClient;
|
||||
use App\Network\Clients\Zte\ZteSshClient;
|
||||
use App\Network\Drivers\Hisfocus\HisfocusOltDriver;
|
||||
use App\Network\Drivers\Hsgq\HsgqOltDriver;
|
||||
use App\Network\Drivers\Mikrotik\MikrotikDriver;
|
||||
use App\Network\Drivers\Zte\ZteC3xxDriver;
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class SyncDeviceBaseCredential implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 55;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly int $deviceId,
|
||||
public readonly ?string $oldUsername = null,
|
||||
) {}
|
||||
|
||||
public function handle(TenantContext $context): void
|
||||
{
|
||||
$context->set($this->tenantId);
|
||||
setPermissionsTeamId($this->tenantId);
|
||||
try {
|
||||
$device = Device::with(['vendor', 'model', 'credentials'])->find($this->deviceId);
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $this->tenantId)->first();
|
||||
if (! $device || ! $setting) {
|
||||
return;
|
||||
}
|
||||
$device->update(['base_sync_status' => 'processing', 'base_sync_attempted_at' => now(), 'base_sync_error_code' => null]);
|
||||
|
||||
if ($device->vendor?->slug === 'zte' && $device->hasConnection('ssh') && preg_match('/C3(?:00|20)/i', $device->model?->name ?? '')) {
|
||||
$this->syncZte($device, $setting);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($device->vendor?->slug === 'hsgq' && ($device->hasConnection('ssh') || $device->hasConnection('telnet'))) {
|
||||
$this->syncHsgq($device, $setting);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($device->vendor?->slug === 'hisfocus' && ($device->hasConnection('ssh') || $device->hasConnection('telnet'))) {
|
||||
$this->syncHisfocus($device, $setting);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($device->vendor?->slug !== 'mikrotik' || ! $device->hasConnection('routeros_api')) {
|
||||
$device->update(['base_sync_status' => 'unsupported', 'base_sync_error_code' => 'DRIVER_NOT_AVAILABLE', 'base_sync_message' => "Driver rotasi Base User untuk {$device->vendor?->name} / {$device->connection_type} belum tersedia.", 'activation_status' => 'failed', 'activation_message' => 'DRIVER_NOT_AVAILABLE']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$credential = $device->credentials->where('is_active', true)->sortByDesc(fn ($item) => $item->name === 'Base User RADIQ' ? 2 : (int) $item->is_master)->first();
|
||||
if (! $credential) {
|
||||
$device->update(['base_sync_status' => 'pending', 'base_sync_error_code' => 'CREDENTIAL_REQUIRED', 'base_sync_message' => 'Menunggu credential aktif untuk masuk ke perangkat.']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = new MikrotikDriver($this->client($device, $credential->username, $credential->password, $setting));
|
||||
$driver->provisionBaseAccess($setting->base_username, $setting->base_password);
|
||||
$newDriver = new MikrotikDriver($this->client($device, $setting->base_username, $setting->base_password, $setting));
|
||||
$newDriver->testConnection();
|
||||
|
||||
$previousUsername = $this->oldUsername ?? ($credential->name === 'Base User RADIQ' ? $credential->username : null);
|
||||
if ($previousUsername && $previousUsername !== $setting->base_username) {
|
||||
$newDriver->deleteUser($previousUsername);
|
||||
}
|
||||
|
||||
$device->credentials()->update(['is_master' => false]);
|
||||
$device->credentials()->updateOrCreate(['name' => 'Base User RADIQ'], [
|
||||
'tenant_id' => $device->tenant_id, 'username' => $setting->base_username,
|
||||
'password' => $setting->base_password, 'connection_type' => $device->connection_type,
|
||||
'privilege_type' => 'master', 'is_master' => true, 'is_active' => true,
|
||||
'last_verified_at' => now(),
|
||||
]);
|
||||
$device->update(['base_sync_status' => 'synced', 'base_sync_error_code' => null, 'base_sync_message' => 'Base User berhasil diperbarui pada perangkat.', 'base_synced_at' => now(), 'status' => 'online', 'last_seen_at' => now()]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$code = str($exception->getMessage())->before(':')->limit(64)->toString() ?: 'UNKNOWN_ERROR';
|
||||
$pending = in_array($code, ['DEVICE_UNREACHABLE', 'CONNECTION_TIMEOUT', 'CONNECTION_FAILED'], true);
|
||||
Device::whereKey($this->deviceId)->update([
|
||||
'base_sync_status' => $pending ? 'pending' : 'failed',
|
||||
'base_sync_error_code' => $code,
|
||||
'base_sync_message' => $pending ? 'Perangkat belum terhubung; scheduler akan mencoba kembali.' : 'Rotasi Base User gagal dan perlu diperiksa.',
|
||||
'status' => $pending ? 'offline' : 'unknown',
|
||||
'activation_status' => $pending ? 'pending' : 'failed',
|
||||
'activation_message' => $code,
|
||||
]);
|
||||
} finally {
|
||||
$context->clear();
|
||||
setPermissionsTeamId(null);
|
||||
}
|
||||
}
|
||||
|
||||
private function client(Device $device, string $username, string $password, TenantDeviceSetting $setting): RouterOsApiClient
|
||||
{
|
||||
return new RouterOsApiClient($device->management_address, $device->portFor('routeros_api'), $username, $password, $setting->connection_timeout, $setting->use_tls, $setting->verify_tls);
|
||||
}
|
||||
|
||||
private function syncZte(Device $device, TenantDeviceSetting $setting): void
|
||||
{
|
||||
$credential = $device->credentials->where('is_active', true)->sortByDesc(fn ($item) => $item->name === 'Base User RADIQ' ? 2 : (int) $item->is_master)->first();
|
||||
if (! $credential) {
|
||||
$device->update(['base_sync_status' => 'pending', 'base_sync_error_code' => 'CREDENTIAL_REQUIRED', 'base_sync_message' => 'Menunggu credential login dan enable ZTE.', 'activation_status' => 'failed', 'activation_message' => 'CREDENTIAL_REQUIRED']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = new ZteC3xxDriver(new ZteSshClient($device->management_address, $device->portFor('ssh'), $setting->connection_timeout));
|
||||
$driver->provisionBaseAccess($credential->username, $credential->password, $credential->enable_password, $setting->base_username, $setting->base_password);
|
||||
$driver->testLogin($setting->base_username, $setting->base_password, $credential->enable_password);
|
||||
|
||||
$previousUsername = $this->oldUsername ?? ($credential->name === 'Base User RADIQ' ? $credential->username : null);
|
||||
if ($previousUsername && $previousUsername !== $setting->base_username) {
|
||||
$driver->deleteUser($setting->base_username, $setting->base_password, $credential->enable_password, $previousUsername);
|
||||
}
|
||||
|
||||
$device->credentials()->update(['is_master' => false]);
|
||||
$device->credentials()->updateOrCreate(['name' => 'Base User RADIQ'], [
|
||||
'tenant_id' => $device->tenant_id, 'username' => $setting->base_username,
|
||||
'password' => $setting->base_password, 'enable_password' => $credential->enable_password,
|
||||
'connection_type' => 'ssh', 'privilege_type' => 'master',
|
||||
'is_master' => true, 'is_active' => true, 'last_verified_at' => now(),
|
||||
]);
|
||||
$device->update(['base_sync_status' => 'synced', 'base_sync_error_code' => null, 'base_sync_message' => 'Base User privilege 15 berhasil diperbarui pada ZTE.', 'base_synced_at' => now(), 'status' => 'online', 'last_seen_at' => now(), 'activation_status' => 'active', 'activation_message' => 'Base User ZTE privilege 15 berhasil dibuat dan diverifikasi.', 'activated_at' => now()]);
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
Device::withoutGlobalScope('tenant')->whereKey($this->deviceId)->update([
|
||||
'base_sync_status' => 'failed',
|
||||
'base_sync_error_code' => 'JOB_TIMEOUT',
|
||||
'base_sync_message' => 'Proses koneksi perangkat melewati batas waktu queue.',
|
||||
'activation_status' => 'failed',
|
||||
'activation_message' => 'JOB_TIMEOUT',
|
||||
]);
|
||||
}
|
||||
|
||||
private function syncHsgq(Device $device, TenantDeviceSetting $setting): void
|
||||
{
|
||||
$credential = $device->credentials->where('is_active', true)->sortByDesc(fn ($item) => $item->name === 'HSGQ Root' ? 2 : (int) $item->is_master)->first();
|
||||
if (! $credential || $credential->username !== 'root') {
|
||||
$device->update(['base_sync_status' => 'pending', 'base_sync_error_code' => 'ROOT_CREDENTIAL_REQUIRED', 'base_sync_message' => 'HSGQ membutuhkan credential root aktif.', 'activation_status' => 'failed', 'activation_message' => 'ROOT_CREDENTIAL_REQUIRED']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$webClient = new HsgqWebClient($device->management_address, $setting->connection_timeout);
|
||||
$hsgqPrimary = $device->hasConnection('ssh')
|
||||
? new HsgqSshClient($device->management_address, $device->portFor('ssh'), $setting->connection_timeout)
|
||||
: new HsgqTelnetClient($device->management_address, $device->portFor('telnet'), $setting->connection_timeout);
|
||||
$driver = new HsgqOltDriver(
|
||||
$hsgqPrimary,
|
||||
$device->hasConnection('ssh') && $device->hasConnection('telnet') ? new HsgqTelnetClient($device->management_address, $device->portFor('telnet'), $setting->connection_timeout) : null,
|
||||
$webClient,
|
||||
);
|
||||
$rootPassword = $credential->password;
|
||||
if ($device->remove_legacy_users_on_activation && $rootPassword !== $setting->base_password) {
|
||||
$driver->rotateRootPassword($rootPassword, $setting->base_password, $credential->enable_password);
|
||||
$rootPassword = $setting->base_password;
|
||||
} else {
|
||||
$driver->testLogin('root', $rootPassword, $credential->enable_password);
|
||||
}
|
||||
|
||||
$device->credentials()->update(['is_master' => false]);
|
||||
$device->credentials()->updateOrCreate(['name' => 'HSGQ Root'], [
|
||||
'tenant_id' => $device->tenant_id, 'username' => 'root', 'password' => $rootPassword,
|
||||
'enable_password' => $credential->enable_password,
|
||||
'connection_type' => 'ssh', 'privilege_type' => 'master', 'is_master' => true,
|
||||
'is_active' => true, 'last_verified_at' => now(),
|
||||
]);
|
||||
$message = $device->remove_legacy_users_on_activation
|
||||
? 'Password root HSGQ telah disinkronkan dengan Base Password tenant.'
|
||||
: 'Credential root awal dipertahankan sesuai opsi perangkat.';
|
||||
$webClient->login('root', $rootPassword);
|
||||
$facts = $webClient->boardInfo();
|
||||
$device->update([
|
||||
'serial_number' => $facts['sn'] ?? $device->serial_number,
|
||||
'firmware_version' => $facts['fw_ver'] ?? $device->firmware_version,
|
||||
'software_version' => $facts['sys_ver'] ?? $device->software_version,
|
||||
'device_facts' => array_merge($device->device_facts ?? [], $facts, ['management_transports' => ['ssh', 'telnet', 'webgui']]),
|
||||
'base_sync_status' => 'synced', 'base_sync_error_code' => null, 'base_sync_message' => $message,
|
||||
'base_synced_at' => now(), 'status' => 'online', 'last_seen_at' => now(),
|
||||
'activation_status' => 'active', 'activation_message' => $message, 'activated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function syncHisfocus(Device $device, TenantDeviceSetting $setting): void
|
||||
{
|
||||
$credential = $device->credentials->where('is_active', true)->sortByDesc('is_master')->first();
|
||||
if (! $credential) {
|
||||
$device->update(['base_sync_status' => 'pending', 'base_sync_error_code' => 'CREDENTIAL_REQUIRED', 'base_sync_message' => 'Menunggu credential awal Hisfocus.', 'activation_status' => 'failed', 'activation_message' => 'CREDENTIAL_REQUIRED']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$client = $device->hasConnection('telnet')
|
||||
? new HsgqTelnetClient($device->management_address, $device->portFor('telnet'), $setting->connection_timeout)
|
||||
: new HisfocusSshClient($device->management_address, $device->portFor('ssh'), $setting->connection_timeout);
|
||||
$driver = new HisfocusOltDriver($client);
|
||||
$facts = $driver->probe($credential->username, $credential->password, $credential->enable_password);
|
||||
$driver->provisionBaseAccess($credential->username, $credential->password, $credential->enable_password, $setting->base_username, $setting->base_password);
|
||||
$webPort = $device->portFor('web_http');
|
||||
if ($webPort) {
|
||||
$web = new HisfocusWebClient($device->management_address, $webPort, $setting->connection_timeout);
|
||||
$webUsers = $web->users($credential->username, $credential->password);
|
||||
if (! isset($webUsers[$setting->base_username])) {
|
||||
$web->addUser($credential->username, $credential->password, $setting->base_username, $setting->base_password, 'Administrator');
|
||||
} elseif (! $web->canLogin($setting->base_username, $setting->base_password)) {
|
||||
$web->deleteUser($credential->username, $credential->password, $setting->base_username);
|
||||
$web->addUser($credential->username, $credential->password, $setting->base_username, $setting->base_password, 'Administrator');
|
||||
}
|
||||
if (! $web->canLogin($setting->base_username, $setting->base_password)) {
|
||||
throw new \RuntimeException('HISFOCUS_WEB_LOGIN_FAILED: Base User belum dapat login ke WebGUI.');
|
||||
}
|
||||
}
|
||||
if ($device->remove_legacy_users_on_activation && $credential->username !== $setting->base_username) {
|
||||
$driver->deleteUser($setting->base_username, $setting->base_password, $credential->enable_password, $credential->username);
|
||||
}
|
||||
$device->credentials()->update(['is_master' => false]);
|
||||
$device->credentials()->updateOrCreate(['name' => 'Base User RADIQ'], [
|
||||
'tenant_id' => $device->tenant_id, 'username' => $setting->base_username,
|
||||
'password' => $setting->base_password, 'enable_password' => $credential->enable_password ?: $credential->password,
|
||||
'connection_type' => $device->connection_type, 'privilege_type' => 'master',
|
||||
'is_master' => true, 'is_active' => true, 'last_verified_at' => now(),
|
||||
]);
|
||||
$message = $device->remove_legacy_users_on_activation
|
||||
? 'Base User Hisfocus dibuat dan user login awal dihapus.'
|
||||
: 'Base User Hisfocus dibuat; user login awal dipertahankan.';
|
||||
$device->update([
|
||||
'serial_number' => $facts['serial_number'] ?? $device->serial_number,
|
||||
'firmware_version' => $facts['firmware_version'] ?? $device->firmware_version,
|
||||
'device_facts' => array_merge($device->device_facts ?? [], $facts, ['user_management' => 'multi_user', 'roles' => ['administrator', 'operator', 'guest'], 'web_management_port' => $webPort, 'user_stores' => $webPort ? ['cli', 'webgui'] : ['cli']]),
|
||||
'base_sync_status' => 'synced', 'base_sync_error_code' => null, 'base_sync_message' => $message,
|
||||
'base_synced_at' => now(), 'status' => 'online', 'last_seen_at' => now(),
|
||||
'activation_status' => 'active', 'activation_message' => $message, 'activated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DeviceUserAssignment;
|
||||
use App\Models\TenantDeviceSetting;
|
||||
use App\Network\Clients\Hisfocus\HisfocusSshClient;
|
||||
use App\Network\Clients\Hisfocus\HisfocusWebClient;
|
||||
use App\Network\Clients\Hsgq\HsgqSshClient;
|
||||
use App\Network\Clients\Hsgq\HsgqTelnetClient;
|
||||
use App\Network\Clients\Hsgq\HsgqWebClient;
|
||||
use App\Network\Clients\RouterOs\RouterOsApiClient;
|
||||
use App\Network\Clients\Zte\ZteSshClient;
|
||||
use App\Network\Drivers\Hisfocus\HisfocusOltDriver;
|
||||
use App\Network\Drivers\Hsgq\HsgqOltDriver;
|
||||
use App\Network\Drivers\Mikrotik\MikrotikDriver;
|
||||
use App\Network\Drivers\Zte\ZteC3xxDriver;
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class SyncDeviceUser implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(public readonly int $tenantId, public readonly int $assignmentId) {}
|
||||
|
||||
public function handle(TenantContext $context): void
|
||||
{
|
||||
$context->set($this->tenantId);
|
||||
setPermissionsTeamId($this->tenantId);
|
||||
try {
|
||||
$assignment = DeviceUserAssignment::with(['device.vendor', 'device.credentials', 'accessUser'])->find($this->assignmentId);
|
||||
if (! $assignment || $assignment->tenant_id !== $this->tenantId) {
|
||||
return;
|
||||
}
|
||||
$mikrotik = $assignment->device->vendor->slug === 'mikrotik' && $assignment->device->hasConnection('routeros_api');
|
||||
$zte = $assignment->device->vendor->slug === 'zte' && $assignment->device->hasConnection('ssh');
|
||||
$hsgq = $assignment->device->vendor->slug === 'hsgq' && ($assignment->device->hasConnection('ssh') || $assignment->device->hasConnection('telnet'));
|
||||
$hisfocus = $assignment->device->vendor->slug === 'hisfocus' && ($assignment->device->hasConnection('ssh') || $assignment->device->hasConnection('telnet'));
|
||||
if (! $mikrotik && ! $zte && ! $hsgq && ! $hisfocus) {
|
||||
$assignment->update(['sync_status' => 'unsupported', 'error_code' => 'DRIVER_NOT_AVAILABLE', 'message' => 'Driver user perangkat belum tersedia untuk vendor ini.']);
|
||||
|
||||
return;
|
||||
}
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $this->tenantId)->first();
|
||||
if (! $setting) {
|
||||
$assignment->update(['sync_status' => 'pending', 'error_code' => 'BASE_SETTING_REQUIRED', 'message' => 'Menunggu Base User perangkat dikonfigurasi.']);
|
||||
|
||||
return;
|
||||
}
|
||||
$credential = $assignment->device->credentials->where('is_active', true)->sortByDesc('is_master')->first();
|
||||
if (! $credential) {
|
||||
$assignment->update(['sync_status' => 'pending', 'error_code' => 'CREDENTIAL_REQUIRED', 'message' => 'Menunggu credential aktif perangkat.']);
|
||||
|
||||
return;
|
||||
}
|
||||
$assignment->update(['sync_status' => 'processing', 'attempts' => $assignment->attempts + 1, 'last_attempted_at' => now(), 'error_code' => null, 'message' => null]);
|
||||
$mikrotikDriver = $mikrotik
|
||||
? new MikrotikDriver(new RouterOsApiClient($assignment->device->management_address, $assignment->device->portFor('routeros_api'), $credential->username, $credential->password, $setting->connection_timeout, $setting->use_tls, $setting->verify_tls))
|
||||
: null;
|
||||
$zteDriver = $zte
|
||||
? new ZteC3xxDriver(new ZteSshClient($assignment->device->management_address, $assignment->device->portFor('ssh'), $setting->connection_timeout))
|
||||
: null;
|
||||
$hsgqDriver = $hsgq
|
||||
? new HsgqOltDriver(
|
||||
$assignment->device->hasConnection('ssh')
|
||||
? new HsgqSshClient($assignment->device->management_address, $assignment->device->portFor('ssh'), $setting->connection_timeout)
|
||||
: new HsgqTelnetClient($assignment->device->management_address, $assignment->device->portFor('telnet'), $setting->connection_timeout),
|
||||
$assignment->device->hasConnection('ssh') && $assignment->device->hasConnection('telnet')
|
||||
? new HsgqTelnetClient($assignment->device->management_address, $assignment->device->portFor('telnet'), $setting->connection_timeout)
|
||||
: null,
|
||||
new HsgqWebClient($assignment->device->management_address, $setting->connection_timeout),
|
||||
)
|
||||
: null;
|
||||
$hisfocusDriver = $hisfocus
|
||||
? new HisfocusOltDriver($assignment->device->hasConnection('telnet')
|
||||
? new HsgqTelnetClient($assignment->device->management_address, $assignment->device->portFor('telnet'), $setting->connection_timeout)
|
||||
: new HisfocusSshClient($assignment->device->management_address, $assignment->device->portFor('ssh'), $setting->connection_timeout))
|
||||
: null;
|
||||
$hisfocusWeb = $hisfocus
|
||||
&& $assignment->device->hasConnection('web_http') ? new HisfocusWebClient(
|
||||
$assignment->device->management_address,
|
||||
$assignment->device->portFor('web_http'),
|
||||
$setting->connection_timeout,
|
||||
)
|
||||
: null;
|
||||
if ($assignment->desired_operation === 'delete') {
|
||||
if ($mikrotik) {
|
||||
$mikrotikDriver->deleteUser($assignment->remote_username ?: $assignment->accessUser->username);
|
||||
} elseif ($zte) {
|
||||
$zteDriver->deleteAccessUser($credential->username, $credential->password, $credential->enable_password, $assignment->remote_username ?: $assignment->accessUser->username);
|
||||
} elseif ($hsgq) {
|
||||
$hsgqDriver->deleteUser($credential->password, $credential->enable_password, $assignment->remote_username ?: $assignment->accessUser->username);
|
||||
} else {
|
||||
$hisfocusDriver->deleteUser($credential->username, $credential->password, $credential->enable_password, $assignment->remote_username ?: $assignment->accessUser->username);
|
||||
$webUsername = $assignment->remote_username ?: $assignment->accessUser->username;
|
||||
if ($hisfocusWeb && isset($hisfocusWeb->users($credential->username, $credential->password)[$webUsername])) {
|
||||
$hisfocusWeb->deleteUser($credential->username, $credential->password, $webUsername);
|
||||
}
|
||||
}
|
||||
$accessUser = $assignment->accessUser;
|
||||
$assignment->delete();
|
||||
if ($accessUser->trashed() && ! $accessUser->assignments()->exists()) {
|
||||
$accessUser->forceDelete();
|
||||
}
|
||||
|
||||
return;
|
||||
} else {
|
||||
if ($assignment->remote_username && $assignment->remote_username !== $assignment->accessUser->username) {
|
||||
if ($mikrotik) {
|
||||
$mikrotikDriver->deleteUser($assignment->remote_username);
|
||||
} elseif ($zte) {
|
||||
$zteDriver->deleteAccessUser($credential->username, $credential->password, $credential->enable_password, $assignment->remote_username);
|
||||
} elseif ($hsgq) {
|
||||
$hsgqDriver->deleteUser($credential->password, $credential->enable_password, $assignment->remote_username);
|
||||
} else {
|
||||
$hisfocusDriver->deleteUser($credential->username, $credential->password, $credential->enable_password, $assignment->remote_username);
|
||||
if ($hisfocusWeb && isset($hisfocusWeb->users($credential->username, $credential->password)[$assignment->remote_username])) {
|
||||
$hisfocusWeb->deleteUser($credential->username, $credential->password, $assignment->remote_username);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($mikrotik) {
|
||||
$result = $mikrotikDriver->syncUser($assignment->accessUser->username, $assignment->accessUser->password, $assignment->group_name, $assignment->accessUser->is_enabled);
|
||||
} elseif ($zte) {
|
||||
$result = $zteDriver->syncUser($credential->username, $credential->password, $credential->enable_password, $assignment->accessUser->username, $assignment->accessUser->password, $assignment->group_name, $assignment->accessUser->is_enabled);
|
||||
} elseif ($hsgq) {
|
||||
$result = $hsgqDriver->syncUser($credential->password, $credential->enable_password, $assignment->accessUser->username, $assignment->accessUser->password, $assignment->group_name, $assignment->accessUser->is_enabled);
|
||||
} else {
|
||||
$result = $hisfocusDriver->syncUser($credential->username, $credential->password, $credential->enable_password, $assignment->accessUser->username, $assignment->accessUser->password, $assignment->group_name, $assignment->accessUser->is_enabled);
|
||||
if ($hisfocusWeb) {
|
||||
$webRoles = ['RADIQ-NOC' => 'Administrator', 'RADIQ-WRITE' => 'Operator', 'RADIQ-READ' => 'Guest'];
|
||||
$webUsers = $hisfocusWeb->users($credential->username, $credential->password);
|
||||
if (isset($webUsers[$assignment->accessUser->username])) {
|
||||
$hisfocusWeb->deleteUser($credential->username, $credential->password, $assignment->accessUser->username);
|
||||
}
|
||||
if ($assignment->accessUser->is_enabled) {
|
||||
$hisfocusWeb->addUser($credential->username, $credential->password, $assignment->accessUser->username, $assignment->accessUser->password, $webRoles[$assignment->group_name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$assignment->update(['sync_status' => 'synced', 'remote_id' => $result['remote_id'] ?? $assignment->remote_id, 'remote_username' => $assignment->accessUser->username, 'last_synced_at' => now(), 'error_code' => null, 'message' => 'Sinkronisasi berhasil.']);
|
||||
$assignment->device->update(['status' => 'online', 'last_seen_at' => now()]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$code = str($exception->getMessage())->before(':')->limit(64)->toString();
|
||||
$pending = in_array($code, ['DEVICE_UNREACHABLE', 'CONNECTION_TIMEOUT', 'CREDENTIAL_REQUIRED'], true);
|
||||
DeviceUserAssignment::whereKey($this->assignmentId)->update(['sync_status' => $pending ? 'pending' : 'failed', 'error_code' => $code ?: 'UNKNOWN_ERROR', 'message' => $pending ? 'Perangkat belum dapat dijangkau; akan dicoba kembali.' : 'Sinkronisasi gagal tanpa membuka data sensitif.']);
|
||||
if ($pending) {
|
||||
DeviceUserAssignment::whereKey($this->assignmentId)->first()?->device()->update(['status' => 'offline']);
|
||||
}
|
||||
} finally {
|
||||
$context->clear();
|
||||
setPermissionsTeamId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
trait BelongsToTenant
|
||||
{
|
||||
protected static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $builder): void {
|
||||
$tenantId = app(TenantContext::class)->id();
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$builder->where($builder->qualifyColumn('tenant_id'), $tenantId);
|
||||
}
|
||||
});
|
||||
|
||||
static::creating(function (self $model): void {
|
||||
$tenantId = app(TenantContext::class)->id();
|
||||
|
||||
if ($tenantId !== null && $model->getAttribute('tenant_id') === null) {
|
||||
$model->setAttribute('tenant_id', $tenantId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\DeploymentMode;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeploymentInstallation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'name', 'deployment_type', 'domain', 'instance_key_hash', 'fingerprint_hash', 'status', 'activated_at', 'last_seen_at'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $installation) => $installation->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['deployment_type' => DeploymentMode::class, 'activated_at' => 'immutable_datetime', 'last_seen_at' => 'immutable_datetime'];
|
||||
}
|
||||
|
||||
public function licenses(): HasMany
|
||||
{
|
||||
return $this->hasMany(License::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Device extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'name', 'hostname', 'device_vendor_id', 'device_type_id', 'device_model_id', 'serial_number', 'management_address', 'connection_type', 'management_port', 'connection_ports', 'location', 'latitude', 'longitude', 'firmware_version', 'software_version', 'status', 'last_seen_at', 'notes', 'is_active', 'created_by', 'updated_by', 'activation_status', 'activated_at', 'activation_message', 'remove_legacy_users_on_activation', 'device_facts', 'base_sync_status', 'base_sync_error_code', 'base_sync_message', 'base_synced_at', 'base_sync_attempted_at'];
|
||||
|
||||
protected $hidden = ['credentials'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (Device $device) => $device->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean', 'remove_legacy_users_on_activation' => 'boolean', 'connection_ports' => 'array', 'device_facts' => 'array', 'last_seen_at' => 'datetime', 'activated_at' => 'datetime', 'base_synced_at' => 'datetime', 'base_sync_attempted_at' => 'datetime', 'latitude' => 'decimal:7', 'longitude' => 'decimal:7'];
|
||||
}
|
||||
|
||||
public function portFor(string $protocol): ?int
|
||||
{
|
||||
$port = $this->connection_ports[$protocol] ?? ($this->connection_type === $protocol ? $this->management_port : null);
|
||||
|
||||
return $port ? (int) $port : null;
|
||||
}
|
||||
|
||||
public function hasConnection(string $protocol): bool
|
||||
{
|
||||
return $this->portFor($protocol) !== null;
|
||||
}
|
||||
|
||||
public function vendor()
|
||||
{
|
||||
return $this->belongsTo(DeviceVendor::class, 'device_vendor_id');
|
||||
}
|
||||
|
||||
public function type()
|
||||
{
|
||||
return $this->belongsTo(DeviceType::class, 'device_type_id');
|
||||
}
|
||||
|
||||
public function model()
|
||||
{
|
||||
return $this->belongsTo(DeviceModel::class, 'device_model_id');
|
||||
}
|
||||
|
||||
public function credentials()
|
||||
{
|
||||
return $this->hasMany(DeviceCredential::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function userAssignments()
|
||||
{
|
||||
return $this->hasMany(DeviceUserAssignment::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\TenantEncrypted;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeviceAccessUser extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = ['tenant_id', 'display_name', 'username', 'password', 'is_enabled', 'notes', 'created_by', 'updated_by'];
|
||||
|
||||
protected $hidden = ['password'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $user) => $user->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['password' => TenantEncrypted::class, 'is_enabled' => 'boolean'];
|
||||
}
|
||||
|
||||
public function assignments()
|
||||
{
|
||||
return $this->hasMany(DeviceUserAssignment::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\TenantEncrypted;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeviceCredential extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'device_id', 'name', 'username', 'password', 'enable_password', 'connection_type', 'privilege_type', 'is_master', 'is_active', 'last_verified_at', 'created_by', 'updated_by'];
|
||||
|
||||
protected $hidden = ['password', 'enable_password'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (DeviceCredential $credential) => $credential->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['password' => TenantEncrypted::class, 'enable_password' => TenantEncrypted::class, 'is_master' => 'boolean', 'is_active' => 'boolean', 'last_verified_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function device()
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DeviceModel extends Model
|
||||
{
|
||||
protected $fillable = ['device_vendor_id', 'device_type_id', 'name', 'model_code', 'description', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function vendor()
|
||||
{
|
||||
return $this->belongsTo(DeviceVendor::class, 'device_vendor_id');
|
||||
}
|
||||
|
||||
public function type()
|
||||
{
|
||||
return $this->belongsTo(DeviceType::class, 'device_type_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DeviceType extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'slug', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function models()
|
||||
{
|
||||
return $this->hasMany(DeviceModel::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeviceUserAssignment extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'device_access_user_id', 'device_id', 'group_name', 'desired_operation', 'sync_status', 'remote_id', 'remote_username', 'error_code', 'message', 'attempts', 'last_attempted_at', 'last_synced_at'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $assignment) => $assignment->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['last_attempted_at' => 'datetime', 'last_synced_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function accessUser()
|
||||
{
|
||||
return $this->belongsTo(DeviceAccessUser::class, 'device_access_user_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function device()
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DeviceVendor extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'slug', 'description', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function models()
|
||||
{
|
||||
return $this->hasMany(DeviceModel::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\LicenseStatus;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class License extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'deployment_installation_id', 'license_key_hash', 'plan', 'status', 'max_devices', 'max_users', 'features', 'signed_payload', 'starts_at', 'expires_at', 'grace_until', 'issued_by'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $license) => $license->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['status' => LicenseStatus::class, 'features' => 'array', 'starts_at' => 'immutable_datetime', 'expires_at' => 'immutable_datetime', 'grace_until' => 'immutable_datetime'];
|
||||
}
|
||||
|
||||
public function installation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeploymentInstallation::class, 'deployment_installation_id');
|
||||
}
|
||||
|
||||
public function issuer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'issued_by');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(LicenseEvent::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LicenseEvent extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'license_id', 'deployment_installation_id', 'actor_id', 'event_type', 'metadata', 'occurred_at'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $event) => $event->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['metadata' => 'array', 'occurred_at' => 'immutable_datetime'];
|
||||
}
|
||||
|
||||
public function license(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(License::class);
|
||||
}
|
||||
|
||||
public function installation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeploymentInstallation::class, 'deployment_installation_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'slug', 'is_active'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Tenant $tenant): void {
|
||||
$tenant->uuid ??= (string) Str::uuid();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
public function roles(): HasMany
|
||||
{
|
||||
return $this->hasMany(Role::class);
|
||||
}
|
||||
|
||||
public function installations(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeploymentInstallation::class);
|
||||
}
|
||||
|
||||
public function licenses(): HasMany
|
||||
{
|
||||
return $this->hasMany(License::class);
|
||||
}
|
||||
|
||||
public function devices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Device::class);
|
||||
}
|
||||
|
||||
public function devicePolicy()
|
||||
{
|
||||
return $this->hasOne(TenantDevicePolicy::class);
|
||||
}
|
||||
|
||||
public function deviceSetting()
|
||||
{
|
||||
return $this->hasOne(TenantDeviceSetting::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDevicePolicy extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'tenant_user_can_create', 'tenant_user_can_update_own', 'tenant_user_can_delete_own'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['tenant_user_can_create' => 'boolean', 'tenant_user_can_update_own' => 'boolean', 'tenant_user_can_delete_own' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\TenantEncrypted;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDeviceSetting extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'base_username', 'base_password', 'use_tls', 'verify_tls', 'connection_timeout'];
|
||||
|
||||
protected $hidden = ['base_password'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['base_password' => TenantEncrypted::class, 'use_tls' => 'boolean', 'verify_tls' => 'boolean', 'connection_timeout' => 'integer'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantEncryptionKey extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'version', 'wrapped_key', 'is_active', 'source', 'created_by', 'retired_at'];
|
||||
|
||||
protected $hidden = ['wrapped_key'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $key) => $key->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean', 'retired_at' => 'immutable_datetime'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Contracts\PasskeyUser;
|
||||
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string $password
|
||||
* @property string|null $two_factor_secret
|
||||
* @property string|null $two_factor_recovery_codes
|
||||
* @property Carbon|null $two_factor_confirmed_at
|
||||
* @property string|null $remember_token
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable(['tenant_id', 'name', 'email', 'password', 'is_active'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use BelongsToTenant, HasApiTokens, HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (User $user): void {
|
||||
$user->uuid ??= (string) Str::uuid();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
'is_platform_admin' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
class TenantPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('tenant.view');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->can('tenant.view') && ($user->tenant_id === $tenant->id || $user->is_platform_admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->is_platform_admin && $user->can('tenant.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->can('tenant.update') && ($user->tenant_id === $tenant->id || $user->is_platform_admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->is_platform_admin && $user->can('tenant.delete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\TenantContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(TenantContext::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::before(fn ($user): ?bool => $user->is_platform_admin && config('deployment.mode') !== 'self_hosted' ? true : null);
|
||||
|
||||
$this->configureDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure default behaviors for production-ready applications.
|
||||
*/
|
||||
protected function configureDefaults(): void
|
||||
{
|
||||
Date::use(CarbonImmutable::class);
|
||||
|
||||
DB::prohibitDestructiveCommands(
|
||||
app()->isProduction(),
|
||||
);
|
||||
|
||||
Password::defaults(fn (): ?Password => app()->isProduction()
|
||||
? Password::min(12)
|
||||
->mixedCase()
|
||||
->letters()
|
||||
->numbers()
|
||||
->symbols()
|
||||
->uncompromised()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureActions();
|
||||
$this->configureViews();
|
||||
$this->configureRateLimiting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify actions.
|
||||
*/
|
||||
private function configureActions(): void
|
||||
{
|
||||
Fortify::authenticateUsing(function (Request $request): ?User {
|
||||
$user = User::query()->withoutGlobalScope('tenant')->where('email', $request->string('email'))->first();
|
||||
|
||||
return $user && $user->is_active && Hash::check($request->string('password'), $user->password)
|
||||
? $user
|
||||
: null;
|
||||
});
|
||||
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify views.
|
||||
*/
|
||||
private function configureViews(): void
|
||||
{
|
||||
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
|
||||
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/reset-password', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
'passwordRules' => Password::defaults()->toPasswordRulesString(),
|
||||
]));
|
||||
|
||||
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/forgot-password', [
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/verify-email', [
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
|
||||
|
||||
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rate limiting.
|
||||
*/
|
||||
private function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return Limit::perMinute(5)->by($request->session()->get('login.id'));
|
||||
});
|
||||
|
||||
RateLimiter::for('login', function (Request $request) {
|
||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
return Limit::perMinute(10)->by(
|
||||
($request->input('credential.id') ?: $request->session()->getId()).'|'.$request->ip(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Devices;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\TenantDeviceSetting;
|
||||
use App\Network\Clients\RouterOs\RouterOsApiClient;
|
||||
use App\Network\Drivers\Mikrotik\MikrotikDriver;
|
||||
use RuntimeException;
|
||||
|
||||
class MikrotikActivationService
|
||||
{
|
||||
public function activate(Device $device): array
|
||||
{
|
||||
abort_unless($device->vendor?->slug === 'mikrotik' && $device->hasConnection('routeros_api'), 422, 'Aktivasi otomatis saat ini hanya mendukung MikroTik RouterOS API.');
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $device->tenant_id)->first();
|
||||
if (! $setting) {
|
||||
throw new RuntimeException('BASE_SETTING_REQUIRED: atur Base User dan Password terlebih dahulu.');
|
||||
}
|
||||
$credential = $device->credentials()->where('is_active', true)->orderByDesc('is_master')->first();
|
||||
if (! $credential) {
|
||||
throw new RuntimeException('CREDENTIAL_REQUIRED: tambahkan credential login perangkat terlebih dahulu.');
|
||||
}
|
||||
|
||||
$client = $this->client($device, $credential->username, $credential->password, $setting);
|
||||
$driver = new MikrotikDriver($client);
|
||||
$deviceInfo = $driver->getDeviceInfo();
|
||||
$provisioned = $driver->provisionBaseAccess($setting->base_username, $setting->base_password);
|
||||
|
||||
// Never clean existing accounts until the new RADIQ account is proven usable.
|
||||
$managementDriver = new MikrotikDriver($this->client($device, $setting->base_username, $setting->base_password, $setting));
|
||||
$managementDriver->testConnection();
|
||||
$cleanup = $device->remove_legacy_users_on_activation
|
||||
? $managementDriver->cleanupLegacyUsers($setting->base_username)
|
||||
: ['deleted' => [], 'preserved' => []];
|
||||
|
||||
$device->credentials()->update(['is_master' => false]);
|
||||
$device->credentials()->updateOrCreate(
|
||||
['name' => 'Base User RADIQ'],
|
||||
[
|
||||
'tenant_id' => $device->tenant_id,
|
||||
'username' => $setting->base_username,
|
||||
'password' => $setting->base_password,
|
||||
'connection_type' => 'routeros_api',
|
||||
'privilege_type' => 'master',
|
||||
'is_master' => true,
|
||||
'is_active' => true,
|
||||
'last_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
return $provisioned + ['device_info' => $deviceInfo, 'cleanup' => $cleanup];
|
||||
}
|
||||
|
||||
private function client(Device $device, string $username, string $password, TenantDeviceSetting $setting): RouterOsApiClient
|
||||
{
|
||||
return new RouterOsApiClient($device->management_address, $device->portFor('routeros_api'), $username, $password, $setting->connection_timeout, $setting->use_tls, $setting->verify_tls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Encryption;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantEncryptionKey;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class TenantEnvelopeEncryption
|
||||
{
|
||||
private const PREFIX = 'radiq:v1:';
|
||||
|
||||
public function ensureKey(int $tenantId, ?int $actorId = null): TenantEncryptionKey
|
||||
{
|
||||
return TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->where('is_active', true)->first()
|
||||
?? $this->createKey($tenantId, null, $actorId);
|
||||
}
|
||||
|
||||
public function encrypt(int $tenantId, string $plaintext, ?TenantEncryptionKey $key = null): string
|
||||
{
|
||||
$key ??= $this->ensureKey($tenantId);
|
||||
$dek = Crypt::decryptString($key->wrapped_key);
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, $nonce, $tag, (string) $tenantId);
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('ENCRYPTION_FAILED');
|
||||
}
|
||||
|
||||
return self::PREFIX.$key->id.':'.base64_encode($nonce).':'.base64_encode($tag).':'.base64_encode($ciphertext);
|
||||
}
|
||||
|
||||
public function decrypt(int $tenantId, string $payload): string
|
||||
{
|
||||
if (! str_starts_with($payload, self::PREFIX)) {
|
||||
return Crypt::decryptString($payload);
|
||||
}
|
||||
$parts = explode(':', $payload, 6);
|
||||
if (count($parts) !== 6) {
|
||||
throw new RuntimeException('INVALID_ENCRYPTED_PAYLOAD');
|
||||
}
|
||||
$key = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->findOrFail((int) $parts[2]);
|
||||
$dek = Crypt::decryptString($key->wrapped_key);
|
||||
$plaintext = openssl_decrypt(base64_decode($parts[5], true), 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, base64_decode($parts[3], true), base64_decode($parts[4], true), (string) $tenantId);
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('DECRYPTION_FAILED');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
public function rotate(int $tenantId, ?string $manualKey, int $actorId): TenantEncryptionKey
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $manualKey, $actorId): TenantEncryptionKey {
|
||||
Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail();
|
||||
$oldKeys = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->lockForUpdate()->get();
|
||||
$newKey = $this->createKey($tenantId, $manualKey, $actorId, ($oldKeys->max('version') ?? 0) + 1);
|
||||
foreach (DB::table('device_credentials')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'password', 'enable_password']) as $credential) {
|
||||
DB::table('device_credentials')->where('id', $credential->id)->update([
|
||||
'password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $credential->password), $newKey),
|
||||
'enable_password' => $credential->enable_password === null ? null : $this->encrypt($tenantId, $this->decrypt($tenantId, $credential->enable_password), $newKey),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
foreach (DB::table('tenant_device_settings')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'base_password']) as $setting) {
|
||||
DB::table('tenant_device_settings')->where('id', $setting->id)->update(['base_password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $setting->base_password), $newKey), 'updated_at' => now()]);
|
||||
}
|
||||
foreach (DB::table('device_access_users')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'password']) as $accessUser) {
|
||||
DB::table('device_access_users')->where('id', $accessUser->id)->update(['password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $accessUser->password), $newKey), 'updated_at' => now()]);
|
||||
}
|
||||
TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->where('id', '!=', $newKey->id)->where('is_active', true)->update(['is_active' => false, 'retired_at' => now()]);
|
||||
|
||||
return $newKey;
|
||||
});
|
||||
}
|
||||
|
||||
private function createKey(int $tenantId, ?string $manualKey, ?int $actorId, int $version = 1): TenantEncryptionKey
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$dek = $manualKey === null ? random_bytes(32) : hash_hkdf('sha256', $manualKey, 32, 'RADIQ-NDM:'.$tenant->uuid);
|
||||
|
||||
return TenantEncryptionKey::withoutGlobalScope('tenant')->create([
|
||||
'tenant_id' => $tenantId, 'version' => $version, 'wrapped_key' => Crypt::encryptString($dek),
|
||||
'is_active' => true, 'source' => $manualKey === null ? 'generated' : 'manual', 'created_by' => $actorId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\DeploymentInstallation;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantDevicePolicy;
|
||||
use App\Models\User;
|
||||
use App\Services\Encryption\TenantEnvelopeEncryption;
|
||||
use App\Support\SystemRolePermissions;
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class TenantProvisioningService
|
||||
{
|
||||
public function __construct(private readonly TenantContext $context) {}
|
||||
|
||||
/** @param array{name:string,slug:string,is_active?:bool,owner_name:string,owner_email:string,owner_password:string} $data */
|
||||
public function createWithOwner(array $data): Tenant
|
||||
{
|
||||
return DB::transaction(function () use ($data): Tenant {
|
||||
$tenant = Tenant::create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'],
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
]);
|
||||
TenantDevicePolicy::create(['tenant_id' => $tenant->id]);
|
||||
app(TenantEnvelopeEncryption::class)->ensureKey($tenant->id);
|
||||
$deploymentType = $data['deployment_type'] ?? 'managed_cloud';
|
||||
DeploymentInstallation::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $deploymentType === 'self_hosted' ? 'Server Tenant' : 'RADIQ Managed Cloud',
|
||||
'deployment_type' => $deploymentType,
|
||||
'domain' => $data['deployment_domain'] ?? null,
|
||||
'instance_key_hash' => hash('sha256', random_bytes(32)),
|
||||
'status' => $deploymentType === 'managed_cloud' ? 'active' : 'pending',
|
||||
'activated_at' => $deploymentType === 'managed_cloud' ? now() : null,
|
||||
]);
|
||||
|
||||
$this->context->set($tenant->id);
|
||||
setPermissionsTeamId($tenant->id);
|
||||
|
||||
try {
|
||||
$roles = $this->createDefaultRoles($tenant);
|
||||
|
||||
$owner = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $data['owner_name'],
|
||||
'email' => $data['owner_email'],
|
||||
'password' => $data['owner_password'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
$owner->forceFill(['email_verified_at' => now()])->save();
|
||||
$owner->assignRole($roles[SystemRole::TenantAdmin->value]);
|
||||
} finally {
|
||||
$this->context->clear();
|
||||
setPermissionsTeamId(null);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string, Role> */
|
||||
private function createDefaultRoles(Tenant $tenant): array
|
||||
{
|
||||
$definitions = SystemRolePermissions::tenantRoles();
|
||||
|
||||
$roles = [];
|
||||
|
||||
foreach ($definitions as $name => $permissions) {
|
||||
$role = Role::create(['tenant_id' => $tenant->id, 'name' => $name, 'guard_name' => 'web']);
|
||||
$role->syncPermissions($permissions);
|
||||
$roles[$name] = $role;
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
|
||||
final class SystemRolePermissions
|
||||
{
|
||||
/** @return array<string, list<string>> */
|
||||
public static function tenantRoles(): array
|
||||
{
|
||||
return [
|
||||
SystemRole::TenantAdmin->value => [
|
||||
'user.view', 'user.create', 'user.update', 'user.delete',
|
||||
'device.view', 'device.create', 'device.update', 'device.delete', 'device.connect',
|
||||
'device.credential.view', 'device.credential.create', 'device.credential.update', 'device.credential.delete',
|
||||
'device.user.view', 'device.user.create', 'device.user.update', 'device.user.delete', 'device.user.mass_update',
|
||||
'device.master_account.manage', 'device.command.execute', 'device.command.mass_execute',
|
||||
'device.config.view', 'device.config.deploy', 'device.backup.create', 'device.backup.download',
|
||||
'device.backup.restore', 'device.monitoring.view', 'audit.view', 'license.view', 'installation.view',
|
||||
],
|
||||
SystemRole::TenantUser->value => [
|
||||
'device.view', 'device.create', 'device.update', 'device.connect',
|
||||
'device.user.view', 'device.user.create', 'device.user.update',
|
||||
'device.command.execute', 'device.config.view', 'device.backup.create',
|
||||
'device.backup.download', 'device.monitoring.view',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class TenantContext
|
||||
{
|
||||
private ?int $tenantId = null;
|
||||
|
||||
public function set(?int $tenantId): void
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
}
|
||||
|
||||
public function id(): ?int
|
||||
{
|
||||
return $this->tenantId;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->tenantId = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureActiveUser;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\SelectAdministrationTenant;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
$middleware->web(append: [
|
||||
SetTenantContext::class,
|
||||
EnsureActiveUser::class,
|
||||
HandleAppearance::class,
|
||||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
]);
|
||||
|
||||
$middleware->api(append: [SetTenantContext::class]);
|
||||
$middleware->alias(['admin.tenant' => SelectAdministrationTenant::class]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
||||
);
|
||||
})->create();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user