22 Commits

Author SHA1 Message Date
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
75943fbe2b feat(logs): structured cron run history + read endpoint
Adds unifi_cron_runs table (one row per scheduled-task execution) and
UnifiCronRun::record() wrapper that captures start/finish/status and
exceptions. The three scheduled commands now write through it:

  - reboot-all-aps    → rebooted/failed AP names per run
  - rotate-passwords  → rotated SSIDs + PPSKs, failures (when actually
                        rotating; the "is it due" early-return is silent
                        so we don't flood the log with no-op rows every
                        minute)
  - sync-ppsk-schedules → enabled/disabled PPSKs (silent when there's
                          no work)

UnifiCronLogsController returns the most-recent 200 runs as JSON,
filterable by command + status. Behind permission:unifi.settings; no
super-admin required — read-only history is fine for any operator
who can see settings.

v1.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:05:36 -04:00
a33f2885ff feat(access): per-page user/group grants, snap-in-local
A snap-in-owned access mechanism. Adds:
  - unifi_page_grants table (nav_item_id, grantee_type, grantee_id)
    with cascadeOnDelete from nav_items so uninstalling the snap-in
    wipes its grant rows automatically
  - UnifiPageGrant model + ::userCanAccess(user, navItem) helper
  - UnifiPagesAccessController (index + update), super-admin only
  - RouteMatched listener in UnifiServiceProvider that 403s any
    unifi.* route if the matched nav_item has grants and the user
    isn't a super-admin / granted user / member of a granted group

Semantics: a page with NO grants stays open per the existing
permission middleware (no behaviour change). The moment grants are
added, ONLY super-admins and listed users/groups can see/open the
page. Super-admins always pass; their access can't be removed.

v1.4.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:47:57 -04:00
a4397c5178 chore: remove AP Groups surfaces (legacy API auth incompatible)
UniFi's /rest/apgroup endpoints (and per-SSID ap_group_ids writes via
/rest/wlanconf) require session-cookie auth — they don't accept the
X-API-Key header. The Integration API doesn't expose AP groups at all.
So with the current deployment running on API-key auth, every AP-group
operation returned 400 api.err.InvalidObject. Removing the dead code
rather than carrying a feature that can't function.

* Deleted ApGroupController, ApGroups.vue, the /ap-groups/* routes,
  and getApGroups/createApGroup/updateApGroup/deleteApGroup from
  UnifiApiClient.
* Removed the per-SSID AP-group assignment from Wifi.vue + the
  updateApGroups action + /wifi/{wlanId}/ap-groups route + the
  ap_group_ids field from the mapWlan output.
* Removed the AP Groups nav entry from composer.json.

If a future deploy adds local-admin username+password auth, AP groups
can be reintroduced — the UnifiApiClient::buildRequest() session-cookie
path is intact.

v1.3.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:35:32 -04:00
fc4f5370ae fix(ppsk): null schedule = always on; disabled global toggle restores all
Previously SyncPpskSchedules returned early when the global setting
was disabled, leaving any PPSK that had been held by a prior sync
stuck in 'held' state. It also only iterated whereNotNull(schedule),
so null-schedule PPSKs ("always on") were never drift-corrected back
to active either.

Now the command always runs and computes a per-PPSK target state:
  - global ppsk_scheduling disabled  → target = active (always)
  - global enabled  + null schedule  → target = active (always)
  - global enabled  + has schedule   → follow the schedule's slot

PPSKs that drift from the target get enabled/disabled accordingly.
Schedules in unifi_ppsks.schedule are preserved across global toggles
either way — disabling the setting doesn't touch them, so re-enabling
resumes the operator's per-PPSK schedules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:09:40 -04:00
e59f193ffc feat(nav): surface AP Groups page; always-fresh device data on edit pages
The AP Groups page (ApGroups.vue + ApGroupController + UnifiApiClient
CRUD methods) has been built but never declared in composer.json's
pages list, so it was hidden from the menu. Added it at sort_order=6,
between WiFi Networks and Portal.

The WiFi Networks page already has per-SSID AP-group assignment via
the existing updateApGroups route — that wires into UniFi's standard
ap_group_ids field on wlanconf. (UniFi doesn't expose per-AP-only
assignment separately; the convention is "make a one-AP group for
this AP and assign the SSID to it.")

For the "always pull from UniFi on load" guarantee:
- getWlans() and getApGroups() are already uncached — fresh on every
  page load
- getDevices() (feeds the AP picker for group membership) is cached
  for unifi.cache_ttl seconds; both ApGroupController::index and
  WifiController::index now Cache::forget('unifi:devices') before
  reading so the device list is always fresh

v1.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:06:49 -04:00
f7672771e0 refactor: read timezone from shell-level site_timezone
Drops unifi.timezone from the settings form (now lives in
Admin → Settings on the shell). Schedulers (PPSK sync, password
rotation) now read \App\Support\Timezone::current() — same fallback
chain as the rest of the platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:26:50 -04:00
996f6f0371 fix(api): try UniFi OS Integration API first for X-API-Key auth
The /api/self/sites and /proxy/network/api/self/sites endpoints belong
to the legacy session-cookie API — they don't accept X-API-Key auth and
return 401 for keys generated in UniFi OS → Control Plane → Integrations.

Adds /proxy/network/integration/v1/sites as the first endpoint tried,
which is the actual home of API keys. Integration response rows look
like { id, internalReference, name }; getSites normalizes them to the
legacy { name, desc } shape using internalReference as the slug so
downstream URLs (which build paths from $this->site) keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:55:04 -04:00
0802ef35f3 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>
2026-05-19 17:54:24 -04:00
32 changed files with 2498 additions and 324 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.0.0",
"version": "1.9.0",
"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,30 @@
<?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::create('unifi_password_rotations', function (Blueprint $table) {
$table->id();
$table->string('name'); // display name (e.g. "Staff WiFi")
$table->json('wlan_ids'); // array of WLAN IDs to rotate
$table->text('wordlist')->nullable(); // one password per line
$table->boolean('enabled')->default(true);
$table->string('frequency')->default('weekly'); // daily | weekly
$table->tinyInteger('day_of_week')->nullable(); // 0=Sun ... 6=Sat (weekly only)
$table->tinyInteger('hour')->default(1);
$table->tinyInteger('minute')->default(0);
$table->timestamp('last_rotated_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('unifi_password_rotations');
}
};

View File

@@ -0,0 +1,25 @@
<?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::create('unifi_vlan_groups', function (Blueprint $table) {
$table->id();
$table->string('name'); // e.g. "Students"
$table->unsignedSmallInteger('vlan_id'); // 14094
$table->string('description')->nullable(); // optional note
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('unifi_vlan_groups');
}
};

View File

@@ -0,0 +1,25 @@
<?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) {
// Tracks whether an active device_offline alert has been sent for this device.
// device_online ("resolved") only fires when in_alert = true.
// This prevents orphan "back online" alerts and duplicate offline alerts.
$table->boolean('in_alert')->default(false)->after('was_online');
});
}
public function down(): void
{
Schema::table('unifi_device_states', function (Blueprint $table) {
$table->dropColumn('in_alert');
});
}
};

View File

@@ -0,0 +1,29 @@
<?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::create('unifi_ppsks', function (Blueprint $table) {
$table->id();
$table->string('wlan_id', 36)->index(); // UniFi WLAN _id
$table->string('unifi_id', 36)->nullable()->index(); // null when held (not currently in UniFi)
$table->string('name', 100);
$table->string('x_passphrase', 63);
$table->unsignedSmallInteger('vlan')->nullable();
$table->enum('state', ['active', 'held'])->default('active')->index();
$table->boolean('rotate_password')->default(false);
$table->json('schedule')->nullable(); // 336 booleans [day*48+slot], null = unscheduled
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('unifi_ppsks');
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Per-page access grants for unifi pages. A user can access a unifi
* page if ANY of these hold:
* - is_super_admin (always)
* - user has the page's required_permission (existing nav_items column)
* - user is in the page's required_group_id (existing column)
* - a row here grants them as a user, or via a group they're in
*
* Snap-in-local table — disappears with the snap-in if uninstalled.
*/
public function up(): void
{
Schema::create('unifi_page_grants', function (Blueprint $table) {
$table->id();
$table->foreignId('nav_item_id')->constrained('nav_items')->cascadeOnDelete();
$table->enum('grantee_type', ['user', 'group']);
$table->unsignedBigInteger('grantee_id');
$table->foreignId('granted_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['nav_item_id', 'grantee_type', 'grantee_id'], 'unifi_page_grants_unique');
$table->index(['grantee_type', 'grantee_id']);
});
}
public function down(): void
{
Schema::dropIfExists('unifi_page_grants');
}
};

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
{
/**
* Structured log of every unifi scheduled-task execution: AP reboots,
* password rotations, PPSK schedule syncs. One row per run.
* Surfaced in the Logs tab of the Unifi settings page.
*/
public function up(): void
{
Schema::create('unifi_cron_runs', function (Blueprint $table) {
$table->id();
$table->string('command', 64)->index(); // 'reboot-all-aps' | 'rotate-passwords' | 'sync-ppsk-schedules'
$table->enum('triggered_by', ['schedule', 'manual']);
$table->foreignId('triggered_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('started_at')->index();
$table->timestamp('finished_at')->nullable();
$table->string('status', 16); // 'running' | 'succeeded' | 'partial' | 'failed' | 'skipped'
$table->longText('details')->nullable(); // JSON: counts, per-item actions, error summary
});
}
public function down(): void
{
Schema::dropIfExists('unifi_cron_runs');
}
};

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

@@ -0,0 +1,69 @@
<?php
namespace Dashboard\Unifi\Console;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class RebootAllAps extends Command
{
protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot} {--triggered-by=schedule}';
protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
public function handle(UnifiApiClient $unifi): int
{
$run = UnifiCronRun::record(
'reboot-all-aps',
$this->option('triggered-by') ?: 'schedule',
null,
function () use ($unifi) {
$aps = $unifi->getAccessPoints();
if (empty($aps)) {
$this->warn('No access points found.');
return ['status' => 'skipped', 'reason' => 'no APs found'];
}
$delay = max(0, (int) $this->option('delay'));
$rebooted = [];
$failed = [];
foreach ($aps as $ap) {
$mac = strtolower($ap['mac']);
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
}
$this->newLine();
foreach ($aps as $ap) {
$mac = strtolower($ap['mac']);
$name = $ap['name'] ?? $mac;
try {
$unifi->rebootDevice($mac);
$this->info("Rebooted: {$name} ({$mac})");
$rebooted[] = $name;
} catch (\Throwable $e) {
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
$failed[] = ['name' => $name, 'error' => $e->getMessage()];
}
if ($delay > 0 && count($rebooted) + count($failed) < count($aps)) {
sleep($delay);
}
}
return [
'status' => count($failed) === 0 ? 'succeeded' : (count($rebooted) > 0 ? 'partial' : 'failed'),
'rebooted' => $rebooted,
'failed' => $failed,
'total' => count($aps),
];
}
);
$this->info("Done. Status: {$run->status}.");
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -0,0 +1,165 @@
<?php
namespace Dashboard\Unifi\Console;
use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command;
class RotatePasswords extends Command
{
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule} {--triggered-by=schedule}';
protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
public function handle(UnifiApiClient $unifi): int
{
if (! Setting::get('unifi.password_rotation.enabled')) {
// Don't log anything — the scheduler runs this every minute
// and we'd flood the logs with "rotation disabled" rows.
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->isDue()) {
// Same reasoning — only log when we actually do something.
return self::SUCCESS;
}
$force = $this->option('force');
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
$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 = [];
$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', '');
$passwords = array_values(array_filter(array_map('trim', explode("\n", $wordlist))));
if (empty($passwords)) {
$this->warn('Password rotation: no passwords in wordlist — skipped.');
return ['status' => 'skipped', 'reason' => 'empty wordlist'];
}
$password = $passwords[array_rand($passwords)];
$rotated = [];
$failedWlans = [];
foreach ($wlanIds as $wlanId) {
try {
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
$rotated[] = $wlanId;
} catch (\Throwable $e) {
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
$failedWlans[] = ['wlan_id' => $wlanId, 'error' => $e->getMessage()];
}
}
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 ($ppskQuery->get() as $ppsk) {
$newPass = $passwords[array_rand($passwords)];
try {
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
// Embedded PPSK: update inside the parent WLAN object.
// Synthetic ID is derived from the new passphrase, so update it too.
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass);
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
$oldPass = $ppsk->x_passphrase;
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
// Sibling WLANs (same SSID name on a different band):
// their embedded PPSK with the same name also needs
// to rotate to the same new password 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();
$siblingOldPass = $sibling?->x_passphrase ?? $oldPass;
try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass);
if ($sibling) {
$sibling->update([
'x_passphrase' => $newPass,
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
]);
}
} catch (\Throwable $e) {
$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()];
}
}
$hasFailures = count($failedWlans) + count($failedPpsks) > 0;
$hasSuccess = count($rotated) + count($rotatedPpsks) > 0;
return [
'status' => $hasFailures ? ($hasSuccess ? 'partial' : 'failed') : 'succeeded',
'rotated_wlans' => $rotated,
'failed_wlans' => $failedWlans,
'rotated_ppsks' => $rotatedPpsks,
'failed_ppsks' => $failedPpsks,
];
});
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
}
private function isDue(): bool
{
$frequency = Setting::get('unifi.password_rotation.frequency', 'weekly');
$hour = (int) Setting::get('unifi.password_rotation.hour', 2);
$minute = (int) Setting::get('unifi.password_rotation.minute', 0);
$dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0);
$tz = \App\Support\Timezone::current();
$now = now($tz);
if ($now->hour !== $hour || $now->minute !== $minute) {
return false;
}
if ($frequency === 'weekly' && $now->dayOfWeek !== $dow) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace Dashboard\Unifi\Console;
use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command;
class SyncPpskSchedules extends Command
{
protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled} {--triggered-by=schedule}';
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
public function handle(UnifiApiClient $unifi): int
{
$ppsks = UnifiPpsk::all();
if ($ppsks->isEmpty()) {
// Don't bother logging — no work, no audit value.
return self::SUCCESS;
}
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
$run = UnifiCronRun::record('sync-ppsk-schedules', $triggeredBy, null, function () use ($unifi, $ppsks) {
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
$tz = \App\Support\Timezone::current();
$now = now($tz);
$day = $now->dayOfWeek;
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0);
$networksByVlan = [];
try {
foreach ($unifi->getNetworkConfs() as $n) {
if (isset($n['vlan'])) {
$networksByVlan[(int) $n['vlan']] = $n;
}
}
} catch (\Throwable $e) {
$this->warn("Could not fetch network configs: {$e->getMessage()}");
}
$enabled = [];
$disabled = [];
$errors = [];
foreach ($ppsks as $ppsk) {
$shouldBeOn = true;
if ($globalEnabled && $ppsk->schedule) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
}
try {
if ($shouldBeOn && $ppsk->state === 'held') {
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
$enabled[] = $ppsk->name;
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
$this->disablePpsk($ppsk, $unifi);
$disabled[] = $ppsk->name;
}
} catch (\Throwable $e) {
$errors[] = ['ppsk' => $ppsk->name, 'error' => $e->getMessage()];
}
}
$hasActions = count($enabled) + count($disabled) > 0;
$status = count($errors) > 0
? ($hasActions ? 'partial' : 'failed')
: ($hasActions ? 'succeeded' : 'skipped');
return [
'status' => $status,
'global_enabled' => $globalEnabled,
'enabled_ppsks' => $enabled,
'disabled_ppsks' => $disabled,
'errors' => $errors,
];
});
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
}
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void
{
try {
$data = [
'name' => $ppsk->name,
'x_passphrase' => $ppsk->x_passphrase,
'wlan_id' => $ppsk->wlan_id,
];
if ($ppsk->vlan && isset($networksByVlan[$ppsk->vlan])) {
$data['networkconf_id'] = $networksByVlan[$ppsk->vlan]['_id'];
}
$result = $unifi->createPpsk($data);
$raw = $result[0] ?? $result;
$newId = $raw['_id'] ?? null;
$ppsk->update(['state' => 'active', 'unifi_id' => $newId]);
$this->info("Enabled: {$ppsk->name} (wlan {$ppsk->wlan_id})");
} catch (\Throwable $e) {
$this->error("Failed to enable PPSK \"{$ppsk->name}\": {$e->getMessage()}");
}
}
private function disablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi): void
{
try {
$kicked = $unifi->kickClientsForPpsk($ppsk->unifi_id);
$unifi->deletePpsk($ppsk->unifi_id);
$ppsk->update(['state' => 'held', 'unifi_id' => null]);
$suffix = $kicked > 0 ? " — kicked {$kicked} client(s)" : '';
$this->info("Disabled: {$ppsk->name}{$suffix}");
} catch (\Throwable $e) {
$this->error("Failed to disable PPSK \"{$ppsk->name}\": {$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,7 +13,15 @@ class DeviceController extends Controller
public function index(UnifiApiClient $unifi)
{
try {
$devices = collect($unifi->getDevices())->map(fn ($d) => [
$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'] ?? '',
@@ -26,15 +35,27 @@ class DeviceController extends Controller
'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,
'channels' => collect($d['radio_table'] ?? [])->map(fn ($r) => [
// 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' => $r['channel'] ?? null,
'ht' => $r['ht'] ?? '',
])->values(),
])->values();
'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

@@ -0,0 +1,43 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Models\UnifiCronRun;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class UnifiCronLogsController extends Controller
{
public function index(Request $request)
{
$filters = $request->only(['command', 'status']);
$runs = UnifiCronRun::query()
->with('triggeredByUser:id,name,email')
->when($filters['command'] ?? null, fn ($q, $c) => $q->where('command', $c))
->when($filters['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
->orderByDesc('started_at')
->limit(200)
->get();
return response()->json([
'runs' => $runs->map(fn ($r) => [
'id' => $r->id,
'command' => $r->command,
'triggered_by' => $r->triggered_by,
'triggered_user' => $r->triggeredByUser ? [
'id' => $r->triggeredByUser->id,
'name' => $r->triggeredByUser->name,
'email' => $r->triggeredByUser->email,
] : null,
'started_at' => $r->started_at?->toIso8601String(),
'finished_at' => $r->finished_at?->toIso8601String(),
'duration_ms' => $r->finished_at && $r->started_at
? (int) $r->finished_at->diffInMilliseconds($r->started_at)
: null,
'status' => $r->status,
'details' => $r->details,
])->values(),
]);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\DashboardApp;
use App\Models\Group;
use App\Models\NavItem;
use App\Models\User;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
/**
* Super-admin-only endpoints for managing per-page access on unifi
* pages. Pages here = nav_items where app_id = unifi's DashboardApp row.
*/
class UnifiPagesAccessController extends Controller
{
public function index()
{
$app = DashboardApp::where('slug', 'unifi')->first();
if (! $app) {
return response()->json(['pages' => [], 'users' => [], 'groups' => []]);
}
$pages = NavItem::where('app_id', $app->id)
->where('is_folder', false)
->whereNotNull('route_name')
->orderBy('sort_order')
->get(['id', 'label', 'route_name']);
$grants = UnifiPageGrant::whereIn('nav_item_id', $pages->pluck('id'))
->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,
'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(),
'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();
if (! $app || $navItem->app_id !== $app->id) {
return response()->json(['error' => 'Not a unifi page.'], 422);
}
$data = $request->validate([
'user_ids' => 'present|array',
'user_ids.*' => 'integer|exists:users,id',
'group_ids' => 'present|array',
'group_ids.*' => 'integer|exists:groups,id',
]);
$grantedBy = $request->user()?->id;
DB::transaction(function () use ($navItem, $data, $grantedBy) {
UnifiPageGrant::where('nav_item_id', $navItem->id)->delete();
$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);
});
return response()->json(['ok' => true]);
}
}

View File

@@ -14,51 +14,93 @@ class UnifiSettingsController extends Controller
{
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),
'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),
'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([
'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',
'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',
'api_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('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30);
// Clear cached sessions so new credentials take effect
\Illuminate\Support\Facades\Cache::forget('unifi:session:' . md5(rtrim($request->controller_url, '/') . $request->username));
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' : '');
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
return back()->with('success', 'UniFi settings saved.');
@@ -103,12 +145,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

@@ -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

@@ -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,70 @@ 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', '{}');
$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,
'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' => [], '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',
'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 (! empty($shared)) {
if ($groupedIds) {
foreach ($groupedIds as $id) {
$unifi->updateWlan($id, $data);
$unifi->updateWlan($id, $shared);
}
} else {
$unifi->updateWlan($wlanId, $data);
$unifi->updateWlan($wlanId, $shared);
}
}
// 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.');
@@ -94,6 +109,288 @@ 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)) {
if (str_starts_with($record->unifi_id, 'emb_') && isset($unifiUpdate['x_passphrase'])) {
// Embedded PPSK update path — modify the WLAN's embedded array.
$newPass = $unifiUpdate['x_passphrase'];
$oldPass = $record->x_passphrase;
$unifi->updateEmbeddedPpsk($record->wlan_id, $oldPass, $newPass);
$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();
$siblingOldPass = $sibling?->x_passphrase ?? $oldPass;
try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $siblingOldPass, $newPass);
if ($sibling) {
$sibling->update([
'x_passphrase' => $newPass,
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
]);
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('unifi.ppsk_sibling_update_failed', [
'sibling_wlan' => $siblingWlanId,
'error' => $e->getMessage(),
]);
}
}
} else {
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
}
}
}
$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);
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 +403,39 @@ 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),
'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';

View File

@@ -8,6 +8,6 @@ class DeviceState extends Model
{
public $timestamps = false;
protected $table = 'unifi_device_states';
protected $fillable = ['device_mac', 'device_name', 'was_online', 'consecutive_count', 'last_seen_at', 'updated_at'];
protected $casts = ['was_online' => 'boolean', 'last_seen_at' => 'datetime', 'updated_at' => 'datetime'];
protected $fillable = ['device_mac', 'device_name', 'was_online', 'in_alert', 'consecutive_count', 'last_seen_at', 'updated_at'];
protected $casts = ['was_online' => 'boolean', 'in_alert' => 'boolean', 'last_seen_at' => 'datetime', 'updated_at' => 'datetime'];
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class UnifiCronRun extends Model
{
protected $table = 'unifi_cron_runs';
public $timestamps = false;
protected $fillable = [
'command',
'triggered_by',
'triggered_by_user_id',
'started_at',
'finished_at',
'status',
'details',
];
protected $casts = [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'details' => 'array',
];
public function triggeredByUser()
{
return $this->belongsTo(\App\Models\User::class, 'triggered_by_user_id');
}
/**
* Wraps a unit of cron work, recording start/finish/status and any
* exception. Returns whatever the work returns; the resulting
* UnifiCronRun row is returned via the $run reference param.
*/
public static function record(string $command, string $triggeredBy, ?int $userId, callable $work): self
{
$run = static::create([
'command' => $command,
'triggered_by' => $triggeredBy,
'triggered_by_user_id' => $userId,
'started_at' => now(),
'status' => 'running',
]);
try {
$details = $work($run);
// Caller can return a status string ("skipped", "partial",
// etc.) by sticking it under the 'status' key in details.
// Default = succeeded.
$status = is_array($details) && isset($details['status'])
? $details['status']
: 'succeeded';
$run->update([
'finished_at' => now(),
'status' => $status,
'details' => is_array($details) ? array_diff_key($details, ['status' => null]) : null,
]);
} catch (\Throwable $e) {
$run->update([
'finished_at' => now(),
'status' => 'failed',
'details' => [
'error' => $e->getMessage(),
'class' => $e::class,
'file' => $e->getFile() . ':' . $e->getLine(),
],
]);
throw $e;
}
return $run->refresh();
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Dashboard\Unifi\Models;
use App\Models\NavItem;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UnifiPageGrant extends Model
{
protected $table = 'unifi_page_grants';
protected $fillable = [
'nav_item_id',
'grantee_type',
'grantee_id',
'granted_by_user_id',
];
public function navItem(): BelongsTo
{
return $this->belongsTo(NavItem::class);
}
public function grantedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'granted_by_user_id');
}
/**
* 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;
$groupIds = $user->groups()->pluck('groups.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);
});
})
->exists();
}
}

30
src/Models/UnifiPpsk.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class UnifiPpsk extends Model
{
protected $table = 'unifi_ppsks';
protected $fillable = [
'wlan_id', 'unifi_id', 'name', 'x_passphrase', 'vlan',
'state', 'rotate_password', 'schedule',
];
protected $casts = [
'vlan' => 'integer',
'rotate_password' => 'boolean',
'schedule' => 'array',
];
/**
* Returns true if this PPSK should be active at the given day (0=Sun…6=Sat)
* and half-hour slot (0=00:00, 47=23:30).
* A null schedule means always-on.
*/
public function isScheduledOnAt(int $day, int $slot): bool
{
if (! $this->schedule) return true;
return (bool) ($this->schedule[$day * 48 + $slot] ?? true);
}
}

12
src/Models/VlanGroup.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class VlanGroup extends Model
{
protected $table = 'unifi_vlan_groups';
protected $fillable = ['name', 'vlan_id', 'description', 'sort_order'];
protected $casts = ['vlan_id' => 'integer', 'sort_order' => 'integer'];
}

View File

@@ -286,6 +286,20 @@ class UnifiApiClient
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
}
private function delete(string $path): void
{
$this->init();
$url = $this->siteUrl($path);
$response = $this->buildRequest()->delete($url);
if ($response->status() === 401) {
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
$response = $this->buildRequest()->delete($url);
}
if (! $response->successful()) {
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
}
}
// ── WiFi Networks / WLANs ─────────────────────────────────────────────────
public function getWlans(): array
@@ -298,6 +312,321 @@ 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 ─────────────────────────────────────────────────────────────────
/**
* Build a v2 API URL. UniFi OS consoles expose /proxy/network/v2/api/site/{site}/...
* Standalone controllers may not have this path.
*/
private function v2SiteUrl(string $path): string
{
$this->init();
$v2Base = str_contains($this->apiPath(), 'proxy') ? '/proxy/network' : '';
return "{$this->baseUrl}{$v2Base}/v2/api/site/{$this->site}{$path}";
}
/**
* Check whether an HTTP response is a usable JSON response (not a 404 HTML page).
*/
private function isJsonResponse($response): bool
{
if (! $response->successful()) return false;
$ct = $response->header('Content-Type', '');
return str_contains($ct, 'json') || (! str_contains($ct, 'html'));
}
/**
* Normalize PPSK entries to a consistent shape regardless of API version.
*
* We merge the original raw entry with our normalized aliases so:
* - All original fields survive (frontend can inspect them)
* - Standard field names (_id, name, x_passphrase, wlan_id, vlan_id) are
* always present, mapped from whatever variant the API used
*/
private function normalizePpsk(array $entries): array
{
return array_values(array_map(function ($e) {
$normalized = [
'_id' => $e['_id'] ?? $e['id'] ?? null,
'name' => $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null,
'x_passphrase' => $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? $e['psk'] ?? null,
'wlan_id' => $e['wlan_id'] ?? $e['wlanId'] ?? null,
'networkconf_id'=> $e['networkconf_id'] ?? $e['network_conf_id'] ?? $e['networkId'] ?? null,
'vlan_id' => $e['vlan_id'] ?? $e['vlanId'] ?? $e['vlan'] ?? null,
];
return array_merge($e, array_filter($normalized, fn ($v) => $v !== null));
}, $entries));
}
public function getNetworkConfs(): array
{
return $this->get('/rest/networkconf');
}
/**
* Fetch PPSK entries for a WLAN.
*
* Tries multiple endpoints in order of likelihood, because the correct path
* varies significantly by controller version and firmware:
*
* 1. Classic REST with wlan_id filter (works on most standalone controllers)
* 2. Classic REST fetch-all + local filter (when query param is ignored)
* 3. v2 hotspot endpoint (UniFi Network App 7.x+)
* 4. v2 wlan password endpoint (some UDM firmware variants)
* 5. Embedded in the WLAN object itself (some controller versions)
*/
public function getPpskEntries(string $wlanId): array
{
// 1. Classic path with filter
try {
$results = $this->get("/rest/ppsk?wlan_id={$wlanId}");
if (! empty($results)) {
Log::info('unifi.ppsk_found', ['path' => 'classic_filter', 'count' => count($results)]);
return $this->normalizePpsk($results);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_classic_filter_failed', ['error' => $e->getMessage()]);
}
// 2. Classic path without filter — fetch all and filter locally
try {
$all = $this->get('/rest/ppsk');
Log::debug('unifi.ppsk_classic_all', ['total' => count($all)]);
if (! empty($all)) {
$filtered = array_filter($all, fn ($e) => ($e['wlan_id'] ?? '') === $wlanId);
if (! empty($filtered)) {
Log::info('unifi.ppsk_found', ['path' => 'classic_all_filtered', 'count' => count($filtered)]);
return $this->normalizePpsk(array_values($filtered));
}
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_classic_all_failed', ['error' => $e->getMessage()]);
}
// 34. v2 API paths (try each, skip HTML error pages)
$v2Paths = [
"/hotspot/op/private-preshared-key?wlanId={$wlanId}",
"/wlan/{$wlanId}/password",
"/wlan/{$wlanId}/private-preshared-key",
];
foreach ($v2Paths as $path) {
try {
$url = $this->v2SiteUrl($path);
$response = $this->buildRequest()->get($url);
Log::debug('unifi.ppsk_v2_probe', ['path' => $path, 'status' => $response->status(), 'ct' => $response->header('Content-Type')]);
if (! $this->isJsonResponse($response)) continue;
$data = $response->json('data') ?? $response->json() ?? [];
if (is_array($data) && ! empty($data)) {
Log::info('unifi.ppsk_found', ['path' => $path, 'count' => count($data)]);
return $this->normalizePpsk(array_values($data));
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_path_failed', ['path' => $path, 'error' => $e->getMessage()]);
}
}
// 5. Check if PPSKs are embedded in the WLAN object
try {
$wlan = $this->get("/rest/wlanconf/{$wlanId}");
$w = $wlan[0] ?? $wlan;
$embedded = $w['private_preshared_keys'] ?? [];
if (! empty($embedded)) {
Log::info('unifi.ppsk_found', ['path' => 'wlan_embedded', 'count' => count($embedded)]);
return $this->normalizePpsk($embedded);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_wlan_embedded_failed', ['error' => $e->getMessage()]);
}
Log::warning('unifi.ppsk_all_paths_empty', ['wlan_id' => $wlanId]);
// All paths exhausted — return empty rather than erroring (network may just have no PPSKs yet)
return [];
}
public function createPpsk(array $data): array
{
$wlanId = $data['wlan_id'] ?? null;
// Try v2 hotspot endpoint first
if ($wlanId) {
try {
$url = $this->v2SiteUrl('/hotspot/op/private-preshared-key');
$response = $this->buildRequest()->asJson()->post($url, $data);
if ($this->isJsonResponse($response)) {
return $this->normalizePpsk([$response->json('data') ?? $response->json()]);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_hotspot_create_failed', ['error' => $e->getMessage()]);
}
// Try v2 wlan password endpoint
try {
$url = $this->v2SiteUrl("/wlan/{$wlanId}/password");
$response = $this->buildRequest()->asJson()->post($url, $data);
if ($this->isJsonResponse($response)) {
$result = $response->json('data') ?? $response->json();
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_wlan_create_failed', ['error' => $e->getMessage()]);
}
}
// Fall back to classic REST
$result = $this->post('/rest/ppsk', $data);
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
}
public function updatePpsk(string $ppskId, array $data): array
{
// Try v2 hotspot endpoint first
try {
$url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}");
$response = $this->buildRequest()->asJson()->put($url, $data);
if ($this->isJsonResponse($response)) {
return $this->normalizePpsk([$response->json('data') ?? $response->json()]);
}
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_hotspot_update_failed', ['error' => $e->getMessage()]);
}
$result = $this->put("/rest/ppsk/{$ppskId}", $data);
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
}
/**
* Update an embedded PPSK (one that lives inside a WLAN's
* private_preshared_keys array rather than as its own REST resource).
*
* Matching is done by current passphrase since embedded entries have
* no controller-side ID. Only changes the entry's passphrase; name
* isn't separately addressable on embedded PPSKs.
*/
public function updateEmbeddedPpsk(string $wlanId, string $oldPassphrase, string $newPassphrase): 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.');
}
$matched = false;
foreach ($entries as &$e) {
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
if ($current === $oldPassphrase) {
// Preserve whichever field name the controller is using.
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 none of the above existed, default to password (most common on embedded).
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
$e['password'] = $newPassphrase;
}
$matched = true;
break;
}
}
unset($e);
if (! $matched) {
throw new \RuntimeException('Embedded PPSK not found by current passphrase.');
}
// UniFi REST expects the full WLAN object on PUT — send what we
// got back, with the mutated PPSK array.
$payload = $wlan;
$payload['private_preshared_keys'] = $entries;
// Strip internal fields the controller rejects on PUT.
unset($payload['_id'], $payload['site_id']);
$this->put("/rest/wlanconf/{$wlanId}", $payload);
// Return a normalized record so callers can read the new state.
return $this->normalizePpsk([[
'_id' => 'emb_' . substr(hash('sha256', $wlanId . ':' . $newPassphrase), 0, 32),
'wlan_id' => $wlanId,
'x_passphrase' => $newPassphrase,
]]);
}
public function deletePpsk(string $ppskId): void
{
// Try v2 hotspot endpoint first
try {
$url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}");
$response = $this->buildRequest()->delete($url);
if ($response->successful()) return;
} catch (\Throwable $e) {
Log::debug('unifi.ppsk_v2_hotspot_delete_failed', ['error' => $e->getMessage()]);
}
$this->delete("/rest/ppsk/{$ppskId}");
}
/**
* Kick (deauth) every client currently connected via the given PPSK.
* UniFi station records include a _psk_id field matching the PPSK's _id.
* Returns the number of clients kicked.
*/
public function kickClientsForPpsk(string $ppskUnifiId): int
{
$kicked = 0;
try {
$clients = $this->get('/rest/sta');
foreach ($clients as $client) {
if (($client['_psk_id'] ?? null) !== $ppskUnifiId) continue;
try {
$this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => $client['mac']]);
$kicked++;
} catch (\Throwable $e) {
// continue kicking remaining clients even if one fails
}
}
} catch (\Throwable $e) {
// non-fatal: if we can't list clients, skip kicking
Log::debug('unifi.ppsk_kick_clients_failed', ['ppsk_id' => $ppskUnifiId, 'error' => $e->getMessage()]);
}
return $kicked;
}
// ── Health / Stats ────────────────────────────────────────────────────────
public function getSiteHealth(): array
@@ -365,8 +694,14 @@ class UnifiApiClient
$http = $this->buildRequest();
}
// Try multiple URL patterns
// Endpoints, in preferred order:
// 1. UniFi OS Integration API — the one X-API-Key keys are issued for
// (response shape: [{ id, internalReference, name }])
// 2. Legacy /proxy/network/api/self/sites — session-cookie API on
// UniFi OS consoles (response: [{ name, desc, ... }])
// 3. Legacy /api/self/sites — standalone controller (no /proxy prefix)
$paths = [
'/proxy/network/integration/v1/sites',
'/proxy/network/api/self/sites',
'/api/self/sites',
];
@@ -385,11 +720,28 @@ class UnifiApiClient
}
$data = $response->json('data', $response->json());
if (is_array($data) && ! empty($data) && isset($data[0]['name'])) {
if (! is_array($data) || empty($data)) {
$lastError = "No sites in response from {$path}";
continue;
}
// Integration API rows have `internalReference` (== legacy
// site slug) and `name` (human-readable). Normalize to
// the legacy {name, desc} shape so downstream code that
// builds URLs with the site slug keeps working.
if (isset($data[0]['internalReference'])) {
$data = array_map(fn ($s) => [
'name' => $s['internalReference'] ?? 'default',
'desc' => $s['name'] ?? $s['internalReference'] ?? 'Default',
], $data);
}
if (isset($data[0]['name'])) {
Log::debug('unifi.sites_found', ['path' => $path, 'count' => count($data)]);
return $data;
}
$lastError = "No sites in response from {$path}";
$lastError = "Unexpected response shape from {$path}";
} else {
$lastError = "HTTP {$response->status()} on {$path}";
}

View File

@@ -5,29 +5,42 @@ namespace Dashboard\Unifi\Services;
use Dashboard\Unifi\Models\DeviceState;
use Dashboard\Unifi\Models\WebhookConfig;
use Dashboard\Unifi\Models\WebhookLog;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WebhookCheckService
{
public const EVENTS = [
// ── Device presence ──────────────────────────────────────────────────────
'device_offline' => 'A UniFi device goes offline',
'device_online' => 'A UniFi device comes back online',
// ── Client tracking ──────────────────────────────────────────────────────
'client_offline' => 'A tracked client (by MAC) disconnects',
'client_online' => 'A tracked client (by MAC) connects',
// ── WAN ──────────────────────────────────────────────────────────────────
'wan_down' => 'Internet / WAN goes down',
'wan_up' => 'Internet / WAN comes back up',
// ── Threshold alerts (fire on entry into alert state) ────────────────────
'client_count_high' => 'AP client count exceeds threshold',
'cu_high' => 'Channel utilization exceeds threshold',
'satisfaction_low' => 'WiFi experience drops below threshold',
'high_error_rate' => 'High retry/drop rate on an AP',
// ── Threshold resolved (fire on exit from alert state) ───────────────────
'client_count_normal' => 'AP client count returns to normal',
'cu_normal' => 'Channel utilization returns to normal',
'satisfaction_normal' => 'WiFi experience returns to normal',
'error_rate_normal' => 'Error rate returns to normal',
// ── Informational ────────────────────────────────────────────────────────
'firmware_available' => 'AP firmware update available',
'ap_unexpected_reboot' => 'AP rebooted unexpectedly (uptime reset)',
];
/**
* Available template variables per event type.
*/
public const TEMPLATE_VARS = [
'device_offline' => ['{{device}}', '{{mac}}', '{{timestamp}}'],
'device_online' => ['{{device}}', '{{mac}}', '{{timestamp}}'],
@@ -36,9 +49,13 @@ class WebhookCheckService
'wan_down' => ['{{timestamp}}'],
'wan_up' => ['{{timestamp}}'],
'client_count_high' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'],
'client_count_normal' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'],
'cu_high' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'],
'cu_normal' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'],
'satisfaction_low' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'],
'satisfaction_normal' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'],
'high_error_rate' => ['{{device}}', '{{mac}}', '{{retries}}', '{{timestamp}}'],
'error_rate_normal' => ['{{device}}', '{{mac}}', '{{timestamp}}'],
'firmware_available' => ['{{device}}', '{{mac}}', '{{version}}', '{{timestamp}}'],
'ap_unexpected_reboot' => ['{{device}}', '{{mac}}', '{{uptime}}', '{{timestamp}}'],
];
@@ -51,9 +68,13 @@ class WebhookCheckService
'wan_down' => '🔴 Internet connection is DOWN',
'wan_up' => '🟢 Internet connection restored',
'client_count_high' => '⚠️ {{device}}: {{clients}} clients connected (threshold: {{threshold}})',
'client_count_normal' => '✅ {{device}}: client count returned to normal ({{clients}} clients)',
'cu_high' => '⚠️ {{device}} ({{radio}}): {{cu}}% channel utilization (threshold: {{threshold}}%)',
'cu_normal' => '✅ {{device}} ({{radio}}): channel utilization returned to normal ({{cu}}%)',
'satisfaction_low' => '⚠️ {{device}}: WiFi experience {{satisfaction}}% (threshold: {{threshold}}%)',
'satisfaction_normal' => '✅ {{device}}: WiFi experience returned to normal ({{satisfaction}}%)',
'high_error_rate' => '⚠️ {{device}}: high retry count ({{retries}})',
'error_rate_normal' => '✅ {{device}}: error rate returned to normal',
'firmware_available' => '🔄 {{device}}: firmware update available (current: {{version}})',
'ap_unexpected_reboot' => '🔄 {{device}}: unexpected reboot (uptime: {{uptime}}s)',
];
@@ -71,7 +92,6 @@ class WebhookCheckService
return 0;
}
// Fetch active clients only if any config tracks client events
$activeClients = null;
$needsClients = $configs->contains(fn ($c) => array_intersect($c->events ?? [], ['client_offline', 'client_online']));
if ($needsClients) {
@@ -88,6 +108,7 @@ class WebhookCheckService
$filter = $config->device_filter ?? [];
$templates = $config->templates ?? [];
$clientMacs = $config->tracked_clients ?? [];
$cid = $config->id;
foreach ($events as $event) {
$alerts = match ($event) {
@@ -97,21 +118,35 @@ class WebhookCheckService
'client_online' => $this->checkClientTransition($activeClients ?? [], $clientMacs, true),
'wan_down' => $this->checkWan($wan, false),
'wan_up' => $this->checkWan($wan, true),
'client_count_high' => $this->checkClientCount($aps, $thresholds['client_count'] ?? 50, $filter),
'cu_high' => $this->checkCu($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter),
'satisfaction_low' => $this->checkSatisfaction($aps, $thresholds['satisfaction_min'] ?? 50, $filter),
'high_error_rate' => $this->checkErrorRate($aps, $filter),
'client_count_high' => $this->checkClientCount($aps, $thresholds['client_count'] ?? 50, $filter, $cid),
'client_count_normal' => $this->checkClientCountResolved($aps, $thresholds['client_count'] ?? 50, $filter, $cid),
'cu_high' => $this->checkCu($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter, $cid, (int) ($thresholds['cu_sustain'] ?? 3)),
'cu_normal' => $this->checkCuResolved($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter, $cid),
'satisfaction_low' => $this->checkSatisfaction($aps, $thresholds['satisfaction_min'] ?? 50, $filter, $cid),
'satisfaction_normal' => $this->checkSatisfactionResolved($aps, $thresholds['satisfaction_min'] ?? 50, $filter, $cid),
'high_error_rate' => $this->checkErrorRate($aps, $filter, $cid),
'error_rate_normal' => $this->checkErrorRateResolved($aps, $filter, $cid),
'firmware_available' => $this->checkFirmware($devices, $filter),
'ap_unexpected_reboot'=> $this->checkReboot($aps, $filter),
default => [],
};
foreach ($alerts as $alert) {
// Extract internal metadata before cooldown/fire
$deviceStateUpdate = $alert['_device_state_update'] ?? null;
unset($alert['_device_state_update']);
if ($this->isInCooldown($config, $event, $alert['key'] ?? '')) continue;
// Apply message template
$alert['message'] = $this->applyTemplate($event, $alert, $templates[$event] ?? null);
$this->fire($config, $event, $alert);
$fired++;
// Update device in_alert state — only AFTER confirmed firing (not if suppressed by cooldown)
if ($deviceStateUpdate !== null) {
[$stateModel, $inAlert] = $deviceStateUpdate;
$stateModel->update(['in_alert' => $inAlert]);
}
}
}
}
@@ -122,6 +157,60 @@ class WebhookCheckService
return $fired;
}
// ── Device transitions ────────────────────────────────────────────────────
/**
* Checks device online/offline transitions.
*
* Offline (2-poll grace): fires when a device has been offline for 2 consecutive polls
* AND no active offline alert is already outstanding (in_alert = false).
* Setting in_alert=true is deferred until after the alert is confirmed not suppressed by cooldown.
*
* Online ("resolved"): fires when a device has been online for 2 consecutive polls
* AND an active offline alert was previously sent (in_alert = true).
* This prevents orphan "back online" notifications with no preceding "offline".
*/
private function checkDeviceTransition(array $devices, array $filter, bool $comingOnline): array
{
$alerts = [];
foreach ($devices as $dev) {
$mac = $dev['mac'];
if (! empty($filter) && ! in_array($mac, $filter)) continue;
$name = $dev['name'] ?? $dev['model'] ?? $mac;
$isOnline = ($dev['state'] ?? 0) == 1;
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) continue;
// Skip planned reboots — these are intentional, not alerts
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
if ($comingOnline) {
// Online: 2nd consecutive online poll, and we previously sent an offline alert
if ($prev->was_online === false && $isOnline && $prev->consecutive_count >= 1 && $prev->in_alert) {
$alerts[] = [
'key' => $mac,
'device' => $name,
'mac' => $mac,
'message' => "{$name} is back online",
'_device_state_update' => [$prev, false], // clear in_alert after fire
];
}
} else {
// Offline: 2nd consecutive offline poll, no active alert already outstanding
if ($prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 1 && ! $prev->in_alert) {
$alerts[] = [
'key' => $mac,
'device' => $name,
'mac' => $mac,
'message' => "{$name} has gone offline",
'_device_state_update' => [$prev, true], // set in_alert after fire
];
}
}
}
return $alerts;
}
// ── Client tracking ───────────────────────────────────────────────────────
private function checkClientTransition(?array $clients, array $trackedMacs, bool $comingOnline): array
@@ -138,15 +227,11 @@ class WebhookCheckService
$prev = DeviceState::where('device_mac', $mac)->first();
$isOnline = in_array($mac, $connectedMacs);
if (! $prev) continue;
$clientInfo = collect($clients)->firstWhere('mac', $mac);
// Fire on the 2nd consecutive observation of the new state.
// Check runs BEFORE sync — when count == 1 here, this poll is the 2nd consecutive
// miss/hit, and sync will flip was_online after we fire.
if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) { // 2nd consecutive poll online
if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) {
$alerts[] = [
'key' => $mac,
'client_name' => $name,
@@ -158,7 +243,7 @@ class WebhookCheckService
'message' => "{$name} ({$mac}) connected",
];
}
if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) { // 3rd consecutive poll offline
if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) {
$alerts[] = [
'key' => $mac,
'client_name' => $name,
@@ -172,89 +257,7 @@ class WebhookCheckService
return $alerts;
}
private function syncClientStates(array $clients): void
{
$connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all();
// Update tracked client states
$tracked = DeviceState::whereNotNull('device_mac')
->where('device_mac', 'NOT LIKE', '%:%:%:%:%:%') // skip MAC format check — just update all
->get();
// Actually, we store client MACs in the same table. Let's just upsert for all connected clients
// that are in tracked_clients lists
$allTracked = WebhookConfig::where('is_active', true)
->whereJsonLength('tracked_clients', '>', 0)
->get()
->flatMap(fn ($c) => collect($c->tracked_clients)->map(fn ($e) => strtolower(is_array($e) ? ($e['mac'] ?? '') : $e)))
->unique()
->filter();
foreach ($allTracked as $mac) {
$isOnline = in_array($mac, $connectedMacs);
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) {
DeviceState::create(['device_mac' => $mac, 'was_online' => $isOnline, 'consecutive_count' => 1,
'last_seen_at' => now(), 'updated_at' => now()]);
continue;
}
if ($prev->was_online === $isOnline) {
$prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now()]);
} else {
$count = $prev->consecutive_count + 1;
$grace = $isOnline ? 2 : 3;
if ($count >= $grace) {
// Confirmed — flip and reset counter
$prev->update(['was_online' => $isOnline, 'consecutive_count' => 0,
'last_seen_at' => now(), 'updated_at' => now()]);
} else {
$prev->update(['consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now()]);
}
}
}
}
// ── Device checks (existing) ──────────────────────────────────────────────
/**
* Check device online/offline transitions.
* Runs BEFORE syncDeviceStates — reads state from the previous poll's sync.
*
* Offline: requires 3 consecutive offline polls (count >= 2) before firing.
* Online: requires 2 consecutive online polls (count >= 1) and was_online must
* already be false (i.e., offline was confirmed) — prevents "back online"
* alerts for devices that blipped and recovered within the offline grace window.
*
* After a confirmed transition, syncDeviceStates resets consecutive_count to 0,
* so the reverse direction must also accumulate from scratch.
*/
private function checkDeviceTransition(array $devices, array $filter, bool $comingOnline): array
{
$alerts = [];
foreach ($devices as $dev) {
$mac = $dev['mac'];
if (! empty($filter) && ! in_array($mac, $filter)) continue;
$name = $dev['name'] ?? $dev['model'] ?? $mac;
$isOnline = ($dev['state'] ?? 0) == 1;
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) continue;
// Online: fires on the 2nd consecutive poll back (count >= 1).
// Only fires if was_online=false, which only happens after offline was confirmed —
// so this naturally prevents spurious "back online" alerts for brief blips.
if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1)
$alerts[] = ['key' => $mac, 'device' => $name, 'mac' => $mac, 'message' => "{$name} is back online"];
// Offline: fires on the 3rd consecutive offline poll (count >= 2).
// Requires 3 consecutive misses (~90s at 30s interval) to avoid false positives
// from transient controller communication gaps (UPS, cameras, etc.).
if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2)
$alerts[] = ['key' => $mac, 'device' => $name, 'mac' => $mac, 'message' => "{$name} has gone offline"];
}
return $alerts;
}
// ── WAN ──────────────────────────────────────────────────────────────────
private function checkWan(?array $wan, bool $comingUp): array
{
@@ -268,21 +271,66 @@ class WebhookCheckService
return [];
}
private function checkClientCount($aps, int $threshold, array $filter): array
// ── Threshold checks (alert on entry, resolved on exit) ──────────────────
/**
* Client count — alert when exceeding threshold (only once per alert state entry).
*/
private function checkClientCount($aps, int $threshold, array $filter, int $configId): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$count = $ap['num_sta'] ?? 0;
$cacheKey = "unifi:alert:{$configId}:client_count:{$ap['mac']}";
if ($count >= $threshold) {
if (! Cache::has($cacheKey)) {
Cache::put($cacheKey, $threshold, now()->addHours(4));
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: {$count} clients"];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: {$count} clients"];
} else {
Cache::put($cacheKey, $threshold, now()->addHours(4)); // refresh TTL
}
}
}
return $alerts;
}
private function checkCu($aps, int $threshold, ?string $band, array $filter): array
/**
* Client count — resolved when dropping back below threshold.
* Only fires if the cached threshold matches the current config (prevents spurious
* resolved alerts after the threshold is raised).
*/
private function checkClientCountResolved($aps, int $threshold, array $filter, int $configId): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$count = $ap['num_sta'] ?? 0;
$cacheKey = "unifi:alert:{$configId}:client_count:{$ap['mac']}";
$cached = Cache::get($cacheKey);
if ($count < $threshold && $cached !== null) {
Cache::forget($cacheKey);
if ((int) $cached === $threshold) {
// Only alert if this is the same threshold that originally triggered
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: client count returned to normal ({$count})"];
}
}
}
return $alerts;
}
/**
* Channel utilization — alert on entry, with sustain guard.
*
* CU must stay above threshold for $sustainPolls consecutive poll cycles before
* the alert fires. A pending counter cache key tracks progress toward that threshold.
* The counter is cleared as soon as CU drops back below threshold.
*/
private function checkCu($aps, int $threshold, ?string $band, array $filter, int $configId, int $sustainPolls = 3): array
{
$alerts = [];
foreach ($aps as $ap) {
@@ -290,39 +338,155 @@ class WebhookCheckService
foreach ($ap['radio_table_stats'] ?? [] as $radio) {
if ($band && ($radio['radio'] ?? '') !== $band) continue;
$cu = $radio['cu_total'] ?? 0;
if ($cu >= $threshold) {
$name = $ap['name'] ?? $ap['mac'];
$rName = $radio['radio'] ?? '?';
$alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'], 'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): {$cu}% CU"];
$alertKey = "unifi:alert:{$configId}:cu:{$ap['mac']}:{$rName}";
$pendingKey = "unifi:alert:{$configId}:cu_pending:{$ap['mac']}:{$rName}";
if ($cu >= $threshold) {
if (Cache::has($alertKey)) {
Cache::put($alertKey, $threshold, now()->addHours(4)); // refresh TTL while still in alert
} else {
$count = (int) Cache::get($pendingKey, 0) + 1;
Cache::put($pendingKey, $count, now()->addHours(1));
if ($count >= $sustainPolls) {
Cache::put($alertKey, $threshold, now()->addHours(4));
Cache::forget($pendingKey);
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'],
'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): {$cu}% CU"];
}
}
} else {
Cache::forget($pendingKey); // reset sustain counter when CU drops below threshold
}
}
}
return $alerts;
}
private function checkSatisfaction($aps, int $minSat, array $filter): array
/**
* Channel utilization — resolved on exit.
* Also clears the pending sustain counter so it doesn't carry over to the next alert cycle.
* Only fires the resolved alert when the cached threshold matches the current config —
* this prevents a spurious "back to normal" after the threshold has been raised.
*/
private function checkCuResolved($aps, int $threshold, ?string $band, array $filter, int $configId): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
foreach ($ap['radio_table_stats'] ?? [] as $radio) {
if ($band && ($radio['radio'] ?? '') !== $band) continue;
$cu = $radio['cu_total'] ?? 0;
$rName = $radio['radio'] ?? '?';
$alertKey = "unifi:alert:{$configId}:cu:{$ap['mac']}:{$rName}";
$pendingKey = "unifi:alert:{$configId}:cu_pending:{$ap['mac']}:{$rName}";
if ($cu < $threshold) {
Cache::forget($pendingKey); // always reset sustain counter when below threshold
$cached = Cache::get($alertKey);
if ($cached !== null) {
Cache::forget($alertKey);
if ((int) $cached === $threshold) {
// Only alert if this is the same threshold that originally triggered
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'],
'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): CU returned to normal ({$cu}%)"];
}
}
}
}
}
return $alerts;
}
/**
* WiFi satisfaction — alert on entry.
*/
private function checkSatisfaction($aps, int $minSat, array $filter, int $configId): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$sat = $ap['satisfaction'] ?? null;
$cacheKey = "unifi:alert:{$configId}:satisfaction:{$ap['mac']}";
if ($sat !== null && $sat >= 0 && $sat < $minSat) {
if (! Cache::has($cacheKey)) {
Cache::put($cacheKey, $minSat, now()->addHours(4));
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: {$sat}% experience"];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: {$sat}% experience"];
} else {
Cache::put($cacheKey, $minSat, now()->addHours(4)); // refresh TTL
}
}
}
return $alerts;
}
private function checkErrorRate($aps, array $filter): array
/**
* WiFi satisfaction — resolved on exit.
* Only fires if the cached threshold matches current config.
*/
private function checkSatisfactionResolved($aps, int $minSat, array $filter, int $configId): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$sat = $ap['satisfaction'] ?? null;
$cacheKey = "unifi:alert:{$configId}:satisfaction:{$ap['mac']}";
$cached = Cache::get($cacheKey);
if ($sat !== null && $sat >= $minSat && $cached !== null) {
Cache::forget($cacheKey);
if ((int) $cached === $minSat) {
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: experience returned to normal ({$sat}%)"];
}
}
}
return $alerts;
}
/**
* Error rate — alert on entry.
*/
private function checkErrorRate($aps, array $filter, int $configId): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$retries = $ap['stat']['ap']['tx_retries'] ?? 0;
$cacheKey = "unifi:alert:{$configId}:error_rate:{$ap['mac']}";
if ($retries > 1000) {
if (! Cache::has($cacheKey)) {
Cache::put($cacheKey, 1000, now()->addHours(4));
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"];
} else {
Cache::put($cacheKey, 1000, now()->addHours(4)); // refresh TTL
}
}
}
return $alerts;
}
/**
* Error rate — resolved on exit.
*/
private function checkErrorRateResolved($aps, array $filter, int $configId): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$retries = $ap['stat']['ap']['tx_retries'] ?? 0;
$cacheKey = "unifi:alert:{$configId}:error_rate:{$ap['mac']}";
if ($retries <= 1000 && Cache::get($cacheKey) !== null) {
Cache::forget($cacheKey);
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
'message' => "{$name}: error rate returned to normal"];
}
}
return $alerts;
@@ -335,7 +499,8 @@ class WebhookCheckService
if (! empty($filter) && ! in_array($dev['mac'], $filter)) continue;
if ($dev['upgradable'] ?? false) {
$name = $dev['name'] ?? $dev['mac'];
$alerts[] = ['key' => $dev['mac'], 'device' => $name, 'mac' => $dev['mac'], 'version' => $dev['version'] ?? '', 'message' => "{$name}: firmware update available"];
$alerts[] = ['key' => $dev['mac'], 'device' => $name, 'mac' => $dev['mac'],
'version' => $dev['version'] ?? '', 'message' => "{$name}: firmware update available"];
}
}
return $alerts;
@@ -346,12 +511,14 @@ class WebhookCheckService
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
$uptime = $ap['uptime'] ?? 0;
if ($uptime > 0 && $uptime < 300) {
$prev = DeviceState::where('device_mac', $ap['mac'])->first();
if ($prev && $prev->was_online) {
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'uptime' => $uptime, 'message' => "{$name}: unexpected reboot"];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
'uptime' => $uptime, 'message' => "{$name}: unexpected reboot"];
}
}
}
@@ -390,7 +557,6 @@ class WebhookCheckService
'data' => $data,
];
// Format payload for the target platform
$url = $config->url;
$payload = $this->formatPayloadForPlatform($url, $message, $internalPayload);
@@ -413,46 +579,35 @@ class WebhookCheckService
WebhookLog::create($log);
}
/**
* Detect the webhook platform from the URL and format the payload accordingly.
*/
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
{
// Google Chat
if (str_contains($url, 'chat.googleapis.com')) {
return ['text' => $message];
return self::buildPlatformPayload($url, $message, $fullPayload);
}
// Slack
if (str_contains($url, 'hooks.slack.com')) {
return ['text' => $message];
}
// Discord
if (str_contains($url, 'discord.com/api/webhooks')) {
return ['content' => $message];
}
// Microsoft Teams
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) {
return ['text' => $message];
}
// Generic / custom — send the full structured payload
/**
* 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;
}
// ── State sync ────────────────────────────────────────────────────────────
/**
* Sync device states with consecutive_count for debouncing.
* Sync device states. Tracks consecutive_count and was_online.
* in_alert is managed separately in checkDeviceTransition (updated after confirmed fire).
*
* Same state → reset counter to 0 (stable, no change pending)
* Different state → increment counter; flip was_online only once the grace threshold
* is reached, then RESET counter to 0 so the opposite direction must
* also accumulate from scratch.
* Offline grace: 2 consecutive offline polls (count reaches 2 → flip was_online=false)
* Online grace: 2 consecutive online polls (count reaches 2 → flip was_online=true)
*
* Thresholds (must match checkDeviceTransition, which fires one poll before the flip):
* Going offline: grace = 3 polls (check fires at count >= 2, flip at count+1 >= 3)
* Coming online: grace = 2 polls (check fires at count >= 1, flip at count+1 >= 2)
* checkDeviceTransition fires one poll before the flip (at count >= 1), which is correct:
* the alert fires on the 2nd consecutive poll, state flips on the same run's sync call.
*/
private function syncDeviceStates(array $devices): void
{
@@ -463,9 +618,13 @@ class WebhookCheckService
if (! $prev) {
DeviceState::create([
'device_mac' => $mac, 'device_name' => $dev['name'] ?? $dev['model'] ?? null,
'was_online' => $isOnline, 'consecutive_count' => 0,
'last_seen_at' => now(), 'updated_at' => now(),
'device_mac' => $mac,
'device_name' => $dev['name'] ?? $dev['model'] ?? null,
'was_online' => $isOnline,
'in_alert' => false,
'consecutive_count' => 0,
'last_seen_at' => now(),
'updated_at' => now(),
]);
continue;
}
@@ -473,22 +632,67 @@ class WebhookCheckService
$prevState = $prev->was_online;
if ($prevState === $isOnline) {
// Same as confirmed state — reset counter (no pending transition)
$prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now(),
'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name]);
// Stable — reset counter
$prev->update([
'consecutive_count' => 0,
'last_seen_at' => now(),
'updated_at' => now(),
'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name,
]);
} else {
// Approaching a state change
$count = $prev->consecutive_count + 1;
$grace = 2; // 2 consecutive polls for both directions
if ($count >= $grace) {
// Confirmed — flip was_online, reset counter
$prev->update([
'was_online' => $isOnline,
'consecutive_count' => 0,
'last_seen_at' => now(),
'updated_at' => now(),
'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name,
]);
} else {
$prev->update([
'consecutive_count' => $count,
'last_seen_at' => now(),
'updated_at' => now(),
]);
}
}
}
}
private function syncClientStates(array $clients): void
{
$connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all();
$allTracked = WebhookConfig::where('is_active', true)
->whereJsonLength('tracked_clients', '>', 0)
->get()
->flatMap(fn ($c) => collect($c->tracked_clients)->map(fn ($e) => strtolower(is_array($e) ? ($e['mac'] ?? '') : $e)))
->unique()
->filter();
foreach ($allTracked as $mac) {
$isOnline = in_array($mac, $connectedMacs);
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) {
DeviceState::create(['device_mac' => $mac, 'was_online' => $isOnline, 'in_alert' => false,
'consecutive_count' => 1, 'last_seen_at' => now(), 'updated_at' => now()]);
continue;
}
if ($prev->was_online === $isOnline) {
$prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now()]);
} else {
// Moving toward a state change — accumulate consecutive count
$count = $prev->consecutive_count + 1;
// Grace threshold: 3 polls to confirm going offline, 2 to confirm coming online
$grace = $isOnline ? 2 : 3;
if ($count >= $grace) {
// Confirmed — flip was_online and RESET counter so the reverse direction
// must also accumulate from zero (prevents immediate back-and-forth firing)
$prev->update(['was_online' => $isOnline, 'consecutive_count' => 0,
'last_seen_at' => now(), 'updated_at' => now(),
'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name]);
'last_seen_at' => now(), 'updated_at' => now()]);
} else {
// Not yet confirmed — just bump counter, keep was_online unchanged
$prev->update(['consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now()]);
}
}

View File

@@ -2,6 +2,11 @@
namespace Dashboard\Unifi;
use App\Models\DashboardApp;
use App\Models\NavItem;
use Dashboard\Unifi\Models\UnifiPageGrant;
use Illuminate\Routing\Events\RouteMatched;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class UnifiServiceProvider extends ServiceProvider
@@ -20,11 +25,44 @@ 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.
Event::listen(RouteMatched::class, function (RouteMatched $event) {
$routeName = $event->route->getName();
if (! $routeName || ! str_starts_with($routeName, 'unifi.')) 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.
}
});
if ($this->app->runningInConsole()) {
$this->commands([
Console\CheckWebhooks::class,
Console\CaptureSnapshots::class,
Console\CleanupSnapshots::class,
Console\RebootAllAps::class,
Console\RotatePasswords::class,
Console\SyncPpskSchedules::class,
]);
$this->publishes([
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),

View File

@@ -4,8 +4,12 @@ use Dashboard\Unifi\Http\Controllers\ClientController;
use Dashboard\Unifi\Http\Controllers\DeviceController;
use Dashboard\Unifi\Http\Controllers\PortalController;
use Dashboard\Unifi\Http\Controllers\StatsController;
use Dashboard\Unifi\Http\Controllers\UnifiCronLogsController;
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;
@@ -22,14 +26,26 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::get('/devices', [DeviceController::class, 'index']) ->name('devices');
Route::get('/clients', [ClientController::class, 'index']) ->name('clients');
Route::get('/client-dashboard', [StatsController::class, 'clientDashboard']) ->name('client.dashboard');
Route::get('/client-ap-traffic', [StatsController::class, 'clientApTraffic']) ->name('client.ap-traffic');
});
// ── Management (write access) ────────────────────────────────────────
Route::middleware('permission:unifi.manage')->group(function () {
// WiFi networks
Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi');
Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update');
Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle']) ->name('wifi.toggle');
Route::post('/wifi/groups', [WifiController::class, 'saveGroups']) ->name('wifi.groups');
// PPSK (per-WLAN pre-shared keys)
Route::get('/wifi/{wlanId}/ppsk', [WifiController::class, 'ppskIndex']) ->name('wifi.ppsk.index');
Route::post('/wifi/{wlanId}/ppsk', [WifiController::class, 'ppskStore']) ->name('wifi.ppsk.store');
Route::put('/wifi/{wlanId}/ppsk/{ppskId}', [WifiController::class, 'ppskUpdate']) ->name('wifi.ppsk.update');
Route::delete('/wifi/{wlanId}/ppsk/{ppskId}', [WifiController::class, 'ppskDestroy']) ->name('wifi.ppsk.destroy');
Route::put('/wifi/{wlanId}/ppsk/{ppskId}/schedule', [WifiController::class, 'ppskSchedule']) ->name('wifi.ppsk.schedule');
Route::patch('/wifi/{wlanId}/ppsk/{ppskId}/rotation',[WifiController::class, 'ppskToggleRotation'])->name('wifi.ppsk.rotation');
// Devices
Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot');
Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick');
});
@@ -43,6 +59,11 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::post('/portal/macs', [PortalController::class, 'storeMac']) ->name('portal.macs.store');
Route::delete('/portal/macs/{mac}', [PortalController::class, 'destroyMac']) ->name('portal.macs.destroy');
Route::post('/portal/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])->name('portal.sessions.disconnect');
// VLAN Groups
Route::post('/portal/vlan-groups', [VlanGroupController::class, 'store']) ->name('portal.vlan-groups.store');
Route::put('/portal/vlan-groups/{vlanGroup}', [VlanGroupController::class, 'update']) ->name('portal.vlan-groups.update');
Route::delete('/portal/vlan-groups/{vlanGroup}', [VlanGroupController::class, 'destroy'])->name('portal.vlan-groups.destroy');
});
// ── Settings ─────────────────────────────────────────────────────────
@@ -52,13 +73,37 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
// 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');
// 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::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 — 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) ─────