Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8769308dfd | |||
| f5848907f5 | |||
| f953fde2be | |||
| 9a37eda302 | |||
| 4b29f55518 | |||
| e8796d443e |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dashboard/unifi",
|
||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||
"version": "1.6.0",
|
||||
"version": "1.8.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"autoload": {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -68,6 +68,9 @@ class RotatePasswords extends Command
|
||||
|
||||
if ($rotated) {
|
||||
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
||||
// 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).');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -31,10 +31,26 @@ 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),
|
||||
]);
|
||||
}
|
||||
|
||||
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([
|
||||
@@ -56,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, '/'));
|
||||
@@ -82,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, '/')));
|
||||
|
||||
|
||||
@@ -87,11 +87,17 @@ class WebhookController extends Controller
|
||||
|
||||
private function fireTest(string $url, ?string $secret)
|
||||
{
|
||||
$payload = [
|
||||
$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 ($secret) {
|
||||
|
||||
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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -581,10 +581,19 @@ class WebhookCheckService
|
||||
|
||||
private function formatPayloadForPlatform(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];
|
||||
if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message];
|
||||
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message];
|
||||
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, 'hooks.slack.com')) return ['text' => $message];
|
||||
if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message];
|
||||
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message];
|
||||
return $fullPayload;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
|
||||
@@ -77,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');
|
||||
});
|
||||
|
||||
@@ -90,9 +92,20 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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'])
|
||||
|
||||
Reference in New Issue
Block a user