18 Commits

Author SHA1 Message Date
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
e5cc075938 fix(banded ssid): treat "PPSK not on this band" as a quiet skip
The sibling-rotation path's "Embedded PPSK not found" error was being
surfaced to the operator as a failure, but it's not — it just means
the PPSK isn't mirrored on that band (GUEST was configured on one
band only, which is a perfectly valid setup). Logging this as a
sibling failure also poisoned the cron run status to "partial".

Now: "not found"-style errors from updateEmbeddedPpsk on a sibling
become info-level log entries and the loop continues. Other errors
(API failures, permissions, etc.) still surface as warnings/failures.

v1.10.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:43:10 -04:00
4ec4a293c0 release: 1.10.0 — rolls up 1.9.1 (banded-SSID PPSK match by name)
Bundled stable cut for prod. Contents since 1.9.0:

* fix(banded ssid): updateEmbeddedPpsk now matches embedded PPSK
  entries by name first (e.g. "GUEST") and falls back to current
  passphrase. Name-matching survives any passphrase drift caused by
  pre-1.8.1 out-of-band manual edits — the sibling-rotation failure
  reported on prod after upgrading to 1.9.0 no longer happens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:40:13 -04:00
720e94c54a fix(banded ssid): match embedded PPSK by name first, passphrase fallback
The sibling-update path on prod failed with "Embedded PPSK not found
by current passphrase" because the DB-stored x_passphrase on the
unedited band was stale — earlier manual edits (pre-1.8.1) only
touched one band, leaving the other band's row out of sync. When
rotation then tried to use that stale passphrase to find the entry,
no match.

updateEmbeddedPpsk now takes an optional $name parameter and tries it
first. PPSK names within a WLAN are unique, so name-matching survives
any passphrase drift caused by historical out-of-band edits.
Passphrase matching stays as a fallback for callers that don't have
a name (none currently — both rotation and the manual modal pass it).

v1.9.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:38:10 -04:00
2be17c70db release: 1.9.0 — rolls up the 1.8.1 patch series
Bundled stable cut for prod. Contents since 1.8.0:

* fix(rotate): unifi.password_rotation.last_password is now saved on
  successful PPSK rotation as well as whole-SSID rotation. PPSK-only
  setups (typical guest-WiFi configurations) will populate the
  Settings → Tasks "current password" display and the
  /api/unifi/wifi/current-password endpoint after the next rotation.

* fix(banded-ssid): when an SSID is split across 2.4 and 5GHz bands
  (band-steering disabled — two wlanconf rows with the same name),
  rotating or manually editing a PPSK on one band now also updates
  the same-name PPSK on every sibling band. Previously the two halves
  drifted out of sync. Both the rotation scheduler and the WiFi modal
  use the new UnifiApiClient::getWlanSiblings helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:33:48 -04:00
31686a35d5 fix(rotate): record PPSK rotation password + sync banded-SSID siblings
Three bugs reported from prod after a PPSK rotation:

1. unifi.password_rotation.last_password was only saved after a
   whole-SSID rotation. PPSK-only setups (the typical guest-WiFi case)
   ran a successful rotation but the setting stayed empty, so the
   Settings → Tasks UI never showed the current password and the
   /api/unifi/wifi/current-password endpoint returned 404
   "no rotated password recorded yet". The PPSK loop now writes
   last_password on every successful PPSK rotation.

2. When an SSID is "banded" (band-steering disabled), UniFi splits it
   into one wlanconf per band — 2.4GHz and 5GHz each get their own _id
   and their own embedded PPSK array. Rotating the PPSK on one band
   left the other band with the old password. New
   UnifiApiClient::getWlanSiblings($wlanId) finds all wlanconfs that
   share an SSID name; both rotation and the manual modal edit now
   call updateEmbeddedPpsk on each sibling and update the matching
   UnifiPpsk DB rows.

3. The manual WiFi modal edit had the same band-blindness as #2 —
   editing the GUEST PPSK on the 2.4GHz half left the 5GHz half stale.
   WifiController::ppskUpdate now walks siblings the same way.

v1.8.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:32:15 -04:00
8769308dfd release: 1.8.0 — rolls up the 1.7.1 patch series
Bundled stable cut for prod. Contents since 1.7.0:

* feat(access): strict allowlist enforcement. A unifi page with NO
  grants is now visible only to super-admins — previously it fell back
  to "open for anyone with the route permission". Matches the new
  dashboard-wide access model.
* feat(access): the Access tab now adds groups by typeahead search,
  mirroring the user-search flow. Only granted groups + super-admin
  groups appear in the matrix; other groups are added on demand.
* fix(access): ungranted users hitting a unifi URL get 404 instead of
  403 so the page doesn't leak its existence.

Breaking note: super-admins continue to see everything. Non-super
users that previously accessed a unifi page via permission alone now
need an explicit grant in the Access tab. Configure grants before
relying on existing permission-based access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:17:21 -04:00
f5848907f5 feat(access): strict allowlist + add groups by search
* UnifiPageGrant::userCanAccess no longer falls back to "open" when a
  page has no grants saved. Pages now require an explicit grant for
  every non-super-admin user — either a direct user grant or via a
  group they belong to. Matches the new dashboard-wide access model.
* Route enforcement returns 404 (was 403) so ungranted users can't even
  confirm the page exists.
* New /settings/pages-access/groups/search endpoint mirrors the
  user typeahead. Groups are no longer all listed by default — only
  super-admin groups (locked-on) and groups with at least one existing
  grant show up in the matrix. Operators add more via search.

v1.7.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:59:28 -04:00
f953fde2be release: 1.7.0 — rolls up the 1.6.1/1.6.2/1.6.3 patch series
Bundled cut for the stable channel. Contents since 1.6.0:

* fix(webhooks): test endpoint formats payload per platform (Google
  Chat / Slack / Discord / Teams) so the Test URL button actually
  succeeds against those targets instead of getting a 400 back.
* fix(schema): add missing unifi_device_states.consecutive_count
  column the scheduled snapshot capture was failing to insert.
* feat(rotate): persist the active rotated password as
  unifi.password_rotation.last_password whenever a whole-SSID
  rotation succeeds. Surfaced in Settings → Tasks under the wordlist.
* feat(api): new GET /api/unifi/wifi/current-password JSON endpoint
  for external signage / kiosks. Token-protected via
  Authorization: Bearer or ?token= query. 401 / 503 / 404 on missing
  auth, disabled API, or no rotation yet.
* feat(settings): "Expose WiFi password API" checkbox under the
  rotate-passwords block. Off by default. Generate / Regenerate /
  Clear token controls and a copy-paste curl example.

No breaking changes. Drop-in upgrade from 1.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:50:44 -04:00
9a37eda302 feat(api): explicit enable toggle for WiFi password endpoint
Previously the API was implicitly active whenever a token existed.
Now there's an explicit unifi.api.enabled setting that gates it:

* WifiApiController returns 503 ("API disabled") when the setting is
  off, even if a valid token is presented. Stops the endpoint from
  silently working if a token is lying around.
* Settings page exposes the toggle under the Rotate-WiFi-Passwords
  block. With it off, the token / URL / curl example are hidden.
* The form submit handles the new api_enabled boolean.

v1.6.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:44:57 -04:00
4b29f55518 feat(rotate): persist current password; add token-protected API
* RotatePasswords now stores the active wordlist entry as
  unifi.password_rotation.last_password whenever a whole-SSID rotation
  succeeds. Per-PPSK rotation continues to store passwords on each
  PPSK row as before.
* Settings → Tasks tab surfaces the current password in bold beneath
  the wordlist textarea so operators can quickly check what's live.
* New JSON endpoint GET /api/unifi/wifi/current-password returns
  {"password": "...", "rotated_at": "..."}. Protected by a token stored
  in unifi.api_token — pass as Authorization: Bearer <token> or
  ?token=<token>. 401 on bad/missing token, 503 if no token is
  configured, 404 if no rotation has happened yet.
* Settings page lets super-admins Generate / Regenerate / Clear the
  token. Generated tokens are 48-char hex from bin2hex(random_bytes(24)).
* The endpoint lives outside the web/auth middleware so external
  signage / kiosks can hit it without a session cookie.

v1.6.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:42:13 -04:00
e8796d443e fix(webhooks): test endpoint formats payload per platform; add missing column
* The Test URL button was POSTing a generic {event, timestamp, data}
  envelope to every endpoint. Google Chat / Slack / Discord / Teams
  reject anything that isn't their specific shape — so a successful
  Laravel request still got a 400 back from the platform, making the
  test look broken. The real webhook events already handle this via
  WebhookCheckService::formatPayloadForPlatform; that helper is now
  exposed as a public static (buildPlatformPayload) and the test
  endpoint uses the same code path, so the test exercises the same
  format real events will.
* unifi_device_states was missing a consecutive_count column the
  WebhookCheckService inserts on every snapshot capture. The scheduler
  was throwing "Unknown column 'consecutive_count'" once a minute.
  Added an idempotent migration.

v1.6.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:34:42 -04:00
24aad5cdc0 release: 1.6.0 — hide Portal page until it's fully implemented
Portal page is removed from the snap-in's nav entry list in composer.json
so it stops appearing in the sidebar. All portal routes, the
PortalController, the Portal.vue page, and the unifi.auth permission
are retained — we'll surface the page again in a later version once the
captive portal flow is fully working.

Minor bump because this is the first release that bundles the recent
batch of features:
* per-page access grants (snap-in-local table, super-admin only)
* cron logs tab with structured per-run history
* PPSK scheduling consistency + drift correction
* settings tabs (Connection / Tasks / Logs / Webhooks / Access)
* webhooks moved under /settings/webhooks + Test URL button
* embedded PPSK update via WLAN config
* page width standardized at max-w-7xl px-4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:19:06 -04:00
27c1584dc3 fix(ppsk): embedded PPSKs update via WLAN config, not /rest/ppsk
Embedded PPSKs live inside the parent WLAN's private_preshared_keys
array — they have no controller-side _id and the synthetic emb_<hash>
we generate locally isn't a real REST id. Hitting /rest/ppsk/emb_xxx
returns HTTP 400/503, which is what the GUEST PPSK rotation was
failing on at the scheduled 3pm run.

* New UnifiApiClient::updateEmbeddedPpsk($wlanId, $oldPass, $newPass):
  GETs /rest/wlanconf/{wlanId}, finds the matching entry in
  private_preshared_keys by current passphrase, swaps the value while
  preserving whichever field name the controller uses (x_passphrase /
  password / passphrase), and PUTs the whole WLAN object back.
* RotatePasswords detects emb_-prefixed unifi_ids and routes through
  the embedded path. The synthetic id is rederived from the new
  passphrase so the DB row stays addressable.
* WifiController::ppskUpdate (manual modal save) does the same — this
  is why manual edits sometimes appeared to succeed but the controller
  side actually rejected them.

Verified live against the GUEST PPSK on 10.81.0.1.
v1.5.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:14:45 -04:00
c89adeea97 fix(webhooks): add missing columns; add pre-save URL test endpoint
* The model+validation referenced tracked_clients and templates columns
  but they were never in the unifi_webhook_configs migration. Any save
  attempt that included those keys 500'd with "Unknown column".
  Added an additive migration (idempotent) that adds both as nullable
  json columns.
* New POST /settings/webhooks/test-url endpoint takes a url+secret in
  the body and fires the standard test payload. Lets operators validate
  their endpoint before saving the row — useful when first wiring up
  Google Chat, Slack, etc.

v1.5.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:59:44 -04:00
8f51be8515 fix(rotate): don't skip when only PPSKs are flagged; move webhooks under /settings
* Password rotation was short-circuiting any run that had no whole-SSID
  wlan_ids configured, even if there were PPSKs with rotate_password=true
  in the database. The PPSK rotation block lived after the early-return,
  so per-PPSK rotation never fired. Now we only skip when there's nothing
  at all to rotate (neither wlan_ids nor PPSK opt-ins).
* Webhook routes moved from /app/network/webhooks to
  /app/network/settings/webhooks so the URL reflects that this is a
  settings tab. Route names unchanged.

v1.5.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:53:48 -04:00
0490a1220b feat(access): only return granted users; add search endpoint
Listing every user in the system on the access page didn't scale —
schools have thousands of user rows. Now:
  - index() only returns users that already have a UnifiPageGrant
    somewhere. Groups stay fully listed (few of them).
  - new searchUsers(q) endpoint returns up to 20 typeahead matches
    against name or email (min 2 chars).

v1.5.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:51:00 -04:00
4b73b53dd6 chore(nav): drop Webhooks nav row — moved into Settings tabs
Webhooks now lives as a tab alongside Connection / Tasks / Logs /
Access in the Settings page. The standalone Webhooks page still
exists at /app/network/webhooks but no longer appears in the sidebar.

v1.5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:43:30 -04:00
14 changed files with 536 additions and 41 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.5.0",
"version": "1.10.2",
"type": "library",
"license": "MIT",
"autoload": {
@@ -27,8 +27,6 @@
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
{ "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
{ "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 },
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
],
"permissions": [

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('unifi_webhook_configs', function (Blueprint $table) {
if (! Schema::hasColumn('unifi_webhook_configs', 'tracked_clients')) {
$table->json('tracked_clients')->nullable()->after('device_filter');
}
if (! Schema::hasColumn('unifi_webhook_configs', 'templates')) {
$table->json('templates')->nullable()->after('tracked_clients');
}
});
}
public function down(): void
{
Schema::table('unifi_webhook_configs', function (Blueprint $table) {
if (Schema::hasColumn('unifi_webhook_configs', 'templates')) {
$table->dropColumn('templates');
}
if (Schema::hasColumn('unifi_webhook_configs', 'tracked_clients')) {
$table->dropColumn('tracked_clients');
}
});
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('unifi_device_states', function (Blueprint $table) {
if (! Schema::hasColumn('unifi_device_states', 'consecutive_count')) {
$table->unsignedSmallInteger('consecutive_count')->default(0)->after('in_alert');
}
});
}
public function down(): void
{
Schema::table('unifi_device_states', function (Blueprint $table) {
if (Schema::hasColumn('unifi_device_states', 'consecutive_count')) {
$table->dropColumn('consecutive_count');
}
});
}
};

View File

@@ -32,9 +32,16 @@ class RotatePasswords extends Command
$run = UnifiCronRun::record('rotate-passwords', $triggeredBy, null, function () use ($unifi, $force) {
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
$wlanIds = json_decode($wlanIdsJson, true);
if (! is_array($wlanIds)) $wlanIds = [];
if (empty($wlanIds) || ! is_array($wlanIds)) {
return ['status' => 'skipped', 'reason' => 'no SSIDs configured for rotation'];
$ppskQuery = UnifiPpsk::where('rotate_password', true)
->where('state', 'active')
->whereNotNull('unifi_id');
// Skip only if there's nothing at all to rotate — neither
// whole-SSID rotation targets nor per-PPSK rotation opt-ins.
if (empty($wlanIds) && ! $ppskQuery->exists()) {
return ['status' => 'skipped', 'reason' => 'no SSIDs or PPSKs configured for rotation'];
}
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
@@ -61,17 +68,70 @@ class RotatePasswords extends Command
if ($rotated) {
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
// Persist the active password so it can be displayed in
// the Settings page and exposed via the API endpoint.
Setting::set('unifi.password_rotation.last_password', $password);
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
}
$rotatedPpsks = [];
$failedPpsks = [];
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
foreach ($ppskQuery->get() as $ppsk) {
$newPass = $passwords[array_rand($passwords)];
try {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
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.
$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) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $ppsk->name)
->where('state', 'active')
->first();
try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name);
if ($sibling) {
$sibling->update([
'x_passphrase' => $newPass,
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
]);
}
} 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,
'ppsk_name' => $ppsk->name,
]);
continue;
}
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
}
}
} else {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
}
$rotatedPpsks[] = $ppsk->name;
// Save the active password every time a rotation
// succeeds — covers PPSK-only rotation setups where
// there's no whole-SSID rotation. Last successful
// password wins if multiple PPSKs rotate in one run.
Setting::set('unifi.password_rotation.last_password', $newPass);
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
} catch (\Throwable $e) {
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];

View File

@@ -34,6 +34,21 @@ class UnifiPagesAccessController extends Controller
->get()
->groupBy('nav_item_id');
// Only return users that ALREADY have grants. The full users list
// can be enormous (thousands of rows); the operator adds more via
// the searchUsers endpoint as needed.
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
// Groups: always include super-admin groups (locked-on across all
// pages) plus any group with at least one grant. Other groups are
// added via searchGroups.
$grantedGroupIds = $grants->flatten(1)->where('grantee_type', 'group')->pluck('grantee_id')->unique();
$groups = Group::where(function ($q) use ($grantedGroupIds) {
$q->where('is_super', true)
->orWhereIn('id', $grantedGroupIds);
})->orderBy('name')->get(['id', 'name', 'is_super']);
return response()->json([
'pages' => $pages->map(fn ($p) => [
'id' => $p->id,
@@ -42,11 +57,55 @@ class UnifiPagesAccessController extends Controller
'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(),
'users' => User::orderBy('name')->get(['id', 'name', 'email']),
'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']),
'users' => $users,
'groups' => $groups,
]);
}
/**
* Typeahead-style search for users to add to the access matrix.
* Returns up to 20 matches against name or email. Empty query returns
* an empty array — caller must enter at least 2 chars.
*/
public function searchUsers(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['users' => []]);
}
$users = User::where(function ($w) use ($q) {
$w->where('name', 'like', '%' . $q . '%')
->orWhere('email', 'like', '%' . $q . '%');
})
->orderBy('name')
->limit(20)
->get(['id', 'name', 'email']);
return response()->json(['users' => $users]);
}
/**
* Typeahead-style search for groups to add to the access matrix.
* Excludes super-admin groups (they're already in the matrix and
* locked-on across every page).
*/
public function searchGroups(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['groups' => []]);
}
$groups = Group::where('name', 'like', '%' . $q . '%')
->where(function ($w) { $w->where('is_super', false)->orWhereNull('is_super'); })
->orderBy('name')
->limit(20)
->get(['id', 'name', 'is_super']);
return response()->json(['groups' => $groups]);
}
public function update(Request $request, NavItem $navItem)
{
$app = DashboardApp::where('slug', 'unifi')->first();

View File

@@ -31,10 +31,26 @@ class UnifiSettingsController extends Controller
'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),
'rotationLastPassword' => Setting::get('unifi.password_rotation.last_password', null),
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
'apiToken' => Setting::get('unifi.api_token', null),
]);
}
public function regenerateApiToken()
{
$token = bin2hex(random_bytes(24));
Setting::set('unifi.api_token', $token);
return response()->json(['token' => $token]);
}
public function clearApiToken()
{
Setting::set('unifi.api_token', '');
return response()->json(['ok' => true]);
}
public function update(Request $request)
{
$request->validate([
@@ -56,6 +72,7 @@ class UnifiSettingsController extends Controller
'rotation_minute' => 'nullable|integer|min:0|max:59',
'rotation_wordlist' => 'nullable|string|max:20000',
'ppsk_scheduling_enabled' => 'boolean',
'api_enabled' => 'boolean',
]);
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
@@ -82,6 +99,7 @@ class UnifiSettingsController extends Controller
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' : '');
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));

View File

@@ -68,20 +68,49 @@ class WebhookController extends Controller
public function test(WebhookConfig $webhook)
{
$payload = [
return $this->fireTest($webhook->url, $webhook->secret);
}
/**
* Test an arbitrary URL+secret before the webhook is saved. Lets the
* operator validate their endpoint from the form without first
* committing a row.
*/
public function testUrl(Request $request)
{
$data = $request->validate([
'url' => 'required|url|max:500',
'secret' => 'nullable|string|max:255',
]);
return $this->fireTest($data['url'], $data['secret'] ?? null);
}
private function fireTest(string $url, ?string $secret)
{
$message = '✅ Test webhook from ' . config('app.name') . ' — endpoint is reachable.';
$genericPayload = [
'event' => 'test',
'timestamp' => now()->toIso8601String(),
'data' => ['message' => 'This is a test webhook from ' . config('app.name')],
'message' => $message,
'data' => ['message' => $message],
];
// Shape the payload to match the target platform (Google Chat,
// Slack, Discord, Teams) so the test exercises the same code
// path real events use.
$payload = \Dashboard\Unifi\Services\WebhookCheckService::buildPlatformPayload($url, $message, $genericPayload);
$headers = ['Content-Type' => 'application/json'];
if ($webhook->secret) {
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $webhook->secret);
if ($secret) {
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $secret);
}
try {
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($webhook->url, $payload);
return response()->json(['ok' => true, 'status' => $response->status()]);
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($url, $payload);
return response()->json([
'ok' => $response->successful(),
'status' => $response->status(),
'body' => mb_substr((string) $response->body(), 0, 500),
]);
} catch (\Throwable $e) {
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
/**
* Token-protected JSON endpoints for external integrations (signage,
* kiosks, room displays, etc.) that need the current rotating WiFi
* password without going through the dashboard UI.
*/
class WifiApiController extends Controller
{
public function currentPassword(Request $request)
{
if (! Setting::get('unifi.api.enabled')) {
return response()->json(['error' => 'API disabled'], 503);
}
$expected = Setting::get('unifi.api_token');
if (! $expected) {
return response()->json(['error' => 'API token not configured'], 503);
}
$provided = $request->bearerToken() ?: $request->query('token');
if (! $provided || ! hash_equals($expected, $provided)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$password = Setting::get('unifi.password_rotation.last_password');
if (! $password) {
return response()->json([
'error' => 'No rotated password recorded yet — wait for the next scheduled rotation or run unifi:rotate-passwords --force.',
], 404);
}
return response()->json([
'password' => $password,
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
]);
}
}

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')
@@ -291,11 +342,46 @@ class WifiController extends Controller
fn ($v) => $v !== null
);
if (! empty($unifiUpdate)) {
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
// Embedded PPSK update path — modify the WLAN's embedded array.
// Match by name (reliable across drift).
$newPass = $unifiUpdate['x_passphrase'];
$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) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $record->name)
->where('state', 'active')
->first();
try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name);
if ($sibling) {
$sibling->update([
'x_passphrase' => $newPass,
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
]);
}
} catch (\Throwable $e) {
// PPSK absent on this band is fine — just
// means it isn't mirrored. Anything else
// gets warning-logged.
$level = str_contains($e->getMessage(), 'not found') ? 'info' : 'warning';
\Illuminate\Support\Facades\Log::log($level, 'unifi.ppsk_sibling_update', [
'sibling_wlan' => $siblingWlanId,
'error' => $e->getMessage(),
]);
}
}
} else {
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
}
}
}
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase']));
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase', 'unifi_id']));
// vlan can be explicitly set to null
if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan'];
if (! empty($dbUpdate)) $record->update($dbUpdate);

View File

@@ -29,18 +29,20 @@ class UnifiPageGrant extends Model
}
/**
* True iff $user is allowed to access $navItem under this grant model.
* Super-admins always pass.
* If there are NO grants for the page, falls back to "open" (anyone
* who can reach the route can access — same as before grants existed).
* 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.
*/
public static function userCanAccess(User $user, NavItem $navItem): bool
{
if ($user->is_super_admin) return true;
$hasGrants = static::where('nav_item_id', $navItem->id)->exists();
if (! $hasGrants) return true;
$groupIds = $user->groups()->pluck('groups.id');
return static::where('nav_item_id', $navItem->id)

View File

@@ -312,6 +312,40 @@ class UnifiApiClient
return $this->put("/rest/wlanconf/{$wlanId}", $data);
}
/**
* Find sibling WLAN configs — same SSID name, different _id. UniFi
* splits a "banded" SSID (band-steering disabled) into one wlanconf
* per band, each with its own _id and its own embedded PPSK array.
* A rotation that updates one band must also update the others, or
* the SSID's two halves drift out of sync.
*
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
* Empty array if the target WLAN is unique or can't be found.
*/
public function getWlanSiblings(string $wlanId): array
{
try {
$all = $this->get('/rest/wlanconf');
} catch (\Throwable) {
return [];
}
$target = null;
foreach ($all as $w) {
if (($w['_id'] ?? null) === $wlanId) { $target = $w; break; }
}
if (! $target || empty($target['name'])) return [];
$siblings = [];
foreach ($all as $w) {
if (($w['_id'] ?? null) === $wlanId) continue;
if (($w['name'] ?? null) === $target['name']) {
$siblings[] = $w['_id'];
}
}
return $siblings;
}
// ── PPSK ─────────────────────────────────────────────────────────────────
/**
@@ -496,6 +530,87 @@ class UnifiApiClient
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
}
/**
* Update an embedded PPSK (one that lives inside a WLAN's
* private_preshared_keys array rather than as its own REST resource).
*
* Matching is done by current passphrase since embedded entries have
* no controller-side ID. Only changes the entry's passphrase; name
* isn't separately addressable on embedded PPSKs.
*/
public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array
{
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
$wlan = $wlanResp[0] ?? $wlanResp;
$entries = $wlan['private_preshared_keys'] ?? [];
if (! is_array($entries) || empty($entries)) {
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
}
// 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)
$applyUpdate = function (array &$e) use ($newPassphrase) {
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
$e['password'] = $newPassphrase;
}
};
$matched = false;
if ($name !== null && $name !== '') {
foreach ($entries as &$e) {
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
if ($entryName === $name) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched && $oldPassphrase !== null && $oldPassphrase !== '') {
foreach ($entries as &$e) {
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
if ($current === $oldPassphrase) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched) {
throw new \RuntimeException(
'Embedded PPSK not found' .
($name !== null ? " by name \"{$name}\"" : '') .
' or by current passphrase.'
);
}
// UniFi REST expects the full WLAN object on PUT — send what we
// got back, with the mutated PPSK array.
$payload = $wlan;
$payload['private_preshared_keys'] = $entries;
// Strip internal fields the controller rejects on PUT.
unset($payload['_id'], $payload['site_id']);
$this->put("/rest/wlanconf/{$wlanId}", $payload);
// Return a normalized record so callers can read the new state.
return $this->normalizePpsk([[
'_id' => 'emb_' . substr(hash('sha256', $wlanId . ':' . $newPassphrase), 0, 32),
'wlan_id' => $wlanId,
'x_passphrase' => $newPassphrase,
]]);
}
public function deletePpsk(string $ppskId): void
{
// Try v2 hotspot endpoint first

View File

@@ -581,10 +581,19 @@ class WebhookCheckService
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
{
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message];
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message];
return self::buildPlatformPayload($url, $message, $fullPayload);
}
/**
* Public/static helper so the test-webhook endpoint produces the
* same per-platform payload shape that real events do.
*/
public static function buildPlatformPayload(string $url, string $message, array $fullPayload): array
{
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message];
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message];
return $fullPayload;
}

View File

@@ -44,7 +44,9 @@ class UnifiServiceProvider extends ServiceProvider
if (! $item) return;
if (! UnifiPageGrant::userCanAccess($user, $item)) {
abort(403, 'You do not have access to this page.');
// 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

View File

@@ -9,6 +9,7 @@ use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
use Dashboard\Unifi\Http\Controllers\VlanGroupController;
use Dashboard\Unifi\Http\Controllers\WebhookController;
use Dashboard\Unifi\Http\Controllers\WifiApiController;
use Dashboard\Unifi\Http\Controllers\WifiController;
use Illuminate\Support\Facades\Route;
@@ -75,22 +76,36 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
// Page Access — super-admin only. Lists unifi pages and lets
// operators assign per-page user/group grants.
Route::middleware('super.admin')->group(function () {
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
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::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
});
// Cron logs — read-only history of scheduled-task runs.
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
// Webhooks
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
Route::put('/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
Route::delete('/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
Route::post('/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
// Webhooks — lives under /settings/* so it reads as a settings tab.
Route::get('/settings/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
Route::post('/settings/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
Route::put('/settings/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
Route::delete('/settings/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
Route::post('/settings/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
Route::post('/settings/webhooks/test-url', [WebhookController::class, 'testUrl'])->name('webhooks.test-url');
// API-token management
Route::post('/settings/api-token/regenerate', [UnifiSettingsController::class, 'regenerateApiToken'])->name('settings.api-token.regenerate');
Route::delete('/settings/api-token', [UnifiSettingsController::class, 'clearApiToken']) ->name('settings.api-token.clear');
});
});
// ── Public API (token-protected) ──────────────────────────────────────────
// External integrations (signage, kiosks) hit these without session auth.
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
Route::get('/wifi/current-password', [WifiApiController::class, 'currentPassword'])
->name('wifi.current-password');
});
// ── Captive portal callback (public — user redirected here by UniFi) ─────
Route::middleware(['web', 'auth'])
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])