Files
dashboard-unifi/src/Services/UnifiApiClient.php
jwed a4397c5178 chore: remove AP Groups surfaces (legacy API auth incompatible)
UniFi's /rest/apgroup endpoints (and per-SSID ap_group_ids writes via
/rest/wlanconf) require session-cookie auth — they don't accept the
X-API-Key header. The Integration API doesn't expose AP groups at all.
So with the current deployment running on API-key auth, every AP-group
operation returned 400 api.err.InvalidObject. Removing the dead code
rather than carrying a feature that can't function.

* Deleted ApGroupController, ApGroups.vue, the /ap-groups/* routes,
  and getApGroups/createApGroup/updateApGroup/deleteApGroup from
  UnifiApiClient.
* Removed the per-SSID AP-group assignment from Wifi.vue + the
  updateApGroups action + /wifi/{wlanId}/ap-groups route + the
  ap_group_ids field from the mapWlan output.
* Removed the AP Groups nav entry from composer.json.

If a future deploy adds local-admin username+password auth, AP groups
can be reintroduced — the UnifiApiClient::buildRequest() session-cookie
path is intact.

v1.3.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:35:32 -04:00

665 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Dashboard\Unifi\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class UnifiApiClient
{
private ?string $baseUrl = null;
private ?string $site = null;
private ?string $username = null;
private ?string $password = null;
private ?string $apiKey = null;
// Cached session cookies for cookie-based auth
private ?array $cookies = null;
// ── Connection ────────────────────────────────────────────────────────────
private function init(): void
{
if ($this->baseUrl) return;
$this->baseUrl = rtrim(Setting::get('unifi.controller_url', ''), '/');
$this->site = Setting::get('unifi.site', 'default');
$this->username = Setting::get('unifi.username', '');
$this->password = Setting::get('unifi.password', '');
$this->apiKey = Setting::get('unifi.api_key', '');
if (! $this->baseUrl) {
throw new \RuntimeException('UniFi controller not configured. Go to Unifi Network → Settings.');
}
if (! $this->username && ! $this->apiKey) {
throw new \RuntimeException('No credentials configured. Set either local account credentials or an API key in Unifi Network → Settings.');
}
}
/**
* Get session cookies, logging in if needed. Cached for 30 minutes.
*/
private function getSessionCookies(?string $overrideUrl = null, ?string $overrideUser = null, ?string $overridePass = null): array
{
$base = $overrideUrl ?? $this->baseUrl;
$user = $overrideUser ?? $this->username;
$pass = $overridePass ?? $this->password;
$cacheKey = 'unifi:session:' . md5($base . $user);
if (! $overrideUrl) {
$cached = Cache::get($cacheKey);
if ($cached) return $cached;
}
$loginUrl = "{$base}/api/auth/login";
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json',
])->withoutVerifying()->post($loginUrl, [
'username' => $user,
'password' => $pass,
]);
if (! $response->successful()) {
$msg = $response->json('message') ?? "HTTP {$response->status()}";
throw new \RuntimeException("UniFi login failed: {$msg}");
}
// Extract cookies from Set-Cookie headers
$cookies = [];
foreach ($response->cookies() as $cookie) {
$cookies[$cookie->getName()] = $cookie->getValue();
}
// Also capture the CSRF token
$csrf = $cookies['csrf_token'] ?? $response->header('X-CSRF-Token') ?? '';
$cookieData = ['cookies' => $cookies, 'csrf' => $csrf];
if (! $overrideUrl) {
Cache::put($cacheKey, $cookieData, 1800); // 30 minutes
}
return $cookieData;
}
private function buildRequest()
{
// Prefer cookie-based auth (local account) for full access
if ($this->username && $this->password) {
$session = $this->getSessionCookies();
$cookieStr = collect($session['cookies'])->map(fn ($v, $k) => "{$k}={$v}")->implode('; ');
return Http::withHeaders([
'Accept' => 'application/json',
'Cookie' => $cookieStr,
'X-CSRF-Token' => $session['csrf'],
])->withoutVerifying();
}
// Fallback to API key (read-only stats)
if ($this->apiKey) {
return Http::withHeaders([
'X-API-Key' => $this->apiKey,
'Accept' => 'application/json',
])->withoutVerifying();
}
throw new \RuntimeException('No credentials configured.');
}
/**
* Determine the API base path. UniFi OS consoles use /proxy/network/api/s/{site},
* standalone controllers use /api/s/{site}.
*/
private function apiPath(): string
{
$cacheKey = 'unifi:api_prefix:' . md5($this->baseUrl);
// Only cache if detection actually succeeds — don't cache a guess
$cached = Cache::get($cacheKey);
if ($cached) return $cached;
$paths = ['/proxy/network/api', '/api'];
foreach ($paths as $prefix) {
try {
$testUrl = "{$this->baseUrl}{$prefix}/s/{$this->site}/stat/sysinfo";
$response = $this->buildRequest()->timeout(10)->get($testUrl);
$ct = $response->header('Content-Type', '');
if ($response->successful() && str_contains($ct, 'json')) {
Log::debug('unifi.api_prefix_detected', ['prefix' => $prefix]);
Cache::put($cacheKey, $prefix, 3600);
return $prefix;
}
} catch (\Throwable $e) {
Log::debug('unifi.api_prefix_probe', ['prefix' => $prefix, 'error' => $e->getMessage()]);
}
}
// Default for modern UniFi OS hardware — but don't cache it so we retry next time
return '/proxy/network/api';
}
private function siteUrl(string $path): string
{
$this->init();
return "{$this->baseUrl}{$this->apiPath()}/s/{$this->site}{$path}";
}
private function get(string $path): array
{
$this->init();
$url = $this->siteUrl($path);
$response = $this->buildRequest()->get($url);
if ($response->status() === 401) {
// Session expired — clear cache and retry once
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
$response = $this->buildRequest()->get($url);
}
if (! $response->successful()) {
Log::warning('unifi.api_error', ['url' => $url, 'status' => $response->status()]);
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
}
return $response->json('data', []);
}
private function post(string $path, array $body = []): array
{
$this->init();
$url = $this->siteUrl($path);
$response = $this->buildRequest()->asJson()->post($url, $body);
if ($response->status() === 401) {
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
$response = $this->buildRequest()->asJson()->post($url, $body);
}
if (! $response->successful()) {
Log::warning('unifi.api_error', ['url' => $url, 'status' => $response->status(), 'body' => $body]);
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
}
return $response->json('data', []);
}
private function put(string $path, array $body = []): array
{
$this->init();
$url = $this->siteUrl($path);
$response = $this->buildRequest()->asJson()->put($url, $body);
if ($response->status() === 401) {
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
$response = $this->buildRequest()->asJson()->put($url, $body);
}
if (! $response->successful()) {
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
}
return $response->json('data', []);
}
// ── Devices / APs ─────────────────────────────────────────────────────────
public function getDevices(): array
{
return Cache::remember('unifi:devices', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
$this->get('/stat/device')
);
}
public function getAccessPoints(): array
{
return collect($this->getDevices())->where('type', 'uap')->values()->all();
}
public function getGateway(): ?array
{
return collect($this->getDevices())->firstWhere('type', 'ugw')
?? collect($this->getDevices())->firstWhere('type', 'udm')
?? null;
}
public function rebootDevice(string $mac): array
{
Cache::forget('unifi:devices');
return $this->post('/cmd/devmgr', ['cmd' => 'restart', 'mac' => strtolower($mac)]);
}
// ── Clients ───────────────────────────────────────────────────────────────
public function getActiveClients(): array
{
return Cache::remember('unifi:clients', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
$this->get('/stat/sta')
);
}
public function isClientConnected(string $mac): bool
{
$data = $this->get('/stat/sta/' . strtolower($mac));
return ! empty($data);
}
public function kickClient(string $mac): array
{
Cache::forget('unifi:clients');
return $this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => strtolower($mac)]);
}
public function blockClient(string $mac): array
{
return $this->post('/cmd/stamgr', ['cmd' => 'block-sta', 'mac' => strtolower($mac)]);
}
public function unblockClient(string $mac): array
{
return $this->post('/cmd/stamgr', ['cmd' => 'unblock-sta', 'mac' => strtolower($mac)]);
}
// ── Guest Portal ──────────────────────────────────────────────────────────
public function authorizeGuest(string $mac, int $minutes = 720, ?int $upKbps = null, ?int $downKbps = null): array
{
$body = ['cmd' => 'authorize-guest', 'mac' => strtolower($mac), 'minutes' => $minutes];
if ($upKbps) $body['up'] = $upKbps;
if ($downKbps) $body['down'] = $downKbps;
Cache::forget('unifi:clients');
return $this->post('/cmd/stamgr', $body);
}
public function unauthorizeGuest(string $mac): array
{
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
{
return $this->get('/rest/wlanconf');
}
public function updateWlan(string $wlanId, array $data): array
{
return $this->put("/rest/wlanconf/{$wlanId}", $data);
}
// ── 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
{
return Cache::remember('unifi:health', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
$this->get('/stat/health')
);
}
public function getEvents(int $limit = 100): array
{
return Cache::remember('unifi:events', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
$this->get("/stat/event?start=0&limit={$limit}")
);
}
public function getAlarms(): array
{
return $this->get('/stat/alarm');
}
public function getHistoricalStats(string $type, int $startEpochMs, int $endEpochMs, array $attrs, ?string $mac = null): array
{
$body = ['attrs' => $attrs, 'start' => $startEpochMs, 'end' => $endEpochMs];
if ($mac) $body['mac'] = strtolower($mac);
return $this->post("/stat/report/{$type}", $body);
}
// ── Connection Test ───────────────────────────────────────────────────────
public function testConnection(): array
{
return $this->get('/stat/sysinfo');
}
/**
* List all sites. Supports both cookie-based and API-key auth.
* Accepts override credentials for the settings page (before they're saved).
*/
public function getSites(?string $overrideUrl = null, ?string $overrideUser = null, ?string $overridePass = null, ?string $overrideKey = null): array
{
$base = $overrideUrl ? rtrim($overrideUrl, '/') : null;
if (! $base) {
$this->init();
$base = $this->baseUrl;
}
// Build the HTTP client with appropriate auth
if ($overrideUser && $overridePass) {
$session = $this->getSessionCookies($base, $overrideUser, $overridePass);
$cookieStr = collect($session['cookies'])->map(fn ($v, $k) => "{$k}={$v}")->implode('; ');
$http = Http::withHeaders([
'Accept' => 'application/json',
'Cookie' => $cookieStr,
'X-CSRF-Token' => $session['csrf'],
])->withoutVerifying();
} elseif ($overrideKey) {
$http = Http::withHeaders([
'X-API-Key' => $overrideKey,
'Accept' => 'application/json',
])->withoutVerifying();
} else {
$http = $this->buildRequest();
}
// Endpoints, in preferred order:
// 1. UniFi OS Integration API — the one X-API-Key keys are issued for
// (response shape: [{ id, internalReference, name }])
// 2. Legacy /proxy/network/api/self/sites — session-cookie API on
// UniFi OS consoles (response: [{ name, desc, ... }])
// 3. Legacy /api/self/sites — standalone controller (no /proxy prefix)
$paths = [
'/proxy/network/integration/v1/sites',
'/proxy/network/api/self/sites',
'/api/self/sites',
];
$lastError = '';
foreach ($paths as $path) {
$url = "{$base}{$path}";
try {
$response = $http->timeout(10)->get($url);
if ($response->successful()) {
$ct = $response->header('Content-Type', '');
if (str_contains($ct, 'text/html')) {
$lastError = "Got HTML on {$path} — not an API endpoint";
continue;
}
$data = $response->json('data', $response->json());
if (! is_array($data) || empty($data)) {
$lastError = "No sites in response from {$path}";
continue;
}
// Integration API rows have `internalReference` (== legacy
// site slug) and `name` (human-readable). Normalize to
// the legacy {name, desc} shape so downstream code that
// builds URLs with the site slug keeps working.
if (isset($data[0]['internalReference'])) {
$data = array_map(fn ($s) => [
'name' => $s['internalReference'] ?? 'default',
'desc' => $s['name'] ?? $s['internalReference'] ?? 'Default',
], $data);
}
if (isset($data[0]['name'])) {
Log::debug('unifi.sites_found', ['path' => $path, 'count' => count($data)]);
return $data;
}
$lastError = "Unexpected response shape from {$path}";
} else {
$lastError = "HTTP {$response->status()} on {$path}";
}
} catch (\Throwable $e) {
$lastError = "{$path}: {$e->getMessage()}";
}
}
throw new \RuntimeException("Could not list sites. {$lastError}");
}
}