Compare commits
46 Commits
v1.2.0
...
dbebb6c964
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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.4",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
@@ -16,6 +16,8 @@
|
||||
]
|
||||
},
|
||||
"dashboard": {
|
||||
"tier": "optional",
|
||||
"stability": "stable",
|
||||
"nav_folder": {
|
||||
"label": "Unifi Network",
|
||||
"icon": "wifi",
|
||||
@@ -27,15 +29,14 @@
|
||||
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
|
||||
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
|
||||
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
|
||||
{ "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
|
||||
{ "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 },
|
||||
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
|
||||
],
|
||||
"permissions": [
|
||||
{ "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,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");
|
||||
}
|
||||
};
|
||||
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
@@ -0,0 +1,288 @@
|
||||
# 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.
|
||||
|
||||
### 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.
|
||||
@@ -2,41 +2,40 @@
|
||||
|
||||
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}';
|
||||
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
|
||||
{
|
||||
try {
|
||||
$run = UnifiCronRun::record(
|
||||
'reboot-all-aps',
|
||||
$this->option('triggered-by') ?: 'schedule',
|
||||
null,
|
||||
function () use ($unifi) {
|
||||
$aps = $unifi->getAccessPoints();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed to fetch APs: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (empty($aps)) {
|
||||
$this->warn('No access points found.');
|
||||
return self::SUCCESS;
|
||||
return ['status' => 'skipped', 'reason' => 'no APs found'];
|
||||
}
|
||||
|
||||
$delay = max(0, (int) $this->option('delay'));
|
||||
$rebooted = [];
|
||||
$failed = [];
|
||||
|
||||
// Pre-mark all APs as planned reboots before sending any commands
|
||||
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();
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
|
||||
foreach ($aps as $ap) {
|
||||
$mac = strtolower($ap['mac']);
|
||||
@@ -44,19 +43,27 @@ class RebootAllAps extends Command
|
||||
try {
|
||||
$unifi->rebootDevice($mac);
|
||||
$this->info("Rebooted: {$name} ({$mac})");
|
||||
$ok++;
|
||||
$rebooted[] = $name;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
|
||||
$fail++;
|
||||
$failed[] = ['name' => $name, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
if ($delay > 0 && $ok + $fail < count($aps)) {
|
||||
if ($delay > 0 && count($rebooted) + count($failed) < count($aps)) {
|
||||
sleep($delay);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done. {$ok} rebooted, {$fail} failed.");
|
||||
return $fail > 0 ? self::FAILURE : self::SUCCESS;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,27 +3,45 @@
|
||||
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;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class RotatePasswords extends Command
|
||||
{
|
||||
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule}';
|
||||
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 = [];
|
||||
|
||||
if (empty($wlanIds) || ! is_array($wlanIds)) {
|
||||
return self::SUCCESS;
|
||||
$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', '');
|
||||
@@ -31,48 +49,123 @@ class RotatePasswords extends Command
|
||||
|
||||
if (empty($passwords)) {
|
||||
$this->warn('Password rotation: no passwords in wordlist — skipped.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->isDue()) {
|
||||
return self::SUCCESS;
|
||||
return ['status' => 'skipped', 'reason' => 'empty wordlist'];
|
||||
}
|
||||
|
||||
$password = $passwords[array_rand($passwords)];
|
||||
$rotated = 0;
|
||||
$rotated = [];
|
||||
$failedWlans = [];
|
||||
|
||||
foreach ($wlanIds as $wlanId) {
|
||||
try {
|
||||
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
|
||||
$rotated++;
|
||||
$rotated[] = $wlanId;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
|
||||
$failedWlans[] = ['wlan_id' => $wlanId, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
if ($rotated > 0) {
|
||||
if ($rotated) {
|
||||
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
||||
$this->info("Rotated password for {$rotated} SSID(s).");
|
||||
// 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).');
|
||||
}
|
||||
|
||||
// ── Rotate PPSK passwords ────────────────────────────────────────────
|
||||
$rotatedPpsks = 0;
|
||||
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
|
||||
// Each PPSK gets its own independently-chosen password from the wordlist
|
||||
$rotatedPpsks = [];
|
||||
$failedPpsks = [];
|
||||
foreach ($ppskQuery->get() as $ppsk) {
|
||||
$newPass = $passwords[array_rand($passwords)];
|
||||
try {
|
||||
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
||||
$ppsk->update(['x_passphrase' => $newPass]);
|
||||
$rotatedPpsks++;
|
||||
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) {
|
||||
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
||||
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()];
|
||||
}
|
||||
if ($rotatedPpsks > 0) {
|
||||
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
// 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
|
||||
@@ -81,7 +174,7 @@ class RotatePasswords extends Command
|
||||
$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 = Setting::get('unifi.timezone', 'UTC');
|
||||
$tz = \App\Support\Timezone::current();
|
||||
$now = now($tz);
|
||||
|
||||
if ($now->hour !== $hour || $now->minute !== $minute) {
|
||||
|
||||
@@ -3,33 +3,33 @@
|
||||
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}';
|
||||
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
|
||||
{
|
||||
if (! $this->option('force') && ! Setting::get('unifi.ppsk_scheduling.enabled')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$tz = Setting::get('unifi.timezone', 'UTC');
|
||||
$now = now($tz);
|
||||
$day = $now->dayOfWeek; // 0=Sun … 6=Sat
|
||||
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 0–47
|
||||
|
||||
$ppsks = UnifiPpsk::whereNotNull('schedule')->get();
|
||||
|
||||
$ppsks = UnifiPpsk::all();
|
||||
if ($ppsks->isEmpty()) {
|
||||
// Don't bother logging — no work, no audit value.
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Fetch network confs once so we can resolve vlan → networkconf_id on re-enable
|
||||
$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) {
|
||||
@@ -41,17 +41,44 @@ class SyncPpskSchedules extends Command
|
||||
$this->warn("Could not fetch network configs: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
foreach ($ppsks as $ppsk) {
|
||||
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
|
||||
$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()];
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
$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
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ApGroupController extends Controller
|
||||
{
|
||||
public function index(UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$groups = collect($unifi->getApGroups())->map(fn ($g) => [
|
||||
'id' => $g['_id'],
|
||||
'name' => $g['name'] ?? 'Unnamed',
|
||||
'device_macs' => $g['device_macs'] ?? [],
|
||||
'is_default' => $g['attr_no_delete'] ?? false,
|
||||
])->values();
|
||||
|
||||
$devices = collect($unifi->getAccessPoints())->map(fn ($d) => [
|
||||
'mac' => strtolower($d['mac']),
|
||||
'name' => $d['name'] ?? $d['model'] ?? $d['mac'],
|
||||
'model' => $d['model'] ?? '',
|
||||
'state' => $d['state'] ?? 0,
|
||||
])->values();
|
||||
|
||||
return Inertia::render('Unifi/ApGroups', [
|
||||
'groups' => $groups,
|
||||
'devices' => $devices,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Inertia::render('Unifi/ApGroups', [
|
||||
'groups' => [], 'devices' => [], 'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'device_macs' => 'present|array',
|
||||
'device_macs.*' => 'string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $unifi->createApGroup([
|
||||
'name' => $data['name'],
|
||||
'device_macs' => array_values(array_map('strtolower', $data['device_macs'])),
|
||||
]);
|
||||
return back()->with('success', 'AP group created.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, string $groupId, UnifiApiClient $unifi)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'device_macs' => 'sometimes|array',
|
||||
'device_macs.*' => 'string',
|
||||
]);
|
||||
|
||||
if (isset($data['device_macs'])) {
|
||||
$data['device_macs'] = array_values(array_map('strtolower', $data['device_macs']));
|
||||
}
|
||||
|
||||
try {
|
||||
$unifi->updateApGroup($groupId, $data);
|
||||
return back()->with('success', 'AP group updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(string $groupId, UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$unifi->deleteApGroup($groupId);
|
||||
return back()->with('success', 'AP group deleted.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,11 @@ class PortalController extends Controller
|
||||
'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);
|
||||
@@ -51,7 +55,11 @@ class PortalController extends Controller
|
||||
'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.');
|
||||
@@ -87,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>
|
||||
@@ -104,6 +141,23 @@ class PortalController extends Controller
|
||||
return response('Missing MAC address', 400);
|
||||
}
|
||||
|
||||
// SECURITY: everything identifying the device is request-supplied, and this
|
||||
// endpoint WRITES network state (authorizeGuest puts a MAC on the mapped VLAN).
|
||||
// Before this check any signed-in account — a student, a parent — could call
|
||||
// GET /portal/wifi/callback?mac=<someone else's MAC>&url=…
|
||||
// and admit a device they do not own, up to max_devices.
|
||||
//
|
||||
// A well-formed MAC is the floor, not a fix: it stops the parameter being used
|
||||
// to smuggle junk into authorizeGuest and into the PortalSession row, but it
|
||||
// cannot prove the caller owns the device. Properly binding it needs the AP
|
||||
// handshake — UniFi can sign the redirect, or we ask the controller whether this
|
||||
// MAC is CURRENTLY associated to $apMac before admitting it. Both need testing
|
||||
// against real hardware, so they are deliberately not guessed at here.
|
||||
$clientMac = str_replace('-', ':', $clientMac);
|
||||
if (! preg_match('/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/', $clientMac)) {
|
||||
return response('Malformed MAC address', 400);
|
||||
}
|
||||
|
||||
// Find the VLAN mapping for this user's group/OU
|
||||
$mapping = $this->resolveMapping($user);
|
||||
if (! $mapping) {
|
||||
@@ -161,9 +215,16 @@ class PortalController extends Controller
|
||||
'expires_at' => now()->addMinutes($minutes),
|
||||
]);
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -36,6 +36,7 @@ class StatsController extends Controller
|
||||
if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null;
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'status' => $wan['status'] ?? 'unknown',
|
||||
'tx_rate' => $wan['tx_bytes-r'] ?? 0,
|
||||
'rx_rate' => $wan['rx_bytes-r'] ?? 0,
|
||||
@@ -44,7 +45,19 @@ class StatsController extends Controller
|
||||
'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['status' => 'error'], 500);
|
||||
// The UniFi controller is unreachable. Degrade gracefully: return 200 with a
|
||||
// friendly "unavailable" payload so the dashboard widget shows an unavailable
|
||||
// state instead of the poll silently failing on a 500.
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'status' => 'unavailable',
|
||||
'message' => 'UniFi controller is unreachable.',
|
||||
'tx_rate' => 0,
|
||||
'rx_rate' => 0,
|
||||
'isp' => null,
|
||||
'wan_ip' => null,
|
||||
'latency' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,12 +309,21 @@ class StatsController extends Controller
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'labels' => $series['labels'],
|
||||
'traffic_rx' => $rx,
|
||||
'traffic_tx' => $tx,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
// UniFi controller unreachable — degrade gracefully (200 with empty series)
|
||||
// so the chart shows an empty/unavailable state rather than erroring on a 500.
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'message' => 'UniFi controller is unreachable.',
|
||||
'labels' => [],
|
||||
'traffic_rx' => [],
|
||||
'traffic_tx' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,16 +10,16 @@ use Inertia\Inertia;
|
||||
|
||||
class UnifiSettingsController extends Controller
|
||||
{
|
||||
public function edit()
|
||||
public function edit(?string $tab = null)
|
||||
{
|
||||
return Inertia::render('Unifi/Settings', [
|
||||
'activeTab' => $tab,
|
||||
'controllerUrl' => Setting::get('unifi.controller_url', ''),
|
||||
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
|
||||
'site' => Setting::get('unifi.site', 'default'),
|
||||
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
|
||||
'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30),
|
||||
'retentionDays' => (int) Setting::get('unifi.retention_days', 30),
|
||||
'timezone' => Setting::get('unifi.timezone', 'UTC'),
|
||||
'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),
|
||||
@@ -32,10 +32,29 @@ class UnifiSettingsController extends Controller
|
||||
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
|
||||
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
|
||||
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
|
||||
'rotationLastPassword' => Setting::get('unifi.password_rotation.last_password', null),
|
||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||
'apiEnabled' => (bool) Setting::get('unifi.api.enabled', false),
|
||||
'apiToken' => Setting::get('unifi.api_token', null),
|
||||
'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([
|
||||
@@ -45,7 +64,6 @@ class UnifiSettingsController extends Controller
|
||||
'poll_interval' => 'nullable|integer|min:5|max:300',
|
||||
'cache_ttl' => 'nullable|integer|min:5|max:300',
|
||||
'retention_days' => 'nullable|integer|min:1|max:365',
|
||||
'timezone' => 'nullable|string|timezone',
|
||||
'auto_reboot_enabled' => 'boolean',
|
||||
'auto_reboot_frequency' => 'in:daily,weekly',
|
||||
'auto_reboot_dow' => 'nullable|integer|min:0|max:6',
|
||||
@@ -58,6 +76,10 @@ class UnifiSettingsController extends Controller
|
||||
'rotation_minute' => 'nullable|integer|min:0|max:59',
|
||||
'rotation_wordlist' => 'nullable|string|max:20000',
|
||||
'ppsk_scheduling_enabled' => 'boolean',
|
||||
'api_enabled' => 'boolean',
|
||||
'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, '/'));
|
||||
@@ -70,7 +92,6 @@ class UnifiSettingsController extends Controller
|
||||
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);
|
||||
if ($request->has('timezone')) Setting::set('unifi.timezone', $request->timezone ?? 'UTC');
|
||||
|
||||
Setting::set('unifi.auto_reboot.enabled', $request->boolean('auto_reboot_enabled') ? '1' : '');
|
||||
Setting::set('unifi.auto_reboot.frequency', $request->input('auto_reboot_frequency', 'daily'));
|
||||
@@ -85,6 +106,10 @@ class UnifiSettingsController extends Controller
|
||||
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
|
||||
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
|
||||
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
|
||||
Setting::set('unifi.api.enabled', $request->boolean('api_enabled') ? '1' : '');
|
||||
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, '/')));
|
||||
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,6 @@ class WifiController extends Controller
|
||||
try {
|
||||
$wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values();
|
||||
|
||||
try {
|
||||
$apGroups = collect($unifi->getApGroups())->map(fn ($g) => [
|
||||
'id' => $g['_id'],
|
||||
'name' => $g['attr_no_delete'] ?? false ? 'Default' : ($g['name'] ?? 'Unnamed'),
|
||||
'device_macs' => $g['device_macs'] ?? [],
|
||||
'is_default' => $g['attr_no_delete'] ?? false,
|
||||
])->values();
|
||||
} catch (\Throwable $e) {
|
||||
$apGroups = collect(); // AP groups not supported by this controller
|
||||
}
|
||||
|
||||
$raw = Setting::get('unifi.ssid_groups', '{}');
|
||||
$groups = json_decode($raw, true);
|
||||
if (! is_array($groups) || array_is_list($groups)) $groups = [];
|
||||
@@ -37,13 +26,12 @@ class WifiController extends Controller
|
||||
return Inertia::render('Unifi/Wifi', [
|
||||
'wlans' => $wlans,
|
||||
'groups' => $groups,
|
||||
'apGroups' => $apGroups,
|
||||
'rotateWlanIds' => $rotateWlanIds,
|
||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Inertia::render('Unifi/Wifi', [
|
||||
'wlans' => [], 'groups' => [], 'apGroups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
|
||||
'wlans' => [], 'groups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -98,21 +86,6 @@ class WifiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AP group assignments for a single WLAN (not synced to group siblings).
|
||||
*/
|
||||
public function updateApGroups(Request $request, string $wlanId, UnifiApiClient $unifi)
|
||||
{
|
||||
$request->validate(['ap_group_ids' => 'required|array']);
|
||||
|
||||
try {
|
||||
$unifi->updateWlan($wlanId, ['ap_group_ids' => $request->ap_group_ids]);
|
||||
return back()->with('success', 'AP groups updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi)
|
||||
{
|
||||
$request->validate(['enabled' => 'required|boolean']);
|
||||
@@ -176,8 +149,18 @@ class WifiController extends Controller
|
||||
$name = $networksById[$nconfId]['name'] ?? null;
|
||||
}
|
||||
|
||||
// Match by unifi_id, or by passphrase for a held embedded record re-appearing
|
||||
// Match in priority order:
|
||||
// 1. by current unifi_id (already-synced row)
|
||||
// 2. by name within this wlan (catches rotation: passphrase
|
||||
// changed → synthetic id changed → row identity unchanged)
|
||||
// 3. by passphrase among held rows (legacy fallback for
|
||||
// cases where name wasn't ingested)
|
||||
$record = UnifiPpsk::where('unifi_id', $uid)->first()
|
||||
?? ($name
|
||||
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->first()
|
||||
: null)
|
||||
?? UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('x_passphrase', $pass)
|
||||
->where('state', 'held')
|
||||
@@ -201,8 +184,8 @@ class WifiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Only mark as held when we have confirmed live IDs —
|
||||
// never wipe on an empty API response (prevents false-holds on API failures)
|
||||
// Mark non-matching active rows as held — but ONLY if there's no
|
||||
// other active row with the same name we just reconnected.
|
||||
if (! empty($liveIds)) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
@@ -211,6 +194,47 @@ class WifiController extends Controller
|
||||
->update(['state' => 'held', 'unifi_id' => null]);
|
||||
}
|
||||
|
||||
// For each active row, salvage any rotate_password / schedule
|
||||
// settings from the held tombstones with the same name BEFORE
|
||||
// we prune them. Otherwise a row that had rotate=on loses the
|
||||
// flag every time a rotation changes its synthetic id.
|
||||
$activeRows = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
->whereNotNull('name')
|
||||
->get();
|
||||
foreach ($activeRows as $active) {
|
||||
$heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'held')
|
||||
->where('name', $active->name)
|
||||
->where(fn ($q) => $q
|
||||
->where('rotate_password', true)
|
||||
->orWhereNotNull('schedule'))
|
||||
->orderByDesc('updated_at')
|
||||
->first();
|
||||
if (! $heldWithSettings) continue;
|
||||
|
||||
$patch = [];
|
||||
if ($heldWithSettings->rotate_password && ! $active->rotate_password) {
|
||||
$patch['rotate_password'] = true;
|
||||
}
|
||||
if ($heldWithSettings->schedule && ! $active->schedule) {
|
||||
$patch['schedule'] = $heldWithSettings->schedule;
|
||||
}
|
||||
if ($patch) $active->update($patch);
|
||||
}
|
||||
|
||||
// Prune obsolete held rows: any held row whose name matches an
|
||||
// active row in the same wlan is a stale tombstone — its
|
||||
// settings have been salvaged above, and its data has been
|
||||
// superseded by the active one.
|
||||
$activeNames = $activeRows->pluck('name')->filter()->unique();
|
||||
if ($activeNames->isNotEmpty()) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'held')
|
||||
->whereIn('name', $activeNames)
|
||||
->delete();
|
||||
}
|
||||
|
||||
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->orderBy('name')
|
||||
@@ -318,11 +342,46 @@ class WifiController extends Controller
|
||||
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']));
|
||||
$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);
|
||||
@@ -414,7 +473,6 @@ class WifiController extends Controller
|
||||
'hide_ssid' => $w['hide_ssid'] ?? false,
|
||||
'passphrase' => $w['x_passphrase'] ?? '',
|
||||
'band' => $this->detectBand($w),
|
||||
'ap_group_ids' => $w['ap_group_ids'] ?? [],
|
||||
'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false,
|
||||
'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny',
|
||||
'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false)
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -312,26 +312,120 @@ class UnifiApiClient
|
||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||||
}
|
||||
|
||||
// ── AP Groups ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getApGroups(): array
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return $this->get('/rest/apgroups');
|
||||
$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);
|
||||
}
|
||||
|
||||
public function createApGroup(array $data): array
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return $this->post('/rest/apgroups', $data);
|
||||
try {
|
||||
$entries = $this->getPpskEntries($wlanId);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'reason' => 'fetch_failed', 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
public function updateApGroup(string $groupId, array $data): array
|
||||
{
|
||||
return $this->put("/rest/apgroups/{$groupId}", $data);
|
||||
$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'];
|
||||
}
|
||||
|
||||
public function deleteApGroup(string $groupId): void
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$this->delete("/rest/apgroups/{$groupId}");
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
@@ -518,6 +612,103 @@ class UnifiApiClient
|
||||
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an embedded PPSK (one that lives inside a WLAN's
|
||||
* private_preshared_keys array rather than as its own REST resource).
|
||||
*
|
||||
* Matching is done by current passphrase since embedded entries have
|
||||
* no controller-side ID. Only changes the entry's passphrase; name
|
||||
* isn't separately addressable on embedded PPSKs.
|
||||
*/
|
||||
public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array
|
||||
{
|
||||
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
|
||||
$wlan = $wlanResp[0] ?? $wlanResp;
|
||||
$entries = $wlan['private_preshared_keys'] ?? [];
|
||||
|
||||
if (! is_array($entries) || empty($entries)) {
|
||||
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -181,7 +181,11 @@ class WebhookCheckService
|
||||
$prev = DeviceState::where('device_mac', $mac)->first();
|
||||
if (! $prev) continue;
|
||||
|
||||
// Skip planned reboots — these are intentional, not alerts
|
||||
// Skip planned reboots — these are intentional, not alerts.
|
||||
// Two layers: a global suppression window set by RebootAllAps
|
||||
// (Setting, survives any cache driver), plus the per-MAC
|
||||
// cache keys for finer granularity.
|
||||
if ($this->inGlobalRebootSuppression()) continue;
|
||||
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
|
||||
|
||||
if ($comingOnline) {
|
||||
@@ -509,6 +513,8 @@ class WebhookCheckService
|
||||
private function checkReboot($aps, array $filter): array
|
||||
{
|
||||
$alerts = [];
|
||||
if ($this->inGlobalRebootSuppression()) return $alerts;
|
||||
|
||||
foreach ($aps as $ap) {
|
||||
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
||||
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
|
||||
@@ -580,6 +586,33 @@ class WebhookCheckService
|
||||
}
|
||||
|
||||
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
|
||||
{
|
||||
return self::buildPlatformPayload($url, $message, $fullPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a fleet reboot in progress right now? RebootAllAps stamps a
|
||||
* suppression-until timestamp as a Setting; while that timestamp
|
||||
* is in the future, we skip all device-offline / device-online /
|
||||
* unexpected-reboot alerts to avoid flooding webhooks during the
|
||||
* known maintenance window.
|
||||
*/
|
||||
private function inGlobalRebootSuppression(): bool
|
||||
{
|
||||
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
|
||||
if (! $until) return false;
|
||||
try {
|
||||
return \Carbon\Carbon::parse($until)->isFuture();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public/static helper so the test-webhook endpoint produces the
|
||||
* same per-platform payload shape that real events do.
|
||||
*/
|
||||
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];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Dashboard\Unifi;
|
||||
|
||||
use App\Models\NavItem;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class UnifiServiceProvider extends ServiceProvider
|
||||
@@ -20,6 +21,45 @@ 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,
|
||||
@@ -34,4 +74,26 @@ class UnifiServiceProvider extends ServiceProvider
|
||||
], '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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<?php
|
||||
|
||||
use Dashboard\Unifi\Http\Controllers\ApGroupController;
|
||||
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;
|
||||
|
||||
@@ -32,7 +34,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
// WiFi networks
|
||||
Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi');
|
||||
Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update');
|
||||
Route::put('/wifi/{wlanId}/ap-groups', [WifiController::class, 'updateApGroups']) ->name('wifi.ap-groups');
|
||||
Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle']) ->name('wifi.toggle');
|
||||
Route::post('/wifi/groups', [WifiController::class, 'saveGroups']) ->name('wifi.groups');
|
||||
|
||||
@@ -44,12 +45,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
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');
|
||||
|
||||
// AP Groups
|
||||
Route::get('/ap-groups', [ApGroupController::class, 'index']) ->name('ap-groups.index');
|
||||
Route::post('/ap-groups', [ApGroupController::class, 'store']) ->name('ap-groups.store');
|
||||
Route::put('/ap-groups/{groupId}', [ApGroupController::class, 'update']) ->name('ap-groups.update');
|
||||
Route::delete('/ap-groups/{groupId}', [ApGroupController::class, 'destroy']) ->name('ap-groups.destroy');
|
||||
|
||||
// Devices
|
||||
Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot');
|
||||
Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick');
|
||||
@@ -74,20 +69,64 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
Route::middleware('permission:unifi.settings')->group(function () {
|
||||
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
|
||||
Route::get('/settings/{tab}', [UnifiSettingsController::class, 'edit'])
|
||||
->where('tab', 'connection|tasks|logs|access')->name('settings.tab');
|
||||
Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update');
|
||||
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
||||
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
||||
|
||||
// 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