Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ec4a293c0 | |||
| 720e94c54a | |||
| 2be17c70db | |||
| 31686a35d5 | |||
| 8769308dfd | |||
| f5848907f5 | |||
| f953fde2be | |||
| 9a37eda302 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dashboard/unifi",
|
||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||
"version": "1.6.2",
|
||||
"version": "1.10.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
|
||||
@@ -81,15 +81,45 @@ class RotatePasswords extends Command
|
||||
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);
|
||||
// Match by name (most reliable) — falls back to
|
||||
// passphrase if name is missing.
|
||||
$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]);
|
||||
|
||||
// Sibling WLANs (same SSID name on a different band):
|
||||
// rotate the matching-name PPSK in each so the
|
||||
// SSID's 2.4/5GHz halves stay in sync.
|
||||
foreach ($unifi->getWlanSiblings($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("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
|
||||
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
} 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()];
|
||||
|
||||
@@ -40,6 +40,15 @@ class UnifiPagesAccessController extends Controller
|
||||
$grantedUserIds = $grants->flatten(1)->where('grantee_type', 'user')->pluck('grantee_id')->unique();
|
||||
$users = User::whereIn('id', $grantedUserIds)->orderBy('name')->get(['id', 'name', 'email']);
|
||||
|
||||
// Groups: always include super-admin groups (locked-on across all
|
||||
// pages) plus any group with at least one grant. Other groups are
|
||||
// added via searchGroups.
|
||||
$grantedGroupIds = $grants->flatten(1)->where('grantee_type', 'group')->pluck('grantee_id')->unique();
|
||||
$groups = Group::where(function ($q) use ($grantedGroupIds) {
|
||||
$q->where('is_super', true)
|
||||
->orWhereIn('id', $grantedGroupIds);
|
||||
})->orderBy('name')->get(['id', 'name', 'is_super']);
|
||||
|
||||
return response()->json([
|
||||
'pages' => $pages->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
@@ -49,7 +58,7 @@ class UnifiPagesAccessController extends Controller
|
||||
'group_ids' => $grants->get($p->id, collect())->where('grantee_type', 'group')->pluck('grantee_id')->all(),
|
||||
])->values(),
|
||||
'users' => $users,
|
||||
'groups' => Group::orderBy('name')->get(['id', 'name', 'is_super']),
|
||||
'groups' => $groups,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -76,6 +85,27 @@ class UnifiPagesAccessController extends Controller
|
||||
return response()->json(['users' => $users]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typeahead-style search for groups to add to the access matrix.
|
||||
* Excludes super-admin groups (they're already in the matrix and
|
||||
* locked-on across every page).
|
||||
*/
|
||||
public function searchGroups(Request $request)
|
||||
{
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
if (strlen($q) < 2) {
|
||||
return response()->json(['groups' => []]);
|
||||
}
|
||||
|
||||
$groups = Group::where('name', 'like', '%' . $q . '%')
|
||||
->where(function ($w) { $w->where('is_super', false)->orWhereNull('is_super'); })
|
||||
->orderBy('name')
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'is_super']);
|
||||
|
||||
return response()->json(['groups' => $groups]);
|
||||
}
|
||||
|
||||
public function update(Request $request, NavItem $navItem)
|
||||
{
|
||||
$app = DashboardApp::where('slug', 'unifi')->first();
|
||||
|
||||
@@ -33,6 +33,7 @@ class UnifiSettingsController extends Controller
|
||||
'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),
|
||||
]);
|
||||
}
|
||||
@@ -71,6 +72,7 @@ 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',
|
||||
]);
|
||||
|
||||
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
|
||||
@@ -97,6 +99,7 @@ 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' : '');
|
||||
|
||||
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ 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);
|
||||
|
||||
@@ -293,9 +293,33 @@ class WifiController extends Controller
|
||||
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);
|
||||
// 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 sibling WLANs (banded SSID — same name
|
||||
// on 2.4 and 5GHz are separate wlanconf rows).
|
||||
foreach ($unifi->getWlanSiblings($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) {
|
||||
\Illuminate\Support\Facades\Log::warning('unifi.ppsk_sibling_update_failed', [
|
||||
'sibling_wlan' => $siblingWlanId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
||||
}
|
||||
|
||||
@@ -29,18 +29,20 @@ class UnifiPageGrant extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff $user is allowed to access $navItem under this grant model.
|
||||
* Super-admins always pass.
|
||||
* If there are NO grants for the page, falls back to "open" (anyone
|
||||
* who can reach the route can access — same as before grants existed).
|
||||
* True iff $user is allowed to access $navItem under strict allowlist
|
||||
* semantics:
|
||||
* * super-admins (the model-level flag) always pass
|
||||
* * otherwise the user must be a direct grantee, OR be in a group
|
||||
* that is a grantee
|
||||
*
|
||||
* A page with NO grants saved is only visible to super-admins —
|
||||
* the admin must explicitly authorize everyone else via the
|
||||
* Access tab.
|
||||
*/
|
||||
public static function userCanAccess(User $user, NavItem $navItem): bool
|
||||
{
|
||||
if ($user->is_super_admin) return true;
|
||||
|
||||
$hasGrants = static::where('nav_item_id', $navItem->id)->exists();
|
||||
if (! $hasGrants) return true;
|
||||
|
||||
$groupIds = $user->groups()->pluck('groups.id');
|
||||
|
||||
return static::where('nav_item_id', $navItem->id)
|
||||
|
||||
@@ -312,6 +312,40 @@ class UnifiApiClient
|
||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -504,7 +538,7 @@ class UnifiApiClient
|
||||
* 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
|
||||
public function updateEmbeddedPpsk(string $wlanId, ?string $oldPassphrase, string $newPassphrase, ?string $name = null): array
|
||||
{
|
||||
$wlanResp = $this->get("/rest/wlanconf/{$wlanId}");
|
||||
$wlan = $wlanResp[0] ?? $wlanResp;
|
||||
@@ -514,26 +548,50 @@ class UnifiApiClient
|
||||
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;
|
||||
// Match in this order — most reliable first:
|
||||
// 1. by PPSK name (if provided) — survives passphrase drift
|
||||
// caused by manual edits or previous out-of-sync rotations.
|
||||
// 2. by current passphrase (legacy)
|
||||
$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;
|
||||
}
|
||||
};
|
||||
|
||||
$matched = false;
|
||||
if ($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);
|
||||
}
|
||||
unset($e);
|
||||
|
||||
if (! $matched) {
|
||||
throw new \RuntimeException('Embedded PPSK not found by current passphrase.');
|
||||
throw new \RuntimeException(
|
||||
'Embedded PPSK not found' .
|
||||
($name !== null ? " by name \"{$name}\"" : '') .
|
||||
' or by current passphrase.'
|
||||
);
|
||||
}
|
||||
|
||||
// UniFi REST expects the full WLAN object on PUT — send what we
|
||||
|
||||
@@ -44,7 +44,9 @@ class UnifiServiceProvider extends ServiceProvider
|
||||
if (! $item) return;
|
||||
|
||||
if (! UnifiPageGrant::userCanAccess($user, $item)) {
|
||||
abort(403, 'You do not have access to this page.');
|
||||
// 404 instead of 403 — don't leak that the page
|
||||
// exists. The Access tab is the only way in.
|
||||
abort(404);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// unifi_page_grants table may not exist yet on a fresh
|
||||
|
||||
@@ -78,6 +78,7 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
Route::middleware('super.admin')->group(function () {
|
||||
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::get('/settings/pages-access/groups/search', [UnifiPagesAccessController::class, 'searchGroups'])->name('settings.pages-access.groups.search');
|
||||
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user