feat: password rotation, PPSK management, VLAN/AP groups

- Add password rotation: RotatePasswords console command + migration + service updates
- Add PPSK management: UnifiPpsk model, migration, SyncPpskSchedules console
- Add VLAN groups and AP groups: VlanGroupController, ApGroupController, model, migration
- Add RebootAllAps console command
- Add in_alert column to device states
- Wire new features through service provider, routes, and existing controllers/services

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 17:54:24 -04:00
parent ce3217d8f4
commit 0802ef35f3
22 changed files with 1771 additions and 305 deletions

View File

@@ -0,0 +1,62 @@
<?php
namespace Dashboard\Unifi\Console;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class RebootAllAps extends Command
{
protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot}';
protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
public function handle(UnifiApiClient $unifi): int
{
try {
$aps = $unifi->getAccessPoints();
} catch (\Throwable $e) {
$this->error('Failed to fetch APs: ' . $e->getMessage());
return self::FAILURE;
}
if (empty($aps)) {
$this->warn('No access points found.');
return self::SUCCESS;
}
$delay = max(0, (int) $this->option('delay'));
// Pre-mark all APs as planned reboots before sending any commands
foreach ($aps as $ap) {
$mac = strtolower($ap['mac']);
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
}
$this->newLine();
$ok = 0;
$fail = 0;
foreach ($aps as $ap) {
$mac = strtolower($ap['mac']);
$name = $ap['name'] ?? $mac;
try {
$unifi->rebootDevice($mac);
$this->info("Rebooted: {$name} ({$mac})");
$ok++;
} catch (\Throwable $e) {
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
$fail++;
}
if ($delay > 0 && $ok + $fail < count($aps)) {
sleep($delay);
}
}
$this->newLine();
$this->info("Done. {$ok} rebooted, {$fail} failed.");
return $fail > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Dashboard\Unifi\Console;
use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class RotatePasswords extends Command
{
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule}';
protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
public function handle(UnifiApiClient $unifi): int
{
if (! Setting::get('unifi.password_rotation.enabled')) {
return self::SUCCESS;
}
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
$wlanIds = json_decode($wlanIdsJson, true);
if (empty($wlanIds) || ! is_array($wlanIds)) {
return self::SUCCESS;
}
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
$passwords = array_values(array_filter(array_map('trim', explode("\n", $wordlist))));
if (empty($passwords)) {
$this->warn('Password rotation: no passwords in wordlist — skipped.');
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->isDue()) {
return self::SUCCESS;
}
$password = $passwords[array_rand($passwords)];
$rotated = 0;
foreach ($wlanIds as $wlanId) {
try {
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
$rotated++;
} catch (\Throwable $e) {
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
}
}
if ($rotated > 0) {
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
$this->info("Rotated password for {$rotated} SSID(s).");
}
// ── Rotate PPSK passwords ────────────────────────────────────────────
$rotatedPpsks = 0;
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
// Each PPSK gets its own independently-chosen password from the wordlist
$newPass = $passwords[array_rand($passwords)];
try {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
$rotatedPpsks++;
} catch (\Throwable $e) {
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
}
}
if ($rotatedPpsks > 0) {
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
}
return self::SUCCESS;
}
private function isDue(): bool
{
$frequency = Setting::get('unifi.password_rotation.frequency', 'weekly');
$hour = (int) Setting::get('unifi.password_rotation.hour', 2);
$minute = (int) Setting::get('unifi.password_rotation.minute', 0);
$dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0);
$tz = Setting::get('unifi.timezone', 'UTC');
$now = now($tz);
if ($now->hour !== $hour || $now->minute !== $minute) {
return false;
}
if ($frequency === 'weekly' && $now->dayOfWeek !== $dow) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace Dashboard\Unifi\Console;
use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command;
class SyncPpskSchedules extends Command
{
protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled}';
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
public function handle(UnifiApiClient $unifi): int
{
if (! $this->option('force') && ! Setting::get('unifi.ppsk_scheduling.enabled')) {
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()) {
return self::SUCCESS;
}
// Fetch network confs once so we can resolve vlan → networkconf_id on re-enable
$networksByVlan = [];
try {
foreach ($unifi->getNetworkConfs() as $n) {
if (isset($n['vlan'])) {
$networksByVlan[(int) $n['vlan']] = $n;
}
}
} catch (\Throwable $e) {
$this->warn("Could not fetch network configs: {$e->getMessage()}");
}
foreach ($ppsks as $ppsk) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
if ($shouldBeOn && $ppsk->state === 'held') {
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
$this->disablePpsk($ppsk, $unifi);
}
}
return self::SUCCESS;
}
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void
{
try {
$data = [
'name' => $ppsk->name,
'x_passphrase' => $ppsk->x_passphrase,
'wlan_id' => $ppsk->wlan_id,
];
if ($ppsk->vlan && isset($networksByVlan[$ppsk->vlan])) {
$data['networkconf_id'] = $networksByVlan[$ppsk->vlan]['_id'];
}
$result = $unifi->createPpsk($data);
$raw = $result[0] ?? $result;
$newId = $raw['_id'] ?? null;
$ppsk->update(['state' => 'active', 'unifi_id' => $newId]);
$this->info("Enabled: {$ppsk->name} (wlan {$ppsk->wlan_id})");
} catch (\Throwable $e) {
$this->error("Failed to enable PPSK \"{$ppsk->name}\": {$e->getMessage()}");
}
}
private function disablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi): void
{
try {
$kicked = $unifi->kickClientsForPpsk($ppsk->unifi_id);
$unifi->deletePpsk($ppsk->unifi_id);
$ppsk->update(['state' => 'held', 'unifi_id' => null]);
$suffix = $kicked > 0 ? " — kicked {$kicked} client(s)" : '';
$this->info("Disabled: {$ppsk->name}{$suffix}");
} catch (\Throwable $e) {
$this->error("Failed to disable PPSK \"{$ppsk->name}\": {$e->getMessage()}");
}
}
}