feat: password rotation, PPSK management, VLAN/AP groups

- Add password rotation: RotatePasswords console command + migration + service updates
- Add PPSK management: UnifiPpsk model, migration, SyncPpskSchedules console
- Add VLAN groups and AP groups: VlanGroupController, ApGroupController, model, migration
- Add RebootAllAps console command
- Add in_alert column to device states
- Wire new features through service provider, routes, and existing controllers/services

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 17:54:24 -04:00
parent ce3217d8f4
commit 0802ef35f3
22 changed files with 1771 additions and 305 deletions

View File

@@ -0,0 +1,88 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Inertia\Inertia;
class ApGroupController extends Controller
{
public function index(UnifiApiClient $unifi)
{
try {
$groups = collect($unifi->getApGroups())->map(fn ($g) => [
'id' => $g['_id'],
'name' => $g['name'] ?? 'Unnamed',
'device_macs' => $g['device_macs'] ?? [],
'is_default' => $g['attr_no_delete'] ?? false,
])->values();
$devices = collect($unifi->getAccessPoints())->map(fn ($d) => [
'mac' => strtolower($d['mac']),
'name' => $d['name'] ?? $d['model'] ?? $d['mac'],
'model' => $d['model'] ?? '',
'state' => $d['state'] ?? 0,
])->values();
return Inertia::render('Unifi/ApGroups', [
'groups' => $groups,
'devices' => $devices,
]);
} catch (\Throwable $e) {
return Inertia::render('Unifi/ApGroups', [
'groups' => [], 'devices' => [], 'error' => $e->getMessage(),
]);
}
}
public function store(Request $request, UnifiApiClient $unifi)
{
$data = $request->validate([
'name' => 'required|string|max:100',
'device_macs' => 'present|array',
'device_macs.*' => 'string',
]);
try {
$result = $unifi->createApGroup([
'name' => $data['name'],
'device_macs' => array_values(array_map('strtolower', $data['device_macs'])),
]);
return back()->with('success', 'AP group created.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function update(Request $request, string $groupId, UnifiApiClient $unifi)
{
$data = $request->validate([
'name' => 'sometimes|string|max:100',
'device_macs' => 'sometimes|array',
'device_macs.*' => 'string',
]);
if (isset($data['device_macs'])) {
$data['device_macs'] = array_values(array_map('strtolower', $data['device_macs']));
}
try {
$unifi->updateApGroup($groupId, $data);
return back()->with('success', 'AP group updated.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function destroy(string $groupId, UnifiApiClient $unifi)
{
try {
$unifi->deleteApGroup($groupId);
return back()->with('success', 'AP group deleted.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
}

View File

@@ -4,6 +4,7 @@ namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Models\KnownMac;
use Dashboard\Unifi\Models\PortalSession;
use Dashboard\Unifi\Models\VlanGroup;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@@ -11,7 +12,7 @@ use Inertia\Inertia;
class ClientController extends Controller
{
public function index(UnifiApiClient $unifi)
public function index(Request $request, UnifiApiClient $unifi)
{
try {
$clients = collect($unifi->getActiveClients())->map(fn ($c) => [
@@ -26,7 +27,10 @@ class ClientController extends Controller
'is_wired' => $c['is_wired'] ?? false,
'is_guest' => $c['is_guest'] ?? false,
'ssid' => $c['essid'] ?? null,
'network' => $c['network'] ?? null,
'ap_mac' => $c['ap_mac'] ?? null,
'sw_mac' => $c['sw_mac'] ?? null,
'sw_port' => $c['sw_port'] ?? null,
'rssi' => $c['rssi'] ?? null,
'signal' => $c['signal'] ?? null,
'channel' => $c['channel'] ?? null,
@@ -34,12 +38,32 @@ class ClientController extends Controller
'rx_bytes' => $c['rx_bytes'] ?? 0,
'tx_rate' => $c['tx_rate'] ?? 0,
'rx_rate' => $c['rx_rate'] ?? 0,
'tx_rate_r' => $c['tx_bytes-r'] ?? 0,
'rx_rate_r' => $c['rx_bytes-r'] ?? 0,
'uptime' => $c['uptime'] ?? 0,
'satisfaction' => $c['satisfaction'] ?? null,
'vlan_id' => ($c['vlan_id'] ?? 0) ?: null,
'dot1x' => $c['1x_identity'] ?? $c['dot1x_identity'] ?? null,
'is_known' => KnownMac::where('mac_address', strtolower($c['mac']))->exists(),
])->values();
return Inertia::render('Unifi/Clients', ['clients' => $clients]);
// APs and switches for the device filter dropdown
$devices = collect($unifi->getDevices())
->filter(fn ($d) => in_array($d['type'] ?? '', ['uap', 'usw']))
->map(fn ($d) => [
'mac' => $d['mac'],
'name' => $d['name'] ?? $d['model'] ?? $d['mac'],
'type' => $d['type'],
])
->sortBy('name')
->values();
return Inertia::render('Unifi/Clients', [
'clients' => $clients,
'vlanGroups' => VlanGroup::orderBy('sort_order')->get(),
'devices' => $devices,
'selectedDevice' => $request->query('device'),
]);
} catch (\Throwable $e) {
return Inertia::render('Unifi/Clients', ['clients' => [], 'error' => $e->getMessage()]);
}

View File

@@ -5,6 +5,7 @@ namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
class DeviceController extends Controller
@@ -12,29 +13,49 @@ class DeviceController extends Controller
public function index(UnifiApiClient $unifi)
{
try {
$devices = collect($unifi->getDevices())->map(fn ($d) => [
'mac' => $d['mac'],
'name' => $d['name'] ?? $d['model'] ?? 'Unknown',
'model' => $d['model'] ?? '',
'type' => $d['type'] ?? '',
'ip' => $d['ip'] ?? '',
'version' => $d['version'] ?? '',
'state' => $d['state'] ?? 0,
'adopted' => $d['adopted'] ?? false,
'upgradable' => $d['upgradable'] ?? false,
'uptime' => $d['uptime'] ?? 0,
'num_sta' => $d['num_sta'] ?? 0,
'tx_bytes' => $d['tx_bytes'] ?? 0,
'rx_bytes' => $d['rx_bytes'] ?? 0,
'cpu' => $d['system-stats']['cpu'] ?? null,
'mem' => $d['system-stats']['mem'] ?? null,
'satisfaction' => $d['satisfaction'] ?? null,
'channels' => collect($d['radio_table'] ?? [])->map(fn ($r) => [
'radio' => $r['radio'] ?? '',
'channel' => $r['channel'] ?? null,
'ht' => $r['ht'] ?? '',
])->values(),
])->values();
$devices = collect($unifi->getDevices())->map(function ($d) {
// radio_table_stats has actual live channel + per-radio client counts + rates
$radioStats = collect($d['radio_table_stats'] ?? [])->keyBy('name');
// Device-level throughput: prefer device field, fall back to sum of radio stats
$txRate = ($d['tx_bytes-r'] ?? 0) ?: $radioStats->sum(fn ($r) => $r['tx_bytes-r'] ?? 0);
$rxRate = ($d['rx_bytes-r'] ?? 0) ?: $radioStats->sum(fn ($r) => $r['rx_bytes-r'] ?? 0);
return [
'mac' => $d['mac'],
'name' => $d['name'] ?? $d['model'] ?? 'Unknown',
'model' => $d['model'] ?? '',
'type' => $d['type'] ?? '',
'ip' => $d['ip'] ?? '',
'version' => $d['version'] ?? '',
'state' => $d['state'] ?? 0,
'adopted' => $d['adopted'] ?? false,
'upgradable' => $d['upgradable'] ?? false,
'uptime' => $d['uptime'] ?? 0,
'num_sta' => $d['num_sta'] ?? 0,
'tx_bytes' => $d['tx_bytes'] ?? 0,
'rx_bytes' => $d['rx_bytes'] ?? 0,
'tx_rate' => $txRate,
'rx_rate' => $rxRate,
'cpu' => $d['system-stats']['cpu'] ?? null,
'mem' => $d['system-stats']['mem'] ?? null,
'satisfaction' => $d['satisfaction'] ?? null,
// Use radio_table_stats for actual channel (not 'auto' from config),
// per-radio client count, and per-radio rates.
'channels' => collect($d['radio_table'] ?? [])->map(function ($r) use ($radioStats) {
$stats = $radioStats->get($r['name'] ?? '');
// stats['channel'] is the real channel in use; 0 = not broadcasting
$channel = $stats ? (($stats['channel'] ?? 0) ?: null) : null;
return [
'radio' => $r['radio'] ?? '',
'channel' => $channel,
'num_sta' => $stats['num_sta'] ?? 0,
'tx_rate' => $stats ? ($stats['tx_bytes-r'] ?? 0) : 0,
'rx_rate' => $stats ? ($stats['rx_bytes-r'] ?? 0) : 0,
];
})->values(),
];
})->values();
return Inertia::render('Unifi/Devices', ['devices' => $devices]);
} catch (\Throwable $e) {
@@ -47,6 +68,9 @@ class DeviceController extends Controller
$request->validate(['mac' => 'required|string|regex:/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i']);
try {
// Suppress offline/online webhook alerts for planned reboots (15-minute window)
Cache::put('unifi:planned_reboot:' . strtolower($request->mac), true, now()->addMinutes(15));
$unifi->rebootDevice($request->mac);
return back()->with('success', 'Reboot command sent.');
} catch (\Throwable $e) {

View File

@@ -4,6 +4,7 @@ namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Models\KnownMac;
use Dashboard\Unifi\Models\PortalSession;
use Dashboard\Unifi\Models\VlanGroup;
use Dashboard\Unifi\Models\VlanMapping;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
@@ -17,6 +18,7 @@ class PortalController extends Controller
public function settings()
{
return Inertia::render('Unifi/Portal', [
'vlanGroups' => VlanGroup::orderBy('sort_order')->get(),
'vlanMappings' => VlanMapping::orderBy('sort_order')->get(),
'knownMacs' => KnownMac::orderBy('device_name')->paginate(50),
'activeSessions' => PortalSession::where('is_active', true)

View File

@@ -8,6 +8,7 @@ use Dashboard\Unifi\Models\ClientSnapshot;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
class StatsController extends Controller
@@ -65,9 +66,9 @@ class StatsController extends Controller
try {
$health = $unifi->getSiteHealth();
$allAps = $unifi->getAccessPoints();
$allAps = Cache::remember('unifi_access_points', 30, fn () => $unifi->getAccessPoints());
$aps = empty($apFilter) ? $allAps : array_values(array_filter($allAps, fn ($a) => in_array($a['mac'], $apFilter)));
$clients = $unifi->getActiveClients();
$clients = Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients());
$ssidGroups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true);
if (! is_array($ssidGroups) || array_is_list($ssidGroups)) $ssidGroups = [];
@@ -173,9 +174,9 @@ class StatsController extends Controller
}
try {
// Current snapshot for categorical counts (device type / OS) — use live API
$clients = collect($unifi->getActiveClients());
$aps = collect($unifi->getAccessPoints());
// Current snapshot for categorical counts (device type / OS) — use live/cached API
$clients = collect(Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients()));
$aps = collect(Cache::remember('unifi_access_points', 30, fn () => $unifi->getAccessPoints()));
$devCatCounts = $clients->map(fn ($c) => ['name' => $this->classifyDeviceType($c)])
->groupBy('name')
@@ -208,6 +209,9 @@ class StatsController extends Controller
'ap_mac' => $apMac,
'ap_name' => $apNames[$apMac] ?? (($c['is_wired'] ?? false) ? 'Wired' : 'Unknown'),
'is_wired' => (bool) ($c['is_wired'] ?? false),
'ssid' => $c['essid'] ?? null,
'vlan_id' => ($c['vlan_id'] ?? 0) ?: null,
'dot1x' => $c['1x_identity'] ?? $c['dot1x_identity'] ?? null,
'tx_rate' => (int) ($c['tx_rate'] ?? 0),
'rx_rate' => (int) ($c['rx_rate'] ?? 0),
'tx_bytes' => (int) ($c['tx_bytes'] ?? 0),
@@ -215,7 +219,11 @@ class StatsController extends Controller
];
})->sortByDesc(fn ($c) => $c['rx_rate'] + $c['tx_rate'])->values();
$series = $this->buildClientSeries($startDt, $endDt);
$rangeKey = $range === 'interval'
? "interval_{$startDt->timestamp}_{$endDt->timestamp}"
: $range;
$series = Cache::remember("unifi_client_series_{$rangeKey}", 45,
fn () => $this->buildClientSeries($startDt, $endDt));
return Inertia::render('Unifi/ClientDashboard', [
'devCatCounts' => $devCatCounts,
@@ -228,11 +236,10 @@ class StatsController extends Controller
'clientDownloadMb' => $series['download_mb'],
'totalDownloadBytes' => $series['total_download_bytes'],
'totalUploadBytes' => $series['total_upload_bytes'],
'downloadSeries' => $series['download_series'],
'uploadSeries' => $series['upload_series'],
'activeClientCount' => $clients->count(),
'apList' => $apList,
'clientList' => $clientList,
'vlanGroups' => \Dashboard\Unifi\Models\VlanGroup::orderBy('sort_order')->get(),
'range' => $range,
'ranges' => array_keys(self::RANGE_MAP),
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
@@ -246,6 +253,58 @@ class StatsController extends Controller
}
}
/**
* AJAX — Traffic time-series for clients currently on a given AP.
* Reads from the cached series (built by clientDashboard), so it's fast.
*/
public function clientApTraffic(Request $request, UnifiApiClient $unifi): \Illuminate\Http\JsonResponse
{
$apMac = strtolower(trim($request->get('ap', '')));
$range = $request->get('range', '4h');
if ($range === 'interval' && $request->get('start') && $request->get('end')) {
$startDt = \Carbon\Carbon::parse($request->get('start'));
$endDt = \Carbon\Carbon::parse($request->get('end'));
} else {
if (! isset(self::RANGE_MAP[$range])) $range = '4h';
$endDt = now();
$startDt = $endDt->copy()->subMinutes(self::RANGE_MAP[$range]);
}
try {
// Which client MACs are currently on this AP?
$clients = collect(Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients()));
$macsOnAp = $clients
->filter(fn ($c) => strtolower($c['ap_mac'] ?? '') === $apMac)
->pluck('mac')
->map(fn ($m) => strtolower($m))
->flip() // flip to key => true for O(1) lookup
->all();
// Fetch (or warm) the cached series
$rangeKey = $range === 'interval'
? "interval_{$startDt->timestamp}_{$endDt->timestamp}"
: $range;
$series = Cache::remember("unifi_client_series_{$rangeKey}", 45,
fn () => $this->buildClientSeries($startDt, $endDt));
$rx = collect($series['traffic_rx'])
->filter(fn ($s) => isset($macsOnAp[strtolower($s['mac'] ?? '')]))
->values();
$tx = collect($series['traffic_tx'])
->filter(fn ($s) => isset($macsOnAp[strtolower($s['mac'] ?? '')]))
->values();
return response()->json([
'labels' => $series['labels'],
'traffic_rx' => $rx,
'traffic_tx' => $tx,
]);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
/**
* Device TYPE = manufacturer + model (when detectable).
*
@@ -438,7 +497,8 @@ class StatsController extends Controller
$labels = $times->map(fn ($t) => $t * 1000);
$snapshots = ClientSnapshot::whereIn(\Illuminate\Support\Facades\DB::raw('UNIX_TIMESTAMP(captured_at)'), $times->all())
$snapshots = ClientSnapshot::whereBetween('captured_at', [$start, $end])
->whereIn(\Illuminate\Support\Facades\DB::raw('UNIX_TIMESTAMP(captured_at)'), $times->all())
->orderBy('captured_at')
->get();
@@ -562,12 +622,12 @@ class StatsController extends Controller
$uploadSeries = $times->map(fn ($t) => (int) ($txByTs[$t] ?? 0))->values()->all();
return [
'labels' => $labels,
'traffic_rx' => $trafficRx,
'traffic_tx' => $trafficTx,
'satisfaction' => $satisfaction,
'signal' => $signal,
'download_mb' => $downloadMb,
'labels' => $labels->values()->all(),
'traffic_rx' => $trafficRx->values()->all(),
'traffic_tx' => $trafficTx->values()->all(),
'satisfaction' => $satisfaction->values()->all(),
'signal' => $signal->values()->all(),
'download_mb' => $downloadMb->values()->all(),
'total_download_bytes' => $totalDownload,
'total_upload_bytes' => $totalUpload,
'download_series' => $downloadSeries,

View File

@@ -13,52 +13,79 @@ class UnifiSettingsController extends Controller
public function edit()
{
return Inertia::render('Unifi/Settings', [
'controllerUrl' => Setting::get('unifi.controller_url', ''),
'username' => Setting::get('unifi.username', ''),
'hasPassword' => (bool) Setting::get('unifi.password'),
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
'site' => Setting::get('unifi.site', 'default'),
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30),
'retentionDays' => (int) Setting::get('unifi.retention_days', 30),
'controllerUrl' => Setting::get('unifi.controller_url', ''),
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
'site' => Setting::get('unifi.site', 'default'),
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30),
'retentionDays' => (int) Setting::get('unifi.retention_days', 30),
'timezone' => Setting::get('unifi.timezone', 'UTC'),
'autoRebootEnabled' => (bool) Setting::get('unifi.auto_reboot.enabled', false),
'autoRebootFrequency' => Setting::get('unifi.auto_reboot.frequency', 'daily'),
'autoRebootDow' => (int) Setting::get('unifi.auto_reboot.day_of_week', 0),
'autoRebootHour' => (int) Setting::get('unifi.auto_reboot.hour', 2),
'autoRebootMinute' => (int) Setting::get('unifi.auto_reboot.minute', 0),
'rotationEnabled' => (bool) Setting::get('unifi.password_rotation.enabled', false),
'rotationFrequency' => Setting::get('unifi.password_rotation.frequency', 'weekly'),
'rotationDow' => (int) Setting::get('unifi.password_rotation.day_of_week', 0),
'rotationHour' => (int) Setting::get('unifi.password_rotation.hour', 2),
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
]);
}
public function update(Request $request)
{
$request->validate([
'controller_url' => 'required|url|max:500',
'username' => 'nullable|string|max:255',
'password' => 'nullable|string|max:255',
'api_key' => 'nullable|string|max:500',
'site' => 'required|string|max:100',
'poll_interval' => 'nullable|integer|min:5|max:300',
'cache_ttl' => 'nullable|integer|min:5|max:300',
'retention_days' => 'nullable|integer|min:1|max:365',
'controller_url' => 'required|url|max:500',
'api_key' => 'nullable|string|max:500',
'site' => 'required|string|max:100',
'poll_interval' => 'nullable|integer|min:5|max:300',
'cache_ttl' => 'nullable|integer|min:5|max:300',
'retention_days' => 'nullable|integer|min:1|max:365',
'timezone' => 'nullable|string|timezone',
'auto_reboot_enabled' => 'boolean',
'auto_reboot_frequency' => 'in:daily,weekly',
'auto_reboot_dow' => 'nullable|integer|min:0|max:6',
'auto_reboot_hour' => 'nullable|integer|min:0|max:23',
'auto_reboot_minute' => 'nullable|integer|min:0|max:59',
'rotation_enabled' => 'boolean',
'rotation_frequency' => 'in:daily,weekly',
'rotation_dow' => 'nullable|integer|min:0|max:6',
'rotation_hour' => 'nullable|integer|min:0|max:23',
'rotation_minute' => 'nullable|integer|min:0|max:59',
'rotation_wordlist' => 'nullable|string|max:20000',
'ppsk_scheduling_enabled' => 'boolean',
]);
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
Setting::set('unifi.site', $request->site);
// Save the chosen auth method and clear the other
Setting::set('unifi.username', $request->username ?? '');
if ($request->password && $request->password !== '••••••••') {
Setting::set('unifi.password', $request->password);
} elseif (! $request->username) {
Setting::set('unifi.password', ''); // clear password when switching to API key mode
}
if ($request->api_key && $request->api_key !== '••••••••') {
Setting::set('unifi.api_key', $request->api_key);
} elseif ($request->username) {
Setting::set('unifi.api_key', ''); // clear API key when switching to local account mode
}
if ($request->has('poll_interval')) Setting::set('unifi.poll_interval', $request->poll_interval ?? 30);
if ($request->has('cache_ttl')) Setting::set('unifi.cache_ttl', $request->cache_ttl ?? 30);
if ($request->has('cache_ttl')) Setting::set('unifi.cache_ttl', $request->cache_ttl ?? 30);
if ($request->has('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30);
if ($request->has('timezone')) Setting::set('unifi.timezone', $request->timezone ?? 'UTC');
Setting::set('unifi.auto_reboot.enabled', $request->boolean('auto_reboot_enabled') ? '1' : '');
Setting::set('unifi.auto_reboot.frequency', $request->input('auto_reboot_frequency', 'daily'));
Setting::set('unifi.auto_reboot.day_of_week',$request->input('auto_reboot_dow', 0));
Setting::set('unifi.auto_reboot.hour', $request->input('auto_reboot_hour', 2));
Setting::set('unifi.auto_reboot.minute', $request->input('auto_reboot_minute', 0));
Setting::set('unifi.password_rotation.enabled', $request->boolean('rotation_enabled') ? '1' : '');
Setting::set('unifi.password_rotation.frequency', $request->input('rotation_frequency', 'weekly'));
Setting::set('unifi.password_rotation.day_of_week', $request->input('rotation_dow', 0));
Setting::set('unifi.password_rotation.hour', $request->input('rotation_hour', 2));
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
// Clear cached sessions so new credentials take effect
\Illuminate\Support\Facades\Cache::forget('unifi:session:' . md5(rtrim($request->controller_url, '/') . $request->username));
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
return back()->with('success', 'UniFi settings saved.');
@@ -103,12 +130,10 @@ class UnifiSettingsController extends Controller
$hint = "Tried URL: {$url}. ";
if (str_contains($url, 'unifi.ui.com')) {
$hint .= "Use your console's direct URL (*.id.ui.direct or local IP) instead of unifi.ui.com.";
} elseif (! $user && ! $key) {
$hint .= "Enter either a local account username/password or an API key.";
} elseif (! $key) {
$hint .= "Enter an API key above.";
} else {
$hint .= $user
? "Check that the local account credentials are correct."
: "The API key may be read-only. Try using a local admin account instead.";
$hint .= "Check that the API key is correct and the controller URL is reachable.";
}
return response()->json(['ok' => false, 'error' => $e->getMessage(), 'hint' => $hint], 422);

View File

@@ -0,0 +1,39 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Models\VlanGroup;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class VlanGroupController extends Controller
{
public function store(Request $request)
{
$data = $request->validate([
'name' => 'required|string|max:100',
'vlan_id' => 'required|integer|min:1|max:4094',
'description' => 'nullable|string|max:255',
]);
$data['sort_order'] = VlanGroup::max('sort_order') + 1;
VlanGroup::create($data);
return back()->with('success', 'VLAN group added.');
}
public function update(Request $request, VlanGroup $vlanGroup)
{
$data = $request->validate([
'name' => 'required|string|max:100',
'vlan_id' => 'required|integer|min:1|max:4094',
'description' => 'nullable|string|max:255',
]);
$vlanGroup->update($data);
return back()->with('success', 'VLAN group updated.');
}
public function destroy(VlanGroup $vlanGroup)
{
$vlanGroup->delete();
return back()->with('success', 'VLAN group deleted.');
}
}

View File

@@ -3,6 +3,7 @@
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@@ -13,56 +14,82 @@ class WifiController extends Controller
public function index(UnifiApiClient $unifi)
{
try {
$wlans = collect($unifi->getWlans())->map(fn ($w) => [
'id' => $w['_id'],
'name' => $w['name'],
'enabled' => $w['enabled'] ?? true,
'security' => $w['security'] ?? 'open',
'wpa_mode' => $w['wpa_mode'] ?? '',
'is_guest' => $w['is_guest'] ?? false,
'vlan_enabled' => $w['vlan_enabled'] ?? false,
'vlan' => $w['vlan'] ?? null,
'hide_ssid' => $w['hide_ssid'] ?? false,
'passphrase' => $w['x_passphrase'] ?? '',
'band' => $this->detectBand($w),
])->values();
$wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values();
// Load saved groups: { "Staff": ["id1", "id2"], ... }
$raw = Setting::get('unifi.ssid_groups', '{}');
try {
$apGroups = collect($unifi->getApGroups())->map(fn ($g) => [
'id' => $g['_id'],
'name' => $g['attr_no_delete'] ?? false ? 'Default' : ($g['name'] ?? 'Unnamed'),
'device_macs' => $g['device_macs'] ?? [],
'is_default' => $g['attr_no_delete'] ?? false,
])->values();
} catch (\Throwable $e) {
$apGroups = collect(); // AP groups not supported by this controller
}
$raw = Setting::get('unifi.ssid_groups', '{}');
$groups = json_decode($raw, true);
if (! is_array($groups) || array_is_list($groups)) $groups = [];
// Force object cast so Vue gets {} not []
$groups = (object) $groups;
$rotateWlanIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: [];
return Inertia::render('Unifi/Wifi', [
'wlans' => $wlans,
'groups' => $groups,
'wlans' => $wlans,
'groups' => $groups,
'apGroups' => $apGroups,
'rotateWlanIds' => $rotateWlanIds,
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
]);
} catch (\Throwable $e) {
return Inertia::render('Unifi/Wifi', ['wlans' => [], 'groups' => [], 'error' => $e->getMessage()]);
return Inertia::render('Unifi/Wifi', [
'wlans' => [], 'groups' => [], 'apGroups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
]);
}
}
public function update(Request $request, string $wlanId, UnifiApiClient $unifi)
{
$data = $request->validate([
'name' => 'sometimes|string|max:255',
'enabled' => 'sometimes|boolean',
'x_passphrase' => 'sometimes|string|min:8|max:63',
'hide_ssid' => 'sometimes|boolean',
'x_passphrase' => 'sometimes|string|min:8|max:63',
'hide_ssid' => 'sometimes|boolean',
'mac_filter_enabled' => 'sometimes|boolean',
'mac_filter_policy' => 'sometimes|string|in:allow,deny',
'rotate_password' => 'sometimes|boolean',
]);
try {
// If this WLAN is in a group, apply the same change to all grouped WLANs
// Password/hide changes apply to all grouped WLANs
$shared = array_filter($data, fn ($k) => in_array($k, ['x_passphrase', 'hide_ssid']), ARRAY_FILTER_USE_KEY);
$groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: [];
$groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids));
if ($groupedIds) {
foreach ($groupedIds as $id) {
$unifi->updateWlan($id, $data);
if (! empty($shared)) {
if ($groupedIds) {
foreach ($groupedIds as $id) {
$unifi->updateWlan($id, $shared);
}
} else {
$unifi->updateWlan($wlanId, $shared);
}
} else {
$unifi->updateWlan($wlanId, $data);
}
// MAC filter changes apply to this WLAN only
$perWlan = array_filter($data, fn ($k) => in_array($k, ['mac_filter_enabled', 'mac_filter_policy']), ARRAY_FILTER_USE_KEY);
if (! empty($perWlan)) {
$unifi->updateWlan($wlanId, $perWlan);
}
// Toggle wlan_id in the rotation list
if ($request->has('rotate_password')) {
$rotateIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: [];
$targetIds = $groupedIds ?: [$wlanId];
if ($request->boolean('rotate_password')) {
$rotateIds = array_values(array_unique(array_merge($rotateIds, $targetIds)));
} else {
$rotateIds = array_values(array_diff($rotateIds, $targetIds));
}
Setting::set('unifi.password_rotation.wlan_ids', json_encode($rotateIds));
}
return back()->with('success', 'WiFi network updated.');
@@ -71,14 +98,29 @@ class WifiController extends Controller
}
}
/**
* Update AP group assignments for a single WLAN (not synced to group siblings).
*/
public function updateApGroups(Request $request, string $wlanId, UnifiApiClient $unifi)
{
$request->validate(['ap_group_ids' => 'required|array']);
try {
$unifi->updateWlan($wlanId, ['ap_group_ids' => $request->ap_group_ids]);
return back()->with('success', 'AP groups updated.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi)
{
$request->validate(['enabled' => 'required|boolean']);
try {
$groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: [];
$groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: [];
$groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids));
$enabled = $request->boolean('enabled');
$enabled = $request->boolean('enabled');
if ($groupedIds) {
foreach ($groupedIds as $id) {
@@ -94,6 +136,256 @@ class WifiController extends Controller
}
}
// ── PPSK ─────────────────────────────────────────────────────────────────
public function ppskIndex(string $wlanId, UnifiApiClient $unifi)
{
try {
$liveEntries = $unifi->getPpskEntries($wlanId);
// Network confs are best-effort — don't let a failure block PPSK display
try {
$networksRaw = $unifi->getNetworkConfs();
} catch (\Throwable $e) {
$networksRaw = [];
}
$networksById = collect($networksRaw)->keyBy('_id');
// ── Sync live entries into DB ────────────────────────────────────
$liveIds = [];
foreach ($liveEntries as $entry) {
$pass = $entry['x_passphrase'] ?? $entry['password'] ?? null;
$uid = $entry['_id'] ?? $entry['id'] ?? null;
// wlan_embedded PPSKs have no _id — derive a stable synthetic ID from the passphrase
if (! $uid && $pass) {
$uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32);
}
if (! $uid) continue;
$liveIds[] = $uid;
$nconfId = $entry['networkconf_id'] ?? null;
$vlan = ($nconfId && isset($networksById[$nconfId]))
? ($networksById[$nconfId]['vlan'] ?? null)
: null;
if ($vlan === null && ! empty($entry['vlan_id'])) {
$vlan = (int) $entry['vlan_id'];
}
$name = $entry['name'] ?? $entry['label'] ?? $entry['username'] ?? null;
// For anonymous PPSKs, use the associated network name as the default label
if (! $name && $nconfId && isset($networksById[$nconfId])) {
$name = $networksById[$nconfId]['name'] ?? null;
}
// Match by unifi_id, or by passphrase for a held embedded record re-appearing
$record = UnifiPpsk::where('unifi_id', $uid)->first()
?? UnifiPpsk::where('wlan_id', $wlanId)
->where('x_passphrase', $pass)
->where('state', 'held')
->first();
if ($record) {
$upd = ['unifi_id' => $uid, 'state' => 'active'];
if ($name) $upd['name'] = $name;
if ($pass) $upd['x_passphrase'] = $pass;
if ($vlan !== null) $upd['vlan'] = $vlan;
$record->update($upd);
} else {
UnifiPpsk::create([
'wlan_id' => $wlanId,
'unifi_id' => $uid,
'name' => $name ?? 'PPSK',
'x_passphrase' => $pass ?? '',
'vlan' => $vlan,
'state' => 'active',
]);
}
}
// Only mark as held when we have confirmed live IDs —
// never wipe on an empty API response (prevents false-holds on API failures)
if (! empty($liveIds)) {
UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'active')
->whereNotNull('unifi_id')
->whereNotIn('unifi_id', $liveIds)
->update(['state' => 'held', 'unifi_id' => null]);
}
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
->orderByRaw("FIELD(state, 'active', 'held')")
->orderBy('name')
->get();
// Fallback: if DB still empty but live entries exist, return live entries directly.
// Applies the same synthetic-ID and networkconf logic so IDs are always non-null.
if ($dbRecords->isEmpty() && ! empty($liveEntries)) {
$entries = collect($liveEntries)->map(function ($e) use ($wlanId, $networksById) {
$pass = $e['x_passphrase'] ?? $e['password'] ?? null;
$uid = $e['_id'] ?? $e['id'] ?? null;
if (! $uid && $pass) {
$uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32);
}
$nconfId = $e['networkconf_id'] ?? null;
$vlan = ($nconfId && isset($networksById[$nconfId]))
? ($networksById[$nconfId]['vlan'] ?? null) : null;
if ($vlan === null && ! empty($e['vlan_id'])) {
$vlan = (int) $e['vlan_id'];
}
$name = $e['name'] ?? $e['label'] ?? $e['username'] ?? null;
if (! $name && $nconfId && isset($networksById[$nconfId])) {
$name = $networksById[$nconfId]['name'] ?? null;
}
return [
'id' => $uid,
'unifi_id' => $uid,
'name' => $name ?? 'PPSK',
'x_passphrase' => $pass,
'vlan' => $vlan,
'state' => 'active',
'rotate_password' => false,
'schedule' => null,
];
})->values();
} else {
$entries = $dbRecords->map(fn ($r) => $this->mapPpsk($r));
}
$networks = $networksById->values()->map(fn ($n) => [
'_id' => $n['_id'],
'name' => $n['name'] ?? 'Unnamed',
'vlan' => $n['vlan'] ?? null,
]);
return response()->json(['ok' => true, 'entries' => $entries, 'networks' => $networks]);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
}
}
public function ppskStore(Request $request, string $wlanId, UnifiApiClient $unifi)
{
$data = $request->validate([
'name' => 'required|string|max:100',
'x_passphrase' => 'required|string|min:8|max:63',
'networkconf_id' => 'nullable|string',
'vlan' => 'nullable|integer',
]);
try {
$pushData = [
'name' => $data['name'],
'x_passphrase' => $data['x_passphrase'],
'wlan_id' => $wlanId,
];
if (! empty($data['networkconf_id'])) {
$pushData['networkconf_id'] = $data['networkconf_id'];
}
$result = $unifi->createPpsk($pushData);
$raw = $result[0] ?? $result;
$unifiId = $raw['_id'] ?? null;
$record = UnifiPpsk::create([
'wlan_id' => $wlanId,
'unifi_id' => $unifiId,
'name' => $data['name'],
'x_passphrase' => $data['x_passphrase'],
'vlan' => $data['vlan'] ?? null,
'state' => 'active',
]);
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
}
}
public function ppskUpdate(Request $request, string $wlanId, string $ppskId, UnifiApiClient $unifi)
{
$record = UnifiPpsk::findOrFail($ppskId);
$data = $request->validate([
'name' => 'sometimes|string|max:100',
'x_passphrase' => 'sometimes|string|min:8|max:63',
'networkconf_id' => 'nullable|string',
'vlan' => 'nullable|integer',
]);
try {
if ($record->unifi_id && $record->state === 'active') {
$unifiUpdate = array_filter(
array_intersect_key($data, array_flip(['name', 'x_passphrase', 'networkconf_id'])),
fn ($v) => $v !== null
);
if (! empty($unifiUpdate)) {
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
}
}
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase']));
// vlan can be explicitly set to null
if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan'];
if (! empty($dbUpdate)) $record->update($dbUpdate);
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
}
}
public function ppskDestroy(string $wlanId, string $ppskId, UnifiApiClient $unifi)
{
$record = UnifiPpsk::findOrFail($ppskId);
try {
if ($record->unifi_id) {
try { $unifi->kickClientsForPpsk($record->unifi_id); } catch (\Throwable) {}
// Embedded PPSKs (synthetic emb_ IDs) aren't deletable via the PPSK REST endpoint;
// skip the API call — the entry will disappear from UniFi when the WLAN is reconfigured.
if (! str_starts_with($record->unifi_id, 'emb_')) {
$unifi->deletePpsk($record->unifi_id);
}
}
$record->delete();
return response()->json(['ok' => true]);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
}
}
public function ppskSchedule(Request $request, string $wlanId, string $ppskId)
{
$record = UnifiPpsk::findOrFail($ppskId);
$request->validate([
'schedule' => 'nullable|array',
'schedule.*' => 'boolean',
]);
$record->update(['schedule' => $request->schedule]);
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]);
}
public function ppskToggleRotation(Request $request, string $wlanId, string $ppskId)
{
$record = UnifiPpsk::findOrFail($ppskId);
$request->validate(['rotate_password' => 'required|boolean']);
$record->update(['rotate_password' => $request->boolean('rotate_password')]);
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]);
}
private function mapPpsk(UnifiPpsk $r): array
{
return [
'id' => $r->id,
'unifi_id' => $r->unifi_id,
'name' => $r->name,
'x_passphrase' => $r->x_passphrase,
'vlan' => $r->vlan,
'state' => $r->state,
'rotate_password' => $r->rotate_password,
'schedule' => $r->schedule,
];
}
/**
* Group/ungroup SSIDs. Groups are stored as { "GroupName": ["wlan_id_1", "wlan_id_2"] }
*/
@@ -106,16 +398,40 @@ class WifiController extends Controller
return back()->with('success', 'SSID groups saved.');
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function mapWlan(array $w): array
{
return [
'id' => $w['_id'],
'name' => $w['name'],
'enabled' => $w['enabled'] ?? true,
'security' => $w['security'] ?? 'open',
'wpa_mode' => $w['wpa_mode'] ?? '',
'is_guest' => $w['is_guest'] ?? false,
'vlan_enabled' => $w['vlan_enabled'] ?? false,
'vlan' => $w['vlan'] ?? null,
'hide_ssid' => $w['hide_ssid'] ?? false,
'passphrase' => $w['x_passphrase'] ?? '',
'band' => $this->detectBand($w),
'ap_group_ids' => $w['ap_group_ids'] ?? [],
'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false,
'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny',
'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false)
|| ($w['ppsk'] ?? false)
|| ($w['private_preshared_keys_enabled'] ?? false)
|| ! empty($w['private_preshared_keys']),
];
}
private function detectBand(array $w): string
{
// UniFi stores band info in wlan_band or in the radio settings
$band = $w['wlan_band'] ?? null;
if ($band === 'ng' || $band === '2g') return '2.4 GHz';
if ($band === 'na' || $band === '5g') return '5 GHz';
if ($band === 'a6' || $band === '6g' || $band === '6e') return '6 GHz';
if ($band === 'both' || $band === null) return 'All bands';
// Try to detect from SSID name as fallback
$name = strtolower($w['name'] ?? '');
if (preg_match('/2\.?4\s*g/i', $name)) return '2.4 GHz';
if (preg_match('/5\s*g/i', $name)) return '5 GHz';