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:
@@ -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()]);
|
||||
}
|
||||
|
||||
// 3–4. 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
|
||||
|
||||
Reference in New Issue
Block a user