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>
878 lines
34 KiB
PHP
878 lines
34 KiB
PHP
<?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()]);
|
||
}
|
||
|
||
// 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]);
|
||
}
|
||
|
||
/**
|
||
* 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}");
|
||
}
|
||
}
|