Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5cc075938 | |||
| 4ec4a293c0 | |||
| 720e94c54a | |||
| 2be17c70db | |||
| 31686a35d5 | |||
| 8769308dfd | |||
| f5848907f5 | |||
| f953fde2be | |||
| 9a37eda302 | |||
| 4b29f55518 | |||
| e8796d443e | |||
| 24aad5cdc0 | |||
| 27c1584dc3 | |||
| c89adeea97 | |||
| 8f51be8515 | |||
| 0490a1220b | |||
| 4b73b53dd6 | |||
| 75943fbe2b | |||
| a33f2885ff | |||
| a4397c5178 | |||
| fc4f5370ae | |||
| e59f193ffc | |||
| f7672771e0 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard/unifi",
|
"name": "dashboard/unifi",
|
||||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||||
"version": "1.0.0",
|
"version": "1.10.1",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -27,8 +27,6 @@
|
|||||||
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
|
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
|
||||||
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
|
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
|
||||||
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
|
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
|
||||||
{ "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
|
|
||||||
{ "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 },
|
|
||||||
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
|
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Per-page access grants for unifi pages. A user can access a unifi
|
||||||
|
* page if ANY of these hold:
|
||||||
|
* - is_super_admin (always)
|
||||||
|
* - user has the page's required_permission (existing nav_items column)
|
||||||
|
* - user is in the page's required_group_id (existing column)
|
||||||
|
* - a row here grants them as a user, or via a group they're in
|
||||||
|
*
|
||||||
|
* Snap-in-local table — disappears with the snap-in if uninstalled.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('unifi_page_grants', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('nav_item_id')->constrained('nav_items')->cascadeOnDelete();
|
||||||
|
$table->enum('grantee_type', ['user', 'group']);
|
||||||
|
$table->unsignedBigInteger('grantee_id');
|
||||||
|
$table->foreignId('granted_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['nav_item_id', 'grantee_type', 'grantee_id'], 'unifi_page_grants_unique');
|
||||||
|
$table->index(['grantee_type', 'grantee_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('unifi_page_grants');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Structured log of every unifi scheduled-task execution: AP reboots,
|
||||||
|
* password rotations, PPSK schedule syncs. One row per run.
|
||||||
|
* Surfaced in the Logs tab of the Unifi settings page.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('unifi_cron_runs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('command', 64)->index(); // 'reboot-all-aps' | 'rotate-passwords' | 'sync-ppsk-schedules'
|
||||||
|
$table->enum('triggered_by', ['schedule', 'manual']);
|
||||||
|
$table->foreignId('triggered_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamp('started_at')->index();
|
||||||
|
$table->timestamp('finished_at')->nullable();
|
||||||
|
$table->string('status', 16); // 'running' | 'succeeded' | 'partial' | 'failed' | 'skipped'
|
||||||
|
$table->longText('details')->nullable(); // JSON: counts, per-item actions, error summary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('unifi_cron_runs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('unifi_webhook_configs', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('unifi_webhook_configs', 'tracked_clients')) {
|
||||||
|
$table->json('tracked_clients')->nullable()->after('device_filter');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('unifi_webhook_configs', 'templates')) {
|
||||||
|
$table->json('templates')->nullable()->after('tracked_clients');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('unifi_webhook_configs', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('unifi_webhook_configs', 'templates')) {
|
||||||
|
$table->dropColumn('templates');
|
||||||
|
}
|
||||||
|
if (Schema::hasColumn('unifi_webhook_configs', 'tracked_clients')) {
|
||||||
|
$table->dropColumn('tracked_clients');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('unifi_device_states', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('unifi_device_states', 'consecutive_count')) {
|
||||||
|
$table->unsignedSmallInteger('consecutive_count')->default(0)->after('in_alert');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('unifi_device_states', function (Blueprint $table) {
|
||||||
|
if (Schema::hasColumn('unifi_device_states', 'consecutive_count')) {
|
||||||
|
$table->dropColumn('consecutive_count');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,41 +2,40 @@
|
|||||||
|
|
||||||
namespace Dashboard\Unifi\Console;
|
namespace Dashboard\Unifi\Console;
|
||||||
|
|
||||||
|
use Dashboard\Unifi\Models\UnifiCronRun;
|
||||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class RebootAllAps extends Command
|
class RebootAllAps extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot}';
|
protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot} {--triggered-by=schedule}';
|
||||||
protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
|
protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
|
||||||
|
|
||||||
public function handle(UnifiApiClient $unifi): int
|
public function handle(UnifiApiClient $unifi): int
|
||||||
{
|
{
|
||||||
try {
|
$run = UnifiCronRun::record(
|
||||||
|
'reboot-all-aps',
|
||||||
|
$this->option('triggered-by') ?: 'schedule',
|
||||||
|
null,
|
||||||
|
function () use ($unifi) {
|
||||||
$aps = $unifi->getAccessPoints();
|
$aps = $unifi->getAccessPoints();
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->error('Failed to fetch APs: ' . $e->getMessage());
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($aps)) {
|
if (empty($aps)) {
|
||||||
$this->warn('No access points found.');
|
$this->warn('No access points found.');
|
||||||
return self::SUCCESS;
|
return ['status' => 'skipped', 'reason' => 'no APs found'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$delay = max(0, (int) $this->option('delay'));
|
$delay = max(0, (int) $this->option('delay'));
|
||||||
|
$rebooted = [];
|
||||||
|
$failed = [];
|
||||||
|
|
||||||
// Pre-mark all APs as planned reboots before sending any commands
|
|
||||||
foreach ($aps as $ap) {
|
foreach ($aps as $ap) {
|
||||||
$mac = strtolower($ap['mac']);
|
$mac = strtolower($ap['mac']);
|
||||||
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
|
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
|
||||||
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
|
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
$ok = 0;
|
|
||||||
$fail = 0;
|
|
||||||
|
|
||||||
foreach ($aps as $ap) {
|
foreach ($aps as $ap) {
|
||||||
$mac = strtolower($ap['mac']);
|
$mac = strtolower($ap['mac']);
|
||||||
@@ -44,19 +43,27 @@ class RebootAllAps extends Command
|
|||||||
try {
|
try {
|
||||||
$unifi->rebootDevice($mac);
|
$unifi->rebootDevice($mac);
|
||||||
$this->info("Rebooted: {$name} ({$mac})");
|
$this->info("Rebooted: {$name} ({$mac})");
|
||||||
$ok++;
|
$rebooted[] = $name;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
|
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
|
||||||
$fail++;
|
$failed[] = ['name' => $name, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delay > 0 && $ok + $fail < count($aps)) {
|
if ($delay > 0 && count($rebooted) + count($failed) < count($aps)) {
|
||||||
sleep($delay);
|
sleep($delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->newLine();
|
return [
|
||||||
$this->info("Done. {$ok} rebooted, {$fail} failed.");
|
'status' => count($failed) === 0 ? 'succeeded' : (count($rebooted) > 0 ? 'partial' : 'failed'),
|
||||||
return $fail > 0 ? self::FAILURE : self::SUCCESS;
|
'rebooted' => $rebooted,
|
||||||
|
'failed' => $failed,
|
||||||
|
'total' => count($aps),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info("Done. Status: {$run->status}.");
|
||||||
|
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,45 @@
|
|||||||
namespace Dashboard\Unifi\Console;
|
namespace Dashboard\Unifi\Console;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use Dashboard\Unifi\Models\UnifiCronRun;
|
||||||
use Dashboard\Unifi\Models\UnifiPpsk;
|
use Dashboard\Unifi\Models\UnifiPpsk;
|
||||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
class RotatePasswords extends Command
|
class RotatePasswords extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule}';
|
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule} {--triggered-by=schedule}';
|
||||||
protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
|
protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
|
||||||
|
|
||||||
public function handle(UnifiApiClient $unifi): int
|
public function handle(UnifiApiClient $unifi): int
|
||||||
{
|
{
|
||||||
if (! Setting::get('unifi.password_rotation.enabled')) {
|
if (! Setting::get('unifi.password_rotation.enabled')) {
|
||||||
|
// Don't log anything — the scheduler runs this every minute
|
||||||
|
// and we'd flood the logs with "rotation disabled" rows.
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->option('force') && ! $this->isDue()) {
|
||||||
|
// Same reasoning — only log when we actually do something.
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$force = $this->option('force');
|
||||||
|
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
|
||||||
|
|
||||||
|
$run = UnifiCronRun::record('rotate-passwords', $triggeredBy, null, function () use ($unifi, $force) {
|
||||||
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
|
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
|
||||||
$wlanIds = json_decode($wlanIdsJson, true);
|
$wlanIds = json_decode($wlanIdsJson, true);
|
||||||
|
if (! is_array($wlanIds)) $wlanIds = [];
|
||||||
|
|
||||||
if (empty($wlanIds) || ! is_array($wlanIds)) {
|
$ppskQuery = UnifiPpsk::where('rotate_password', true)
|
||||||
return self::SUCCESS;
|
->where('state', 'active')
|
||||||
|
->whereNotNull('unifi_id');
|
||||||
|
|
||||||
|
// Skip only if there's nothing at all to rotate — neither
|
||||||
|
// whole-SSID rotation targets nor per-PPSK rotation opt-ins.
|
||||||
|
if (empty($wlanIds) && ! $ppskQuery->exists()) {
|
||||||
|
return ['status' => 'skipped', 'reason' => 'no SSIDs or PPSKs configured for rotation'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
|
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
|
||||||
@@ -31,48 +49,108 @@ class RotatePasswords extends Command
|
|||||||
|
|
||||||
if (empty($passwords)) {
|
if (empty($passwords)) {
|
||||||
$this->warn('Password rotation: no passwords in wordlist — skipped.');
|
$this->warn('Password rotation: no passwords in wordlist — skipped.');
|
||||||
return self::SUCCESS;
|
return ['status' => 'skipped', 'reason' => 'empty wordlist'];
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->option('force') && ! $this->isDue()) {
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$password = $passwords[array_rand($passwords)];
|
$password = $passwords[array_rand($passwords)];
|
||||||
$rotated = 0;
|
$rotated = [];
|
||||||
|
$failedWlans = [];
|
||||||
|
|
||||||
foreach ($wlanIds as $wlanId) {
|
foreach ($wlanIds as $wlanId) {
|
||||||
try {
|
try {
|
||||||
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
|
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
|
||||||
$rotated++;
|
$rotated[] = $wlanId;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
|
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
|
||||||
|
$failedWlans[] = ['wlan_id' => $wlanId, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($rotated > 0) {
|
if ($rotated) {
|
||||||
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
||||||
$this->info("Rotated password for {$rotated} SSID(s).");
|
// Persist the active password so it can be displayed in
|
||||||
|
// the Settings page and exposed via the API endpoint.
|
||||||
|
Setting::set('unifi.password_rotation.last_password', $password);
|
||||||
|
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rotate PPSK passwords ────────────────────────────────────────────
|
$rotatedPpsks = [];
|
||||||
$rotatedPpsks = 0;
|
$failedPpsks = [];
|
||||||
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
|
foreach ($ppskQuery->get() as $ppsk) {
|
||||||
// Each PPSK gets its own independently-chosen password from the wordlist
|
|
||||||
$newPass = $passwords[array_rand($passwords)];
|
$newPass = $passwords[array_rand($passwords)];
|
||||||
try {
|
try {
|
||||||
|
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
|
||||||
|
// Embedded PPSK: update inside the parent WLAN object.
|
||||||
|
// Match by name (most reliable) — falls back to
|
||||||
|
// passphrase if name is missing.
|
||||||
|
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
|
||||||
|
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
|
||||||
|
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
|
||||||
|
|
||||||
|
// Sibling WLANs (same SSID name on a different band):
|
||||||
|
// rotate the matching-name PPSK in each so the
|
||||||
|
// SSID's 2.4/5GHz halves stay in sync.
|
||||||
|
foreach ($unifi->getWlanSiblings($ppsk->wlan_id) as $siblingWlanId) {
|
||||||
|
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||||
|
->where('name', $ppsk->name)
|
||||||
|
->where('state', 'active')
|
||||||
|
->first();
|
||||||
|
try {
|
||||||
|
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name);
|
||||||
|
if ($sibling) {
|
||||||
|
$sibling->update([
|
||||||
|
'x_passphrase' => $newPass,
|
||||||
|
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// "Not found" in a sibling just means the
|
||||||
|
// PPSK isn't mirrored on that band — totally
|
||||||
|
// normal if GUEST was only configured on one
|
||||||
|
// band. Skip quietly; don't poison the
|
||||||
|
// run status.
|
||||||
|
if (str_contains($e->getMessage(), 'not found')) {
|
||||||
|
\Illuminate\Support\Facades\Log::info('unifi.ppsk_sibling_skipped', [
|
||||||
|
'sibling_wlan' => $siblingWlanId,
|
||||||
|
'ppsk_name' => $ppsk->name,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
|
||||||
|
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
||||||
$ppsk->update(['x_passphrase' => $newPass]);
|
$ppsk->update(['x_passphrase' => $newPass]);
|
||||||
$rotatedPpsks++;
|
}
|
||||||
|
$rotatedPpsks[] = $ppsk->name;
|
||||||
|
|
||||||
|
// Save the active password every time a rotation
|
||||||
|
// succeeds — covers PPSK-only rotation setups where
|
||||||
|
// there's no whole-SSID rotation. Last successful
|
||||||
|
// password wins if multiple PPSKs rotate in one run.
|
||||||
|
Setting::set('unifi.password_rotation.last_password', $newPass);
|
||||||
|
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
||||||
|
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($rotatedPpsks > 0) {
|
|
||||||
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
$hasFailures = count($failedWlans) + count($failedPpsks) > 0;
|
||||||
|
$hasSuccess = count($rotated) + count($rotatedPpsks) > 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $hasFailures ? ($hasSuccess ? 'partial' : 'failed') : 'succeeded',
|
||||||
|
'rotated_wlans' => $rotated,
|
||||||
|
'failed_wlans' => $failedWlans,
|
||||||
|
'rotated_ppsks' => $rotatedPpsks,
|
||||||
|
'failed_ppsks' => $failedPpsks,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isDue(): bool
|
private function isDue(): bool
|
||||||
@@ -81,7 +159,7 @@ class RotatePasswords extends Command
|
|||||||
$hour = (int) Setting::get('unifi.password_rotation.hour', 2);
|
$hour = (int) Setting::get('unifi.password_rotation.hour', 2);
|
||||||
$minute = (int) Setting::get('unifi.password_rotation.minute', 0);
|
$minute = (int) Setting::get('unifi.password_rotation.minute', 0);
|
||||||
$dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0);
|
$dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0);
|
||||||
$tz = Setting::get('unifi.timezone', 'UTC');
|
$tz = \App\Support\Timezone::current();
|
||||||
$now = now($tz);
|
$now = now($tz);
|
||||||
|
|
||||||
if ($now->hour !== $hour || $now->minute !== $minute) {
|
if ($now->hour !== $hour || $now->minute !== $minute) {
|
||||||
|
|||||||
@@ -3,33 +3,33 @@
|
|||||||
namespace Dashboard\Unifi\Console;
|
namespace Dashboard\Unifi\Console;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use Dashboard\Unifi\Models\UnifiCronRun;
|
||||||
use Dashboard\Unifi\Models\UnifiPpsk;
|
use Dashboard\Unifi\Models\UnifiPpsk;
|
||||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class SyncPpskSchedules extends Command
|
class SyncPpskSchedules extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled}';
|
protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled} {--triggered-by=schedule}';
|
||||||
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
|
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
|
||||||
|
|
||||||
public function handle(UnifiApiClient $unifi): int
|
public function handle(UnifiApiClient $unifi): int
|
||||||
{
|
{
|
||||||
if (! $this->option('force') && ! Setting::get('unifi.ppsk_scheduling.enabled')) {
|
$ppsks = UnifiPpsk::all();
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tz = Setting::get('unifi.timezone', 'UTC');
|
|
||||||
$now = now($tz);
|
|
||||||
$day = $now->dayOfWeek; // 0=Sun … 6=Sat
|
|
||||||
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 0–47
|
|
||||||
|
|
||||||
$ppsks = UnifiPpsk::whereNotNull('schedule')->get();
|
|
||||||
|
|
||||||
if ($ppsks->isEmpty()) {
|
if ($ppsks->isEmpty()) {
|
||||||
|
// Don't bother logging — no work, no audit value.
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch network confs once so we can resolve vlan → networkconf_id on re-enable
|
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
|
||||||
|
|
||||||
|
$run = UnifiCronRun::record('sync-ppsk-schedules', $triggeredBy, null, function () use ($unifi, $ppsks) {
|
||||||
|
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
|
||||||
|
$tz = \App\Support\Timezone::current();
|
||||||
|
$now = now($tz);
|
||||||
|
$day = $now->dayOfWeek;
|
||||||
|
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0);
|
||||||
|
|
||||||
$networksByVlan = [];
|
$networksByVlan = [];
|
||||||
try {
|
try {
|
||||||
foreach ($unifi->getNetworkConfs() as $n) {
|
foreach ($unifi->getNetworkConfs() as $n) {
|
||||||
@@ -41,17 +41,44 @@ class SyncPpskSchedules extends Command
|
|||||||
$this->warn("Could not fetch network configs: {$e->getMessage()}");
|
$this->warn("Could not fetch network configs: {$e->getMessage()}");
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($ppsks as $ppsk) {
|
$enabled = [];
|
||||||
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
|
$disabled = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ($ppsks as $ppsk) {
|
||||||
|
$shouldBeOn = true;
|
||||||
|
if ($globalEnabled && $ppsk->schedule) {
|
||||||
|
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if ($shouldBeOn && $ppsk->state === 'held') {
|
if ($shouldBeOn && $ppsk->state === 'held') {
|
||||||
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
|
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
|
||||||
|
$enabled[] = $ppsk->name;
|
||||||
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
|
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
|
||||||
$this->disablePpsk($ppsk, $unifi);
|
$this->disablePpsk($ppsk, $unifi);
|
||||||
|
$disabled[] = $ppsk->name;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors[] = ['ppsk' => $ppsk->name, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::SUCCESS;
|
$hasActions = count($enabled) + count($disabled) > 0;
|
||||||
|
$status = count($errors) > 0
|
||||||
|
? ($hasActions ? 'partial' : 'failed')
|
||||||
|
: ($hasActions ? 'succeeded' : 'skipped');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'global_enabled' => $globalEnabled,
|
||||||
|
'enabled_ppsks' => $enabled,
|
||||||
|
'disabled_ppsks' => $disabled,
|
||||||
|
'errors' => $errors,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void
|
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Dashboard\Unifi\Http\Controllers;
|
|
||||||
|
|
||||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Routing\Controller;
|
|
||||||
use Inertia\Inertia;
|
|
||||||
|
|
||||||
class ApGroupController extends Controller
|
|
||||||
{
|
|
||||||
public function index(UnifiApiClient $unifi)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$groups = collect($unifi->getApGroups())->map(fn ($g) => [
|
|
||||||
'id' => $g['_id'],
|
|
||||||
'name' => $g['name'] ?? 'Unnamed',
|
|
||||||
'device_macs' => $g['device_macs'] ?? [],
|
|
||||||
'is_default' => $g['attr_no_delete'] ?? false,
|
|
||||||
])->values();
|
|
||||||
|
|
||||||
$devices = collect($unifi->getAccessPoints())->map(fn ($d) => [
|
|
||||||
'mac' => strtolower($d['mac']),
|
|
||||||
'name' => $d['name'] ?? $d['model'] ?? $d['mac'],
|
|
||||||
'model' => $d['model'] ?? '',
|
|
||||||
'state' => $d['state'] ?? 0,
|
|
||||||
])->values();
|
|
||||||
|
|
||||||
return Inertia::render('Unifi/ApGroups', [
|
|
||||||
'groups' => $groups,
|
|
||||||
'devices' => $devices,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return Inertia::render('Unifi/ApGroups', [
|
|
||||||
'groups' => [], 'devices' => [], 'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request, UnifiApiClient $unifi)
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'name' => 'required|string|max:100',
|
|
||||||
'device_macs' => 'present|array',
|
|
||||||
'device_macs.*' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $unifi->createApGroup([
|
|
||||||
'name' => $data['name'],
|
|
||||||
'device_macs' => array_values(array_map('strtolower', $data['device_macs'])),
|
|
||||||
]);
|
|
||||||
return back()->with('success', 'AP group created.');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return back()->withErrors(['error' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, string $groupId, UnifiApiClient $unifi)
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'name' => 'sometimes|string|max:100',
|
|
||||||
'device_macs' => 'sometimes|array',
|
|
||||||
'device_macs.*' => 'string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isset($data['device_macs'])) {
|
|
||||||
$data['device_macs'] = array_values(array_map('strtolower', $data['device_macs']));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$unifi->updateApGroup($groupId, $data);
|
|
||||||
return back()->with('success', 'AP group updated.');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return back()->withErrors(['error' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(string $groupId, UnifiApiClient $unifi)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$unifi->deleteApGroup($groupId);
|
|
||||||
return back()->with('success', 'AP group deleted.');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return back()->withErrors(['error' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
43
src/Http/Controllers/UnifiCronLogsController.php
Normal file
43
src/Http/Controllers/UnifiCronLogsController.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Unifi\Http\Controllers;
|
||||||
|
|
||||||
|
use Dashboard\Unifi\Models\UnifiCronRun;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
class UnifiCronLogsController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$filters = $request->only(['command', 'status']);
|
||||||
|
|
||||||
|
$runs = UnifiCronRun::query()
|
||||||
|
->with('triggeredByUser:id,name,email')
|
||||||
|
->when($filters['command'] ?? null, fn ($q, $c) => $q->where('command', $c))
|
||||||
|
->when($filters['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
|
||||||
|
->orderByDesc('started_at')
|
||||||
|
->limit(200)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'runs' => $runs->map(fn ($r) => [
|
||||||
|
'id' => $r->id,
|
||||||
|
'command' => $r->command,
|
||||||
|
'triggered_by' => $r->triggered_by,
|
||||||
|
'triggered_user' => $r->triggeredByUser ? [
|
||||||
|
'id' => $r->triggeredByUser->id,
|
||||||
|
'name' => $r->triggeredByUser->name,
|
||||||
|
'email' => $r->triggeredByUser->email,
|
||||||
|
] : null,
|
||||||
|
'started_at' => $r->started_at?->toIso8601String(),
|
||||||
|
'finished_at' => $r->finished_at?->toIso8601String(),
|
||||||
|
'duration_ms' => $r->finished_at && $r->started_at
|
||||||
|
? (int) $r->finished_at->diffInMilliseconds($r->started_at)
|
||||||
|
: null,
|
||||||
|
'status' => $r->status,
|
||||||
|
'details' => $r->details,
|
||||||
|
])->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/Http/Controllers/UnifiPagesAccessController.php
Normal file
141
src/Http/Controllers/UnifiPagesAccessController.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Unifi\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\DashboardApp;
|
||||||
|
use App\Models\Group;
|
||||||
|
use App\Models\NavItem;
|
||||||
|
use App\Models\User;
|
||||||
|
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super-admin-only endpoints for managing per-page access on unifi
|
||||||
|
* pages. Pages here = nav_items where app_id = unifi's DashboardApp row.
|
||||||
|
*/
|
||||||
|
class UnifiPagesAccessController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||||
|
if (! $app) {
|
||||||
|
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pages = NavItem::where('app_id', $app->id)
|
||||||
|
->where('is_folder', false)
|
||||||
|
->whereNotNull('route_name')
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get(['id', 'label', 'route_name']);
|
||||||
|
|
||||||
|
$grants = UnifiPageGrant::whereIn('nav_item_id', $pages->pluck('id'))
|
||||||
|
->get()
|
||||||
|
->groupBy('nav_item_id');
|
||||||
|
|
||||||
|
// Only return users that ALREADY have grants. The full users list
|
||||||
|
// can be enormous (thousands of rows); the operator adds more via
|
||||||
|
// the searchUsers endpoint as needed.
|
||||||
|
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
|
||||||
|
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
|
||||||
|
|
||||||
|
// Groups: always include super-admin groups (locked-on across all
|
||||||
|
// pages) plus any group with at least one grant. Other groups are
|
||||||
|
// added via searchGroups.
|
||||||
|
$grantedGroupIds = $grants->flatten(1)->where('grantee_type', 'group')->pluck('grantee_id')->unique();
|
||||||
|
$groups = Group::where(function ($q) use ($grantedGroupIds) {
|
||||||
|
$q->where('is_super', true)
|
||||||
|
->orWhereIn('id', $grantedGroupIds);
|
||||||
|
})->orderBy('name')->get(['id', 'name', 'is_super']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'pages' => $pages->map(fn ($p) => [
|
||||||
|
'id' => $p->id,
|
||||||
|
'label' => $p->label,
|
||||||
|
'route_name' => $p->route_name,
|
||||||
|
'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(),
|
||||||
|
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
|
||||||
|
])->values(),
|
||||||
|
'users' => $users,
|
||||||
|
'groups' => $groups,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typeahead-style search for users to add to the access matrix.
|
||||||
|
* Returns up to 20 matches against name or email. Empty query returns
|
||||||
|
* an empty array — caller must enter at least 2 chars.
|
||||||
|
*/
|
||||||
|
public function searchUsers(Request $request)
|
||||||
|
{
|
||||||
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
if (strlen($q) < 2) {
|
||||||
|
return response()->json(['users' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = User::where(function ($w) use ($q) {
|
||||||
|
$w->where('name', 'like', '%' . $q . '%')
|
||||||
|
->orWhere('email', 'like', '%' . $q . '%');
|
||||||
|
})
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(20)
|
||||||
|
->get(['id', 'name', 'email']);
|
||||||
|
|
||||||
|
return response()->json(['users' => $users]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typeahead-style search for groups to add to the access matrix.
|
||||||
|
* Excludes super-admin groups (they're already in the matrix and
|
||||||
|
* locked-on across every page).
|
||||||
|
*/
|
||||||
|
public function searchGroups(Request $request)
|
||||||
|
{
|
||||||
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
if (strlen($q) < 2) {
|
||||||
|
return response()->json(['groups' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = Group::where('name', 'like', '%' . $q . '%')
|
||||||
|
->where(function ($w) { $w->where('is_super', false)->orWhereNull('is_super'); })
|
||||||
|
->orderBy('name')
|
||||||
|
->limit(20)
|
||||||
|
->get(['id', 'name', 'is_super']);
|
||||||
|
|
||||||
|
return response()->json(['groups' => $groups]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, NavItem $navItem)
|
||||||
|
{
|
||||||
|
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||||
|
if (! $app || $navItem->app_id !== $app->id) {
|
||||||
|
return response()->json(['error' => 'Not a unifi page.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'user_ids' => 'present|array',
|
||||||
|
'user_ids.*' => 'integer|exists:users,id',
|
||||||
|
'group_ids' => 'present|array',
|
||||||
|
'group_ids.*' => 'integer|exists:groups,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$grantedBy = $request->user()?->id;
|
||||||
|
|
||||||
|
DB::transaction(function () use ($navItem, $data, $grantedBy) {
|
||||||
|
UnifiPageGrant::where('nav_item_id', $navItem->id)->delete();
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$now = now();
|
||||||
|
foreach ($data['user_ids'] as $uid) {
|
||||||
|
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'user', 'grantee_id' => $uid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
|
||||||
|
}
|
||||||
|
foreach ($data['group_ids'] as $gid) {
|
||||||
|
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'group', 'grantee_id' => $gid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
|
||||||
|
}
|
||||||
|
if ($rows) UnifiPageGrant::insert($rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ class UnifiSettingsController extends Controller
|
|||||||
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
|
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
|
||||||
'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30),
|
'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30),
|
||||||
'retentionDays' => (int) Setting::get('unifi.retention_days', 30),
|
'retentionDays' => (int) Setting::get('unifi.retention_days', 30),
|
||||||
'timezone' => Setting::get('unifi.timezone', 'UTC'),
|
|
||||||
'autoRebootEnabled' => (bool) Setting::get('unifi.auto_reboot.enabled', false),
|
'autoRebootEnabled' => (bool) Setting::get('unifi.auto_reboot.enabled', false),
|
||||||
'autoRebootFrequency' => Setting::get('unifi.auto_reboot.frequency', 'daily'),
|
'autoRebootFrequency' => Setting::get('unifi.auto_reboot.frequency', 'daily'),
|
||||||
'autoRebootDow' => (int) Setting::get('unifi.auto_reboot.day_of_week', 0),
|
'autoRebootDow' => (int) Setting::get('unifi.auto_reboot.day_of_week', 0),
|
||||||
@@ -32,10 +31,26 @@ class UnifiSettingsController extends Controller
|
|||||||
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
|
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
|
||||||
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
|
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
|
||||||
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
|
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
|
||||||
|
'rotationLastPassword' => Setting::get('unifi.password_rotation.last_password', null),
|
||||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||||
|
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
|
||||||
|
'apiToken' => Setting::get('unifi.api_token', null),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function regenerateApiToken()
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(24));
|
||||||
|
Setting::set('unifi.api_token', $token);
|
||||||
|
return response()->json(['token' => $token]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearApiToken()
|
||||||
|
{
|
||||||
|
Setting::set('unifi.api_token', '');
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
public function update(Request $request)
|
public function update(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
@@ -45,7 +60,6 @@ class UnifiSettingsController extends Controller
|
|||||||
'poll_interval' => 'nullable|integer|min:5|max:300',
|
'poll_interval' => 'nullable|integer|min:5|max:300',
|
||||||
'cache_ttl' => 'nullable|integer|min:5|max:300',
|
'cache_ttl' => 'nullable|integer|min:5|max:300',
|
||||||
'retention_days' => 'nullable|integer|min:1|max:365',
|
'retention_days' => 'nullable|integer|min:1|max:365',
|
||||||
'timezone' => 'nullable|string|timezone',
|
|
||||||
'auto_reboot_enabled' => 'boolean',
|
'auto_reboot_enabled' => 'boolean',
|
||||||
'auto_reboot_frequency' => 'in:daily,weekly',
|
'auto_reboot_frequency' => 'in:daily,weekly',
|
||||||
'auto_reboot_dow' => 'nullable|integer|min:0|max:6',
|
'auto_reboot_dow' => 'nullable|integer|min:0|max:6',
|
||||||
@@ -58,6 +72,7 @@ class UnifiSettingsController extends Controller
|
|||||||
'rotation_minute' => 'nullable|integer|min:0|max:59',
|
'rotation_minute' => 'nullable|integer|min:0|max:59',
|
||||||
'rotation_wordlist' => 'nullable|string|max:20000',
|
'rotation_wordlist' => 'nullable|string|max:20000',
|
||||||
'ppsk_scheduling_enabled' => 'boolean',
|
'ppsk_scheduling_enabled' => 'boolean',
|
||||||
|
'api_enabled' => 'boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
|
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
|
||||||
@@ -70,7 +85,6 @@ class UnifiSettingsController extends Controller
|
|||||||
if ($request->has('poll_interval')) Setting::set('unifi.poll_interval', $request->poll_interval ?? 30);
|
if ($request->has('poll_interval')) Setting::set('unifi.poll_interval', $request->poll_interval ?? 30);
|
||||||
if ($request->has('cache_ttl')) Setting::set('unifi.cache_ttl', $request->cache_ttl ?? 30);
|
if ($request->has('cache_ttl')) Setting::set('unifi.cache_ttl', $request->cache_ttl ?? 30);
|
||||||
if ($request->has('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30);
|
if ($request->has('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30);
|
||||||
if ($request->has('timezone')) Setting::set('unifi.timezone', $request->timezone ?? 'UTC');
|
|
||||||
|
|
||||||
Setting::set('unifi.auto_reboot.enabled', $request->boolean('auto_reboot_enabled') ? '1' : '');
|
Setting::set('unifi.auto_reboot.enabled', $request->boolean('auto_reboot_enabled') ? '1' : '');
|
||||||
Setting::set('unifi.auto_reboot.frequency', $request->input('auto_reboot_frequency', 'daily'));
|
Setting::set('unifi.auto_reboot.frequency', $request->input('auto_reboot_frequency', 'daily'));
|
||||||
@@ -85,6 +99,7 @@ class UnifiSettingsController extends Controller
|
|||||||
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
|
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
|
||||||
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
|
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
|
||||||
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
|
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
|
||||||
|
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
||||||
|
|
||||||
|
|||||||
@@ -68,20 +68,49 @@ class WebhookController extends Controller
|
|||||||
|
|
||||||
public function test(WebhookConfig $webhook)
|
public function test(WebhookConfig $webhook)
|
||||||
{
|
{
|
||||||
$payload = [
|
return $this->fireTest($webhook->url, $webhook->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test an arbitrary URL+secret before the webhook is saved. Lets the
|
||||||
|
* operator validate their endpoint from the form without first
|
||||||
|
* committing a row.
|
||||||
|
*/
|
||||||
|
public function testUrl(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'url' => 'required|url|max:500',
|
||||||
|
'secret' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
return $this->fireTest($data['url'], $data['secret'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fireTest(string $url, ?string $secret)
|
||||||
|
{
|
||||||
|
$message = '✅ Test webhook from ' . config('app.name') . ' — endpoint is reachable.';
|
||||||
|
$genericPayload = [
|
||||||
'event' => 'test',
|
'event' => 'test',
|
||||||
'timestamp' => now()->toIso8601String(),
|
'timestamp' => now()->toIso8601String(),
|
||||||
'data' => ['message' => 'This is a test webhook from ' . config('app.name')],
|
'message' => $message,
|
||||||
|
'data' => ['message' => $message],
|
||||||
];
|
];
|
||||||
|
// Shape the payload to match the target platform (Google Chat,
|
||||||
|
// Slack, Discord, Teams) so the test exercises the same code
|
||||||
|
// path real events use.
|
||||||
|
$payload = \Dashboard\Unifi\Services\WebhookCheckService::buildPlatformPayload($url, $message, $genericPayload);
|
||||||
|
|
||||||
$headers = ['Content-Type' => 'application/json'];
|
$headers = ['Content-Type' => 'application/json'];
|
||||||
if ($webhook->secret) {
|
if ($secret) {
|
||||||
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $webhook->secret);
|
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($webhook->url, $payload);
|
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($url, $payload);
|
||||||
return response()->json(['ok' => true, 'status' => $response->status()]);
|
return response()->json([
|
||||||
|
'ok' => $response->successful(),
|
||||||
|
'status' => $response->status(),
|
||||||
|
'body' => mb_substr((string) $response->body(), 0, 500),
|
||||||
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/Http/Controllers/WifiApiController.php
Normal file
44
src/Http/Controllers/WifiApiController.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Unifi\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token-protected JSON endpoints for external integrations (signage,
|
||||||
|
* kiosks, room displays, etc.) that need the current rotating WiFi
|
||||||
|
* password without going through the dashboard UI.
|
||||||
|
*/
|
||||||
|
class WifiApiController extends Controller
|
||||||
|
{
|
||||||
|
public function currentPassword(Request $request)
|
||||||
|
{
|
||||||
|
if (! Setting::get('unifi.api.enabled')) {
|
||||||
|
return response()->json(['error' => 'API disabled'], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = Setting::get('unifi.api_token');
|
||||||
|
if (! $expected) {
|
||||||
|
return response()->json(['error' => 'API token not configured'], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$provided = $request->bearerToken() ?: $request->query('token');
|
||||||
|
if (! $provided || ! hash_equals($expected, $provided)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = Setting::get('unifi.password_rotation.last_password');
|
||||||
|
if (! $password) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'No rotated password recorded yet — wait for the next scheduled rotation or run unifi:rotate-passwords --force.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'password' => $password,
|
||||||
|
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,17 +16,6 @@ class WifiController extends Controller
|
|||||||
try {
|
try {
|
||||||
$wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values();
|
$wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values();
|
||||||
|
|
||||||
try {
|
|
||||||
$apGroups = collect($unifi->getApGroups())->map(fn ($g) => [
|
|
||||||
'id' => $g['_id'],
|
|
||||||
'name' => $g['attr_no_delete'] ?? false ? 'Default' : ($g['name'] ?? 'Unnamed'),
|
|
||||||
'device_macs' => $g['device_macs'] ?? [],
|
|
||||||
'is_default' => $g['attr_no_delete'] ?? false,
|
|
||||||
])->values();
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$apGroups = collect(); // AP groups not supported by this controller
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = Setting::get('unifi.ssid_groups', '{}');
|
$raw = Setting::get('unifi.ssid_groups', '{}');
|
||||||
$groups = json_decode($raw, true);
|
$groups = json_decode($raw, true);
|
||||||
if (! is_array($groups) || array_is_list($groups)) $groups = [];
|
if (! is_array($groups) || array_is_list($groups)) $groups = [];
|
||||||
@@ -37,13 +26,12 @@ class WifiController extends Controller
|
|||||||
return Inertia::render('Unifi/Wifi', [
|
return Inertia::render('Unifi/Wifi', [
|
||||||
'wlans' => $wlans,
|
'wlans' => $wlans,
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
'apGroups' => $apGroups,
|
|
||||||
'rotateWlanIds' => $rotateWlanIds,
|
'rotateWlanIds' => $rotateWlanIds,
|
||||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return Inertia::render('Unifi/Wifi', [
|
return Inertia::render('Unifi/Wifi', [
|
||||||
'wlans' => [], 'groups' => [], 'apGroups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
|
'wlans' => [], 'groups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,21 +86,6 @@ class WifiController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update AP group assignments for a single WLAN (not synced to group siblings).
|
|
||||||
*/
|
|
||||||
public function updateApGroups(Request $request, string $wlanId, UnifiApiClient $unifi)
|
|
||||||
{
|
|
||||||
$request->validate(['ap_group_ids' => 'required|array']);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$unifi->updateWlan($wlanId, ['ap_group_ids' => $request->ap_group_ids]);
|
|
||||||
return back()->with('success', 'AP groups updated.');
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return back()->withErrors(['error' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi)
|
public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi)
|
||||||
{
|
{
|
||||||
$request->validate(['enabled' => 'required|boolean']);
|
$request->validate(['enabled' => 'required|boolean']);
|
||||||
@@ -318,11 +291,46 @@ class WifiController extends Controller
|
|||||||
fn ($v) => $v !== null
|
fn ($v) => $v !== null
|
||||||
);
|
);
|
||||||
if (! empty($unifiUpdate)) {
|
if (! empty($unifiUpdate)) {
|
||||||
|
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
|
||||||
|
// Embedded PPSK update path — modify the WLAN's embedded array.
|
||||||
|
// Match by name (reliable across drift).
|
||||||
|
$newPass = $unifiUpdate['x_passphrase'];
|
||||||
|
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
|
||||||
|
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
|
||||||
|
|
||||||
|
// Also update sibling WLANs (banded SSID — same name
|
||||||
|
// on 2.4 and 5GHz are separate wlanconf rows).
|
||||||
|
foreach ($unifi->getWlanSiblings($record->wlan_id) as $siblingWlanId) {
|
||||||
|
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||||
|
->where('name', $record->name)
|
||||||
|
->where('state', 'active')
|
||||||
|
->first();
|
||||||
|
try {
|
||||||
|
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name);
|
||||||
|
if ($sibling) {
|
||||||
|
$sibling->update([
|
||||||
|
'x_passphrase' => $newPass,
|
||||||
|
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// PPSK absent on this band is fine — just
|
||||||
|
// means it isn't mirrored. Anything else
|
||||||
|
// gets warning-logged.
|
||||||
|
$level = str_contains($e->getMessage(), 'not found') ? 'info' : 'warning';
|
||||||
|
\Illuminate\Support\Facades\Log::log($level, 'unifi.ppsk_sibling_update', [
|
||||||
|
'sibling_wlan' => $siblingWlanId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase']));
|
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase', 'unifi_id']));
|
||||||
// vlan can be explicitly set to null
|
// vlan can be explicitly set to null
|
||||||
if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan'];
|
if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan'];
|
||||||
if (! empty($dbUpdate)) $record->update($dbUpdate);
|
if (! empty($dbUpdate)) $record->update($dbUpdate);
|
||||||
@@ -414,7 +422,6 @@ class WifiController extends Controller
|
|||||||
'hide_ssid' => $w['hide_ssid'] ?? false,
|
'hide_ssid' => $w['hide_ssid'] ?? false,
|
||||||
'passphrase' => $w['x_passphrase'] ?? '',
|
'passphrase' => $w['x_passphrase'] ?? '',
|
||||||
'band' => $this->detectBand($w),
|
'band' => $this->detectBand($w),
|
||||||
'ap_group_ids' => $w['ap_group_ids'] ?? [],
|
|
||||||
'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false,
|
'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false,
|
||||||
'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny',
|
'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny',
|
||||||
'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false)
|
'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false)
|
||||||
|
|||||||
79
src/Models/UnifiCronRun.php
Normal file
79
src/Models/UnifiCronRun.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Unifi\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class UnifiCronRun extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'unifi_cron_runs';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'command',
|
||||||
|
'triggered_by',
|
||||||
|
'triggered_by_user_id',
|
||||||
|
'started_at',
|
||||||
|
'finished_at',
|
||||||
|
'status',
|
||||||
|
'details',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'started_at' => 'datetime',
|
||||||
|
'finished_at' => 'datetime',
|
||||||
|
'details' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function triggeredByUser()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\User::class, 'triggered_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a unit of cron work, recording start/finish/status and any
|
||||||
|
* exception. Returns whatever the work returns; the resulting
|
||||||
|
* UnifiCronRun row is returned via the $run reference param.
|
||||||
|
*/
|
||||||
|
public static function record(string $command, string $triggeredBy, ?int $userId, callable $work): self
|
||||||
|
{
|
||||||
|
$run = static::create([
|
||||||
|
'command' => $command,
|
||||||
|
'triggered_by' => $triggeredBy,
|
||||||
|
'triggered_by_user_id' => $userId,
|
||||||
|
'started_at' => now(),
|
||||||
|
'status' => 'running',
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = $work($run);
|
||||||
|
|
||||||
|
// Caller can return a status string ("skipped", "partial",
|
||||||
|
// etc.) by sticking it under the 'status' key in details.
|
||||||
|
// Default = succeeded.
|
||||||
|
$status = is_array($details) && isset($details['status'])
|
||||||
|
? $details['status']
|
||||||
|
: 'succeeded';
|
||||||
|
|
||||||
|
$run->update([
|
||||||
|
'finished_at' => now(),
|
||||||
|
'status' => $status,
|
||||||
|
'details' => is_array($details) ? array_diff_key($details, ['status' => null]) : null,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$run->update([
|
||||||
|
'finished_at' => now(),
|
||||||
|
'status' => 'failed',
|
||||||
|
'details' => [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'class' => $e::class,
|
||||||
|
'file' => $e->getFile() . ':' . $e->getLine(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $run->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Models/UnifiPageGrant.php
Normal file
58
src/Models/UnifiPageGrant.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Dashboard\Unifi\Models;
|
||||||
|
|
||||||
|
use App\Models\NavItem;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UnifiPageGrant extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'unifi_page_grants';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'nav_item_id',
|
||||||
|
'grantee_type',
|
||||||
|
'grantee_id',
|
||||||
|
'granted_by_user_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function navItem(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(NavItem::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function grantedBy(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'granted_by_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True iff $user is allowed to access $navItem under strict allowlist
|
||||||
|
* semantics:
|
||||||
|
* * super-admins (the model-level flag) always pass
|
||||||
|
* * otherwise the user must be a direct grantee, OR be in a group
|
||||||
|
* that is a grantee
|
||||||
|
*
|
||||||
|
* A page with NO grants saved is only visible to super-admins —
|
||||||
|
* the admin must explicitly authorize everyone else via the
|
||||||
|
* Access tab.
|
||||||
|
*/
|
||||||
|
public static function userCanAccess(User $user, NavItem $navItem): bool
|
||||||
|
{
|
||||||
|
if ($user->is_super_admin) return true;
|
||||||
|
|
||||||
|
$groupIds = $user->groups()->pluck('groups.id');
|
||||||
|
|
||||||
|
return static::where('nav_item_id', $navItem->id)
|
||||||
|
->where(function ($q) use ($user, $groupIds) {
|
||||||
|
$q->where(function ($u) use ($user) {
|
||||||
|
$u->where('grantee_type', 'user')->where('grantee_id', $user->id);
|
||||||
|
})->orWhere(function ($g) use ($groupIds) {
|
||||||
|
$g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,26 +312,38 @@ class UnifiApiClient
|
|||||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AP Groups ─────────────────────────────────────────────────────────────
|
/**
|
||||||
|
* Find sibling WLAN configs — same SSID name, different _id. UniFi
|
||||||
public function getApGroups(): array
|
* splits a "banded" SSID (band-steering disabled) into one wlanconf
|
||||||
|
* per band, each with its own _id and its own embedded PPSK array.
|
||||||
|
* A rotation that updates one band must also update the others, or
|
||||||
|
* the SSID's two halves drift out of sync.
|
||||||
|
*
|
||||||
|
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
|
||||||
|
* Empty array if the target WLAN is unique or can't be found.
|
||||||
|
*/
|
||||||
|
public function getWlanSiblings(string $wlanId): array
|
||||||
{
|
{
|
||||||
return $this->get('/rest/apgroups');
|
try {
|
||||||
|
$all = $this->get('/rest/wlanconf');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createApGroup(array $data): array
|
$target = null;
|
||||||
{
|
foreach ($all as $w) {
|
||||||
return $this->post('/rest/apgroups', $data);
|
if (($w['_id'] ?? null) === $wlanId) { $target = $w; break; }
|
||||||
}
|
}
|
||||||
|
if (! $target || empty($target['name'])) return [];
|
||||||
|
|
||||||
public function updateApGroup(string $groupId, array $data): array
|
$siblings = [];
|
||||||
{
|
foreach ($all as $w) {
|
||||||
return $this->put("/rest/apgroups/{$groupId}", $data);
|
if (($w['_id'] ?? null) === $wlanId) continue;
|
||||||
|
if (($w['name'] ?? null) === $target['name']) {
|
||||||
|
$siblings[] = $w['_id'];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public function deleteApGroup(string $groupId): void
|
return $siblings;
|
||||||
{
|
|
||||||
$this->delete("/rest/apgroups/{$groupId}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PPSK ─────────────────────────────────────────────────────────────────
|
// ── PPSK ─────────────────────────────────────────────────────────────────
|
||||||
@@ -518,6 +530,87 @@ class UnifiApiClient
|
|||||||
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an embedded PPSK (one that lives inside a WLAN's
|
||||||
|
* private_preshared_keys array rather than as its own REST resource).
|
||||||
|
*
|
||||||
|
* Matching is done by current passphrase since embedded entries have
|
||||||
|
* no controller-side ID. Only changes the entry's passphrase; name
|
||||||
|
* isn't separately addressable on embedded PPSKs.
|
||||||
|
*/
|
||||||
|
public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array
|
||||||
|
{
|
||||||
|
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
|
||||||
|
$wlan = $wlanResp[0] ?? $wlanResp;
|
||||||
|
$entries = $wlan['private_preshared_keys'] ?? [];
|
||||||
|
|
||||||
|
if (! is_array($entries) || empty($entries)) {
|
||||||
|
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match in this order — most reliable first:
|
||||||
|
// 1. by PPSK name (if provided) — survives passphrase drift
|
||||||
|
// caused by manual edits or previous out-of-sync rotations.
|
||||||
|
// 2. by current passphrase (legacy)
|
||||||
|
$applyUpdate = function (array &$e) use ($newPassphrase) {
|
||||||
|
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
|
||||||
|
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
|
||||||
|
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
|
||||||
|
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
|
||||||
|
$e['password'] = $newPassphrase;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$matched = false;
|
||||||
|
if ($name !== null && $name !== '') {
|
||||||
|
foreach ($entries as &$e) {
|
||||||
|
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
||||||
|
if ($entryName === $name) {
|
||||||
|
$applyUpdate($e);
|
||||||
|
$matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $matched && $oldPassphrase !== null && $oldPassphrase !== '') {
|
||||||
|
foreach ($entries as &$e) {
|
||||||
|
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
||||||
|
if ($current === $oldPassphrase) {
|
||||||
|
$applyUpdate($e);
|
||||||
|
$matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $matched) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'Embedded PPSK not found' .
|
||||||
|
($name !== null ? " by name \"{$name}\"" : '') .
|
||||||
|
' or by current passphrase.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniFi REST expects the full WLAN object on PUT — send what we
|
||||||
|
// got back, with the mutated PPSK array.
|
||||||
|
$payload = $wlan;
|
||||||
|
$payload['private_preshared_keys'] = $entries;
|
||||||
|
// Strip internal fields the controller rejects on PUT.
|
||||||
|
unset($payload['_id'], $payload['site_id']);
|
||||||
|
|
||||||
|
$this->put("/rest/wlanconf/{$wlanId}", $payload);
|
||||||
|
|
||||||
|
// Return a normalized record so callers can read the new state.
|
||||||
|
return $this->normalizePpsk([[
|
||||||
|
'_id' => 'emb_' . substr(hash('sha256', $wlanId . ':' . $newPassphrase), 0, 32),
|
||||||
|
'wlan_id' => $wlanId,
|
||||||
|
'x_passphrase' => $newPassphrase,
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
|
||||||
public function deletePpsk(string $ppskId): void
|
public function deletePpsk(string $ppskId): void
|
||||||
{
|
{
|
||||||
// Try v2 hotspot endpoint first
|
// Try v2 hotspot endpoint first
|
||||||
|
|||||||
@@ -580,6 +580,15 @@ class WebhookCheckService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
|
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
|
||||||
|
{
|
||||||
|
return self::buildPlatformPayload($url, $message, $fullPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public/static helper so the test-webhook endpoint produces the
|
||||||
|
* same per-platform payload shape that real events do.
|
||||||
|
*/
|
||||||
|
public static function buildPlatformPayload(string $url, string $message, array $fullPayload): array
|
||||||
{
|
{
|
||||||
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
|
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
|
||||||
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
|
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
namespace Dashboard\Unifi;
|
namespace Dashboard\Unifi;
|
||||||
|
|
||||||
|
use App\Models\DashboardApp;
|
||||||
|
use App\Models\NavItem;
|
||||||
|
use Dashboard\Unifi\Models\UnifiPageGrant;
|
||||||
|
use Illuminate\Routing\Events\RouteMatched;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class UnifiServiceProvider extends ServiceProvider
|
class UnifiServiceProvider extends ServiceProvider
|
||||||
@@ -20,6 +25,36 @@ class UnifiServiceProvider extends ServiceProvider
|
|||||||
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
||||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||||
|
|
||||||
|
// Per-page access enforcement for unifi routes. If a unifi page has
|
||||||
|
// any UnifiPageGrant rows, only super-admins and granted users/
|
||||||
|
// groups can hit it; otherwise (no grants) it's open per the existing
|
||||||
|
// permission middleware. Super-admins always bypass.
|
||||||
|
Event::listen(RouteMatched::class, function (RouteMatched $event) {
|
||||||
|
$routeName = $event->route->getName();
|
||||||
|
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
|
||||||
|
|
||||||
|
$user = $event->request->user();
|
||||||
|
if (! $user || $user->is_super_admin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$appId = DashboardApp::where('slug', 'unifi')->value('id');
|
||||||
|
$item = NavItem::where('route_name', $routeName)
|
||||||
|
->where('app_id', $appId)
|
||||||
|
->first();
|
||||||
|
if (! $item) return;
|
||||||
|
|
||||||
|
if (! UnifiPageGrant::userCanAccess($user, $item)) {
|
||||||
|
// 404 instead of 403 — don't leak that the page
|
||||||
|
// exists. The Access tab is the only way in.
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// unifi_page_grants table may not exist yet on a fresh
|
||||||
|
// install before this snap-in's migrations have run —
|
||||||
|
// fail open in that narrow window.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([
|
$this->commands([
|
||||||
Console\CheckWebhooks::class,
|
Console\CheckWebhooks::class,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Dashboard\Unifi\Http\Controllers\ApGroupController;
|
|
||||||
use Dashboard\Unifi\Http\Controllers\ClientController;
|
use Dashboard\Unifi\Http\Controllers\ClientController;
|
||||||
use Dashboard\Unifi\Http\Controllers\DeviceController;
|
use Dashboard\Unifi\Http\Controllers\DeviceController;
|
||||||
use Dashboard\Unifi\Http\Controllers\PortalController;
|
use Dashboard\Unifi\Http\Controllers\PortalController;
|
||||||
use Dashboard\Unifi\Http\Controllers\StatsController;
|
use Dashboard\Unifi\Http\Controllers\StatsController;
|
||||||
|
use Dashboard\Unifi\Http\Controllers\UnifiCronLogsController;
|
||||||
|
use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
|
||||||
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
|
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
|
||||||
use Dashboard\Unifi\Http\Controllers\VlanGroupController;
|
use Dashboard\Unifi\Http\Controllers\VlanGroupController;
|
||||||
use Dashboard\Unifi\Http\Controllers\WebhookController;
|
use Dashboard\Unifi\Http\Controllers\WebhookController;
|
||||||
|
use Dashboard\Unifi\Http\Controllers\WifiApiController;
|
||||||
use Dashboard\Unifi\Http\Controllers\WifiController;
|
use Dashboard\Unifi\Http\Controllers\WifiController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -32,7 +34,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
|||||||
// WiFi networks
|
// WiFi networks
|
||||||
Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi');
|
Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi');
|
||||||
Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update');
|
Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update');
|
||||||
Route::put('/wifi/{wlanId}/ap-groups', [WifiController::class, 'updateApGroups']) ->name('wifi.ap-groups');
|
|
||||||
Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle']) ->name('wifi.toggle');
|
Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle']) ->name('wifi.toggle');
|
||||||
Route::post('/wifi/groups', [WifiController::class, 'saveGroups']) ->name('wifi.groups');
|
Route::post('/wifi/groups', [WifiController::class, 'saveGroups']) ->name('wifi.groups');
|
||||||
|
|
||||||
@@ -44,12 +45,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
|||||||
Route::put('/wifi/{wlanId}/ppsk/{ppskId}/schedule', [WifiController::class, 'ppskSchedule']) ->name('wifi.ppsk.schedule');
|
Route::put('/wifi/{wlanId}/ppsk/{ppskId}/schedule', [WifiController::class, 'ppskSchedule']) ->name('wifi.ppsk.schedule');
|
||||||
Route::patch('/wifi/{wlanId}/ppsk/{ppskId}/rotation',[WifiController::class, 'ppskToggleRotation'])->name('wifi.ppsk.rotation');
|
Route::patch('/wifi/{wlanId}/ppsk/{ppskId}/rotation',[WifiController::class, 'ppskToggleRotation'])->name('wifi.ppsk.rotation');
|
||||||
|
|
||||||
// AP Groups
|
|
||||||
Route::get('/ap-groups', [ApGroupController::class, 'index']) ->name('ap-groups.index');
|
|
||||||
Route::post('/ap-groups', [ApGroupController::class, 'store']) ->name('ap-groups.store');
|
|
||||||
Route::put('/ap-groups/{groupId}', [ApGroupController::class, 'update']) ->name('ap-groups.update');
|
|
||||||
Route::delete('/ap-groups/{groupId}', [ApGroupController::class, 'destroy']) ->name('ap-groups.destroy');
|
|
||||||
|
|
||||||
// Devices
|
// Devices
|
||||||
Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot');
|
Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot');
|
||||||
Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick');
|
Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick');
|
||||||
@@ -78,15 +73,39 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
|||||||
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
||||||
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
||||||
|
|
||||||
// Webhooks
|
// Page Access — super-admin only. Lists unifi pages and lets
|
||||||
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
|
// operators assign per-page user/group grants.
|
||||||
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
|
Route::middleware('super.admin')->group(function () {
|
||||||
Route::put('/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
|
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
|
||||||
Route::delete('/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
|
Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search');
|
||||||
Route::post('/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
|
Route::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search');
|
||||||
|
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cron logs — read-only history of scheduled-task runs.
|
||||||
|
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
|
||||||
|
|
||||||
|
// Webhooks — lives under /settings/* so it reads as a settings tab.
|
||||||
|
Route::get('/settings/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
|
||||||
|
Route::post('/settings/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
|
||||||
|
Route::put('/settings/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
|
||||||
|
Route::delete('/settings/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
|
||||||
|
Route::post('/settings/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
|
||||||
|
Route::post('/settings/webhooks/test-url', [WebhookController::class, 'testUrl'])->name('webhooks.test-url');
|
||||||
|
|
||||||
|
// API-token management
|
||||||
|
Route::post('/settings/api-token/regenerate', [UnifiSettingsController::class, 'regenerateApiToken'])->name('settings.api-token.regenerate');
|
||||||
|
Route::delete('/settings/api-token', [UnifiSettingsController::class, 'clearApiToken']) ->name('settings.api-token.clear');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Public API (token-protected) ──────────────────────────────────────────
|
||||||
|
// External integrations (signage, kiosks) hit these without session auth.
|
||||||
|
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
|
||||||
|
Route::get('/wifi/current-password', [WifiApiController::class, 'currentPassword'])
|
||||||
|
->name('wifi.current-password');
|
||||||
|
});
|
||||||
|
|
||||||
// ── Captive portal callback (public — user redirected here by UniFi) ─────
|
// ── Captive portal callback (public — user redirected here by UniFi) ─────
|
||||||
Route::middleware(['web', 'auth'])
|
Route::middleware(['web', 'auth'])
|
||||||
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
|
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
|
||||||
|
|||||||
Reference in New Issue
Block a user