Files
dashboard-unifi/src/Services/UnifiApiClient.php
jwed 720e94c54a fix(banded ssid): match embedded PPSK by name first, passphrase fallback
The sibling-update path on prod failed with "Embedded PPSK not found
by current passphrase" because the DB-stored x_passphrase on the
unedited band was stale — earlier manual edits (pre-1.8.1) only
touched one band, leaving the other band's row out of sync. When
rotation then tried to use that stale passphrase to find the entry,
no match.

updateEmbeddedPpsk now takes an optional $name parameter and tries it
first. PPSK names within a WLAN are unique, so name-matching survives
any passphrase drift caused by historical out-of-band edits.
Passphrase matching stays as a fallback for callers that don't have
a name (none currently — both rotation and the manual modal pass it).

v1.9.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:38:10 -04:00

780 lines
30 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);
}
/**
* Find sibling WLAN configs — same SSID name, different _id. UniFi
* splits a "banded" SSID (band-steering disabled) into one wlanconf
* per band, each with its own _id and its own embedded PPSK array.
* A rotation that updates one band must also update the others, or
* the SSID's two halves drift out of sync.
*
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
* Empty array if the target WLAN is unique or can't be found.
*/
public function getWlanSiblings(string $wlanId): array
{
try {
$all = $this->get('/rest/wlanconf');
} catch (\Throwable) {
return [];
}
$target = null;
foreach ($all as $w) {
if (($w['_id'] ?? null) === $wlanId) { $target = $w; break; }
}
if (! $target || empty($target['name'])) return [];
$siblings = [];
foreach ($all as $w) {
if (($w['_id'] ?? null) === $wlanId) continue;
if (($w['name'] ?? null) === $target['name']) {
$siblings[] = $w['_id'];
}
}
return $siblings;
}
// ── 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]);
}
/**
* Update an embedded PPSK (one that lives inside a WLAN's
* private_preshared_keys array rather than as its own REST resource).
*
* Matching is done by current passphrase since embedded entries have
* no controller-side ID. Only changes the entry's passphrase; name
* isn't separately addressable on embedded PPSKs.
*/
public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array
{
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
$wlan = $wlanResp[0] ?? $wlanResp;
$entries = $wlan['private_preshared_keys'] ?? [];
if (! is_array($entries) || empty($entries)) {
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
}
// Match in this order — most reliable first:
// 1. by PPSK name (if provided) — survives passphrase drift
// caused by manual edits or previous out-of-sync rotations.
// 2. by current passphrase (legacy)
$applyUpdate = function (array &$e) use ($newPassphrase) {
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
$e['password'] = $newPassphrase;
}
};
$matched = false;
if ($name !== null && $name !== '') {
foreach ($entries as &$e) {
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
if ($entryName === $name) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched && $oldPassphrase !== null && $oldPassphrase !== '') {
foreach ($entries as &$e) {
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
if ($current === $oldPassphrase) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched) {
throw new \RuntimeException(
'Embedded PPSK not found' .
($name !== null ? " by name \"{$name}\"" : '') .
' or by current passphrase.'
);
}
// UniFi REST expects the full WLAN object on PUT — send what we
// got back, with the mutated PPSK array.
$payload = $wlan;
$payload['private_preshared_keys'] = $entries;
// Strip internal fields the controller rejects on PUT.
unset($payload['_id'], $payload['site_id']);
$this->put("/rest/wlanconf/{$wlanId}", $payload);
// Return a normalized record so callers can read the new state.
return $this->normalizePpsk([[
'_id' => 'emb_' . substr(hash('sha256', $wlanId . ':' . $newPassphrase), 0, 32),
'wlan_id' => $wlanId,
'x_passphrase' => $newPassphrase,
]]);
}
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}");
}
}