23 Commits

Author SHA1 Message Date
720e94c54a fix(banded ssid): match embedded PPSK by name first, passphrase fallback
The sibling-update path on prod failed with "Embedded PPSK not found
by current passphrase" because the DB-stored x_passphrase on the
unedited band was stale — earlier manual edits (pre-1.8.1) only
touched one band, leaving the other band's row out of sync. When
rotation then tried to use that stale passphrase to find the entry,
no match.

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

v1.9.1.

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

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

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

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

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

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

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

v1.8.1.

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

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

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

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

v1.7.1.

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

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

No breaking changes. Drop-in upgrade from 1.6.0.

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

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

v1.6.3.

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

v1.6.2.

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

v1.6.1.

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

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

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

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

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

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

v1.5.4.

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

v1.5.3.

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

v1.5.2.

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

v1.5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:43:30 -04:00
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 2519 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.1",
"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,163 @@
<?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.
// Match by name (most reliable) — falls back to
// passphrase if name is missing.
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
// Sibling WLANs (same SSID name on a different band):
// rotate the matching-name PPSK in each so the
// SSID's 2.4/5GHz halves stay in sync.
foreach ($unifi->getWlanSiblings($ppsk->wlan_id) as $siblingWlanId) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $ppsk->name)
->where('state', 'active')
->first();
try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $ppsk->name);
if ($sibling) {
$sibling->update([
'x_passphrase' => $newPass,
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
]);
}
} catch (\Throwable $e) {
$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,287 @@ 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.
// Match by name (reliable across drift).
$newPass = $unifiUpdate['x_passphrase'];
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
// Also update sibling WLANs (banded SSID — same name
// on 2.4 and 5GHz are separate wlanconf rows).
foreach ($unifi->getWlanSiblings($record->wlan_id) as $siblingWlanId) {
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
->where('name', $record->name)
->where('state', 'active')
->first();
try {
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name);
if ($sibling) {
$sibling->update([
'x_passphrase' => $newPass,
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
]);
}
} catch (\Throwable $e) {
\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 +402,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,345 @@ 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, ?string $name = null): array
{
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
$wlan = $wlanResp[0] ?? $wlanResp;
$entries = $wlan['private_preshared_keys'] ?? [];
if (! is_array($entries) || empty($entries)) {
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
}
// Match in this order — most reliable first:
// 1. by PPSK name (if provided) — survives passphrase drift
// caused by manual edits or previous out-of-sync rotations.
// 2. by current passphrase (legacy)
$applyUpdate = function (array &$e) use ($newPassphrase) {
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
$e['password'] = $newPassphrase;
}
};
$matched = false;
if ($name !== null && $name !== '') {
foreach ($entries as &$e) {
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
if ($entryName === $name) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched && $oldPassphrase !== null && $oldPassphrase !== '') {
foreach ($entries as &$e) {
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
if ($current === $oldPassphrase) {
$applyUpdate($e);
$matched = true;
break;
}
}
unset($e);
}
if (! $matched) {
throw new \RuntimeException(
'Embedded PPSK not found' .
($name !== null ? " by name \"{$name}\"" : '') .
' or by current passphrase.'
);
}
// UniFi REST expects the full WLAN object on PUT — send what we
// got back, with the mutated PPSK array.
$payload = $wlan;
$payload['private_preshared_keys'] = $entries;
// Strip internal fields the controller rejects on PUT.
unset($payload['_id'], $payload['site_id']);
$this->put("/rest/wlanconf/{$wlanId}", $payload);
// Return a normalized record so callers can read the new state.
return $this->normalizePpsk([[
'_id' => 'emb_' . substr(hash('sha256', $wlanId . ':' . $newPassphrase), 0, 32),
'wlan_id' => $wlanId,
'x_passphrase' => $newPassphrase,
]]);
}
public function deletePpsk(string $ppskId): void
{
// Try v2 hotspot endpoint first
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 +718,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 +744,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) ─────