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()]); } // 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 { 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}"); } }