Files
dashboard-unifi/src/Services/UnifiApiClient.php
jwed f533208b37 feat(grouped wifi): route updates through user-defined SSID groups + verify
User-defined SSID groups (configured on the WiFi Networks page and
stored in unifi.ssid_groups) now drive PPSK sibling propagation. The
previous same-SSID-name detection missed cases where two grouped
WLANs have *different* names — e.g. "VCS Guest" on 2.4 and "VCS
Guest 5G" on 5GHz manually grouped by the operator. Falls back to
same-name siblings when no group is configured.

Match-by-name fix: embedded PPSKs on this controller don't carry a
name field — the human "GUEST" label is the *network's* name, with
the entry referenced via networkconf_id. updateEmbeddedPpsk and
verifyEmbeddedPpsk now resolve name → networkconf_id first and match
on that, with entry-name and current-passphrase as fallbacks for
other controller variants.

After every rotation we re-fetch each affected WLAN and verify the
new passphrase is actually present on the named network. Failures
("mismatch" or "fetch_failed" on the primary, anything other than
"not_found" on a sibling) surface in the cron run details as failed
PPSKs so the operator sees what didn't propagate.

v1.10.4.

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

878 lines
34 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 every other WLAN that should rotate/update together with this
* one. Authoritative source: the user-defined "SSID groups" setting
* (unifi.ssid_groups) from the WiFi Networks page, which lets the
* operator manually couple WLANs that may have different SSID names.
*
* Falls back to same-SSID-name siblings for installs that haven't
* configured groups yet.
*
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
*/
public function getGroupedWlans(string $wlanId): array
{
$groupsJson = Setting::get('unifi.ssid_groups', '{}');
$groups = json_decode($groupsJson, true);
if (is_array($groups)) {
foreach ($groups as $wlanIds) {
if (! is_array($wlanIds)) continue;
if (in_array($wlanId, $wlanIds, true)) {
return array_values(array_filter($wlanIds, fn ($id) => $id !== $wlanId));
}
}
}
return $this->getWlanSiblings($wlanId);
}
/**
* Verify an embedded PPSK has the expected passphrase right now.
* Used after an update to confirm the change actually applied —
* UniFi sometimes 200s an update that didn't stick (cluster sync
* race, hot-restart in progress, etc.).
*
* Returns ['ok' => true] on a clean match, or
* ['ok' => false, 'reason' => 'fetch_failed'|'not_found'|'mismatch']
* with optional 'error' on fetch failures.
*/
public function verifyEmbeddedPpsk(string $wlanId, string $name, string $expectedPassphrase): array
{
try {
$entries = $this->getPpskEntries($wlanId);
} catch (\Throwable $e) {
return ['ok' => false, 'reason' => 'fetch_failed', 'error' => $e->getMessage()];
}
$networkconfId = $this->findNetworkconfIdByName($name);
foreach ($entries as $e) {
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
$entryNetId = $e['networkconf_id'] ?? null;
$entryMatches = ($networkconfId !== null && $entryNetId === $networkconfId)
|| ($entryName !== null && $entryName === $name);
if (! $entryMatches) continue;
$entryPass = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
return $entryPass === $expectedPassphrase
? ['ok' => true]
: ['ok' => false, 'reason' => 'mismatch'];
}
return ['ok' => false, 'reason' => 'not_found'];
}
/**
* Look up a networkconf (VLAN/network) by its display name. Embedded
* PPSKs on this controller use networkconf_id as their stable
* identifier — the human "name" the operator sees is actually the
* network's name.
*/
private function findNetworkconfIdByName(string $name): ?string
{
try {
$networks = $this->getNetworkConfs();
} catch (\Throwable) {
return null;
}
foreach ($networks as $n) {
if (($n['name'] ?? null) === $name) {
return $n['_id'] ?? null;
}
}
return null;
}
/**
* 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.');
}
// Embedded PPSKs on this controller don't carry a name field —
// the human label ("GUEST", "3DPrinters", …) is the *network's*
// name, and each entry references it via networkconf_id. So when
// the caller passes a name, first resolve it to a networkconf_id
// and match on that. Falls back to entry-level name (other
// controller versions DO put a name on the entry) and finally
// to current passphrase.
$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;
}
};
$networkconfId = ($name !== null && $name !== '') ? $this->findNetworkconfIdByName($name) : null;
$matched = false;
if ($networkconfId !== null) {
foreach ($entries as &$e) {
if (($e['networkconf_id'] ?? null) === $networkconfId) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched && $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 ? " for network \"{$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}");
}
}