3 Commits

Author SHA1 Message Date
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
7 changed files with 153 additions and 18 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.5.2",
"version": "1.5.5",
"type": "library",
"license": "MIT",
"autoload": {

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

@@ -32,9 +32,16 @@ class RotatePasswords extends Command
$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 ['status' => 'skipped', 'reason' => 'no SSIDs configured for rotation'];
$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', '');
@@ -66,11 +73,19 @@ class RotatePasswords extends Command
$rotatedPpsks = [];
$failedPpsks = [];
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
foreach ($ppskQuery->get() as $ppsk) {
$newPass = $passwords[array_rand($passwords)];
try {
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
// Embedded PPSK: update inside the parent WLAN object.
// Synthetic ID is derived from the new passphrase, so update it too.
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass);
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
} else {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
}
$rotatedPpsks[] = $ppsk->name;
} catch (\Throwable $e) {
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");

View File

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

View File

@@ -291,11 +291,18 @@ 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.
$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);
}
}
}
$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);

View File

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

View File

@@ -83,12 +83,13 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
// Cron logs — read-only history of scheduled-task runs.
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
// 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');
// 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');
});
});