feat: initial commit — UniFi snap-in package
Full UniFi dashboard snap-in including: - WiFi/client/device stats with time-series snapshots - Client Dashboard with traffic, satisfaction, signal, download charts - Webhook alerting with debounced offline/online detection - AP snapshot collection, client snapshot collection - Device classification (type and OS) from OUI/hostname heuristics - Webhook cooldown, templates, and multi-platform delivery Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
202
src/Http/Controllers/PortalController.php
Normal file
202
src/Http/Controllers/PortalController.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Models\KnownMac;
|
||||
use Dashboard\Unifi\Models\PortalSession;
|
||||
use Dashboard\Unifi\Models\VlanMapping;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PortalController extends Controller
|
||||
{
|
||||
// ── Settings page (VLAN mappings + known MACs) ────────────────────────────
|
||||
|
||||
public function settings()
|
||||
{
|
||||
return Inertia::render('Unifi/Portal', [
|
||||
'vlanMappings' => VlanMapping::orderBy('sort_order')->get(),
|
||||
'knownMacs' => KnownMac::orderBy('device_name')->paginate(50),
|
||||
'activeSessions' => PortalSession::where('is_active', true)
|
||||
->with('user:id,name,email')
|
||||
->latest('authorized_at')
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeMapping(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'group_name' => 'required|string|max:255',
|
||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
||||
'match_value' => 'required|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'max_devices' => 'required|integer|min:1|max:50',
|
||||
'session_minutes' => 'required|integer|min:1',
|
||||
]);
|
||||
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
|
||||
VlanMapping::create($data);
|
||||
return back()->with('success', 'VLAN mapping created.');
|
||||
}
|
||||
|
||||
public function updateMapping(Request $request, VlanMapping $mapping)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'group_name' => 'required|string|max:255',
|
||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
||||
'match_value' => 'required|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'max_devices' => 'required|integer|min:1|max:50',
|
||||
'session_minutes' => 'required|integer|min:1',
|
||||
]);
|
||||
$mapping->update($data);
|
||||
return back()->with('success', 'Mapping updated.');
|
||||
}
|
||||
|
||||
public function destroyMapping(VlanMapping $mapping)
|
||||
{
|
||||
$mapping->delete();
|
||||
return back()->with('success', 'Mapping deleted.');
|
||||
}
|
||||
|
||||
// ── Known MACs ────────────────────────────────────────────────────────────
|
||||
|
||||
public function storeMac(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'mac_address' => 'required|string|regex:/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/|unique:unifi_known_macs,mac_address',
|
||||
'device_name' => 'nullable|string|max:255',
|
||||
'device_type' => 'nullable|string|max:100',
|
||||
'owner' => 'nullable|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
KnownMac::create($data);
|
||||
return back()->with('success', 'MAC address added.');
|
||||
}
|
||||
|
||||
public function destroyMac(KnownMac $mac)
|
||||
{
|
||||
$mac->delete();
|
||||
return back()->with('success', 'MAC address removed.');
|
||||
}
|
||||
|
||||
// ── Captive portal callback (called by UniFi external portal redirect) ───
|
||||
|
||||
public function captiveCallback(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
|
||||
// At this point the user has already authenticated via Google OAuth (handled by the shell's auth system)
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$clientMac = strtolower($request->input('mac', $request->input('id', '')));
|
||||
$ssid = $request->input('ssid', '');
|
||||
$apMac = $request->input('ap', '');
|
||||
|
||||
if (! $clientMac) {
|
||||
return response('Missing MAC address', 400);
|
||||
}
|
||||
|
||||
// Find the VLAN mapping for this user's group/OU
|
||||
$mapping = $this->resolveMapping($user);
|
||||
if (! $mapping) {
|
||||
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
|
||||
}
|
||||
|
||||
// Check device limit
|
||||
$activeSessions = PortalSession::where('user_id', $user->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
foreach ($activeSessions as $session) {
|
||||
if ($session->mac_address === $clientMac) {
|
||||
// Same device reconnecting — refresh
|
||||
$session->update(['is_active' => false]);
|
||||
continue;
|
||||
}
|
||||
// Check if other device is still actually connected
|
||||
if (! $unifi->isClientConnected($session->mac_address)) {
|
||||
$session->update(['is_active' => false]); // stale session
|
||||
}
|
||||
}
|
||||
|
||||
$activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count();
|
||||
if ($activeCount >= $mapping->max_devices) {
|
||||
return Inertia::render('Unifi/PortalDenied', [
|
||||
'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).",
|
||||
'sessions' => $activeSessions->map(fn ($s) => [
|
||||
'id' => $s->id,
|
||||
'mac' => $s->mac_address,
|
||||
'hostname' => $s->device_hostname,
|
||||
'os' => $s->device_os,
|
||||
'since' => $s->authorized_at->toISOString(),
|
||||
]),
|
||||
'can_disconnect' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Authorize the guest
|
||||
$minutes = $mapping->session_minutes;
|
||||
$unifi->authorizeGuest($clientMac, $minutes);
|
||||
|
||||
// Record the session
|
||||
PortalSession::create([
|
||||
'user_id' => $user->id,
|
||||
'mac_address' => $clientMac,
|
||||
'device_hostname' => $request->input('hostname'),
|
||||
'device_os' => $request->input('os'),
|
||||
'device_type' => $request->input('dev_cat'),
|
||||
'ssid' => $ssid,
|
||||
'vlan_id' => $mapping->vlan_id,
|
||||
'ap_mac' => $apMac,
|
||||
'is_active' => true,
|
||||
'authorized_at' => now(),
|
||||
'expires_at' => now()->addMinutes($minutes),
|
||||
]);
|
||||
|
||||
// Redirect to the original URL the user was trying to reach
|
||||
$redirectUrl = $request->input('url', '/');
|
||||
return redirect($redirectUrl);
|
||||
}
|
||||
|
||||
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)
|
||||
{
|
||||
// Allow users to disconnect their own sessions
|
||||
abort_if($session->user_id !== auth()->id() && ! auth()->user()->can('unifi.auth'), 403);
|
||||
|
||||
try {
|
||||
$unifi->kickClient($session->mac_address);
|
||||
$unifi->unauthorizeGuest($session->mac_address);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
$session->update(['is_active' => false]);
|
||||
|
||||
return back()->with('success', 'Device disconnected.');
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private function resolveMapping($user): ?VlanMapping
|
||||
{
|
||||
$mappings = VlanMapping::orderBy('sort_order')->get();
|
||||
|
||||
// Try to match by email domain, group, OU, etc.
|
||||
foreach ($mappings as $mapping) {
|
||||
$matched = match ($mapping->match_type) {
|
||||
'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value),
|
||||
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
|
||||
'ou' => str_contains($user->ou ?? '', $mapping->match_value),
|
||||
'default' => true,
|
||||
default => false,
|
||||
};
|
||||
if ($matched) return $mapping;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user