Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27c1584dc3 | |||
| c89adeea97 | |||
| 8f51be8515 | |||
| 0490a1220b |
@@ -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.5.1",
|
"version": "1.5.5",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -32,9 +32,16 @@ class RotatePasswords extends Command
|
|||||||
$run = UnifiCronRun::record('rotate-passwords', $triggeredBy, null, function () use ($unifi, $force) {
|
$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 ['status' => 'skipped', 'reason' => 'no SSIDs configured for rotation'];
|
->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', '');
|
||||||
@@ -66,11 +73,19 @@ class RotatePasswords extends Command
|
|||||||
|
|
||||||
$rotatedPpsks = [];
|
$rotatedPpsks = [];
|
||||||
$failedPpsks = [];
|
$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)];
|
$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[] = $ppsk->name;
|
$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()}");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ class WebhookController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function test(WebhookConfig $webhook)
|
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 = [
|
$payload = [
|
||||||
'event' => 'test',
|
'event' => 'test',
|
||||||
@@ -75,13 +94,17 @@ class WebhookController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
$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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -76,18 +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');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cron logs — read-only history of scheduled-task runs.
|
// Cron logs — read-only history of scheduled-task runs.
|
||||||
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
|
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
|
||||||
|
|
||||||
// Webhooks
|
// Webhooks — lives under /settings/* so it reads as a settings tab.
|
||||||
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
|
Route::get('/settings/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
|
||||||
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
|
Route::post('/settings/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
|
||||||
Route::put('/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
|
Route::put('/settings/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
|
||||||
Route::delete('/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
|
Route::delete('/settings/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
|
||||||
Route::post('/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user