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

@@ -286,6 +286,20 @@ class UnifiApiClient
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
}
private function delete(string $path): void
{
$this->init();
$url = $this->siteUrl($path);
$response = $this->buildRequest()->delete($url);
if ($response->status() === 401) {
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
$response = $this->buildRequest()->delete($url);
}
if (! $response->successful()) {
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
}
}
// ── WiFi Networks / WLANs ─────────────────────────────────────────────────
public function getWlans(): array
@@ -298,6 +312,252 @@ class UnifiApiClient
return $this->put("/rest/wlanconf/{$wlanId}", $data);
}
// ── AP Groups ─────────────────────────────────────────────────────────────
public function getApGroups(): array
{
return $this->get('/rest/apgroups');
}
public function createApGroup(array $data): array
{
return $this->post('/rest/apgroups', $data);
}
public function updateApGroup(string $groupId, array $data): array
{
return $this->put("/rest/apgroups/{$groupId}", $data);
}
public function deleteApGroup(string $groupId): void
{
$this->delete("/rest/apgroups/{$groupId}");
}
// ── PPSK ─────────────────────────────────────────────────────────────────
/**
* Build a v2 API URL. UniFi OS consoles expose /proxy/network/v2/api/site/{site}/...
* Standalone controllers may not have this path.
*/
private function v2SiteUrl(string $path): string
{
$this->init();
$v2Base = str_contains($this->apiPath(), 'proxy') ? '/proxy/network' : '';
return "{$this->baseUrl}{$v2Base}/v2/api/site/{$this->site}{$path}";
}
/**
* Check whether an HTTP response is a usable JSON response (not a 404 HTML page).
*/
private function isJsonResponse($response): bool
{
if (! $response->successful()) return false;
$ct = $response->header('Content-Type', '');
return str_contains($ct, 'json') || (! str_contains($ct, 'html'));
}
/**
* Normalize PPSK entries to a consistent shape regardless of API version.
*
* We merge the original raw entry with our normalized aliases so:
* - All original fields survive (frontend can inspect them)
* - Standard field names (_id, name, x_passphrase, wlan_id, vlan_id) are
* always present, mapped from whatever variant the API used
*/
private function normalizePpsk(array $entries): array
{
return array_values(array_map(function ($e) {
$normalized = [
'_id' => $e['_id'] ?? $e['id'] ?? null,
'name' => $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null,
'x_passphrase' => $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? $e['psk'] ?? null,
'wlan_id' => $e['wlan_id'] ?? $e['wlanId'] ?? null,
'networkconf_id'=> $e['networkconf_id'] ?? $e['network_conf_id'] ?? $e['networkId'] ?? null,
'vlan_id' => $e['vlan_id'] ?? $e['vlanId'] ?? $e['vlan'] ?? null,
];
return array_merge($e, array_filter($normalized, fn ($v) => $v !== null));
}, $entries));
}
public function getNetworkConfs(): array
{
return $this->get('/rest/networkconf');
}
/**
* Fetch PPSK entries for a WLAN.
*
* Tries multiple endpoints in order of likelihood, because the correct path
* varies significantly by controller version and firmware:
*
* 1. Classic REST with wlan_id filter (works on most standalone controllers)
* 2. Classic REST fetch-all + local filter (when query param is ignored)
* 3. v2 hotspot endpoint (UniFi Network App 7.x+)
* 4. v2 wlan password endpoint (some UDM firmware variants)
* 5. Embedded in the WLAN object itself (some controller versions)
*/
public function getPpskEntries(string $wlanId): array
{
// 1. Classic path with filter
try {
$results = $this->get("/rest/ppsk?wlan_id={$wlanId}");
if (! empty($results)) {
Log::info('unifi.ppsk_found', ['path' => 'classic_filter', 'count' => count($results)]);
return $this->normalizePpsk($results);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_classic_filter_failed', ['error' => $e->getMessage()]);
}
// 2. Classic path without filter — fetch all and filter locally
try {
$all = $this->get('/rest/ppsk');
Log::debug('unifi.ppsk_classic_all', ['total' => count($all)]);
if (! empty($all)) {
$filtered = array_filter($all, fn ($e) => ($e['wlan_id'] ?? '') === $wlanId);
if (! empty($filtered)) {
Log::info('unifi.ppsk_found', ['path' => 'classic_all_filtered', 'count' => count($filtered)]);
return $this->normalizePpsk(array_values($filtered));
}
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_classic_all_failed', ['error' => $e->getMessage()]);
}
// 34. v2 API paths (try each, skip HTML error pages)
$v2Paths = [
"/hotspot/op/private-preshared-key?wlanId={$wlanId}",
"/wlan/{$wlanId}/password",
"/wlan/{$wlanId}/private-preshared-key",
];
foreach ($v2Paths as $path) {
try {
$url = $this->v2SiteUrl($path);
$response = $this->buildRequest()->get($url);
Log::debug('unifi.ppsk_v2_probe', ['path' => $path, 'status' => $response->status(), 'ct' => $response->header('Content-Type')]);
if (! $this->isJsonResponse($response)) continue;
$data = $response->json('data') ?? $response->json() ?? [];
if (is_array($data) && ! empty($data)) {
Log::info('unifi.ppsk_found', ['path' => $path, 'count' => count($data)]);
return $this->normalizePpsk(array_values($data));
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_path_failed', ['path' => $path, 'error' => $e->getMessage()]);
}
}
// 5. Check if PPSKs are embedded in the WLAN object
try {
$wlan = $this->get("/rest/wlanconf/{$wlanId}");
$w = $wlan[0] ?? $wlan;
$embedded = $w['private_preshared_keys'] ?? [];
if (! empty($embedded)) {
Log::info('unifi.ppsk_found', ['path' => 'wlan_embedded', 'count' => count($embedded)]);
return $this->normalizePpsk($embedded);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_wlan_embedded_failed', ['error' => $e->getMessage()]);
}
Log::warning('unifi.ppsk_all_paths_empty', ['wlan_id' => $wlanId]);
// All paths exhausted — return empty rather than erroring (network may just have no PPSKs yet)
return [];
}
public function createPpsk(array $data): array
{
$wlanId = $data['wlan_id'] ?? null;
// Try v2 hotspot endpoint first
if ($wlanId) {
try {
$url = $this->v2SiteUrl('/hotspot/op/private-preshared-key');
$response = $this->buildRequest()->asJson()->post($url, $data);
if ($this->isJsonResponse($response)) {
return $this->normalizePpsk([$response->json('data') ?? $response->json()]);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_hotspot_create_failed', ['error' => $e->getMessage()]);
}
// Try v2 wlan password endpoint
try {
$url = $this->v2SiteUrl("/wlan/{$wlanId}/password");
$response = $this->buildRequest()->asJson()->post($url, $data);
if ($this->isJsonResponse($response)) {
$result = $response->json('data') ?? $response->json();
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_wlan_create_failed', ['error' => $e->getMessage()]);
}
}
// Fall back to classic REST
$result = $this->post('/rest/ppsk', $data);
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
}
public function updatePpsk(string $ppskId, array $data): array
{
// Try v2 hotspot endpoint first
try {
$url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}");
$response = $this->buildRequest()->asJson()->put($url, $data);
if ($this->isJsonResponse($response)) {
return $this->normalizePpsk([$response->json('data') ?? $response->json()]);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_hotspot_update_failed', ['error' => $e->getMessage()]);
}
$result = $this->put("/rest/ppsk/{$ppskId}", $data);
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
}
public function deletePpsk(string $ppskId): void
{
// Try v2 hotspot endpoint first
try {
$url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}");
$response = $this->buildRequest()->delete($url);
if ($response->successful()) return;
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_hotspot_delete_failed', ['error' => $e->getMessage()]);
}
$this->delete("/rest/ppsk/{$ppskId}");
}
/**
* Kick (deauth) every client currently connected via the given PPSK.
* UniFi station records include a _psk_id field matching the PPSK's _id.
* Returns the number of clients kicked.
*/
public function kickClientsForPpsk(string $ppskUnifiId): int
{
$kicked = 0;
try {
$clients = $this->get('/rest/sta');
foreach ($clients as $client) {
if (($client['_psk_id'] ?? null) !== $ppskUnifiId) continue;
try {
$this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => $client['mac']]);
$kicked++;
} catch (\Throwable $e) {
// continue kicking remaining clients even if one fails
}
}
} catch (\Throwable $e) {
// non-fatal: if we can't list clients, skip kicking
Log::debug('unifi.ppsk_kick_clients_failed', ['ppsk_id' => $ppskUnifiId, 'error' => $e->getMessage()]);
}
return $kicked;
}
// ── Health / Stats ────────────────────────────────────────────────────────
public function getSiteHealth(): array