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', '[]'); $wlanIds = json_decode($wlanIdsJson, true); if (! is_array($wlanIds)) $wlanIds = []; $ppskQuery = UnifiPpsk::where('rotate_password', true) ->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', ''); $passwords = array_values(array_filter(array_map('trim', explode("\n", $wordlist)))); if (empty($passwords)) { $this->warn('Password rotation: no passwords in wordlist — skipped.'); return ['status' => 'skipped', 'reason' => 'empty wordlist']; } $password = $passwords[array_rand($passwords)]; $rotated = []; $failedWlans = []; foreach ($wlanIds as $wlanId) { try { $unifi->updateWlan($wlanId, ['x_passphrase' => $password]); $rotated[] = $wlanId; } catch (\Throwable $e) { $this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}"); $failedWlans[] = ['wlan_id' => $wlanId, 'error' => $e->getMessage()]; } } if ($rotated) { Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String()); // 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).'); } $rotatedPpsks = []; $failedPpsks = []; foreach ($ppskQuery->get() as $ppsk) { $newPass = $passwords[array_rand($passwords)]; try { if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) { // Embedded PPSK: update inside the parent WLAN object, // matched by name (synthetic id changes with the // passphrase, so it's not a stable matcher). $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]); // Update every grouped sibling (user-defined SSID // groups take precedence; same-name fallback for // installs that haven't grouped manually). foreach ($unifi->getGroupedWlans($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) { 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()]; } } // Verify that the new passphrase actually applied // on every grouped WLAN. UniFi can 200 an update // that doesn't stick (cluster sync race, etc). // Anything we expected to rotate that didn't is a // failure — surface it in the cron log. $allWlanIds = array_merge([$ppsk->wlan_id], $unifi->getGroupedWlans($ppsk->wlan_id)); foreach ($allWlanIds as $checkWlanId) { $result = $unifi->verifyEmbeddedPpsk($checkWlanId, $ppsk->name, $newPass); if ($result['ok']) continue; // 'not_found' on a sibling = PPSK isn't on that band — ignore // (consistent with the skip in the update loop). if ($result['reason'] === 'not_found' && $checkWlanId !== $ppsk->wlan_id) continue; $failedPpsks[] = [ 'name' => $ppsk->name . ' (verify wlan ' . $checkWlanId . ')', 'error' => 'verification ' . $result['reason'] . ($result['error'] ?? null ? ': ' . $result['error'] : ''), ]; } } else { $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); $ppsk->update(['x_passphrase' => $newPass]); } $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) { $this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}"); $failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()]; } } $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 { $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 = \App\Support\Timezone::current(); $now = now($tz); if ($now->hour !== $hour || $now->minute !== $minute) { return false; } if ($frequency === 'weekly' && $now->dayOfWeek !== $dow) { return false; } return true; } }