Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f621e3973 | |||
| 868f5edeed | |||
| 33e465898c | |||
| a8d7b34266 | |||
| 030fac3961 | |||
| b59b5bf6be | |||
| 4322d07b40 | |||
| ccd6fc49b2 | |||
| 655d8dd50c | |||
| a74818bdaf | |||
| dbebb6c964 | |||
| c745aa8b2c | |||
| 79a8360714 | |||
| 6287054a2a | |||
| b0287f1b10 | |||
| 186c1eaf2b | |||
| c93f73ce02 | |||
| cac47b74bd | |||
| 7c6bf99201 | |||
| bc3f415647 | |||
| 9cbc8e682a | |||
| f4631d1da0 | |||
|
|
9413cf9ab9 | ||
| 070001ffff | |||
| 71807c8815 | |||
| 462a1a3611 | |||
| ee27bee716 | |||
| 429cd44ac5 | |||
| 274f210337 | |||
| c0f12ce931 | |||
| dd4e0ca564 | |||
| f533208b37 | |||
| bb74edf4c1 | |||
| e5cc075938 | |||
| 4ec4a293c0 | |||
| 720e94c54a | |||
| 2be17c70db | |||
| 31686a35d5 | |||
| 8769308dfd | |||
| f5848907f5 | |||
| f953fde2be | |||
| 9a37eda302 | |||
| 4b29f55518 | |||
| e8796d443e | |||
| 24aad5cdc0 | |||
| 27c1584dc3 | |||
| c89adeea97 | |||
| 8f51be8515 | |||
| 0490a1220b | |||
| 4b73b53dd6 | |||
| 75943fbe2b | |||
| a33f2885ff | |||
| a4397c5178 | |||
| fc4f5370ae | |||
| e59f193ffc | |||
| f7672771e0 | |||
| 996f6f0371 | |||
| 0802ef35f3 |
@@ -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.13.5",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
@@ -16,6 +16,8 @@
|
||||
]
|
||||
},
|
||||
"dashboard": {
|
||||
"tier": "optional",
|
||||
"stability": "stable",
|
||||
"nav_folder": {
|
||||
"label": "Unifi Network",
|
||||
"icon": "wifi",
|
||||
@@ -27,15 +29,15 @@
|
||||
{ "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": "Captive Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
|
||||
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
|
||||
],
|
||||
"permissions": [
|
||||
{ "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" },
|
||||
{ "key": "unifi.manage", "label": "Manage Network", "description": "Reboot APs, manage SSIDs, change WiFi passwords" },
|
||||
{ "key": "unifi.auth", "label": "Manage Portal Auth", "description": "Configure captive portal, VLAN mappings, MAC allowlist" },
|
||||
{ "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" }
|
||||
{ "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" },
|
||||
{ "key": "unifi.guest_wifi", "label": "View Guest Wi-Fi Password", "description": "See the guest network name, its current rotating password, and the join QR code on the home page. Grants no other network access." }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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'); // 1–4094
|
||||
$table->string('description')->nullable(); // optional note
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('unifi_vlan_groups');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Brings unifi_page_grants to parity with adm_access_grants /
|
||||
* directory_access_grants: widens grantee_type to include 'default' and
|
||||
* 'role', allows a NULL grantee_id (used by the per-page default row),
|
||||
* and adds a can_view column. The default row's can_view carries the
|
||||
* deny/allow fallback for users not matched by a more specific grant
|
||||
* (deny by default — no row means deny).
|
||||
*
|
||||
* Existing user/group rows keep working: adding can_view with a default
|
||||
* of true backfills them as explicit allow grants.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('unifi_page_grants')) return;
|
||||
|
||||
// Widen the enum and allow NULL grantee_id (default rows). MySQL
|
||||
// enum changes require raw DDL.
|
||||
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('default','user','group','role') NOT NULL");
|
||||
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NULL");
|
||||
|
||||
if (! Schema::hasColumn('unifi_page_grants', 'can_view')) {
|
||||
Schema::table('unifi_page_grants', function (Blueprint $table) {
|
||||
$table->boolean('can_view')->default(true)->after('grantee_id');
|
||||
});
|
||||
|
||||
// Belt and braces: make sure every pre-existing user/group row
|
||||
// is an explicit allow grant.
|
||||
DB::table('unifi_page_grants')->update(['can_view' => true]);
|
||||
}
|
||||
|
||||
// The unique index on (nav_item_id, grantee_type, grantee_id)
|
||||
// already exists from the create migration
|
||||
// ('unifi_page_grants_unique') and still applies — grantee_id is
|
||||
// NULL for default rows, and MySQL treats NULLs as distinct, so
|
||||
// app code enforces one default row per nav_item.
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('unifi_page_grants')) return;
|
||||
|
||||
if (Schema::hasColumn('unifi_page_grants', 'can_view')) {
|
||||
Schema::table('unifi_page_grants', function (Blueprint $table) {
|
||||
$table->dropColumn('can_view');
|
||||
});
|
||||
}
|
||||
|
||||
DB::table('unifi_page_grants')->whereIn('grantee_type', ['default', 'role'])->delete();
|
||||
|
||||
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_type ENUM('user','group') NOT NULL");
|
||||
DB::statement("ALTER TABLE unifi_page_grants MODIFY grantee_id BIGINT UNSIGNED NOT NULL");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Whether the device presented a private (randomized) address.
|
||||
*
|
||||
* Nullable, because rows written before this existed genuinely do not know —
|
||||
* defaulting them to false would assert something untrue about every historical
|
||||
* session.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('unifi_portal_sessions', function (Blueprint $table) {
|
||||
$table->boolean('is_randomized')->nullable()->after('mac_address');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('unifi_portal_sessions', function (Blueprint $table) {
|
||||
$table->dropColumn('is_randomized');
|
||||
});
|
||||
}
|
||||
};
|
||||
1415
docs/superpowers/plans/2026-08-21-byod-wifi-access-model.md
Normal file
1415
docs/superpowers/plans/2026-08-21-byod-wifi-access-model.md
Normal file
File diff suppressed because it is too large
Load Diff
322
docs/superpowers/reviews/2026-08-21-plan1-review.md
Normal file
322
docs/superpowers/reviews/2026-08-21-plan1-review.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Plan 1 review findings — BYOD Wi-Fi access model
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Reviewed:** the six commits implementing
|
||||
`docs/superpowers/plans/2026-08-21-byod-wifi-access-model.md`
|
||||
(`central-unifi` `a74818b`..`a8d7b34`, `dashboard-shell` `f3de4aa`..HEAD)
|
||||
**Verdict:** CHANGES NEEDED. Nothing needs redesigning; the list below needs fixing.
|
||||
**Suite at time of review:** 48 passed in `tests/Feature/Unifi/`; full suite
|
||||
6293 passed / 10 failed, all in the unrelated pre-existing `Logs\ScriptRunsTest`.
|
||||
|
||||
Recorded here because these findings outlived the session that produced them and
|
||||
every one of them is about code that is already committed.
|
||||
|
||||
---
|
||||
|
||||
## 0. The request IP is trusted, and the app has no opinion of its own
|
||||
|
||||
`dashboard-shell/bootstrap/app.php:14` — `$middleware->trustProxies(at: '*')`.
|
||||
Laravel routes `'*'` to `setTrustedProxyIpAddressesToTheCallingIp()`, so **the
|
||||
immediate peer is unconditionally trusted as a proxy** and its `X-Forwarded-For`
|
||||
becomes `$request->ip()`. `docker/nginx/default.conf` sets no `real_ip_header` or
|
||||
`X-Forwarded-For` of its own, so a client-supplied header reaches PHP verbatim.
|
||||
|
||||
`DeviceResolver::resolve()` reads `$request->ip()` and derives the MAC from it, so
|
||||
if a client can deliver a forged header the resolver identifies a device of the
|
||||
attacker's choosing. The AP check does not help: `ap` is also a request parameter,
|
||||
so an attacker forges both.
|
||||
|
||||
### What is NOT established
|
||||
|
||||
An earlier session demonstrated forgery by sending `X-Forwarded-For` **directly to
|
||||
nginx on `:80`**. That path is not available to a real client: the Wi-Fi VLAN is
|
||||
firewalled off from the server and can reach only Traefik. So that demonstration
|
||||
does **not** show a live exploit, and it should not be cited as one.
|
||||
|
||||
### What IS established: the legitimate value arrives correctly
|
||||
|
||||
Confirmed by the operator on 2026-08-21 from the production Logged-in Users list,
|
||||
served through Traefik: sessions show **real per-client addresses** —
|
||||
`10.22.5.254` and `10.22.5.154` for on-site staff, and a public address for an
|
||||
off-site phone. So `TrustProxies` plus Traefik does yield the true client IP, and
|
||||
UniFi can map an on-site `10.x` address to a MAC.
|
||||
|
||||
That settles two things:
|
||||
|
||||
- **The IP → MAC approach is viable.** This was the operator's original question and
|
||||
the answer is yes, with production evidence.
|
||||
- **Spec §E had the release-gate risk backwards.** It called "whether TrustProxies
|
||||
yields the true client IP" the thing that gates release, fearing a fail-closed
|
||||
outage where nobody can get online. That fear is unfounded — it demonstrably
|
||||
works. Remove it as a blocker.
|
||||
|
||||
Note the off-site public address: a portal request only ever originates on-site, so
|
||||
a request whose IP is public will correctly find no UniFi client and refuse. That is
|
||||
the right behaviour, not a gap.
|
||||
|
||||
### The open question, which is narrower than it first appeared
|
||||
|
||||
**A legitimate value arriving correctly and a forged value being rejected are
|
||||
different properties.** The evidence above establishes the first. It does not
|
||||
establish the second, because nothing in that list would look wrong if Traefik were
|
||||
also passing through client-supplied headers.
|
||||
|
||||
So one question remains, and it is a one-line check
|
||||
|
||||
Whether a client can deliver a forged header depends entirely on Traefik's
|
||||
`forwardedHeaders` for the entry point, on the Traefik host (162.223.102.234 —
|
||||
a separate LXC, not reachable from the dev box):
|
||||
|
||||
- `forwardedHeaders.insecure: true`, or `trustedIPs` covering client ranges
|
||||
→ client headers are passed through → **the resolver's guarantee is void**
|
||||
- unset (Traefik's default) → Traefik replaces client-supplied forwarded headers
|
||||
→ the design works as intended
|
||||
|
||||
**Treat this as the release gate.** Narrowing `trustProxies` from `*` to the
|
||||
Traefik network is worth doing either way as defence in depth: as written, the
|
||||
application delegates the entire question to whoever happens to be talking to it.
|
||||
|
||||
### The consequence that was missed, if it is forgeable
|
||||
|
||||
Not a quota bypass — quotas key on `user_id`, so an attacker cannot exceed their
|
||||
own limit. It is worse in a different way. `updateOrCreate` writes a row keyed
|
||||
`(attacker_user_id, victim_mac)`, making the attacker the legitimate **owner** of a
|
||||
session pointing at somebody else's device. `disconnectSession` then passes its
|
||||
owner check and runs `kickClient` + `unauthorizeGuest` on the victim's MAC.
|
||||
|
||||
The spec declared `disconnectSession` "already correct and not being changed". That
|
||||
was true of the function and false of its inputs — the one component held out of
|
||||
scope as safe is what turns this into an eviction primitive. Worth remembering as a
|
||||
general lesson about scoping something out on the strength of its internals.
|
||||
|
||||
Also note the three distinct refusal reasons (`no_client` / `ap_mismatch` / success)
|
||||
form an oracle: iterating candidate IPs enumerates the live client table with MAC
|
||||
and AP. They were added for operability and double as reconnaissance.
|
||||
|
||||
**Nothing in the six commits is defective on this point.** `DeviceResolver` is a
|
||||
correct implementation of "derive the MAC from the source IP" sitting on a platform
|
||||
assumption. Spec §E named this exact dependency as the release gate and got the
|
||||
direction backwards — it worried `TrustProxies` would be too strict (a visible
|
||||
outage) and never considered it being too permissive (silent fail-open). The
|
||||
blind-spot note in `b59b5bf` reasons carefully and still only considers the strict
|
||||
direction.
|
||||
|
||||
**Exposure is wider than this feature.** Every consumer of `$request->ip()` in the
|
||||
shell inherits it — session client IP, the Logged-in Users list, anything keyed on
|
||||
IP. File against `dashboard-shell`, not `central-unifi`.
|
||||
|
||||
### What would not help
|
||||
|
||||
Assessed and rejected, all reducing to trusting the same value:
|
||||
|
||||
- **Re-checking the client's reported IP** — circular; the record was *selected* by
|
||||
that IP, so the check cannot fail.
|
||||
- **Requiring the client to be unauthorized** — breaks the renewal path and with it
|
||||
the spec's headline promise, while still leaving first-time connectors targetable.
|
||||
- **Association recency** — narrows harvesting to a race the attacker wins by
|
||||
polling, and wrongly refuses a device revisiting after a long window expires.
|
||||
|
||||
The durable fix is to stop making the request IP load-bearing: have the controller
|
||||
vouch for the MAC (a signed redirect / HMAC over `mac, ap, timestamp`). That
|
||||
survives any proxy misconfiguration. Whether this controller supports it is a
|
||||
question for the hardware session — **add it as a fourth item to spec §E**, ranked
|
||||
above the health line in Plan 2.
|
||||
|
||||
---
|
||||
|
||||
## 1. The reconciler would revoke every guest authorization on the whole site
|
||||
|
||||
`src/Console/ReconcilePortal.php` — `array_diff($authorized, $live)` then
|
||||
`unauthorizeGuest` on each, where `$authorized` comes from `getAuthorizedGuestMacs()`
|
||||
reading **site-wide** `/stat/guest`. Nothing scopes it to authorizations this portal
|
||||
created.
|
||||
|
||||
A second guest SSID, UniFi's own voucher portal, front-desk visitors, contractors,
|
||||
a gym rental, or anything an admin authorized by hand: all revoked on the first
|
||||
scheduled run, and again every run. Inversely, a device an admin deliberately
|
||||
un-authorized in the UniFi UI gets re-authorized — the dashboard silently
|
||||
overriding the network admin, forever.
|
||||
|
||||
Fix: only revoke a MAC that appears in `unifi_portal_sessions` at all, or filter by
|
||||
the portal's SSID/VLAN. **This is why "not scheduled" is currently load-bearing
|
||||
rather than merely cautious.**
|
||||
|
||||
Unverifiable without hardware, and worth adding to §E: what window does
|
||||
`GET /stat/guest` return with no `within`, and does the controller rewrite `end`
|
||||
when an authorization is revoked? If not, job 3 silently stops re-asserting.
|
||||
|
||||
## 2. The pre-DHCP retry cannot succeed, and costs a worker 1.5s per failure
|
||||
|
||||
`DeviceResolver` retries three times, but `findClientByIp()` → `getActiveClients()`
|
||||
is `Cache::remember('unifi:clients', 30s)`. All three attempts read one snapshot.
|
||||
|
||||
Worse than a no-op: the first attempt **warms** the cache with a snapshot lacking
|
||||
the connecting device, so "try again" stays refused for the rest of the TTL, for
|
||||
that pupil and everyone else. Meanwhile each failure holds a PHP worker for 1.5s of
|
||||
`usleep` — the exact cost the comment claims to bound, with none of the benefit. A
|
||||
class of 25 arriving together is 25 workers asleep on one stale array.
|
||||
|
||||
`UnifiApiClient`'s own comment justifies the cache on the grounds that "the portal
|
||||
calls this once per connection attempt". The resolver calls it three times.
|
||||
|
||||
Fix: `Cache::forget('unifi:clients')` between retries, or an uncached read path.
|
||||
|
||||
## 3a. Device type should come from the User-Agent, not from UniFi
|
||||
|
||||
Raised by the operator on 2026-08-21, from the same production screenshot: the
|
||||
Logged-in Users list already labels sessions "Safari · iPhone" and
|
||||
"Chrome · macOS", and does it well.
|
||||
|
||||
That is parsed from the **User-Agent**, by a hand-rolled private method
|
||||
`ActiveSessionController::device(string $ua)` — no library involved. Its OS table
|
||||
already distinguishes `iPhone`, `iPad`, `Android` from `Windows`, `macOS`,
|
||||
`Linux`, `CrOS`, which is precisely the phone-versus-laptop distinction the
|
||||
no-phones-for-students policy needs.
|
||||
|
||||
**This is a better source than UniFi's fingerprint for this purpose**, and it
|
||||
removes a dependency the spec could never verify:
|
||||
|
||||
- **Available at authorization time.** A portal request comes *from the device*, so
|
||||
its User-Agent is the device's own. UniFi's `dev_cat` is inferred from observed
|
||||
traffic and does not exist yet at that moment — the reason §C had to push capture
|
||||
into the reconciler at all.
|
||||
- **Known to be populated.** `unifi_client_snapshots` has zero rows on dev, so
|
||||
whether `dev_cat` is ever filled in this environment was never established. The
|
||||
User-Agent is always present.
|
||||
- **It fixes finding 3 with a value that is actually there.** Replacing the three
|
||||
always-NULL `$request->input(...)` writes with the parsed label gives the denial
|
||||
page real device names, which is the single case that list exists for.
|
||||
|
||||
Recommendation: extract `device()` into a shared support class (e.g.
|
||||
`App\Support\UserAgent::describe()`) and have both the Logged-in Users controller
|
||||
and the portal call it. Small refactor, one owner for the parsing, no duplication.
|
||||
|
||||
**The spoofing caveat stands and does not weaken this.** A mobile browser's
|
||||
"Request Desktop Site" toggle changes the User-Agent, so this is sound for
|
||||
*reporting* and unsound for *enforcement* — which is exactly decision 6
|
||||
(report-only). Keep UniFi's `dev_cat` capture in the reconciler as a corroborating
|
||||
second opinion: it is not client-controlled, so a disagreement between the two is
|
||||
itself a useful signal if enforcement is ever wanted.
|
||||
|
||||
## 3. Every portal visit blanks the fingerprint the reconciler filled in
|
||||
|
||||
`PortalController` writes `device_hostname`, `device_os` and `device_type` from
|
||||
`$request->input('hostname'/'os'/'dev_cat')`. UniFi's redirect sends `id`, `ap`,
|
||||
`t`, `url`, `ssid` — the controller's own comment says so — so all three are
|
||||
written NULL on every authorization.
|
||||
|
||||
The exact mirror of the bug `captureFingerprints()` goes out of its way to prevent.
|
||||
Consequence: the denial page's device list always reads "Unknown device", so a pupil
|
||||
at their limit chooses between two unlabelled MACs — the single case that list
|
||||
exists for. Plan 2's Wi-Fi Devices page inherits it.
|
||||
|
||||
`$device->client` already carries UniFi's `hostname`/`os_name`/`dev_cat` and is
|
||||
never read. Use it. These are also the last request-supplied values written to a
|
||||
session, so `?hostname=Principal's Laptop` is shown to admins as fact.
|
||||
|
||||
## 4. Windows slide, contradicting the spec
|
||||
|
||||
`expires_at => now()->addMinutes($minutes)` on the renewal branch grants a fresh
|
||||
full window on every visit. With `session_minutes` set to a term, anyone who
|
||||
bookmarks the callback never ages out — which is what §C's fixed-window rule exists
|
||||
to prevent. Either preserve `expires_at` on renewal, or amend the spec and say why
|
||||
in a comment; today there is no comment where a reader would ask.
|
||||
|
||||
## 5. Two routes to a wrong quota
|
||||
|
||||
No unique index on `(user_id, mac_address)` (confirmed: only PRIMARY,
|
||||
`(user_id,is_active)`, `(mac_address)`) and no lock around read-then-authorize.
|
||||
|
||||
- **Over quota:** two devices of a 1-device pupil hit the callback concurrently,
|
||||
both read zero live sessions, both are authorized. The reconciler cannot correct
|
||||
it — both rows are live and both authorized.
|
||||
- **Phantom slots:** two concurrent renewals of the same MAC each miss the other and
|
||||
insert twice. One phone, two live rows, permanently. The reconciler's
|
||||
`keyBy(mac)` silently collapses the pair so neither direction sees it, and
|
||||
self-disconnect deactivates only the row that was clicked.
|
||||
|
||||
Fix: unique index plus `lockForUpdate` in a transaction. Consider a reconciler step
|
||||
trimming a user's live rows to their quota, newest first — nothing currently
|
||||
enforces the quota against our own rows.
|
||||
|
||||
## 6. Denial copy contradicts itself on the commonest path
|
||||
|
||||
Controller says "Still connecting — wait a moment and try again"; `PortalDenied.vue`
|
||||
then prints "Please contact the school office or IT for help getting online". Two
|
||||
instructions in one view, on the most frequent denial. `resolveFailure` is passed
|
||||
and never declared as a prop.
|
||||
|
||||
---
|
||||
|
||||
## Tests that pass for the wrong reason
|
||||
|
||||
1. **The headline quota-leak regression test** asserts `component('Unifi/PortalDenied')`
|
||||
and a live count — but four branches render that component. It passes with no
|
||||
role and no mapping at all, via "No WiFi access configured". Fix: assert the
|
||||
`reason` string.
|
||||
2. **The proxy-outage test** sets `X-Forwarded-For` and `REMOTE_ADDR` to the same
|
||||
value, so its condition holds by construction; mutating the production
|
||||
condition to `($forwarded)` alone still passes. (Note: under
|
||||
`trustProxies(at: '*')`, `$ip === $raw` only holds when no XFF was sent, so
|
||||
`proxy_untrusted` is unreachable in this environment — a consequence of #0.)
|
||||
3. **The reconciler re-assert test** constrains neither MAC nor minutes. A sign
|
||||
error in `now()->diffInMinutes($expires_at, false)` would clamp every
|
||||
re-assertion to one minute and the test would not notice — and Carbon 3.13.1
|
||||
changed that method's return type and default `$absolute`.
|
||||
4. **The `sort_order` precedence test** inserts the low-`sort_order` row first, so
|
||||
replacing `orderBy('sort_order')` with `all()` still passes. It also never
|
||||
asserts the user holds `parent`.
|
||||
|
||||
Untested: the unreachable-controller guard — the fail-safe between a network blip
|
||||
and mass revocation. Worth a test now, and more so once #1 is fixed.
|
||||
|
||||
---
|
||||
|
||||
## Smaller notes
|
||||
|
||||
- `PortalDeniedTest`'s docblock explains why a device "must look CONNECTED, or the
|
||||
controller treats the session as stale, frees the slot and authorises" —
|
||||
documenting behaviour this work **deleted**. Most confidently-worded comment in
|
||||
the suite and now false. The dead `isClientConnected` stub goes with it.
|
||||
- `ResolvedDevice::$client` is never read (see #3). `$macParameterDisagreed` is
|
||||
asserted in a test and consumed nowhere — surface it or drop it and keep the log.
|
||||
- The disagreement test asserts the flag, not that `Log::warning` fired, which is
|
||||
what §B actually asks for.
|
||||
- Unused `use ...DB;` in two test files, copied from the plan.
|
||||
- Three resolver tests burn 1.5s of real `usleep` each; injecting the pause would
|
||||
pay for itself.
|
||||
- `UnifiApiClient::isClientConnected()` is now referenced nowhere in `src/`.
|
||||
Correct per spec (display-only, Plan 2) — say so in a comment or the next
|
||||
dead-code sweep deletes it.
|
||||
|
||||
## Release sequencing — not a finding, but it changes the plan
|
||||
|
||||
`Pages/Unifi/Portal.vue` still offers "Default (catch-all)", which now 422s, and
|
||||
offers **no `role` option**. Wi-Fi quotas are therefore *unconfigurable* today:
|
||||
the reachable match types are `ou`/`group`/`email_domain`. Spec §F step 3
|
||||
("configure `role` → student / staff") cannot be carried out until Plan 2, which
|
||||
makes **Plan 2 a hard prerequisite for release rather than a follow-up**.
|
||||
|
||||
---
|
||||
|
||||
## What is good and should not be disturbed
|
||||
|
||||
- **`scopeLive`** is the right shape in the right place: the model owns the
|
||||
definition of a consumed slot, a NULL expiry is not live, and the *why* is in the
|
||||
docblock. Leak 2 is fixed at the query level and does not depend on the
|
||||
unscheduled reconciler. The most valuable thing in the six commits, and untouched
|
||||
by #0.
|
||||
- **`?mac=` really is dead.** Verified end to end: with a supplied MAC present,
|
||||
`authorizeGuest` receives the derived one and no other row is written. No
|
||||
fallback anywhere; an absent `?ap=` fails closed as `ap_mismatch` rather than
|
||||
skipping the check. The fail-closed discipline is genuine and thorough — it is
|
||||
the input that betrays it, not the logic.
|
||||
- **`DeviceResolver` split out of the controller** — four named refusal reasons, no
|
||||
quota knowledge, no Inertia. When the platform is fixed this is the right object
|
||||
in the right place, and where an eventual HMAC check belongs.
|
||||
- **`captureFingerprints()`' fill-only rule** and its reasoning. #3 is the
|
||||
controller violating this rule, not this code being wrong.
|
||||
- **The Mockery declaration-order docblock** in the reconciler test will save the
|
||||
next person an hour.
|
||||
- **Removing `default` while `unifi_vlan_mappings` was empty**, and recording in
|
||||
code why doing it later would be worse.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Registered guest / BYOD Wi-Fi — hardening the captive portal
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Repos:** `central-unifi` (most of it), `dashboard-shell` (tests, one Vue page)
|
||||
**Status:** design approved, no implementation plan yet
|
||||
|
||||
---
|
||||
|
||||
## Why this document is short on new features
|
||||
|
||||
Nearly everything asked for already exists in `central-unifi` v1.13.4 and has for
|
||||
some time. The request was "design registered guest Wi-Fi"; the finding is that
|
||||
the feature is largely built, partly broken in ways that only show up under real
|
||||
use, and **currently unreachable**. So this is a hardening spec, not a greenfield
|
||||
one.
|
||||
|
||||
### What already works
|
||||
|
||||
| Requirement | Where it lives |
|
||||
|---|---|
|
||||
| Captive portal + Google auth | `PortalController::captiveCallback`, `/portal/wifi/callback`; the shell's OAuth has already authenticated the user by then |
|
||||
| Per-cohort device limits | `unifi_vlan_mappings.max_devices` |
|
||||
| Per-cohort VLAN | `unifi_vlan_mappings.vlan_id` |
|
||||
| Session length | `unifi_vlan_mappings.session_minutes` (validated 1..525600) |
|
||||
| Self-service eviction | `Unifi/PortalDenied` page lists the user's devices with `can_disconnect`, plus `unifi.portal.self-disconnect` |
|
||||
| Admin eviction | `unifi.portal.sessions.disconnect` behind `app.access:unifi` + `permission:unifi.auth` |
|
||||
| Device fingerprint capture | `CaptureSnapshots` already writes `dev_cat` and `os_name` per MAC into `unifi_client_snapshots` |
|
||||
|
||||
Eviction authorisation is already correct and is **not** being changed:
|
||||
`disconnectSession()` does `kickClient` + `unauthorizeGuest` and authorises
|
||||
owner-or-`unifi.auth` inside the action, so a student can free their own slot and
|
||||
nobody else's.
|
||||
|
||||
### The answer to "must users re-do the portal every time?"
|
||||
|
||||
No, and the mechanism is already wired. `authorizeGuest($mac, $minutes)` sends
|
||||
UniFi `cmd: authorize-guest` with a **minutes** value taken from
|
||||
`session_minutes`. UniFi holds that MAC as authorized for the whole window and
|
||||
does not redirect it again. Set the window to a term and a student signs in once
|
||||
a term.
|
||||
|
||||
The portal is not the obstacle. **MAC randomization is** — see decision 2.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | Harden and finish the existing portal rather than redesign | 80% exists and the schema shape is right; the defects are in accounting and trust, not structure |
|
||||
| 2 | Detect a randomized MAC and coach the user; keep manual eviction | Manual choice is the stated requirement and already built; randomization is detectable, so the confusing case can be explained rather than hidden |
|
||||
| 3 | Derive the MAC from the request's source IP and verify AP association; ignore `?mac=` | Closes the ownership hole by construction — the caller never gets to name a MAC |
|
||||
| 4 | Add `role` as a `match_type` | Quotas were asked for per role; roles are the platform's own authority and need no external sync |
|
||||
| 5 | One Wi-Fi Devices page grouped by person; no profile card | Matches the requirement exactly; keeps `/admin/users` as the one directory |
|
||||
| 6 | Device type is **reported, not enforced**, for now | Whether `dev_cat` is populated in this environment is unverified; enforcing on unvalidated data can lock out the wrong people |
|
||||
| 7 | **Explicit allow-list only** — no `default` mapping | No Wi-Fi unless an admin has added that role and set an amount. A `default` row silently grants everyone, which is the opposite of the requirement |
|
||||
| 8 | Roles are chosen from a dropdown; session length entered in days | A free-text role slug that matches nothing fails silently — it presents as "this student has no Wi-Fi" with no error anywhere to explain it |
|
||||
|
||||
---
|
||||
|
||||
## A. What consumes a device slot
|
||||
|
||||
### The defect
|
||||
|
||||
A slot is currently freed when `isClientConnected($mac)` is false. That conflates
|
||||
**connected right now** with **authorized**, and they are different facts. Two
|
||||
leaks follow, in opposite directions, and both get worse the longer
|
||||
`session_minutes` is — which is exactly the direction this design pushes it.
|
||||
|
||||
**Leak 1 — offline treated as unauthorized.** A phone leaves the building. Its
|
||||
session row is marked inactive, freeing a slot, while UniFi still holds the MAC
|
||||
authorized for the rest of its window. The user authorizes a second device. Two
|
||||
devices are now on the network against a one-device quota, and one of them is an
|
||||
orphan nothing tracks.
|
||||
|
||||
**Leak 2 — `expires_at` is written and never read.** No scheduled job expires
|
||||
sessions. A row stays `is_active` long after UniFi stopped honouring it, so the
|
||||
user is refused by slots that do not exist.
|
||||
|
||||
They compound: the same student can be simultaneously over-provisioned on the
|
||||
network and locked out of the portal.
|
||||
|
||||
### The model
|
||||
|
||||
- A slot is consumed by a **live authorization**: `is_active AND expires_at > now()`.
|
||||
Presence on the network is irrelevant to the count.
|
||||
- A slot is freed by exactly three things: eviction (user or admin), expiry, or the
|
||||
reconciler finding UniFi no longer holds it.
|
||||
- `isClientConnected()` is **no longer an eviction trigger**. It becomes a display
|
||||
value only — "connected" / "away" on the admin page.
|
||||
- Quota resolution gains `role`. Precedence stays `sort_order`, so a mapping for
|
||||
`staff` above one for `parent` gives a staff-parent 4 devices.
|
||||
|
||||
---
|
||||
|
||||
## B. Identifying the device
|
||||
|
||||
### Flow
|
||||
|
||||
1. UniFi redirects the device to `/portal/wifi/callback?ap=<ap_mac>&ssid=…&url=…`.
|
||||
2. Google auth via the shell — unchanged.
|
||||
3. Resolve the device **server-side**: match the request's client IP against
|
||||
UniFi's live client table (`getActiveClients()`, on `ip`) to obtain the real
|
||||
MAC, its `ap_mac`, and its authorization state.
|
||||
4. Require that the resolved client's `ap_mac` equals the `ap` in the redirect —
|
||||
the device must be on the AP that sent it here.
|
||||
5. `?mac=` is **ignored**: not validated, not used. It is read for one purpose —
|
||||
if present and disagreeing with the derived MAC, log it. That disagreement is
|
||||
somebody attempting to authorize a device they do not own, and today nothing
|
||||
would notice.
|
||||
6. Check the locally-administered bit on the **derived** MAC. Record it on the
|
||||
session; it is what triggers the coaching copy on the denial page.
|
||||
|
||||
This replaces the hole the original author documented and declined to guess at:
|
||||
every identifying value was request-supplied, so any signed-in account could
|
||||
authorize somebody else's MAC up to `max_devices`.
|
||||
|
||||
### Failure modes, deliberately closed
|
||||
|
||||
- **IP matches no client** → refuse.
|
||||
- **IP resolves to the proxy** → refuse, and report it distinctly (see below).
|
||||
- **AP mismatch** → refuse.
|
||||
- **Client not in the table yet** (associated, pre-DHCP) → retry twice at ~750ms,
|
||||
then show "still connecting, try again" rather than a hard error. Bounded on
|
||||
purpose: this runs inside a web request, and a longer wait would hold a worker
|
||||
per connecting device at exactly the moment a class of them arrives at once.
|
||||
|
||||
### The operational risk this creates
|
||||
|
||||
Failing closed means it can fail closed **for everyone**. If `TrustProxies` is
|
||||
not correct for Traefik, every request resolves to the proxy, no client ever
|
||||
matches, and nobody can get on the Wi-Fi — a worse outage than the vulnerability
|
||||
being fixed.
|
||||
|
||||
Mitigation: the resolver distinguishes "no client matched this IP" from "this IP
|
||||
is the proxy", and the second is surfaced as a health line on the Wi-Fi Devices
|
||||
page. The failure becomes one glance to diagnose instead of a mystery. This is
|
||||
also the single most important thing to verify against the live controller.
|
||||
|
||||
**Known blind spot in that detection** (found during implementation, 2026-08-21).
|
||||
`proxy_untrusted` is inferred from `X-Forwarded-For` being present while
|
||||
`$request->ip()` still equals the raw `REMOTE_ADDR` — that is, the header arrived
|
||||
but `TrustProxies` did not apply it. It therefore does NOT catch a Traefik
|
||||
misconfiguration that sends no forwarded header at all: that degrades to
|
||||
`no_client`, which reads as "your device is not on the network" rather than "this
|
||||
is broken, tell IT".
|
||||
|
||||
So `proxy_untrusted` is a useful signal but **not a sufficient outage alarm**. The
|
||||
hardware validation step must confirm the true client IP actually arrives, rather
|
||||
than confirming that the misconfiguration alarm is quiet — a quiet alarm is
|
||||
exactly what the blind spot produces. Plan 2's health line should read the
|
||||
resolved IP itself and say what it is, not merely report a reason code.
|
||||
|
||||
### MAC randomization
|
||||
|
||||
Normally stable per SSID, so it does not break the design. When it does rotate,
|
||||
the device looks new: it consumes a slot and the user is asked to evict what
|
||||
appears to be the same phone. With `max_devices: 1` this is the likeliest support
|
||||
call.
|
||||
|
||||
Handling: when the derived MAC is randomized **and** the user is at limit, the
|
||||
denial page leads with an explanation — the device is using a private Wi-Fi
|
||||
address, which is why it looks new — followed by the iOS and Android steps to
|
||||
turn it off for this network, then the normal eviction list. Detection is the
|
||||
locally-administered bit; no guessing.
|
||||
|
||||
---
|
||||
|
||||
## C. Persistence, and the reconciler
|
||||
|
||||
`session_minutes` is the lever for "no portal on every connection". Default to a
|
||||
term (~120 days) rather than a year, so lost or handed-down devices age out.
|
||||
|
||||
A scheduled reconciler (following `CaptureSnapshots` and the `unifi_cron_runs`
|
||||
pattern) does three jobs:
|
||||
|
||||
1. **Expire** rows past `expires_at` — fixes leak 2.
|
||||
2. **Reconcile both directions** — revoke UniFi authorizations with no live row
|
||||
(the orphans leak 1 produced), and expire rows UniFi no longer holds.
|
||||
3. **Capture device type** onto the session from the client record — the
|
||||
report-only fingerprint data for decision 6.
|
||||
|
||||
Job 2 also heals controller-side loss: if UniFi forgets an authorization while our
|
||||
row is still live and in-window (upgrade, restore), the reconciler re-authorizes
|
||||
it. The user is not bounced to the portal for something that was not their doing.
|
||||
|
||||
Windows are **fixed, not sliding** — a device's authorization ends when it ends,
|
||||
so "one term" means one term.
|
||||
|
||||
---
|
||||
|
||||
## C2. Settings: who gets Wi-Fi, and how much
|
||||
|
||||
Access is an **explicit allow-list**. A person gets Wi-Fi only if one of their
|
||||
roles has been added here with an amount. No role added means no Wi-Fi, and that
|
||||
is the intended answer rather than a gap to be filled by a catch-all.
|
||||
|
||||
Consequences, stated so nobody re-adds the hole later:
|
||||
|
||||
- **`default` is removed from the available match types.** A `default` row matches
|
||||
everybody, so its existence makes the allow-list advisory. `resolveMapping()`
|
||||
already returns null when nothing matches, and null already produces the
|
||||
"No WiFi access configured for your account." denial — so deny-by-default is
|
||||
the behaviour once the catch-all cannot be created.
|
||||
- Safe to remove now: **there are zero rows in `unifi_vlan_mappings` in any
|
||||
environment**, so nothing is being revoked from anyone. Doing this later, after
|
||||
a school had leaned on a catch-all, would cut off every unmapped person at once.
|
||||
- `email_domain`, `group` and `ou` stay available — they are narrow matches, not
|
||||
catch-alls, and removing working options is not part of this.
|
||||
|
||||
### The settings screen
|
||||
|
||||
Extends the existing Portal settings page rather than adding another one. Per row:
|
||||
|
||||
| Field | Control | Why |
|
||||
|---|---|---|
|
||||
| Role | **dropdown of real seeded roles** | Free text is the defect. `match_value` is a string compared exactly, so `Student` or `studnet` matches nobody and the failure surfaces as a pupil with no Wi-Fi and nothing logged. The dropdown makes an unmatchable value impossible to enter. |
|
||||
| Devices | number, 1..50 | The quota. Column is already bounded. |
|
||||
| Session length | **days, with presets** (a week / a month / a term / a year) | `session_minutes` stays the stored unit, but a term is 172,800 minutes and nobody should type that. Same reasoning as presenting cron schedules as presets rather than raw strings. |
|
||||
| VLAN | existing select | **Still required** — the network needs to know where to put the device, and this was not in the original request, so it is called out rather than quietly dropped. |
|
||||
| Order | existing `sort_order` | Precedence for multi-role people: staff above parent gives a staff-parent 4. |
|
||||
|
||||
Roles already in the system and available to add: staff, student, parent,
|
||||
oncall-staff, coach, alumni, applicant, board-member, contact, homeschool,
|
||||
homeschool-parent, homeschool-student, open-house, tuition-payee, forms-payer.
|
||||
|
||||
## D. The Wi-Fi Devices page
|
||||
|
||||
New page under Unifi, gated on the existing `unifi.auth`, grouped by person.
|
||||
|
||||
- **Person row:** name, role, quota usage ("staff · 2 of 4").
|
||||
- **Device rows:** hostname, device type (reported), MAC in `font-mono` as a
|
||||
technical id, a "private address" marker when randomized, AP, SSID, authorized
|
||||
at, expires at, and connected/away — display only.
|
||||
- **Per-device disconnect** as a `.btn-icon-danger` row action behind the shared
|
||||
confirm modal. Never a native `confirm()`.
|
||||
- Search, plus a filter for over-quota people.
|
||||
- The portal health line from section B.
|
||||
|
||||
Follows the house table and empty-state conventions in the shell's `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## E. Testing
|
||||
|
||||
### Covered here, with `Http::fake()` for the controller
|
||||
|
||||
- Both quota leaks, one regression test each: an offline device keeps its slot; an
|
||||
expired row stops consuming one.
|
||||
- `role` matching, and `sort_order` precedence including the staff-parent case.
|
||||
- The reconciler in both directions, including the re-authorize-on-controller-loss
|
||||
path.
|
||||
- Randomized-MAC bit detection, both values.
|
||||
- Resolver fail-closed paths: no client match, IP resolves to the proxy, AP
|
||||
mismatch, client not yet in the table.
|
||||
- That `?mac=` is ignored, and that a disagreement is logged.
|
||||
- Authorization-not-connectivity: a slot survives the device going away.
|
||||
|
||||
Plus a browser pass over the denial page (including the randomized-MAC copy),
|
||||
self-service eviction, and the admin page — UI behaviour changes, so the
|
||||
before-push browser rule applies.
|
||||
|
||||
### NOT provable without the live controller
|
||||
|
||||
- The actual parameters UniFi's redirect sends.
|
||||
- Whether `dev_cat` is populated for these clients — `unifi_client_snapshots` has
|
||||
zero rows on dev, so there is no evidence either way.
|
||||
- Whether `TrustProxies` yields the true client IP through Traefik.
|
||||
|
||||
These need a supervised session against real hardware, in the same spirit as the
|
||||
Bosch cutover. The third is the one that gates release: it is the difference
|
||||
between a closed hole and nobody getting online.
|
||||
|
||||
---
|
||||
|
||||
## F. Rollout
|
||||
|
||||
1. **Register the app.** Every route here 404s today, because `unifi` has no
|
||||
`dashboard_apps` row and `app.access:unifi` aborts 404 when the lookup misses.
|
||||
Nothing in this spec is reachable until that is resolved. Note `central-unifi`
|
||||
declares `stability: stable`, so unlike On-Call this is a gap rather than a
|
||||
decision — **and it is still an open question, not settled by this document.**
|
||||
2. Verify the three hardware-dependent items above against the live controller.
|
||||
3. Configure mappings: `role` → student (1 device) and staff (4 devices), with
|
||||
`sort_order` set so multi-role people land deliberately.
|
||||
4. Pilot with staff, then students.
|
||||
|
||||
---
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- **Enforcing** a no-phones rule. Decision 6 is report-only until the fingerprint
|
||||
data is shown to be trustworthy. When it is, enforcement belongs in the
|
||||
reconciler (authorize, then sweep), because UniFi's fingerprint is derived from
|
||||
observed traffic and is not available at the moment of authorization.
|
||||
- MAC OUI as a device-type signal. A randomized MAC has no real OUI, so this
|
||||
would fail on phones — its intended target — while working on the school
|
||||
laptops that should not be blocked.
|
||||
- Blocking on the portal's User-Agent. Defeated by one "Request Desktop Site" tap.
|
||||
- Tethering. A blocked phone can share a laptop's connection and vice versa; no
|
||||
design here addresses that, and none is proposed.
|
||||
- Any change to `disconnectSession`, which is already correct.
|
||||
@@ -0,0 +1,214 @@
|
||||
# Captive portal splash designer
|
||||
|
||||
**Date:** 2026-08-21
|
||||
**Repos:** `central-unifi` (routes, controller, model, migration), `dashboard-shell` (editor page, Blade splash view, tests)
|
||||
**Status:** design approved, no implementation plan yet
|
||||
**Sequencing:** queues behind the six open findings in
|
||||
`docs/superpowers/reviews/2026-08-21-plan1-review.md`. Nothing here depends on
|
||||
them, but they touch the same controller.
|
||||
|
||||
---
|
||||
|
||||
## What this is
|
||||
|
||||
Let an operator lay out what a pupil sees at the captive portal, with the device's
|
||||
own facts available as variables — IP, MAC, browser, machine type — rather than the
|
||||
four hardcoded strings the portal has today.
|
||||
|
||||
## What already exists and is being reused
|
||||
|
||||
| Need | Existing thing |
|
||||
|---|---|
|
||||
| `{token}` substitution + `{if}/{else}` conditionals | `App\Support\TemplateRender::apply()` — already used by signatures, NoE PDFs, payment receipts |
|
||||
| Value escaping | the same method's `$escapeValues` parameter; signatures already pass it |
|
||||
| Grouped variable sidebar | `TemplateRender`'s helper that turns a token→label map into grouped, alphabetised lists for a template editor |
|
||||
| Live preview | the JS mirror `resources/js/utils/template.js` (`applyTemplate`) |
|
||||
| Visual, source-free editing | the shared EmailComposer's anatomy (visual-only, no HTML source) |
|
||||
| Device type from a User-Agent | `ActiveSessionController::device()` — already yields "Safari · iPhone", and its OS table separates iPhone/iPad/Android from the desktop platforms |
|
||||
|
||||
So this is mostly assembly. The genuinely new parts are a public Blade route, a
|
||||
templates table, and the editor screen.
|
||||
|
||||
**Prerequisite:** `ActiveSessionController::device()` is a private method on a
|
||||
controller. Extract it to `App\Support\UserAgent::describe()` and have the Logged-in
|
||||
Users page and the portal both call it. This is also finding 3a of the Plan 1
|
||||
review, which wants the same extraction for a different reason.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | The designer covers **four** templates: splash, success, over-limit, refused | The after-login pages are where a pupil is actually stuck, and they can use quota facts the splash cannot |
|
||||
| 2 | **Visual editor inside a fixed frame** | The sign-in button must not be removable; a bad edit would brick the portal for everyone at once |
|
||||
| 3 | Device variables everywhere; person and quota variables on after-login pages only | Pre-login there is no signed-in user, so they would render blank |
|
||||
| 4 | The splash is a **plain server-rendered Blade page**, not Inertia | The captive-network mini-browser is a stripped-down WebView; SPA bootstrapping is unreliable there |
|
||||
| 5 | **Escape every substituted value; no `rawKeys`** | `{browser}` and `{device_type}` derive from a client-controlled header, on a public page |
|
||||
| 6 | **No HTML sanitiser** on the operator's body | No library present, and a homemade allowlist is worse than none. Mitigated by a source-free editor and the `unifi.manage` gate. Residual risk stated below |
|
||||
|
||||
---
|
||||
|
||||
## A. The four templates
|
||||
|
||||
| Template key | Rendered by | Auth | Variables |
|
||||
|---|---|---|---|
|
||||
| `splash` | new public route, Blade | none | device only |
|
||||
| `success` | `captiveCallback`, after authorizing | auth | device + person/quota |
|
||||
| `over_limit` | `captiveCallback`, quota branch | auth | device + person/quota + the device list |
|
||||
| `refused` | `captiveCallback`, no-mapping or resolver refusal | auth | device only — there may be no mapping to quote |
|
||||
|
||||
**The splash needs a new public route.** `captiveCallback` sits behind `['web','auth']`
|
||||
today, so an unauthenticated hit does `redirect()->route('login')` and the pupil
|
||||
lands on the ordinary shell login. That is why there is no splash at all now.
|
||||
|
||||
Flow: UniFi redirects → public splash (device facts, "Sign in with Google") →
|
||||
Google → `captiveCallback` → success / over-limit / refused.
|
||||
|
||||
The three after-login pages stay Inertia: by then the pupil has been through Google
|
||||
in a real browser.
|
||||
|
||||
### Storage
|
||||
|
||||
A `unifi_portal_templates` table, one row per key: `key`, `body`, `updated_at`.
|
||||
Not `Setting`, because these are long HTML bodies and a table gives a per-template
|
||||
`updated_at` and a clean per-template reset to default.
|
||||
|
||||
### Variables
|
||||
|
||||
Device — available on all four:
|
||||
|
||||
| Token | Source | Trustworthy? |
|
||||
|---|---|---|
|
||||
| `{ip}` | `$request->ip()` via Traefik | Yes in practice — production shows real per-client addresses |
|
||||
| `{mac}` | derived from the IP via UniFi's client table | Yes — server-derived |
|
||||
| `{is_randomized}` | locally-administered bit on the derived MAC | Yes — server-derived |
|
||||
| `{browser}` | `App\Support\UserAgent::describe()` | **No — client-controlled** |
|
||||
| `{device_type}` | same | **No — client-controlled** |
|
||||
| `{ssid}`, `{ap_name}` | the redirect / the client record | Yes |
|
||||
|
||||
Person and quota — after-login pages only: `{name}`, `{role}`, `{devices_used}`,
|
||||
`{device_limit}`, `{expires_on}`.
|
||||
|
||||
On the splash template the person and quota tokens are **shown disabled with the
|
||||
reason**, not hidden and not silently rendering blank. Hiding them invites the
|
||||
question "why can't I show their name"; blanking them ships a broken page.
|
||||
|
||||
---
|
||||
|
||||
## B. The editor
|
||||
|
||||
A new tab on the existing Unifi → Portal settings page, gated on `unifi.manage` —
|
||||
the key that already guards portal settings.
|
||||
|
||||
- **Template switcher** across the four keys. One screen, not four.
|
||||
- **Fixed frame.** Logo slot, editable content region, sign-in button, footer. The
|
||||
frame and the button are our markup and are not part of the editable body, so no
|
||||
edit can remove the way in.
|
||||
- **Variable picker** beside the editor, grouped Device / Person & Quota, built from
|
||||
`TemplateRender`'s grouping helper.
|
||||
- **Live preview** through the JS mirror, fed sample values. The token set is
|
||||
defined once, server-side, and handed to the page — the memory on this engine is
|
||||
explicit that the JS mirror and the PHP engine must change together, so a single
|
||||
source for the token list is the point.
|
||||
- **Images must be local.** The picker offers uploaded images only, and pasting an
|
||||
external URL warns. See section C — a remote asset cannot load on the splash.
|
||||
- **Reset to default** per template.
|
||||
|
||||
---
|
||||
|
||||
## C. Rendering safety
|
||||
|
||||
### Escape everything
|
||||
|
||||
`TemplateRender::apply($body, $values, escapeValues: true)` with **no `rawKeys`**.
|
||||
`{browser}` and `{device_type}` come from the User-Agent, so a crafted header would
|
||||
otherwise be reflected XSS on an unauthenticated page. `{ip}` and `{mac}` are
|
||||
server-derived and escaped anyway, because there is no reason not to. Nothing needs
|
||||
to bypass escaping: with a fixed frame the sign-in button is markup, not a token.
|
||||
|
||||
The engine defaults `escapeValues` to false, which is correct for its email and PDF
|
||||
callers. This is therefore an explicit decision at the call site, and the reason
|
||||
belongs in a comment there.
|
||||
|
||||
### No sanitiser on the operator's body, and what that leaves
|
||||
|
||||
There is no HTML sanitising library in the project and no allowlist helper. Writing
|
||||
one is rejected: a homemade allowlist is leaky in ways nobody audits, which is worse
|
||||
than not having one.
|
||||
|
||||
Mitigations: the editor is visual-only and offers no source view or script
|
||||
insertion, and the body is editable only by `unifi.manage` holders.
|
||||
|
||||
**Residual risk, stated plainly:** an admin holding `unifi.manage` can put arbitrary
|
||||
HTML on a public page. That is a trusted-insider scenario rather than an escalation —
|
||||
the same admin already controls the portal, the VLAN mappings and the device quotas.
|
||||
But it is a real difference from signatures, which use the same engine and render
|
||||
into email rather than onto the open internet. If the trust boundary ever changes —
|
||||
say portal editing is delegated to a non-admin role — this decision must be revisited
|
||||
first.
|
||||
|
||||
### The constraint most likely to break a rollout
|
||||
|
||||
**The splash is served before the device has internet access.** That is what a
|
||||
captive portal is. Any external asset — a CDN, Google Fonts, a remote logo — silently
|
||||
fails to load, and the pupil gets an unstyled page at the worst possible moment.
|
||||
|
||||
Therefore: all CSS and images local, no external fonts, and UniFi's pre-auth walled
|
||||
garden must allow this host. The likeliest way this breaks in practice is a
|
||||
well-meaning operator pasting a remote logo URL into the editor, which is why
|
||||
section B restricts images rather than merely advising.
|
||||
|
||||
### Fallback
|
||||
|
||||
If a template row is missing, empty, or throws while rendering, render a built-in
|
||||
default body. A blank or broken template must never leave a pupil with no way in —
|
||||
the same reasoning as the fixed frame, applied to content instead of structure.
|
||||
|
||||
### Headers
|
||||
|
||||
The splash carries a device's MAC and IP, so: `Cache-Control: no-store` and
|
||||
`noindex`.
|
||||
|
||||
---
|
||||
|
||||
## D. Testing
|
||||
|
||||
Covered here:
|
||||
|
||||
- Escaping: a hostile User-Agent (`<script>` and an `onerror=` attribute) renders
|
||||
escaped in the splash body.
|
||||
- The public splash route renders with **no** authentication.
|
||||
- Person and quota tokens on the splash render as their disabled placeholder rather
|
||||
than leaking a blank or a previous user's value.
|
||||
- A missing template row, and an empty body, each fall back to the default.
|
||||
- The after-login pages render quota values — `devices_used`, `device_limit`,
|
||||
`expires_on`.
|
||||
- **Rendered splash HTML references no external hosts.** This is the regression most
|
||||
likely to creep back in, and it is invisible on a developer machine that has
|
||||
internet.
|
||||
- `UserAgent::describe()` against real iOS, Android, macOS and Windows strings, plus
|
||||
an empty one.
|
||||
|
||||
Browser pass on the splash in a plain, non-Inertia context.
|
||||
|
||||
**Not provable here** — same hardware list as the access-model spec: whether UniFi's
|
||||
walled garden lets this host through pre-auth, and how the real Captive Network
|
||||
Assistant behaves with the page.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Drag-and-drop or free positioning.** A captive portal is a single narrow column
|
||||
on a phone; absolute positioning is actively wrong for it.
|
||||
- **Conditionals on `{device_type}` as a decision.** The engine supports
|
||||
`{if device_type == 'iPhone'}` and the picker will not encourage it: it puts a
|
||||
spoofable value into a decision rather than a display, which contradicts the
|
||||
report-only stance agreed for device type.
|
||||
- **Enforcement of any kind.** This designer changes what a pupil is told, never
|
||||
what they are allowed.
|
||||
- **Per-role or per-SSID template variants.** One template per key. Add variants
|
||||
when somebody asks for them.
|
||||
- **A block builder.** Considered and rejected: it invents a pattern this codebase
|
||||
has no precedent for, and the layout freedom it buys is limited on a phone.
|
||||
69
src/Console/RebootAllAps.php
Normal file
69
src/Console/RebootAllAps.php
Normal 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;
|
||||
}
|
||||
}
|
||||
110
src/Console/ReconcilePortal.php
Normal file
110
src/Console/ReconcilePortal.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Console;
|
||||
|
||||
use Dashboard\Unifi\Models\PortalSession;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Keep our rows and UniFi's authorizations in step.
|
||||
*
|
||||
* Necessary because session windows are now measured in months: any drift that
|
||||
* used to be washed away by short sessions and frequent portal visits now
|
||||
* persists. Three jobs, and the middle one is the one that matters most.
|
||||
*/
|
||||
class ReconcilePortal extends Command
|
||||
{
|
||||
protected $signature = 'unifi:reconcile-portal';
|
||||
protected $description = 'Expire finished Wi-Fi sessions and reconcile them against UniFi authorizations';
|
||||
|
||||
public function handle(UnifiApiClient $unifi): int
|
||||
{
|
||||
// 1. Expiry. expires_at used to be written and never read, so a finished
|
||||
// row kept consuming a slot indefinitely.
|
||||
$expired = PortalSession::where('is_active', true)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->update(['is_active' => false]);
|
||||
|
||||
if ($expired) {
|
||||
$this->info("Expired {$expired} finished session(s).");
|
||||
}
|
||||
|
||||
try {
|
||||
$authorized = $unifi->getAuthorizedGuestMacs();
|
||||
} catch (\Throwable $e) {
|
||||
// A controller we cannot reach is not evidence that anything should
|
||||
// change. Expiry above is local and already done.
|
||||
$this->error('Could not read UniFi authorizations: ' . $e->getMessage());
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$live = PortalSession::live()->get()->keyBy(fn ($s) => strtolower($s->mac_address));
|
||||
|
||||
// 2. Orphans: authorized on the network with no live row of ours. These
|
||||
// are what the old presence-based sweep produced — a device nobody
|
||||
// can see in the UI and nobody's quota is paying for.
|
||||
foreach (array_diff($authorized, $live->keys()->all()) as $orphan) {
|
||||
$unifi->unauthorizeGuest($orphan);
|
||||
$this->warn("Revoked orphan authorization {$orphan}.");
|
||||
}
|
||||
|
||||
// 3. The other direction: we consider it live but UniFi has forgotten it
|
||||
// (upgrade, restore). Re-assert rather than making the person sign in
|
||||
// again for something that was not their doing.
|
||||
foreach ($live->keys()->diff($authorized) as $mac) {
|
||||
$session = $live->get($mac);
|
||||
$minutes = max(1, (int) now()->diffInMinutes($session->expires_at, false));
|
||||
$unifi->authorizeGuest($mac, $minutes);
|
||||
$this->warn("Re-asserted authorization for {$mac} ({$minutes}m remaining).");
|
||||
}
|
||||
|
||||
$this->captureFingerprints($unifi, $live);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what UniFi thinks each device IS. Reported only — nothing acts on
|
||||
* it, per the decision that enforcing on unvalidated fingerprint data would
|
||||
* lock out the wrong people.
|
||||
*
|
||||
* This is the earliest point the data exists: UniFi infers dev_cat from
|
||||
* observed traffic, so it is absent at the moment of authorization.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection $live keyed by lower-case MAC
|
||||
*/
|
||||
private function captureFingerprints(UnifiApiClient $unifi, $live): void
|
||||
{
|
||||
try {
|
||||
$clients = collect($unifi->getActiveClients())
|
||||
->keyBy(fn ($c) => strtolower((string) ($c['mac'] ?? '')));
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Could not read clients for fingerprints: ' . $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($live as $mac => $session) {
|
||||
$client = $clients->get($mac);
|
||||
if (! $client) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only ever fill in. An unfingerprinted client must not blank a type
|
||||
// we already recorded — that would lose the answer every time the
|
||||
// device happened to be quiet.
|
||||
$updates = array_filter([
|
||||
'device_type' => $client['dev_cat'] ?? null,
|
||||
'device_os' => $client['os_name'] ?? null,
|
||||
'device_hostname' => $client['hostname'] ?? null,
|
||||
], fn ($v) => $v !== null && $v !== '');
|
||||
|
||||
if ($updates) {
|
||||
$session->update($updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
190
src/Console/RotatePasswords.php
Normal file
190
src/Console/RotatePasswords.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?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,
|
||||
// matched by name (synthetic id changes with the
|
||||
// passphrase, so it's not a stable matcher).
|
||||
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
|
||||
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
|
||||
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
|
||||
|
||||
// Update every grouped sibling (user-defined SSID
|
||||
// groups take precedence; same-name fallback for
|
||||
// installs that haven't grouped manually).
|
||||
foreach ($unifi->getGroupedWlans($ppsk->wlan_id) as $siblingWlanId) {
|
||||
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||
->where('name', $ppsk->name)
|
||||
->where('state', 'active')
|
||||
->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) {
|
||||
if (str_contains($e->getMessage(), 'not found')) {
|
||||
\Illuminate\Support\Facades\Log::info('unifi.ppsk_sibling_skipped', [
|
||||
'sibling_wlan' => $siblingWlanId,
|
||||
'ppsk_name' => $ppsk->name,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
|
||||
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the new passphrase actually applied
|
||||
// on every grouped WLAN. UniFi can 200 an update
|
||||
// that doesn't stick (cluster sync race, etc).
|
||||
// Anything we expected to rotate that didn't is a
|
||||
// failure — surface it in the cron log.
|
||||
$allWlanIds = array_merge([$ppsk->wlan_id], $unifi->getGroupedWlans($ppsk->wlan_id));
|
||||
foreach ($allWlanIds as $checkWlanId) {
|
||||
$result = $unifi->verifyEmbeddedPpsk($checkWlanId, $ppsk->name, $newPass);
|
||||
if ($result['ok']) continue;
|
||||
|
||||
// 'not_found' on a sibling = PPSK isn't on that band — ignore
|
||||
// (consistent with the skip in the update loop).
|
||||
if ($result['reason'] === 'not_found' && $checkWlanId !== $ppsk->wlan_id) continue;
|
||||
|
||||
$failedPpsks[] = [
|
||||
'name' => $ppsk->name . ' (verify wlan ' . $checkWlanId . ')',
|
||||
'error' => 'verification ' . $result['reason'] . ($result['error'] ?? null ? ': ' . $result['error'] : ''),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
||||
$ppsk->update(['x_passphrase' => $newPass]);
|
||||
}
|
||||
$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;
|
||||
}
|
||||
}
|
||||
121
src/Console/SyncPpskSchedules.php
Normal file
121
src/Console/SyncPpskSchedules.php
Normal 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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
124
src/Http/Controllers/GuestCardController.php
Normal file
124
src/Http/Controllers/GuestCardController.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
/**
|
||||
* The "Guest Wi-Fi" dashboard home card: network name, the current
|
||||
* rotating password, and a QR code that re-generates when the password
|
||||
* changes. The password is the rotation engine's last-applied value
|
||||
* (unifi.password_rotation.last_password), so the card tracks whatever
|
||||
* unifi:rotate-passwords sets. The card polls data() so it updates
|
||||
* without a page reload.
|
||||
*/
|
||||
class GuestCardController extends Controller
|
||||
{
|
||||
/**
|
||||
* Is this card set up enough to ever appear?
|
||||
*
|
||||
* The three things payload() needs, asked as one question, so the permission
|
||||
* screens can say "granted, but not configured yet" instead of leaving
|
||||
* somebody staring at a home page wondering what they got wrong. The
|
||||
* operator granted this key to two groups in production and saw nothing
|
||||
* (2026-08-17); the card was simply never switched on.
|
||||
*
|
||||
* Note the third clause: last_password is written ONLY by
|
||||
* unifi:rotate-passwords, so a school that sets a static guest password by
|
||||
* hand and never rotates has no card and no obvious reason why.
|
||||
*/
|
||||
public static function configured(): bool
|
||||
{
|
||||
return (bool) Setting::get('unifi.guest_card.enabled')
|
||||
&& trim((string) Setting::get('unifi.guest_card.ssid', '')) !== ''
|
||||
&& filled(Setting::get('unifi.password_rotation.last_password'));
|
||||
}
|
||||
|
||||
/** Payload shared by the card resolver and the poll endpoint. */
|
||||
public static function payload(): ?array
|
||||
{
|
||||
if (! Setting::get('unifi.guest_card.enabled')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$password = Setting::get('unifi.password_rotation.last_password');
|
||||
$ssid = trim((string) Setting::get('unifi.guest_card.ssid', ''));
|
||||
if (! $password || $ssid === '') {
|
||||
return null; // nothing meaningful to show yet
|
||||
}
|
||||
|
||||
$freq = Setting::get('unifi.password_rotation.frequency', 'weekly');
|
||||
|
||||
return [
|
||||
'title' => trim((string) Setting::get('unifi.guest_card.title', '')) ?: 'Guest Wi-Fi',
|
||||
'ssid' => $ssid,
|
||||
'password' => $password,
|
||||
'rotated_at' => Setting::get('unifi.password_rotation.last_rotated_at'),
|
||||
// WPA/WPA2 join string a phone camera understands. Special
|
||||
// chars in SSID/password must be backslash-escaped per the
|
||||
// Wi-Fi QR spec.
|
||||
'qr' => self::wifiQrString($ssid, $password),
|
||||
'frequency' => in_array($freq, ['daily', 'weekly'], true) ? $freq : 'weekly',
|
||||
];
|
||||
}
|
||||
|
||||
/** The dashboard card this endpoint feeds — must match UnifiServiceProvider. */
|
||||
private const CARD_KEY = 'unifi.guest_wifi';
|
||||
|
||||
/**
|
||||
* SECURITY: authorize against the card's own visibility.
|
||||
*
|
||||
* This was `web,auth` only, and the route comment claimed "card visibility is
|
||||
* grant-gated by the shell" — but that gate is PRESENTATION only
|
||||
* (DashboardController decides which cards to render). The endpoint itself was
|
||||
* unchecked, so any signed-in account — every student and parent included —
|
||||
* could fetch the live guest Wi-Fi password by requesting the URL directly.
|
||||
*
|
||||
* The endpoint enforces what the comment always claimed: you may poll it iff
|
||||
* you may see the card. Still asked as the CARD gate — via the registry, which
|
||||
* now answers from the card's permission (unifi.guest_wifi) instead of the
|
||||
* retired dashboard_card_grants table. Asking the registry rather than naming
|
||||
* the key here is deliberate: the endpoint and the home page cannot drift.
|
||||
* Super admins pass, as everywhere else (keysFor handles that).
|
||||
*/
|
||||
public function data(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
abort_unless(
|
||||
app(\App\Support\DashboardCardRegistry::class)->visibleTo($user, self::CARD_KEY),
|
||||
403,
|
||||
);
|
||||
|
||||
return response()->json(self::payload() ?? ['title' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card registry resolver.
|
||||
*
|
||||
* Re-checks the permission the registry already gated on, the way bosch's and
|
||||
* signinout's resolvers do. Redundant on the home-page path and deliberately
|
||||
* kept: renderFor() renders whatever key list it is handed, and the payload
|
||||
* here is a live credential — the one card where "the caller asked the right
|
||||
* question" is not a good enough reason to hand it over.
|
||||
*/
|
||||
public static function resolve(User $user): ?array
|
||||
{
|
||||
if (! $user->can('unifi.guest_wifi')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::payload();
|
||||
}
|
||||
|
||||
private static function wifiQrString(string $ssid, string $password): string
|
||||
{
|
||||
$esc = fn (string $v) => preg_replace('/([\\\\;,:"])/', '\\\\$1', $v);
|
||||
|
||||
return sprintf('WIFI:T:WPA;S:%s;P:%s;;', $esc($ssid), $esc($password));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -30,11 +32,15 @@ class PortalController extends Controller
|
||||
{
|
||||
$data = $request->validate([
|
||||
'group_name' => 'required|string|max:255',
|
||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
||||
'match_type' => 'required|in:ou,group,email_domain,role',
|
||||
'match_value' => 'required|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'max_devices' => 'required|integer|min:1|max:50',
|
||||
'session_minutes' => 'required|integer|min:1',
|
||||
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
|
||||
// is unsignedInteger, so an unbounded value reached the INSERT and raised
|
||||
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
|
||||
// 525600 minutes is a year, far more session than anyone will ever grant.
|
||||
'session_minutes' => 'required|integer|min:1|max:525600',
|
||||
]);
|
||||
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
|
||||
VlanMapping::create($data);
|
||||
@@ -45,11 +51,15 @@ class PortalController extends Controller
|
||||
{
|
||||
$data = $request->validate([
|
||||
'group_name' => 'required|string|max:255',
|
||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
||||
'match_type' => 'required|in:ou,group,email_domain,role',
|
||||
'match_value' => 'required|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'max_devices' => 'required|integer|min:1|max:50',
|
||||
'session_minutes' => 'required|integer|min:1',
|
||||
// Bounded by the column that stores it: unifi_vlan_mappings.session_minutes
|
||||
// is unsignedInteger, so an unbounded value reached the INSERT and raised
|
||||
// SQLSTATE 22003 — a 500 where a validation error belongs (central-unifi#5).
|
||||
// 525600 minutes is a year, far more session than anyone will ever grant.
|
||||
'session_minutes' => 'required|integer|min:1|max:525600',
|
||||
]);
|
||||
$mapping->update($data);
|
||||
return back()->with('success', 'Mapping updated.');
|
||||
@@ -85,6 +95,35 @@ class PortalController extends Controller
|
||||
|
||||
// ── Captive portal callback (called by UniFi external portal redirect) ───
|
||||
|
||||
/**
|
||||
* A redirect target that cannot be turned into a phishing or script payload.
|
||||
*
|
||||
* Accepts only absolute http/https URLs and site-relative paths. Rejects
|
||||
* javascript:/data:/vbscript: schemes, and protocol-relative "//host" values which
|
||||
* redirect() would happily send off-site. Anything rejected falls back to the
|
||||
* dashboard rather than erroring — the guest is already online at this point, so a
|
||||
* bad `url` parameter should not look like a failed login.
|
||||
*/
|
||||
private function safeRedirectTarget(?string $url): string
|
||||
{
|
||||
$url = trim((string) $url);
|
||||
|
||||
if ($url === '' || str_starts_with($url, '//')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Site-relative is always fine.
|
||||
if (str_starts_with($url, '/')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
|
||||
|
||||
return in_array($scheme, ['http', 'https'], true) && parse_url($url, PHP_URL_HOST)
|
||||
? $url
|
||||
: '/';
|
||||
}
|
||||
|
||||
public function captiveCallback(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
|
||||
@@ -94,42 +133,55 @@ class PortalController extends Controller
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$clientMac = strtolower($request->input('mac', $request->input('id', '')));
|
||||
$ssid = $request->input('ssid', '');
|
||||
$apMac = $request->input('ap', '');
|
||||
$apMac = (string) $request->input('ap', '');
|
||||
$ssid = (string) $request->input('ssid', '');
|
||||
|
||||
if (! $clientMac) {
|
||||
return response('Missing MAC address', 400);
|
||||
$device = app(\Dashboard\Unifi\Services\DeviceResolver::class)->resolve($request, $apMac);
|
||||
|
||||
if (! $device->ok) {
|
||||
return Inertia::render('Unifi/PortalDenied', [
|
||||
'reason' => match ($device->reason) {
|
||||
'ap_mismatch' => 'That device is not connected to this access point. Reconnect and try again.',
|
||||
'proxy_untrusted' => 'Wi-Fi sign-in is misconfigured and cannot identify your device. Please tell IT.',
|
||||
default => 'Still connecting — wait a moment and try again.',
|
||||
},
|
||||
'resolveFailure' => $device->reason,
|
||||
]);
|
||||
}
|
||||
|
||||
$clientMac = $device->mac;
|
||||
$isRandomized = $device->isRandomized;
|
||||
|
||||
// Find the VLAN mapping for this user's group/OU
|
||||
$mapping = $this->resolveMapping($user);
|
||||
if (! $mapping) {
|
||||
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
|
||||
}
|
||||
|
||||
// Check device limit
|
||||
$activeSessions = PortalSession::where('user_id', $user->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
/*
|
||||
* Presence is NOT the question — a live authorization is.
|
||||
*
|
||||
* This used to walk the user's sessions and mark any device UniFi did not
|
||||
* currently see as stale, freeing its slot. But UniFi holds an
|
||||
* authorization for its whole window regardless of whether the device is
|
||||
* in the building, so a phone that went home had its slot freed while
|
||||
* still being authorized: the owner then authorized a second device and
|
||||
* one of the two was an orphan nothing tracked. The longer
|
||||
* session_minutes is — and it is now meant to be a term — the more of
|
||||
* those accumulate.
|
||||
*
|
||||
* Slots are now freed by eviction, by expiry, or by ReconcilePortal.
|
||||
*/
|
||||
$live = PortalSession::live()->where('user_id', $user->id)->get();
|
||||
|
||||
foreach ($activeSessions as $session) {
|
||||
if ($session->mac_address === $clientMac) {
|
||||
// Same device reconnecting — refresh
|
||||
$session->update(['is_active' => false]);
|
||||
continue;
|
||||
}
|
||||
// Check if other device is still actually connected
|
||||
if (! $unifi->isClientConnected($session->mac_address)) {
|
||||
$session->update(['is_active' => false]); // stale session
|
||||
}
|
||||
}
|
||||
// The same device coming back is a renewal, not a new device: it keeps
|
||||
// its slot and its row, and simply gets a fresh window.
|
||||
$existing = $live->firstWhere('mac_address', $clientMac);
|
||||
|
||||
$activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count();
|
||||
if ($activeCount >= $mapping->max_devices) {
|
||||
if (! $existing && $live->count() >= $mapping->max_devices) {
|
||||
return Inertia::render('Unifi/PortalDenied', [
|
||||
'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).",
|
||||
'sessions' => $activeSessions->map(fn ($s) => [
|
||||
'reason' => "You already have {$live->count()} device(s) connected (limit: {$mapping->max_devices}).",
|
||||
'sessions' => $live->map(fn ($s) => [
|
||||
'id' => $s->id,
|
||||
'mac' => $s->mac_address,
|
||||
'hostname' => $s->device_hostname,
|
||||
@@ -144,10 +196,9 @@ class PortalController extends Controller
|
||||
$minutes = $mapping->session_minutes;
|
||||
$unifi->authorizeGuest($clientMac, $minutes);
|
||||
|
||||
// Record the session
|
||||
PortalSession::create([
|
||||
'user_id' => $user->id,
|
||||
'mac_address' => $clientMac,
|
||||
PortalSession::updateOrCreate(
|
||||
['user_id' => $user->id, 'mac_address' => $clientMac],
|
||||
[
|
||||
'device_hostname' => $request->input('hostname'),
|
||||
'device_os' => $request->input('os'),
|
||||
'device_type' => $request->input('dev_cat'),
|
||||
@@ -157,11 +208,20 @@ class PortalController extends Controller
|
||||
'is_active' => true,
|
||||
'authorized_at' => now(),
|
||||
'expires_at' => now()->addMinutes($minutes),
|
||||
]);
|
||||
'is_randomized' => $isRandomized,
|
||||
]
|
||||
);
|
||||
|
||||
// Redirect to the original URL the user was trying to reach
|
||||
$redirectUrl = $request->input('url', '/');
|
||||
return redirect($redirectUrl);
|
||||
// Redirect to the original URL the user was trying to reach.
|
||||
//
|
||||
// SECURITY: this was `redirect($request->input('url', '/'))` — an unvalidated
|
||||
// open redirect on an authenticated route, i.e. a phishing primitive wearing
|
||||
// this school's domain. The whole point of a captive portal is to send someone
|
||||
// to an ARBITRARY external site they were already trying to reach, so an
|
||||
// allowlist is not available; what is available is refusing anything that is
|
||||
// not a plain http(s) URL, which blocks javascript:, data: and protocol-relative
|
||||
// //evil.example payloads.
|
||||
return redirect($this->safeRedirectTarget($request->input('url')));
|
||||
}
|
||||
|
||||
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)
|
||||
@@ -191,7 +251,16 @@ class PortalController extends Controller
|
||||
'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value),
|
||||
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
|
||||
'ou' => str_contains($user->ou ?? '', $mapping->match_value),
|
||||
'default' => true,
|
||||
/*
|
||||
* Roles are the platform's own authority and need no external
|
||||
* sync to be correct, which is why quotas key off them.
|
||||
*
|
||||
* `default` is deliberately absent. It matched everybody, so its
|
||||
* existence made this an advisory list rather than an allow-list.
|
||||
* Returning null here is already the deny path: captiveCallback
|
||||
* renders "No WiFi access configured for your account."
|
||||
*/
|
||||
'role' => $user->hasRole($mapping->match_value),
|
||||
default => false,
|
||||
};
|
||||
if ($matched) return $mapping;
|
||||
|
||||
@@ -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
|
||||
@@ -35,6 +36,7 @@ class StatsController extends Controller
|
||||
if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null;
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'status' => $wan['status'] ?? 'unknown',
|
||||
'tx_rate' => $wan['tx_bytes-r'] ?? 0,
|
||||
'rx_rate' => $wan['rx_bytes-r'] ?? 0,
|
||||
@@ -43,7 +45,19 @@ class StatsController extends Controller
|
||||
'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['status' => 'error'], 500);
|
||||
// The UniFi controller is unreachable. Degrade gracefully: return 200 with a
|
||||
// friendly "unavailable" payload so the dashboard widget shows an unavailable
|
||||
// state instead of the poll silently failing on a 500.
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'status' => 'unavailable',
|
||||
'message' => 'UniFi controller is unreachable.',
|
||||
'tx_rate' => 0,
|
||||
'rx_rate' => 0,
|
||||
'isp' => null,
|
||||
'wan_ip' => null,
|
||||
'latency' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +79,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 +187,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 +222,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 +232,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 +249,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 +266,67 @@ 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([
|
||||
'available' => true,
|
||||
'labels' => $series['labels'],
|
||||
'traffic_rx' => $rx,
|
||||
'traffic_tx' => $tx,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// UniFi controller unreachable — degrade gracefully (200 with empty series)
|
||||
// so the chart shows an empty/unavailable state rather than erroring on a 500.
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'message' => 'UniFi controller is unreachable.',
|
||||
'labels' => [],
|
||||
'traffic_rx' => [],
|
||||
'traffic_tx' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Device TYPE = manufacturer + model (when detectable).
|
||||
*
|
||||
@@ -438,7 +519,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 +644,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,
|
||||
|
||||
43
src/Http/Controllers/UnifiCronLogsController.php
Normal file
43
src/Http/Controllers/UnifiCronLogsController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,55 +10,107 @@ use Inertia\Inertia;
|
||||
|
||||
class UnifiSettingsController extends Controller
|
||||
{
|
||||
public function edit()
|
||||
public function edit(?string $tab = null)
|
||||
{
|
||||
return Inertia::render('Unifi/Settings', [
|
||||
'activeTab' => $tab,
|
||||
'controllerUrl' => Setting::get('unifi.controller_url', ''),
|
||||
'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),
|
||||
'guestCardEnabled' => (bool) Setting::get('unifi.guest_card.enabled', false),
|
||||
'guestCardSsid' => Setting::get('unifi.guest_card.ssid', ''),
|
||||
'guestCardTitle' => Setting::get('unifi.guest_card.title', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
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',
|
||||
'guest_card_enabled' => 'boolean',
|
||||
'guest_card_ssid' => 'nullable|string|max:100',
|
||||
'guest_card_title' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
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' : '');
|
||||
Setting::set('unifi.guest_card.enabled', $request->boolean('guest_card_enabled') ? '1' : '');
|
||||
Setting::set('unifi.guest_card.ssid', trim((string) $request->input('guest_card_ssid', '')));
|
||||
Setting::set('unifi.guest_card.title', trim((string) $request->input('guest_card_title', '')));
|
||||
|
||||
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
||||
|
||||
return back()->with('success', 'UniFi settings saved.');
|
||||
@@ -103,12 +155,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);
|
||||
|
||||
39
src/Http/Controllers/VlanGroupController.php
Normal file
39
src/Http/Controllers/VlanGroupController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
44
src/Http/Controllers/WifiApiController.php
Normal file
44
src/Http/Controllers/WifiApiController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,342 @@ 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 in priority order:
|
||||
// 1. by current unifi_id (already-synced row)
|
||||
// 2. by name within this wlan (catches rotation: passphrase
|
||||
// changed → synthetic id changed → row identity unchanged)
|
||||
// 3. by passphrase among held rows (legacy fallback for
|
||||
// cases where name wasn't ingested)
|
||||
$record = UnifiPpsk::where('unifi_id', $uid)->first()
|
||||
?? ($name
|
||||
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->first()
|
||||
: null)
|
||||
?? UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('x_passphrase', $pass)
|
||||
->where('state', 'held')
|
||||
->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark non-matching active rows as held — but ONLY if there's no
|
||||
// other active row with the same name we just reconnected.
|
||||
if (! empty($liveIds)) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
->whereNotNull('unifi_id')
|
||||
->whereNotIn('unifi_id', $liveIds)
|
||||
->update(['state' => 'held', 'unifi_id' => null]);
|
||||
}
|
||||
|
||||
// For each active row, salvage any rotate_password / schedule
|
||||
// settings from the held tombstones with the same name BEFORE
|
||||
// we prune them. Otherwise a row that had rotate=on loses the
|
||||
// flag every time a rotation changes its synthetic id.
|
||||
$activeRows = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
->whereNotNull('name')
|
||||
->get();
|
||||
foreach ($activeRows as $active) {
|
||||
$heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'held')
|
||||
->where('name', $active->name)
|
||||
->where(fn ($q) => $q
|
||||
->where('rotate_password', true)
|
||||
->orWhereNotNull('schedule'))
|
||||
->orderByDesc('updated_at')
|
||||
->first();
|
||||
if (! $heldWithSettings) continue;
|
||||
|
||||
$patch = [];
|
||||
if ($heldWithSettings->rotate_password && ! $active->rotate_password) {
|
||||
$patch['rotate_password'] = true;
|
||||
}
|
||||
if ($heldWithSettings->schedule && ! $active->schedule) {
|
||||
$patch['schedule'] = $heldWithSettings->schedule;
|
||||
}
|
||||
if ($patch) $active->update($patch);
|
||||
}
|
||||
|
||||
// Prune obsolete held rows: any held row whose name matches an
|
||||
// active row in the same wlan is a stale tombstone — its
|
||||
// settings have been salvaged above, and its data has been
|
||||
// superseded by the active one.
|
||||
$activeNames = $activeRows->pluck('name')->filter()->unique();
|
||||
if ($activeNames->isNotEmpty()) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'held')
|
||||
->whereIn('name', $activeNames)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->orderBy('name')
|
||||
->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 grouped WLAN siblings (user-defined
|
||||
// SSID groups, falling back to same-name).
|
||||
foreach ($unifi->getGroupedWlans($record->wlan_id) as $siblingWlanId) {
|
||||
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||
->where('name', $record->name)
|
||||
->where('state', 'active')
|
||||
->first();
|
||||
try {
|
||||
$unifi->updateEmbeddedPpsk($siblingWlanId, $sibling?->x_passphrase, $newPass, $record->name);
|
||||
if ($sibling) {
|
||||
$sibling->update([
|
||||
'x_passphrase' => $newPass,
|
||||
'unifi_id' => 'emb_' . substr(hash('sha256', $siblingWlanId . ':' . $newPass), 0, 32),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// PPSK absent on this band is fine — just
|
||||
// means it isn't mirrored. Anything else
|
||||
// gets warning-logged.
|
||||
$level = str_contains($e->getMessage(), 'not found') ? 'info' : 'warning';
|
||||
\Illuminate\Support\Facades\Log::log($level, 'unifi.ppsk_sibling_update', [
|
||||
'sibling_wlan' => $siblingWlanId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase', '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 +457,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';
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ class PortalSession extends Model
|
||||
protected $fillable = [
|
||||
'user_id', 'mac_address', 'device_hostname', 'device_os', 'device_type',
|
||||
'ssid', 'vlan_id', 'ap_mac', 'is_active', 'authorized_at', 'expires_at',
|
||||
'is_randomized',
|
||||
];
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'authorized_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'is_randomized' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
@@ -23,6 +25,23 @@ class PortalSession extends Model
|
||||
return $this->belongsTo(\App\Models\User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows that consume a device slot.
|
||||
*
|
||||
* Presence on the network is deliberately NOT part of this. UniFi holds an
|
||||
* authorization for its full window whether or not the device is in the
|
||||
* building, so a phone that went home still owns its slot — freeing it here
|
||||
* is what let one person hold two authorizations against a one-device quota.
|
||||
*
|
||||
* A NULL expires_at is not live: it predates this being enforced.
|
||||
*/
|
||||
public function scopeLive($query)
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '>', now());
|
||||
}
|
||||
|
||||
public function setMacAddressAttribute($value)
|
||||
{
|
||||
$this->attributes['mac_address'] = strtolower($value);
|
||||
|
||||
79
src/Models/UnifiCronRun.php
Normal file
79
src/Models/UnifiCronRun.php
Normal 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();
|
||||
}
|
||||
}
|
||||
30
src/Models/UnifiPpsk.php
Normal file
30
src/Models/UnifiPpsk.php
Normal 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
12
src/Models/VlanGroup.php
Normal 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'];
|
||||
}
|
||||
104
src/Services/DeviceResolver.php
Normal file
104
src/Services/DeviceResolver.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Services;
|
||||
|
||||
use Dashboard\Unifi\Support\MacAddress;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/** The outcome of trying to identify the device making a portal request. */
|
||||
class ResolvedDevice
|
||||
{
|
||||
public function __construct(
|
||||
public bool $ok,
|
||||
public ?string $mac = null,
|
||||
public ?string $reason = null,
|
||||
public bool $isRandomized = false,
|
||||
public bool $macParameterDisagreed = false,
|
||||
public array $client = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the device that made a captive-portal request, or refuse.
|
||||
*
|
||||
* The `?mac=` parameter is NOT trusted. It is read only so a disagreement with
|
||||
* the derived value can be logged — that disagreement is somebody trying to
|
||||
* authorize a device they do not own, and before this nothing anywhere noticed.
|
||||
*
|
||||
* Every path refuses rather than guessing. The cost of that is real: if
|
||||
* TrustProxies is misconfigured, `proxy_untrusted` is returned for everyone and
|
||||
* nobody gets online, which is why it is a distinct reason the portal page can
|
||||
* report instead of a generic miss.
|
||||
*/
|
||||
class DeviceResolver
|
||||
{
|
||||
public function __construct(private UnifiApiClient $unifi) {}
|
||||
|
||||
/** Attempts, and the pause between them, for a device that has just associated. */
|
||||
private const ATTEMPTS = 3;
|
||||
private const PAUSE_US = 750_000;
|
||||
|
||||
public function resolve(Request $request, string $apMac): ResolvedDevice
|
||||
{
|
||||
$ip = $request->ip();
|
||||
|
||||
/*
|
||||
* A device that has only just associated may have no IP yet, so UniFi's
|
||||
* client table does not know it. Retry briefly rather than telling a
|
||||
* pupil their device is not connected when it is about to be.
|
||||
*
|
||||
* Bounded on purpose: this runs inside a web request, and a longer wait
|
||||
* would hold a worker per connecting device at exactly the moment a
|
||||
* class of them arrives at once.
|
||||
*/
|
||||
$client = null;
|
||||
for ($attempt = 1; $attempt <= self::ATTEMPTS; $attempt++) {
|
||||
$client = $this->unifi->findClientByIp($ip);
|
||||
if ($client) {
|
||||
break;
|
||||
}
|
||||
if ($attempt < self::ATTEMPTS) {
|
||||
usleep(self::PAUSE_US);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $client) {
|
||||
// A forwarded header present while the request IP is still the raw
|
||||
// socket address means TrustProxies is not applying, so every lookup
|
||||
// here is doomed for the same reason. Say which failure this is.
|
||||
$forwarded = $request->headers->get('X-Forwarded-For');
|
||||
$raw = $request->server('REMOTE_ADDR');
|
||||
$reason = ($forwarded && $ip === $raw) ? 'proxy_untrusted' : 'no_client';
|
||||
|
||||
return new ResolvedDevice(ok: false, reason: $reason);
|
||||
}
|
||||
|
||||
$mac = MacAddress::normalise($client['mac'] ?? null);
|
||||
if (! $mac) {
|
||||
return new ResolvedDevice(ok: false, reason: 'no_client');
|
||||
}
|
||||
|
||||
$clientAp = MacAddress::normalise($client['ap_mac'] ?? null);
|
||||
if (! $clientAp || $clientAp !== MacAddress::normalise($apMac)) {
|
||||
return new ResolvedDevice(ok: false, reason: 'ap_mismatch');
|
||||
}
|
||||
|
||||
$supplied = MacAddress::normalise($request->input('mac'));
|
||||
$disagreed = $supplied !== null && $supplied !== $mac;
|
||||
|
||||
if ($disagreed) {
|
||||
Log::warning('unifi.portal: supplied MAC disagreed with the derived one', [
|
||||
'derived' => $mac, 'supplied' => $supplied, 'ip' => $ip, 'ap' => $apMac,
|
||||
]);
|
||||
}
|
||||
|
||||
return new ResolvedDevice(
|
||||
ok: true,
|
||||
mac: $mac,
|
||||
isRandomized: MacAddress::isRandomized($mac),
|
||||
macParameterDisagreed: $disagreed,
|
||||
client: $client,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,24 @@ class UnifiApiClient
|
||||
return ! empty($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* The client currently holding this IP, or null.
|
||||
*
|
||||
* Reads the cached client list rather than querying per IP: the portal calls
|
||||
* this once per connection attempt, and a class arriving at once would
|
||||
* otherwise be one controller round trip each.
|
||||
*/
|
||||
public function findClientByIp(string $ip): ?array
|
||||
{
|
||||
foreach ($this->getActiveClients() as $client) {
|
||||
if (($client['ip'] ?? null) === $ip) {
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function kickClient(string $mac): array
|
||||
{
|
||||
Cache::forget('unifi:clients');
|
||||
@@ -286,6 +304,39 @@ class UnifiApiClient
|
||||
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The MACs UniFi currently holds an active guest authorization for.
|
||||
*
|
||||
* Read from /stat/guest, which is the authorization list — deliberately not
|
||||
* /stat/sta, which is who is associated right now. Those are the two facts
|
||||
* this whole feature had been conflating.
|
||||
*/
|
||||
public function getAuthorizedGuestMacs(): array
|
||||
{
|
||||
$rows = $this->get('/stat/guest');
|
||||
|
||||
return collect($rows)
|
||||
->filter(fn ($g) => (int) ($g['end'] ?? 0) > now()->timestamp)
|
||||
->pluck('mac')
|
||||
->map(fn ($m) => strtolower((string) $m))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
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 +349,443 @@ class UnifiApiClient
|
||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every other WLAN that should rotate/update together with this
|
||||
* one. Authoritative source: the user-defined "SSID groups" setting
|
||||
* (unifi.ssid_groups) from the WiFi Networks page, which lets the
|
||||
* operator manually couple WLANs that may have different SSID names.
|
||||
*
|
||||
* Falls back to same-SSID-name siblings for installs that haven't
|
||||
* configured groups yet.
|
||||
*
|
||||
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
|
||||
*/
|
||||
public function getGroupedWlans(string $wlanId): array
|
||||
{
|
||||
$groupsJson = Setting::get('unifi.ssid_groups', '{}');
|
||||
$groups = json_decode($groupsJson, true);
|
||||
if (is_array($groups)) {
|
||||
foreach ($groups as $wlanIds) {
|
||||
if (! is_array($wlanIds)) continue;
|
||||
if (in_array($wlanId, $wlanIds, true)) {
|
||||
return array_values(array_filter($wlanIds, fn ($id) => $id !== $wlanId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return $this->getWlanSiblings($wlanId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an embedded PPSK has the expected passphrase right now.
|
||||
* Used after an update to confirm the change actually applied —
|
||||
* UniFi sometimes 200s an update that didn't stick (cluster sync
|
||||
* race, hot-restart in progress, etc.).
|
||||
*
|
||||
* Returns ['ok' => true] on a clean match, or
|
||||
* ['ok' => false, 'reason' => 'fetch_failed'|'not_found'|'mismatch']
|
||||
* with optional 'error' on fetch failures.
|
||||
*/
|
||||
public function verifyEmbeddedPpsk(string $wlanId, string $name, string $expectedPassphrase): array
|
||||
{
|
||||
try {
|
||||
$entries = $this->getPpskEntries($wlanId);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'reason' => 'fetch_failed', 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$networkconfId = $this->findNetworkconfIdByName($name);
|
||||
|
||||
foreach ($entries as $e) {
|
||||
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
||||
$entryNetId = $e['networkconf_id'] ?? null;
|
||||
$entryMatches = ($networkconfId !== null && $entryNetId === $networkconfId)
|
||||
|| ($entryName !== null && $entryName === $name);
|
||||
if (! $entryMatches) continue;
|
||||
|
||||
$entryPass = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
||||
return $entryPass === $expectedPassphrase
|
||||
? ['ok' => true]
|
||||
: ['ok' => false, 'reason' => 'mismatch'];
|
||||
}
|
||||
return ['ok' => false, 'reason' => 'not_found'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a networkconf (VLAN/network) by its display name. Embedded
|
||||
* PPSKs on this controller use networkconf_id as their stable
|
||||
* identifier — the human "name" the operator sees is actually the
|
||||
* network's name.
|
||||
*/
|
||||
private function findNetworkconfIdByName(string $name): ?string
|
||||
{
|
||||
try {
|
||||
$networks = $this->getNetworkConfs();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
foreach ($networks as $n) {
|
||||
if (($n['name'] ?? null) === $name) {
|
||||
return $n['_id'] ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sibling WLAN configs — same SSID name, different _id. UniFi
|
||||
* splits a "banded" SSID (band-steering disabled) into one wlanconf
|
||||
* 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()]);
|
||||
}
|
||||
|
||||
// 3–4. 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.');
|
||||
}
|
||||
|
||||
// Embedded PPSKs on this controller don't carry a name field —
|
||||
// the human label ("GUEST", "3DPrinters", …) is the *network's*
|
||||
// name, and each entry references it via networkconf_id. So when
|
||||
// the caller passes a name, first resolve it to a networkconf_id
|
||||
// and match on that. Falls back to entry-level name (other
|
||||
// controller versions DO put a name on the entry) and finally
|
||||
// to current passphrase.
|
||||
$applyUpdate = function (array &$e) use ($newPassphrase) {
|
||||
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
|
||||
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
|
||||
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
|
||||
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
|
||||
$e['password'] = $newPassphrase;
|
||||
}
|
||||
};
|
||||
|
||||
$networkconfId = ($name !== null && $name !== '') ? $this->findNetworkconfIdByName($name) : null;
|
||||
|
||||
$matched = false;
|
||||
if ($networkconfId !== null) {
|
||||
foreach ($entries as &$e) {
|
||||
if (($e['networkconf_id'] ?? null) === $networkconfId) {
|
||||
$applyUpdate($e);
|
||||
$matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($e);
|
||||
}
|
||||
|
||||
if (! $matched && $name !== null && $name !== '') {
|
||||
foreach ($entries as &$e) {
|
||||
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
||||
if ($entryName === $name) {
|
||||
$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 ? " for network \"{$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 +853,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 +879,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}";
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
'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,64 @@ 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.
|
||||
// Two layers: a global suppression window set by RebootAllAps
|
||||
// (Setting, survives any cache driver), plus the per-MAC
|
||||
// cache keys for finer granularity.
|
||||
if ($this->inGlobalRebootSuppression()) continue;
|
||||
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
|
||||
|
||||
if ($comingOnline) {
|
||||
// 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 +231,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 +247,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 +261,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 +275,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 +342,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 +503,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;
|
||||
@@ -344,14 +513,18 @@ class WebhookCheckService
|
||||
private function checkReboot($aps, array $filter): array
|
||||
{
|
||||
$alerts = [];
|
||||
if ($this->inGlobalRebootSuppression()) return $alerts;
|
||||
|
||||
foreach ($aps as $ap) {
|
||||
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
||||
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
|
||||
$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 +563,6 @@ class WebhookCheckService
|
||||
'data' => $data,
|
||||
];
|
||||
|
||||
// Format payload for the target platform
|
||||
$url = $config->url;
|
||||
$payload = $this->formatPayloadForPlatform($url, $message, $internalPayload);
|
||||
|
||||
@@ -413,46 +585,53 @@ 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];
|
||||
/**
|
||||
* Is a fleet reboot in progress right now? RebootAllAps stamps a
|
||||
* suppression-until timestamp as a Setting; while that timestamp
|
||||
* is in the future, we skip all device-offline / device-online /
|
||||
* unexpected-reboot alerts to avoid flooding webhooks during the
|
||||
* known maintenance window.
|
||||
*/
|
||||
private function inGlobalRebootSuppression(): bool
|
||||
{
|
||||
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
|
||||
if (! $until) return false;
|
||||
try {
|
||||
return \Carbon\Carbon::parse($until)->isFuture();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +642,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 +656,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()]);
|
||||
}
|
||||
}
|
||||
|
||||
31
src/Support/MacAddress.php
Normal file
31
src/Support/MacAddress.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Support;
|
||||
|
||||
/**
|
||||
* Pure MAC helpers. No dependencies, so they are cheap to test and safe to call
|
||||
* from anywhere.
|
||||
*/
|
||||
class MacAddress
|
||||
{
|
||||
/** Lower-case, colon-separated, or null if it is not a MAC at all. */
|
||||
public static function normalise(?string $mac): ?string
|
||||
{
|
||||
$mac = strtolower(str_replace('-', ':', trim((string) $mac)));
|
||||
|
||||
return preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $mac) ? $mac : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a private (randomized) address rather than a real manufacturer one?
|
||||
*
|
||||
* The locally-administered bit is the second-least-significant bit of the
|
||||
* first octet. iOS "Private Wi-Fi Address" and Android per-network
|
||||
* randomization both set it. This is why OUI is useless for identifying
|
||||
* device types: a randomized MAC has no real manufacturer prefix.
|
||||
*/
|
||||
public static function isRandomized(string $mac): bool
|
||||
{
|
||||
return (bool) (hexdec(substr($mac, 0, 2)) & 0b10);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Dashboard\Unifi;
|
||||
|
||||
use App\Models\NavItem;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class UnifiServiceProvider extends ServiceProvider
|
||||
@@ -20,15 +21,80 @@ class UnifiServiceProvider extends ServiceProvider
|
||||
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
|
||||
|
||||
// "/" home card: guest Wi-Fi network + current rotating password +
|
||||
// QR (re-renders when the password changes). Self-hides until enabled
|
||||
// and a password has been rotated.
|
||||
//
|
||||
// The ONE card in the platform that earned a NEW permission key rather
|
||||
// than reusing one. It cannot be universal — the payload is a live
|
||||
// credential, and publishing it to every student and parent is the exact
|
||||
// hole GuestCardController::data was written to close. And no existing
|
||||
// unifi key fits: unifi.stats is the network dashboards, unifi.settings is
|
||||
// the controller connection, and handing the front desk either one to let
|
||||
// them read a guest password would widen access, not reuse it. So the
|
||||
// granularity goes in the catalogue (same move as the papercut/inventory
|
||||
// split), and the key is grantable to non-unifi staff — which is the whole
|
||||
// point of a home-page card.
|
||||
//
|
||||
// Declared in composer.json extra.dashboard.permissions, so the catalogue
|
||||
// row appears when unifi is installed/updated — NOT seeded to any group.
|
||||
try {
|
||||
app(\App\Support\DashboardCardRegistry::class)->register(
|
||||
'unifi.guest_wifi',
|
||||
'Guest Wi-Fi',
|
||||
'UnifiGuestWifi',
|
||||
fn (\App\Models\User $user) => \Dashboard\Unifi\Http\Controllers\GuestCardController::resolve($user),
|
||||
'The guest network name, current password, and a join QR code — updates automatically when the password rotates.',
|
||||
permission: 'unifi.guest_wifi',
|
||||
// So the permission screens can say "granted, but not switched
|
||||
// on yet" rather than leaving a grantee staring at a home page
|
||||
// with nothing new on it.
|
||||
configured: fn () => \Dashboard\Unifi\Http\Controllers\GuestCardController::configured(),
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Older shell without the card registry — skip.
|
||||
}
|
||||
|
||||
// Tell the shell's nav sidebar which unifi nav items the user
|
||||
// can see: pages whose required_permission the user holds through
|
||||
// groups, plus pages granted via the Settings → Access tab. The
|
||||
// shell also folds these grants back into User::can()/allPermissions
|
||||
// so the route-level permission middleware passes for grantees.
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
Console\CheckWebhooks::class,
|
||||
Console\CaptureSnapshots::class,
|
||||
Console\CleanupSnapshots::class,
|
||||
Console\RebootAllAps::class,
|
||||
Console\RotatePasswords::class,
|
||||
Console\SyncPpskSchedules::class,
|
||||
Console\ReconcilePortal::class,
|
||||
]);
|
||||
$this->publishes([
|
||||
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),
|
||||
], 'unifi-config');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group + direct permissions only. Deliberately NOT allPermissions():
|
||||
* the shell folds page-grant permissions back into allPermissions/can,
|
||||
* so using it here would turn a single granted page into every page
|
||||
* carrying the same permission.
|
||||
*/
|
||||
public static function realPermissionKeysFor(\App\Models\User $user): \Illuminate\Support\Collection
|
||||
{
|
||||
if ($user->is_super_admin) {
|
||||
return \App\Models\Permission::pluck('key');
|
||||
}
|
||||
|
||||
$direct = $user->directPermissions()->get();
|
||||
|
||||
return \App\Models\Permission::whereHas('groups', fn ($q) => $q->whereIn('group_id', $user->groups()->pluck('groups.id')))
|
||||
->pluck('key')
|
||||
->merge($direct->where('granted', true)->pluck('permission_key'))
|
||||
->diff($direct->where('granted', false)->pluck('permission_key'))
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,15 +25,27 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
Route::get('/wan-status', [StatsController::class, 'wanStatus'])->name('wan.status');
|
||||
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-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');
|
||||
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,25 +59,74 @@ 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 ─────────────────────────────────────────────────────────
|
||||
Route::middleware('permission:unifi.settings')->group(function () {
|
||||
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
|
||||
Route::get('/settings/{tab}', [UnifiSettingsController::class, 'edit'])
|
||||
->where('tab', 'connection|tasks|logs|access')->name('settings.tab');
|
||||
Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update');
|
||||
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
||||
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
||||
|
||||
// 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');
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Guest Wi-Fi home-card poll. Authorises inside the controller against the
|
||||
// card's own visibility (unifi.guest_wifi) — it returns a live credential, so
|
||||
// `auth` alone is not a gate. Deliberately outside the permission groups above
|
||||
// because it's a home-page widget, not a unifi page: unifi.guest_wifi is
|
||||
// grantable to front-desk staff without any other network access. ──
|
||||
Route::middleware(['web', 'auth'])
|
||||
->get('/app/unifi/guest-card/data', [\Dashboard\Unifi\Http\Controllers\GuestCardController::class, 'data'])
|
||||
->name('unifi.guest-card.data');
|
||||
|
||||
// ── Public API (token-protected) ──────────────────────────────────────────
|
||||
// External integrations (signage, kiosks) hit these without session auth.
|
||||
Route::prefix('api/unifi')->name('unifi.api.')->group(function () {
|
||||
Route::get('/wifi/current-password', [WifiApiController::class, 'currentPassword'])
|
||||
->name('wifi.current-password');
|
||||
});
|
||||
|
||||
// ── Captive portal callback (public — user redirected here by UniFi) ─────
|
||||
Route::middleware(['web', 'auth'])
|
||||
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
|
||||
->name('unifi.portal.callback');
|
||||
|
||||
/*
|
||||
* Let a user disconnect their OWN device from the captive-portal denial page.
|
||||
*
|
||||
* The admin route for this (unifi.portal.sessions.disconnect) sits behind
|
||||
* `app.access:unifi` + `permission:unifi.auth`, so a student who is told "you
|
||||
* already have 3 devices connected, disconnect one" was blocked by middleware
|
||||
* before reaching the action — the page's `can_disconnect: true` was a promise
|
||||
* the routing could not keep.
|
||||
*
|
||||
* Safe to expose with only `auth`: disconnectSession() already authorises
|
||||
* owner-or-unifi.auth itself (`abort_if($session->user_id !== auth()->id() && !
|
||||
* auth()->user()->can('unifi.auth'), 403)`), so a non-owner still gets 403. The
|
||||
* admin route is left exactly as it was rather than loosened.
|
||||
*/
|
||||
Route::middleware(['web', 'auth'])
|
||||
->post('/portal/wifi/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])
|
||||
->name('unifi.portal.self-disconnect');
|
||||
|
||||
Reference in New Issue
Block a user