feat: initial commit — UniFi snap-in package
Full UniFi dashboard snap-in including: - WiFi/client/device stats with time-series snapshots - Client Dashboard with traffic, satisfaction, signal, download charts - Webhook alerting with debounced offline/online detection - AP snapshot collection, client snapshot collection - Device classification (type and OS) from OUI/hostname heuristics - Webhook cooldown, templates, and multi-platform delivery Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
403
src/Services/UnifiApiClient.php
Normal file
403
src/Services/UnifiApiClient.php
Normal file
@@ -0,0 +1,403 @@
|
||||
<?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)]);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
|
||||
// Try multiple URL patterns
|
||||
$paths = [
|
||||
'/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) && isset($data[0]['name'])) {
|
||||
Log::debug('unifi.sites_found', ['path' => $path, 'count' => count($data)]);
|
||||
return $data;
|
||||
}
|
||||
$lastError = "No sites in response from {$path}";
|
||||
} else {
|
||||
$lastError = "HTTP {$response->status()} on {$path}";
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$lastError = "{$path}: {$e->getMessage()}";
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Could not list sites. {$lastError}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user