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. // Synthetic ID is derived from the new passphrase, so update it too. $unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass); $newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32); $ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]); } else { $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); $ppsk->update(['x_passphrase' => $newPass]); } $rotatedPpsks[] = $ppsk->name; } 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; } }