9 Commits

Author SHA1 Message Date
71807c8815 UniFi: settings tabs are deep-linkable path routes (/settings/{tab})
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh8RnYXrC8E6z79LWs8ggd
2026-06-21 20:19:05 -04:00
462a1a3611 feat(1.13.0): page grants on the standard admissions-style pattern
unifi_page_grants gains role + default grantee types and can_view
(deny-by-default "Everyone else" row); enforcement moves from the
RouteMatched listener — where request->user() is always null and the
check silently failed open — into route-appended middleware with the
permission-holder pass. Pages-access endpoints gain role search +
default-row handling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 21:31:41 -04:00
ee27bee716 UniFi: degrade wan-status & client-ap-traffic to 200 'unavailable' when controller unreachable
Instead of returning HTTP 500 when the UniFi controller can't be reached, these JSON
data feeds now return 200 with an 'available: false' / status 'unavailable' payload so the
dashboard widgets show a friendly unavailable state and the WAN poll stops silently failing.
2026-06-11 17:38:24 -04:00
429cd44ac5 fix: register unifi pages with shell NavVisibilityRegistry; v1.12.1
The Access tab persists user/group grants in unifi_page_grants and the
existing RouteMatched listener honors them on the request path, but
NavItem::visibleTo() only consulted the page's required_permission —
so a granted user never saw the menu entry to reach the page. Register
the unifi.* prefix with the shell's NavVisibilityRegistry so the
sidebar lists exactly the pages the grant table allows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 12:12:58 -04:00
274f210337 release: 1.12.0 — rolls up 1.11.1 (reboot suppression hardening)
Bundled stable cut for prod. Contents since 1.11.0:

* fix(reboot): suppress webhook alerts during a fleet reboot
  regardless of cache driver. RebootAllAps now stamps a Setting
  (unifi.reboot_suppression_until) for 20 minutes at the start of a
  fleet reboot, and WebhookCheckService consults that Setting in
  checkDeviceTransition + checkReboot in addition to the existing
  per-MAC cache keys. Database-backed Setting always crosses
  container/process/cache-driver boundaries so the suppression can
  no longer be defeated by config differences.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:09:08 -04:00
c0f12ce931 fix(reboot): suppress webhook alerts during a fleet reboot, regardless of cache driver
Existing Cache::has('unifi:planned_reboot:{mac}') per-MAC suppression
relies on the cache driver being shared across the scheduler and the
snapshot-capture containers. In environments where the cache is
backed by something process-local (or where the keys expire before a
slow reboot completes), webhook alerts fire even though the dashboard
itself initiated the reboots.

RebootAllAps now also stamps a single Setting
(unifi.reboot_suppression_until) at the start of a fleet reboot,
covering a 20-minute window. WebhookCheckService checks this Setting
in addition to the per-MAC cache key, in checkDeviceTransition and
checkReboot. Setting is database-backed so it's always visible across
containers regardless of cache configuration.

v1.11.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:07:58 -04:00
dd4e0ca564 release: 1.11.0 — rolls up the 1.10.1/1.10.2/1.10.3/1.10.4 patches
Bundled stable cut for prod. Contents since 1.10.0:

* fix(banded ssid): treat "PPSK not on this band" as a quiet
  info-level skip rather than a failure (1.10.1).

* fix(ppsk sync): the WiFi modal's ingest sync now matches by NAME
  within a wlan before falling back to held-by-passphrase, and
  salvages rotate_password / schedule from held tombstones into the
  active row before pruning them. Prevents the modal from
  accumulating phantom "held" duplicates after every rotation and
  keeps the rotate flag on the row that's actually live (1.10.2).

* feat(grouped wifi): PPSK updates (both rotation and the manual
  modal edit) now follow user-defined SSID groups from the WiFi
  Networks page first, falling back to same-SSID-name detection.
  Lets the operator pair WLANs whose SSIDs have different names
  (e.g. "VCS Guest" and "VCS Guest 5G") (1.10.3).

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

* feat(verify): after every rotation, each affected WLAN is
  re-fetched and the new passphrase is checked at the named network.
  Anything that didn't actually propagate (mismatch, fetch failure)
  shows up as a failed PPSK in the cron run details (1.10.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 21:01:22 -04:00
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
bb74edf4c1 fix(ppsk sync): match by name + salvage settings, prune dup tombstones
Every rotation changes an embedded PPSK's synthetic id (it's derived
from sha256(wlan_id : passphrase)). The ingest sync matched only by
unifi_id, so after rotation the row's id was "new" — the sync created
a fresh active row and marked the previous one held. Over multiple
rotations this accumulated: each rotation left a held tombstone, and
the rotate_password / schedule flags were stuck on the original
tombstone instead of transferring to the new active row.

Dev's GUEST PPSK had 3 rows after a few rotations: two held (with
rotate_password=true on the first), one active with rotate=false.
Future rotations would silently skip that PPSK because the active row
no longer had the rotate flag set.

Fix in three layers, all in WifiController::ppskIndex:

1. Match priority extended: unifi_id → name within wlan → held by
   passphrase. The name match means a passphrase change just updates
   the existing row in place. No more new-row creation per rotation.

2. Salvage step before pruning: for each active row, scan held
   tombstones with the same name and copy over rotate_password and
   schedule. Operator's rotation opt-in survives history.

3. Prune step: held rows with the same name as an active row in the
   same wlan are now hard-deleted (their settings were just salvaged,
   their data is stale). Keeps the WiFi modal clean instead of
   accumulating phantoms.

v1.10.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:49:26 -04:00
13 changed files with 549 additions and 95 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.10.1",
"version": "1.13.1",
"type": "library",
"license": "MIT",
"autoload": {

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Brings unifi_page_grants to parity with adm_access_grants /
* directory_access_grants: widens grantee_type to include 'default' and
* 'role', allows a NULL grantee_id (used by the per-page default row),
* and adds a can_view column. The default row's can_view carries the
* deny/allow fallback for users not matched by a more specific grant
* (deny by default — no row means deny).
*
* Existing user/group rows keep working: adding can_view with a default
* of true backfills them as explicit allow grants.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('unifi_page_grants')) return;
// Widen the enum and allow NULL grantee_id (default rows). MySQL
// enum changes require raw DDL.
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('default','user','group','role') NOT NULL");
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NULL");
if (! Schema::hasColumn('unifi_page_grants', 'can_view')) {
Schema::table('unifi_page_grants', function (Blueprint $table) {
$table->boolean('can_view')->default(true)->after('grantee_id');
});
// Belt and braces: make sure every pre-existing user/group row
// is an explicit allow grant.
DB::table('unifi_page_grants')->update(['can_view' => true]);
}
// The unique index on (nav_item_id, grantee_type, grantee_id)
// already exists from the create migration
// ('unifi_page_grants_unique') and still applies — grantee_id is
// NULL for default rows, and MySQL treats NULLs as distinct, so
// app code enforces one default row per nav_item.
}
public function down(): void
{
if (! Schema::hasTable('unifi_page_grants')) return;
if (Schema::hasColumn('unifi_page_grants', 'can_view')) {
Schema::table('unifi_page_grants', function (Blueprint $table) {
$table->dropColumn('can_view');
});
}
DB::table('unifi_page_grants')->whereIn('grantee_type', ['default', 'role'])->delete();
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('user','group') NOT NULL");
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NOT NULL");
}
};

View File

@@ -80,17 +80,17 @@ class RotatePasswords extends Command
$newPass = $passwords[array_rand($passwords)];
try {
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
// Embedded PPSK: update inside the parent WLAN object.
// Match by name (most reliable) — falls back to
// passphrase if name is missing.
// Embedded PPSK: update inside the parent WLAN object,
// matched by name (synthetic id changes with the
// passphrase, so it's not a stable matcher).
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
// Sibling WLANs (same SSID name on a different band):
// rotate the matching-name PPSK in each so the
// SSID's 2.4/5GHz halves stay in sync.
foreach ($unifi->getWlanSiblings($ppsk->wlan_id) as $siblingWlanId) {
// Update every grouped sibling (user-defined SSID
// groups take precedence; same-name fallback for
// installs that haven't grouped manually).
foreach ($unifi->getGroupedWlans($ppsk->wlan_id) as $siblingWlanId) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $ppsk->name)
->where('state', 'active')
@@ -104,11 +104,6 @@ class RotatePasswords extends Command
]);
}
} catch (\Throwable $e) {
// "Not found" in a sibling just means the
// PPSK isn't mirrored on that band — totally
// normal if GUEST was only configured on one
// band. Skip quietly; don't poison the
// run status.
if (str_contains($e->getMessage(), 'not found')) {
\Illuminate\Support\Facades\Log::info('unifi.ppsk_sibling_skipped', [
'sibling_wlan' => $siblingWlanId,
@@ -120,6 +115,26 @@ class RotatePasswords extends Command
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
}
}
// Verify that the new passphrase actually applied
// on every grouped WLAN. UniFi can 200 an update
// that doesn't stick (cluster sync race, etc).
// Anything we expected to rotate that didn't is a
// failure — surface it in the cron log.
$allWlanIds = array_merge([$ppsk->wlan_id], $unifi->getGroupedWlans($ppsk->wlan_id));
foreach ($allWlanIds as $checkWlanId) {
$result = $unifi->verifyEmbeddedPpsk($checkWlanId, $ppsk->name, $newPass);
if ($result['ok']) continue;
// 'not_found' on a sibling = PPSK isn't on that band — ignore
// (consistent with the skip in the update loop).
if ($result['reason'] === 'not_found' && $checkWlanId !== $ppsk->wlan_id) continue;
$failedPpsks[] = [
'name' => $ppsk->name . ' (verify wlan ' . $checkWlanId . ')',
'error' => 'verification ' . $result['reason'] . ($result['error'] ?? null ? ': ' . $result['error'] : ''),
];
}
} else {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);

View File

@@ -36,6 +36,7 @@ class StatsController extends Controller
if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null;
return response()->json([
'available' => true,
'status' => $wan['status'] ?? 'unknown',
'tx_rate' => $wan['tx_bytes-r'] ?? 0,
'rx_rate' => $wan['rx_bytes-r'] ?? 0,
@@ -44,7 +45,19 @@ class StatsController extends Controller
'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null,
]);
} catch (\Throwable $e) {
return response()->json(['status' => 'error'], 500);
// The UniFi controller is unreachable. Degrade gracefully: return 200 with a
// friendly "unavailable" payload so the dashboard widget shows an unavailable
// state instead of the poll silently failing on a 500.
return response()->json([
'available' => false,
'status' => 'unavailable',
'message' => 'UniFi controller is unreachable.',
'tx_rate' => 0,
'rx_rate' => 0,
'isp' => null,
'wan_ip' => null,
'latency' => null,
]);
}
}
@@ -296,12 +309,21 @@ class StatsController extends Controller
->values();
return response()->json([
'available' => true,
'labels' => $series['labels'],
'traffic_rx' => $rx,
'traffic_tx' => $tx,
]);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()], 500);
// UniFi controller unreachable — degrade gracefully (200 with empty series)
// so the chart shows an empty/unavailable state rather than erroring on a 500.
return response()->json([
'available' => false,
'message' => 'UniFi controller is unreachable.',
'labels' => [],
'traffic_rx' => [],
'traffic_tx' => [],
]);
}
}

View File

@@ -5,6 +5,7 @@ namespace Dashboard\Unifi\Http\Controllers;
use App\Models\DashboardApp;
use App\Models\Group;
use App\Models\NavItem;
use App\Models\Role;
use App\Models\User;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Http\Request;
@@ -21,7 +22,7 @@ class UnifiPagesAccessController extends Controller
{
$app = DashboardApp::where('slug', 'unifi')->first();
if (! $app) {
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
return response()->json(['pages' => [], 'users' => [], 'groups' => [], 'roles' => []]);
}
$pages = NavItem::where('app_id', $app->id)
@@ -49,16 +50,27 @@ class UnifiPagesAccessController extends Controller
->orWhereIn('id', $grantedGroupIds);
})->orderBy('name')->get(['id', 'name', 'is_super']);
// Roles: only ones with at least one grant — more added via searchRoles.
$grantedRoleIds = $grants->flatten(1)->where('grantee_type', 'role')->pluck('grantee_id')->unique();
$roles = Role::whereIn('id', $grantedRoleIds)->orderBy('label')->get(['id', 'slug', 'label']);
return response()->json([
'pages' => $pages->map(fn ($p) => [
'id' => $p->id,
'label' => $p->label,
'route_name' => $p->route_name,
'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(),
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
])->values(),
'pages' => $pages->map(function ($p) use ($grants) {
$pageGrants = $grants->get($p->id, collect());
$defaultRow = $pageGrants->firstWhere('grantee_type', 'default');
return [
'id' => $p->id,
'label' => $p->label,
'route_name' => $p->route_name,
'default_allow' => (bool) ($defaultRow?->can_view ?? false),
'user_ids' => $pageGrants->where('grantee_type', 'user')->where('can_view', true)->pluck('grantee_id')->values()->all(),
'group_ids' => $pageGrants->where('grantee_type', 'group')->where('can_view', true)->pluck('grantee_id')->values()->all(),
'role_ids' => $pageGrants->where('grantee_type', 'role')->where('can_view', true)->pluck('grantee_id')->values()->all(),
];
})->values(),
'users' => $users,
'groups' => $groups,
'roles' => $roles,
]);
}
@@ -106,6 +118,26 @@ class UnifiPagesAccessController extends Controller
return response()->json(['groups' => $groups]);
}
/**
* Typeahead-style search for roles to add to the access matrix.
* An empty query returns every role (the role list is small).
*/
public function searchRoles(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 1) {
return response()->json(['roles' => Role::orderBy('label')->get(['id', 'slug', 'label'])]);
}
$roles = Role::where('label', 'like', '%' . $q . '%')
->orWhere('slug', 'like', '%' . $q . '%')
->orderBy('label')
->limit(20)
->get(['id', 'slug', 'label']);
return response()->json(['roles' => $roles]);
}
public function update(Request $request, NavItem $navItem)
{
$app = DashboardApp::where('slug', 'unifi')->first();
@@ -114,28 +146,62 @@ class UnifiPagesAccessController extends Controller
}
$data = $request->validate([
'user_ids' => 'present|array',
'user_ids.*' => 'integer|exists:users,id',
'group_ids' => 'present|array',
'group_ids.*' => 'integer|exists:groups,id',
'default_allow' => 'boolean',
'user_ids' => 'present|array',
'user_ids.*' => 'integer|exists:users,id',
'group_ids' => 'present|array',
'group_ids.*' => 'integer|exists:groups,id',
'role_ids' => 'array',
'role_ids.*' => 'integer|exists:roles,id',
]);
$grantedBy = $request->user()?->id;
DB::transaction(function () use ($navItem, $data, $grantedBy) {
UnifiPageGrant::where('nav_item_id', $navItem->id)->delete();
// Upsert the default row (one per nav_item). firstOrCreate can't
// match on grantee_id=NULL reliably in MySQL, so look it up first.
$default = UnifiPageGrant::where('nav_item_id', $navItem->id)
->where('grantee_type', 'default')
->first();
if ($default) {
$default->update(['can_view' => (bool) ($data['default_allow'] ?? false)]);
} else {
UnifiPageGrant::create([
'nav_item_id' => $navItem->id,
'grantee_type' => 'default',
'grantee_id' => null,
'can_view' => (bool) ($data['default_allow'] ?? false),
'granted_by_user_id' => $grantedBy,
]);
}
$rows = [];
$now = now();
foreach ($data['user_ids'] as $uid) {
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'user', 'grantee_id' => $uid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
}
foreach ($data['group_ids'] as $gid) {
$rows[] = ['nav_item_id' => $navItem->id, 'grantee_type' => 'group', 'grantee_id' => $gid, 'granted_by_user_id' => $grantedBy, 'created_at' => $now, 'updated_at' => $now];
}
if ($rows) UnifiPageGrant::insert($rows);
$this->syncGrantsOfType($navItem->id, 'user', $data['user_ids'] ?? [], $grantedBy);
$this->syncGrantsOfType($navItem->id, 'group', $data['group_ids'] ?? [], $grantedBy);
$this->syncGrantsOfType($navItem->id, 'role', $data['role_ids'] ?? [], $grantedBy);
});
return response()->json(['ok' => true]);
}
private function syncGrantsOfType(int $navItemId, string $type, array $ids, ?int $actorId): void
{
UnifiPageGrant::where('nav_item_id', $navItemId)
->where('grantee_type', $type)
->whereNotIn('grantee_id', $ids ?: [0])
->delete();
foreach ($ids as $id) {
UnifiPageGrant::updateOrCreate(
[
'nav_item_id' => $navItemId,
'grantee_type' => $type,
'grantee_id' => $id,
],
[
'can_view' => true,
'granted_by_user_id' => $actorId,
],
);
}
}
}

View File

@@ -10,9 +10,10 @@ use Inertia\Inertia;
class UnifiSettingsController extends Controller
{
public function edit()
public function edit(?string $tab = null)
{
return Inertia::render('Unifi/Settings', [
'activeTab' => $tab,
'controllerUrl' => Setting::get('unifi.controller_url', ''),
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
'site' => Setting::get('unifi.site', 'default'),

View File

@@ -149,8 +149,18 @@ class WifiController extends Controller
$name = $networksById[$nconfId]['name'] ?? null;
}
// Match by unifi_id, or by passphrase for a held embedded record re-appearing
// Match in priority order:
// 1. by current unifi_id (already-synced row)
// 2. by name within this wlan (catches rotation: passphrase
// changed → synthetic id changed → row identity unchanged)
// 3. by passphrase among held rows (legacy fallback for
// cases where name wasn't ingested)
$record = UnifiPpsk::where('unifi_id', $uid)->first()
?? ($name
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
->orderByRaw("FIELD(state, 'active', 'held')")
->first()
: null)
?? UnifiPpsk::where('wlan_id', $wlanId)
->where('x_passphrase', $pass)
->where('state', 'held')
@@ -174,8 +184,8 @@ class WifiController extends Controller
}
}
// Only mark as held when we have confirmed live IDs —
// never wipe on an empty API response (prevents false-holds on API failures)
// Mark non-matching active rows as held — but ONLY if there's no
// other active row with the same name we just reconnected.
if (! empty($liveIds)) {
UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'active')
@@ -184,6 +194,47 @@ class WifiController extends Controller
->update(['state' => 'held', 'unifi_id' => null]);
}
// For each active row, salvage any rotate_password / schedule
// settings from the held tombstones with the same name BEFORE
// we prune them. Otherwise a row that had rotate=on loses the
// flag every time a rotation changes its synthetic id.
$activeRows = UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'active')
->whereNotNull('name')
->get();
foreach ($activeRows as $active) {
$heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'held')
->where('name', $active->name)
->where(fn ($q) => $q
->where('rotate_password', true)
->orWhereNotNull('schedule'))
->orderByDesc('updated_at')
->first();
if (! $heldWithSettings) continue;
$patch = [];
if ($heldWithSettings->rotate_password && ! $active->rotate_password) {
$patch['rotate_password'] = true;
}
if ($heldWithSettings->schedule && ! $active->schedule) {
$patch['schedule'] = $heldWithSettings->schedule;
}
if ($patch) $active->update($patch);
}
// Prune obsolete held rows: any held row whose name matches an
// active row in the same wlan is a stale tombstone — its
// settings have been salvaged above, and its data has been
// superseded by the active one.
$activeNames = $activeRows->pluck('name')->filter()->unique();
if ($activeNames->isNotEmpty()) {
UnifiPpsk::where('wlan_id', $wlanId)
->where('state', 'held')
->whereIn('name', $activeNames)
->delete();
}
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
->orderByRaw("FIELD(state, 'active', 'held')")
->orderBy('name')
@@ -298,9 +349,9 @@ class WifiController extends Controller
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
// Also update sibling WLANs (banded SSID — same name
// on 2.4 and 5GHz are separate wlanconf rows).
foreach ($unifi->getWlanSiblings($record->wlan_id) as $siblingWlanId) {
// Also update grouped WLAN siblings (user-defined
// SSID groups, falling back to same-name).
foreach ($unifi->getGroupedWlans($record->wlan_id) as $siblingWlanId) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $record->name)
->where('state', 'active')

View File

@@ -0,0 +1,51 @@
<?php
namespace Dashboard\Unifi\Http\Middleware;
use App\Models\NavItem;
use Closure;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Dashboard\Unifi\UnifiServiceProvider;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Per-page access for unifi pages. Attached dynamically by the
* RouteMatched listener in UnifiServiceProvider — the check cannot
* live in the listener because RouteMatched fires before session/auth
* middleware, when request->user() is still null.
*/
class EnforceUnifiPageGrant
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$routeName = $request->route()?->getName();
if (! $routeName || ! $user || $user->is_super_admin) {
return $next($request);
}
try {
$item = NavItem::where('route_name', $routeName)->first();
if ($item) {
// Permission holders keep access — grants extend, never revoke.
$holdsPermission = $item->required_permission
&& UnifiServiceProvider::realPermissionKeysFor($user)->contains($item->required_permission);
if (! $holdsPermission && ! UnifiPageGrant::userCanAccess($user, $item)) {
// 404 instead of 403 — don't leak that the page exists.
abort(404);
}
}
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
throw $e;
} catch (\Throwable) {
// unifi_page_grants may not exist yet on first install — fail
// open in that narrow window (permission middleware still
// guards the route).
}
return $next($request);
}
}

View File

@@ -13,11 +13,16 @@ class UnifiPageGrant extends Model
protected $fillable = [
'nav_item_id',
'grantee_type',
'grantee_id',
'grantee_type', // default | user | group | role
'grantee_id', // null for grantee_type=default
'can_view',
'granted_by_user_id',
];
protected $casts = [
'can_view' => 'boolean',
];
public function navItem(): BelongsTo
{
return $this->belongsTo(NavItem::class);
@@ -29,30 +34,32 @@ class UnifiPageGrant extends Model
}
/**
* True iff $user is allowed to access $navItem under strict allowlist
* semantics:
* * super-admins (the model-level flag) always pass
* * otherwise the user must be a direct grantee, OR be in a group
* that is a grantee
*
* A page with NO grants saved is only visible to super-admins —
* the admin must explicitly authorize everyone else via the
* Access tab.
* Grant check with a per-page default fallback. Passes if the user
* matches an explicit user/group/role grant; otherwise falls back
* to the page's `default` row (deny by default — no row or
* can_view=false denies). Permission-based access is checked
* separately by the caller — grants extend, never revoke.
*/
public static function userCanAccess(User $user, NavItem $navItem): bool
{
if ($user->is_super_admin) return true;
$groupIds = $user->groups()->pluck('groups.id');
$roleIds = $user->roles()->pluck('roles.id');
return static::where('nav_item_id', $navItem->id)
->where(function ($q) use ($user, $groupIds) {
$q->where(function ($u) use ($user) {
$u->where('grantee_type', 'user')->where('grantee_id', $user->id);
})->orWhere(function ($g) use ($groupIds) {
$g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds);
});
$hasExplicit = static::where('nav_item_id', $navItem->id)
->where('can_view', true)
->where(function ($q) use ($user, $groupIds, $roleIds) {
$q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id))
->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds))
->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds));
})
->exists();
if ($hasExplicit) return true;
return (bool) static::where('nav_item_id', $navItem->id)
->where('grantee_type', 'default')
->value('can_view');
}
}

View File

@@ -312,6 +312,88 @@ class UnifiApiClient
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
@@ -548,10 +630,13 @@ class UnifiApiClient
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
}
// Match in this order — most reliable first:
// 1. by PPSK name (if provided) — survives passphrase drift
// caused by manual edits or previous out-of-sync rotations.
// 2. by current passphrase (legacy)
// 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;
@@ -561,8 +646,21 @@ class UnifiApiClient
}
};
$networkconfId = ($name !== null && $name !== '') ? $this->findNetworkconfIdByName($name) : null;
$matched = false;
if ($name !== null && $name !== '') {
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) {
@@ -589,7 +687,7 @@ class UnifiApiClient
if (! $matched) {
throw new \RuntimeException(
'Embedded PPSK not found' .
($name !== null ? " by name \"{$name}\"" : '') .
($name !== null ? " for network \"{$name}\"" : '') .
' or by current passphrase.'
);
}

View File

@@ -181,7 +181,11 @@ class WebhookCheckService
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) continue;
// Skip planned reboots — these are intentional, not alerts
// Skip planned reboots — these are intentional, not alerts.
// Two layers: a global suppression window set by RebootAllAps
// (Setting, survives any cache driver), plus the per-MAC
// cache keys for finer granularity.
if ($this->inGlobalRebootSuppression()) continue;
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
if ($comingOnline) {
@@ -509,6 +513,8 @@ class WebhookCheckService
private function checkReboot($aps, array $filter): array
{
$alerts = [];
if ($this->inGlobalRebootSuppression()) return $alerts;
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
@@ -584,6 +590,24 @@ class WebhookCheckService
return self::buildPlatformPayload($url, $message, $fullPayload);
}
/**
* Is a fleet reboot in progress right now? RebootAllAps stamps a
* suppression-until timestamp as a Setting; while that timestamp
* is in the future, we skip all device-offline / device-online /
* unexpected-reboot alerts to avoid flooding webhooks during the
* known maintenance window.
*/
private function inGlobalRebootSuppression(): bool
{
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
if (! $until) return false;
try {
return \Carbon\Carbon::parse($until)->isFuture();
} catch (\Throwable) {
return false;
}
}
/**
* Public/static helper so the test-webhook endpoint produces the
* same per-platform payload shape that real events do.

View File

@@ -2,7 +2,6 @@
namespace Dashboard\Unifi;
use App\Models\DashboardApp;
use App\Models\NavItem;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Routing\Events\RouteMatched;
@@ -25,34 +24,34 @@ class UnifiServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
// Per-page access enforcement for unifi routes. If a unifi page has
// any UnifiPageGrant rows, only super-admins and granted users/
// groups can hit it; otherwise (no grants) it's open per the existing
// permission middleware. Super-admins always bypass.
// Tell the shell's nav sidebar which unifi nav items the user
// can see: pages whose required_permission the user holds through
// groups, plus pages granted via the Settings → Access tab. The
// shell also folds these grants back into User::can()/allPermissions
// so the route-level permission middleware passes for grantees.
try {
app(\App\Support\NavVisibilityRegistry::class)->register(
'unifi.',
fn (\App\Models\User $user) => $this->visibleUnifiNavItemIdsFor($user),
);
} catch (\Throwable) {
// Shell may not have the registry yet (older shell version).
// Sidebar will fall back to legacy permission filter.
}
// Per-page enforcement for unifi pages. Settings stays on its
// permission:unifi.settings route middleware (the Access tab
// itself lives there and must not be able to lock itself out).
// The user-dependent check lives in middleware appended to the
// matched route: RouteMatched fires before the session/auth
// middleware run, so request->user() is null here and any check
// at this point silently fails open.
Event::listen(RouteMatched::class, function (RouteMatched $event) {
$routeName = $event->route->getName();
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) return;
if (str_starts_with($routeName, 'unifi.settings')) return;
$user = $event->request->user();
if (! $user || $user->is_super_admin) return;
try {
$appId = DashboardApp::where('slug', 'unifi')->value('id');
$item = NavItem::where('route_name', $routeName)
->where('app_id', $appId)
->first();
if (! $item) return;
if (! UnifiPageGrant::userCanAccess($user, $item)) {
// 404 instead of 403 — don't leak that the page
// exists. The Access tab is the only way in.
abort(404);
}
} catch (\Throwable) {
// unifi_page_grants table may not exist yet on a fresh
// install before this snap-in's migrations have run —
// fail open in that narrow window.
}
$event->route->middleware(\Dashboard\Unifi\Http\Middleware\EnforceUnifiPageGrant::class);
});
if ($this->app->runningInConsole()) {
@@ -69,4 +68,58 @@ class UnifiServiceProvider extends ServiceProvider
], 'unifi-config');
}
}
/** Collect every unifi nav_item_id this user is allowed to see. */
protected function visibleUnifiNavItemIdsFor(\App\Models\User $user): \Illuminate\Support\Collection
{
$ids = collect();
// Pages whose required_permission the user already holds through
// groups keep showing, independent of the access-grant matrix.
$permittedIds = NavItem::where('route_name', 'like', 'unifi.%')
->whereIn('required_permission', static::realPermissionKeysFor($user))
->pluck('id');
$ids = $ids->merge($permittedIds);
if (\Illuminate\Support\Facades\Schema::hasTable('unifi_page_grants')) {
$groupIds = $user->groups()->pluck('groups.id');
$roleIds = $user->roles()->pluck('roles.id');
$grantedIds = UnifiPageGrant::query()
->where('can_view', true)
->where(function ($q) use ($user, $groupIds, $roleIds) {
$q->where(fn ($u) => $u->where('grantee_type', 'user')->where('grantee_id', $user->id))
->orWhere(fn ($g) => $g->where('grantee_type', 'group')->whereIn('grantee_id', $groupIds))
->orWhere(fn ($r) => $r->where('grantee_type', 'role')->whereIn('grantee_id', $roleIds))
// Default-allow row makes the page visible to everyone.
->orWhere('grantee_type', 'default');
})
->pluck('nav_item_id');
$ids = $ids->merge($grantedIds);
}
return $ids->unique()->values();
}
/**
* Group + direct permissions only. Deliberately NOT allPermissions():
* the shell folds page-grant permissions back into allPermissions/can,
* so using it here would turn a single granted page into every page
* carrying the same permission.
*/
public static function realPermissionKeysFor(\App\Models\User $user): \Illuminate\Support\Collection
{
if ($user->is_super_admin) {
return \App\Models\Permission::pluck('key');
}
$direct = $user->directPermissions()->get();
return \App\Models\Permission::whereHas('groups', fn ($q) => $q->whereIn('group_id', $user->groups()->pluck('groups.id')))
->pluck('key')
->merge($direct->where('granted', true)->pluck('permission_key'))
->diff($direct->where('granted', false)->pluck('permission_key'))
->unique()
->values();
}
}

View File

@@ -69,16 +69,20 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
// ── Settings ─────────────────────────────────────────────────────────
Route::middleware('permission:unifi.settings')->group(function () {
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
Route::get('/settings/{tab}', [UnifiSettingsController::class, 'edit'])
->where('tab', 'connection|tasks|logs|access')->name('settings.tab');
Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update');
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
// Page Access — super-admin only. Lists unifi pages and lets
// operators assign per-page user/group grants.
// operators assign per-page user/group/role grants plus the
// per-page "everyone else" default row (deny by default).
Route::middleware('super.admin')->group(function () {
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search');
Route::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search');
Route::get('/settings/pages-access/roles/search', [UnifiPagesAccessController::class, 'searchRoles'])->name('settings.pages-access.roles.search');
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
});