8 Commits

Author SHA1 Message Date
e8796d443e fix(webhooks): test endpoint formats payload per platform; add missing column
* The Test URL button was POSTing a generic {event, timestamp, data}
  envelope to every endpoint. Google Chat / Slack / Discord / Teams
  reject anything that isn't their specific shape — so a successful
  Laravel request still got a 400 back from the platform, making the
  test look broken. The real webhook events already handle this via
  WebhookCheckService::formatPayloadForPlatform; that helper is now
  exposed as a public static (buildPlatformPayload) and the test
  endpoint uses the same code path, so the test exercises the same
  format real events will.
* unifi_device_states was missing a consecutive_count column the
  WebhookCheckService inserts on every snapshot capture. The scheduler
  was throwing "Unknown column 'consecutive_count'" once a minute.
  Added an idempotent migration.

v1.6.1.

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

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

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

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

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

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

v1.5.4.

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

v1.5.3.

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

v1.5.2.

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

v1.5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:43:30 -04:00
75943fbe2b feat(logs): structured cron run history + read endpoint
Adds unifi_cron_runs table (one row per scheduled-task execution) and
UnifiCronRun::record() wrapper that captures start/finish/status and
exceptions. The three scheduled commands now write through it:

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

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

v1.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:05:36 -04:00
15 changed files with 552 additions and 148 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "dashboard/unifi", "name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.4.0", "version": "1.6.1",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"autoload": { "autoload": {
@@ -27,8 +27,6 @@
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 }, { "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": "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": "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 } { "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
], ],
"permissions": [ "permissions": [

View File

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

View File

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

View File

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

View File

@@ -2,41 +2,40 @@
namespace Dashboard\Unifi\Console; namespace Dashboard\Unifi\Console;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Services\UnifiApiClient; use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
class RebootAllAps extends Command 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'; protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
public function handle(UnifiApiClient $unifi): int 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(); $aps = $unifi->getAccessPoints();
} catch (\Throwable $e) {
$this->error('Failed to fetch APs: ' . $e->getMessage());
return self::FAILURE;
}
if (empty($aps)) { if (empty($aps)) {
$this->warn('No access points found.'); $this->warn('No access points found.');
return self::SUCCESS; return ['status' => 'skipped', 'reason' => 'no APs found'];
} }
$delay = max(0, (int) $this->option('delay')); $delay = max(0, (int) $this->option('delay'));
$rebooted = [];
$failed = [];
// Pre-mark all APs as planned reboots before sending any commands
foreach ($aps as $ap) { foreach ($aps as $ap) {
$mac = strtolower($ap['mac']); $mac = strtolower($ap['mac']);
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20)); Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
$this->line("Marked planned reboot: {$ap['name']} ({$mac})"); $this->line("Marked planned reboot: {$ap['name']} ({$mac})");
} }
$this->newLine(); $this->newLine();
$ok = 0;
$fail = 0;
foreach ($aps as $ap) { foreach ($aps as $ap) {
$mac = strtolower($ap['mac']); $mac = strtolower($ap['mac']);
@@ -44,19 +43,27 @@ class RebootAllAps extends Command
try { try {
$unifi->rebootDevice($mac); $unifi->rebootDevice($mac);
$this->info("Rebooted: {$name} ({$mac})"); $this->info("Rebooted: {$name} ({$mac})");
$ok++; $rebooted[] = $name;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error("Failed to reboot {$name}: {$e->getMessage()}"); $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); sleep($delay);
} }
} }
$this->newLine(); return [
$this->info("Done. {$ok} rebooted, {$fail} failed."); 'status' => count($failed) === 0 ? 'succeeded' : (count($rebooted) > 0 ? 'partial' : 'failed'),
return $fail > 0 ? self::FAILURE : self::SUCCESS; 'rebooted' => $rebooted,
'failed' => $failed,
'total' => count($aps),
];
}
);
$this->info("Done. Status: {$run->status}.");
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
} }
} }

View File

@@ -3,27 +3,45 @@
namespace Dashboard\Unifi\Console; namespace Dashboard\Unifi\Console;
use App\Models\Setting; use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Models\UnifiPpsk; use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient; use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
class RotatePasswords extends Command 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'; protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
public function handle(UnifiApiClient $unifi): int public function handle(UnifiApiClient $unifi): int
{ {
if (! Setting::get('unifi.password_rotation.enabled')) { 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; 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', '[]'); $wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
$wlanIds = json_decode($wlanIdsJson, true); $wlanIds = json_decode($wlanIdsJson, true);
if (! is_array($wlanIds)) $wlanIds = [];
if (empty($wlanIds) || ! is_array($wlanIds)) { $ppskQuery = UnifiPpsk::where('rotate_password', true)
return self::SUCCESS; ->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', ''); $wordlist = Setting::get('unifi.password_rotation.wordlist', '');
@@ -31,48 +49,63 @@ class RotatePasswords extends Command
if (empty($passwords)) { if (empty($passwords)) {
$this->warn('Password rotation: no passwords in wordlist — skipped.'); $this->warn('Password rotation: no passwords in wordlist — skipped.');
return self::SUCCESS; return ['status' => 'skipped', 'reason' => 'empty wordlist'];
}
if (! $this->option('force') && ! $this->isDue()) {
return self::SUCCESS;
} }
$password = $passwords[array_rand($passwords)]; $password = $passwords[array_rand($passwords)];
$rotated = 0; $rotated = [];
$failedWlans = [];
foreach ($wlanIds as $wlanId) { foreach ($wlanIds as $wlanId) {
try { try {
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]); $unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
$rotated++; $rotated[] = $wlanId;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}"); $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()); Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
$this->info("Rotated password for {$rotated} SSID(s)."); $this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
} }
// ── Rotate PPSK passwords ──────────────────────────────────────────── $rotatedPpsks = [];
$rotatedPpsks = 0; $failedPpsks = [];
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) { foreach ($ppskQuery->get() as $ppsk) {
// Each PPSK gets its own independently-chosen password from the wordlist
$newPass = $passwords[array_rand($passwords)]; $newPass = $passwords[array_rand($passwords)];
try { try {
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
// Embedded PPSK: update inside the parent WLAN object.
// Synthetic ID is derived from the new passphrase, so update it too.
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass);
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
} else {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]); $ppsk->update(['x_passphrase' => $newPass]);
$rotatedPpsks++; }
$rotatedPpsks[] = $ppsk->name;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}"); $this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];
} }
} }
if ($rotatedPpsks > 0) {
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
}
return self::SUCCESS; $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 private function isDue(): bool

View File

@@ -3,38 +3,33 @@
namespace Dashboard\Unifi\Console; namespace Dashboard\Unifi\Console;
use App\Models\Setting; use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiCronRun;
use Dashboard\Unifi\Models\UnifiPpsk; use Dashboard\Unifi\Models\UnifiPpsk;
use Dashboard\Unifi\Services\UnifiApiClient; use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class SyncPpskSchedules extends 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'; protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
public function handle(UnifiApiClient $unifi): int public function handle(UnifiApiClient $unifi): int
{ {
// Always run, even when global ppsk_scheduling is disabled — in
// that case the target state for every PPSK is "active" (always
// on). That way disabling the global setting actually restores
// any held PPSKs to active without operators having to do
// anything else, and null-schedule PPSKs always end up active.
// Schedules in the DB are preserved regardless of toggle state,
// so re-enabling resumes the per-PPSK schedule.
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
$tz = \App\Support\Timezone::current();
$now = now($tz);
$day = $now->dayOfWeek; // 0=Sun … 6=Sat
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 047
$ppsks = UnifiPpsk::all(); $ppsks = UnifiPpsk::all();
if ($ppsks->isEmpty()) { if ($ppsks->isEmpty()) {
// Don't bother logging — no work, no audit value.
return self::SUCCESS; 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 = []; $networksByVlan = [];
try { try {
foreach ($unifi->getNetworkConfs() as $n) { foreach ($unifi->getNetworkConfs() as $n) {
@@ -46,22 +41,44 @@ class SyncPpskSchedules extends Command
$this->warn("Could not fetch network configs: {$e->getMessage()}"); $this->warn("Could not fetch network configs: {$e->getMessage()}");
} }
$enabled = [];
$disabled = [];
$errors = [];
foreach ($ppsks as $ppsk) { foreach ($ppsks as $ppsk) {
// Default to "always on". Only consult the schedule if
// global scheduling is enabled AND this PPSK has one.
$shouldBeOn = true; $shouldBeOn = true;
if ($globalEnabled && $ppsk->schedule) { if ($globalEnabled && $ppsk->schedule) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true); $shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
} }
try {
if ($shouldBeOn && $ppsk->state === 'held') { if ($shouldBeOn && $ppsk->state === 'held') {
$this->enablePpsk($ppsk, $unifi, $networksByVlan); $this->enablePpsk($ppsk, $unifi, $networksByVlan);
$enabled[] = $ppsk->name;
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) { } elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
$this->disablePpsk($ppsk, $unifi); $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 private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void

View File

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

View File

@@ -34,6 +34,12 @@ class UnifiPagesAccessController extends Controller
->get() ->get()
->groupBy('nav_item_id'); ->groupBy('nav_item_id');
// Only return users that ALREADY have grants. The full users list
// can be enormous (thousands of rows); the operator adds more via
// the searchUsers endpoint as needed.
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
return response()->json([ return response()->json([
'pages' => $pages->map(fn ($p) => [ 'pages' => $pages->map(fn ($p) => [
'id' => $p->id, 'id' => $p->id,
@@ -42,11 +48,34 @@ class UnifiPagesAccessController extends Controller
'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(), 'user_ids' => $grants->get($p->id, collect())->where('grantee_type', 'user')->pluck('grantee_id')->all(),
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(), 'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
])->values(), ])->values(),
'users' => User::orderBy('name')->get(['id', 'name', 'email']), 'users' => $users,
'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']), 'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']),
]); ]);
} }
/**
* Typeahead-style search for users to add to the access matrix.
* Returns up to 20 matches against name or email. Empty query returns
* an empty array — caller must enter at least 2 chars.
*/
public function searchUsers(Request $request)
{
$q = trim((string) $request->query('q', ''));
if (strlen($q) < 2) {
return response()->json(['users' => []]);
}
$users = User::where(function ($w) use ($q) {
$w->where('name', 'like', '%' . $q . '%')
->orWhere('email', 'like', '%' . $q . '%');
})
->orderBy('name')
->limit(20)
->get(['id', 'name', 'email']);
return response()->json(['users' => $users]);
}
public function update(Request $request, NavItem $navItem) public function update(Request $request, NavItem $navItem)
{ {
$app = DashboardApp::where('slug', 'unifi')->first(); $app = DashboardApp::where('slug', 'unifi')->first();

View File

@@ -68,20 +68,49 @@ class WebhookController extends Controller
public function test(WebhookConfig $webhook) 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', 'event' => 'test',
'timestamp' => now()->toIso8601String(), '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']; $headers = ['Content-Type' => 'application/json'];
if ($webhook->secret) { if ($secret) {
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $webhook->secret); $headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $secret);
} }
try { try {
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($webhook->url, $payload); $response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($url, $payload);
return response()->json(['ok' => true, 'status' => $response->status()]); return response()->json([
'ok' => $response->successful(),
'status' => $response->status(),
'body' => mb_substr((string) $response->body(), 0, 500),
]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
} }

View File

@@ -291,11 +291,18 @@ class WifiController extends Controller
fn ($v) => $v !== null fn ($v) => $v !== null
); );
if (! empty($unifiUpdate)) { 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.
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $unifiUpdate['x_passphrase']);
// Synthetic id is derived from the new passphrase.
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $unifiUpdate['x_passphrase']), 0, 32);
} else {
$unifi->updatePpsk($record->unifi_id, $unifiUpdate); $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 // vlan can be explicitly set to null
if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan']; if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan'];
if (! empty($dbUpdate)) $record->update($dbUpdate); if (! empty($dbUpdate)) $record->update($dbUpdate);

View File

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

View File

@@ -496,6 +496,63 @@ class UnifiApiClient
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]); return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
} }
/**
* Update an embedded PPSK (one that lives inside a WLAN's
* private_preshared_keys array rather than as its own REST resource).
*
* Matching is done by current passphrase since embedded entries have
* no controller-side ID. Only changes the entry's passphrase; name
* isn't separately addressable on embedded PPSKs.
*/
public function updateEmbeddedPpsk(string $wlanId, string $oldPassphrase, string $newPassphrase): array
{
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
$wlan = $wlanResp[0] ?? $wlanResp;
$entries = $wlan['private_preshared_keys'] ?? [];
if (! is_array($entries) || empty($entries)) {
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
}
$matched = false;
foreach ($entries as &$e) {
$current = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
if ($current === $oldPassphrase) {
// Preserve whichever field name the controller is using.
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $newPassphrase;
if (array_key_exists('password', $e)) $e['password'] = $newPassphrase;
if (array_key_exists('passphrase', $e)) $e['passphrase'] = $newPassphrase;
// If none of the above existed, default to password (most common on embedded).
if (! isset($e['x_passphrase']) && ! isset($e['password']) && ! isset($e['passphrase'])) {
$e['password'] = $newPassphrase;
}
$matched = true;
break;
}
}
unset($e);
if (! $matched) {
throw new \RuntimeException('Embedded PPSK not found by current passphrase.');
}
// UniFi REST expects the full WLAN object on PUT — send what we
// got back, with the mutated PPSK array.
$payload = $wlan;
$payload['private_preshared_keys'] = $entries;
// Strip internal fields the controller rejects on PUT.
unset($payload['_id'], $payload['site_id']);
$this->put("/rest/wlanconf/{$wlanId}", $payload);
// Return a normalized record so callers can read the new state.
return $this->normalizePpsk([[
'_id' => 'emb_' . substr(hash('sha256', $wlanId . ':' . $newPassphrase), 0, 32),
'wlan_id' => $wlanId,
'x_passphrase' => $newPassphrase,
]]);
}
public function deletePpsk(string $ppskId): void public function deletePpsk(string $ppskId): void
{ {
// Try v2 hotspot endpoint first // Try v2 hotspot endpoint first

View File

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

View File

@@ -4,6 +4,7 @@ use Dashboard\Unifi\Http\Controllers\ClientController;
use Dashboard\Unifi\Http\Controllers\DeviceController; use Dashboard\Unifi\Http\Controllers\DeviceController;
use Dashboard\Unifi\Http\Controllers\PortalController; use Dashboard\Unifi\Http\Controllers\PortalController;
use Dashboard\Unifi\Http\Controllers\StatsController; use Dashboard\Unifi\Http\Controllers\StatsController;
use Dashboard\Unifi\Http\Controllers\UnifiCronLogsController;
use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController; use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController; use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
use Dashboard\Unifi\Http\Controllers\VlanGroupController; use Dashboard\Unifi\Http\Controllers\VlanGroupController;
@@ -75,15 +76,20 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
// operators assign per-page user/group grants. // operators assign per-page user/group grants.
Route::middleware('super.admin')->group(function () { Route::middleware('super.admin')->group(function () {
Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index'); Route::get('/settings/pages-access', [UnifiPagesAccessController::class, 'index']) ->name('settings.pages-access.index');
Route::get('/settings/pages-access/users/search', [UnifiPagesAccessController::class, 'searchUsers'])->name('settings.pages-access.users.search');
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update'); Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
}); });
// Webhooks // Cron logs — read-only history of scheduled-task runs.
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index'); Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
Route::put('/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update'); // Webhooks — lives under /settings/* so it reads as a settings tab.
Route::delete('/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy'); Route::get('/settings/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
Route::post('/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test'); 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');
}); });
}); });