User-defined SSID groups (configured on the WiFi Networks page and
stored in unifi.ssid_groups) now drive PPSK sibling propagation. The
previous same-SSID-name detection missed cases where two grouped
WLANs have *different* names — e.g. "VCS Guest" on 2.4 and "VCS
Guest 5G" on 5GHz manually grouped by the operator. Falls back to
same-name siblings when no group is configured.
Match-by-name fix: embedded PPSKs on this controller don't carry a
name field — the human "GUEST" label is the *network's* name, with
the entry referenced via networkconf_id. updateEmbeddedPpsk and
verifyEmbeddedPpsk now resolve name → networkconf_id first and match
on that, with entry-name and current-passphrase as fallbacks for
other controller variants.
After every rotation we re-fetch each affected WLAN and verify the
new passphrase is actually present on the named network. Failures
("mismatch" or "fetch_failed" on the primary, anything other than
"not_found" on a sibling) surface in the cron run details as failed
PPSKs so the operator sees what didn't propagate.
v1.10.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
501 lines
23 KiB
PHP
501 lines
23 KiB
PHP
<?php
|
|
|
|
namespace Dashboard\Unifi\Http\Controllers;
|
|
|
|
use App\Models\Setting;
|
|
use Dashboard\Unifi\Models\UnifiPpsk;
|
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Controller;
|
|
use Inertia\Inertia;
|
|
|
|
class WifiController extends Controller
|
|
{
|
|
public function index(UnifiApiClient $unifi)
|
|
{
|
|
try {
|
|
$wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values();
|
|
|
|
$raw = Setting::get('unifi.ssid_groups', '{}');
|
|
$groups = json_decode($raw, true);
|
|
if (! is_array($groups) || array_is_list($groups)) $groups = [];
|
|
$groups = (object) $groups;
|
|
|
|
$rotateWlanIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: [];
|
|
|
|
return Inertia::render('Unifi/Wifi', [
|
|
'wlans' => $wlans,
|
|
'groups' => $groups,
|
|
'rotateWlanIds' => $rotateWlanIds,
|
|
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
return Inertia::render('Unifi/Wifi', [
|
|
'wlans' => [], 'groups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
public function update(Request $request, string $wlanId, UnifiApiClient $unifi)
|
|
{
|
|
$data = $request->validate([
|
|
'x_passphrase' => 'sometimes|string|min:8|max:63',
|
|
'hide_ssid' => 'sometimes|boolean',
|
|
'mac_filter_enabled' => 'sometimes|boolean',
|
|
'mac_filter_policy' => 'sometimes|string|in:allow,deny',
|
|
'rotate_password' => 'sometimes|boolean',
|
|
]);
|
|
|
|
try {
|
|
// Password/hide changes apply to all grouped WLANs
|
|
$shared = array_filter($data, fn ($k) => in_array($k, ['x_passphrase', 'hide_ssid']), ARRAY_FILTER_USE_KEY);
|
|
$groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: [];
|
|
$groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids));
|
|
|
|
if (! empty($shared)) {
|
|
if ($groupedIds) {
|
|
foreach ($groupedIds as $id) {
|
|
$unifi->updateWlan($id, $shared);
|
|
}
|
|
} else {
|
|
$unifi->updateWlan($wlanId, $shared);
|
|
}
|
|
}
|
|
|
|
// MAC filter changes apply to this WLAN only
|
|
$perWlan = array_filter($data, fn ($k) => in_array($k, ['mac_filter_enabled', 'mac_filter_policy']), ARRAY_FILTER_USE_KEY);
|
|
if (! empty($perWlan)) {
|
|
$unifi->updateWlan($wlanId, $perWlan);
|
|
}
|
|
|
|
// Toggle wlan_id in the rotation list
|
|
if ($request->has('rotate_password')) {
|
|
$rotateIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: [];
|
|
$targetIds = $groupedIds ?: [$wlanId];
|
|
if ($request->boolean('rotate_password')) {
|
|
$rotateIds = array_values(array_unique(array_merge($rotateIds, $targetIds)));
|
|
} else {
|
|
$rotateIds = array_values(array_diff($rotateIds, $targetIds));
|
|
}
|
|
Setting::set('unifi.password_rotation.wlan_ids', json_encode($rotateIds));
|
|
}
|
|
|
|
return back()->with('success', 'WiFi network updated.');
|
|
} catch (\Throwable $e) {
|
|
return back()->withErrors(['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi)
|
|
{
|
|
$request->validate(['enabled' => 'required|boolean']);
|
|
|
|
try {
|
|
$groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: [];
|
|
$groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids));
|
|
$enabled = $request->boolean('enabled');
|
|
|
|
if ($groupedIds) {
|
|
foreach ($groupedIds as $id) {
|
|
$unifi->updateWlan($id, ['enabled' => $enabled]);
|
|
}
|
|
} else {
|
|
$unifi->updateWlan($wlanId, ['enabled' => $enabled]);
|
|
}
|
|
|
|
return back()->with('success', 'WiFi network ' . ($enabled ? 'enabled' : 'disabled') . '.');
|
|
} catch (\Throwable $e) {
|
|
return back()->withErrors(['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
|
|
// ── PPSK ─────────────────────────────────────────────────────────────────
|
|
|
|
public function ppskIndex(string $wlanId, UnifiApiClient $unifi)
|
|
{
|
|
try {
|
|
$liveEntries = $unifi->getPpskEntries($wlanId);
|
|
|
|
// Network confs are best-effort — don't let a failure block PPSK display
|
|
try {
|
|
$networksRaw = $unifi->getNetworkConfs();
|
|
} catch (\Throwable $e) {
|
|
$networksRaw = [];
|
|
}
|
|
$networksById = collect($networksRaw)->keyBy('_id');
|
|
|
|
// ── Sync live entries into DB ────────────────────────────────────
|
|
$liveIds = [];
|
|
foreach ($liveEntries as $entry) {
|
|
$pass = $entry['x_passphrase'] ?? $entry['password'] ?? null;
|
|
$uid = $entry['_id'] ?? $entry['id'] ?? null;
|
|
// wlan_embedded PPSKs have no _id — derive a stable synthetic ID from the passphrase
|
|
if (! $uid && $pass) {
|
|
$uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32);
|
|
}
|
|
if (! $uid) continue;
|
|
$liveIds[] = $uid;
|
|
|
|
$nconfId = $entry['networkconf_id'] ?? null;
|
|
$vlan = ($nconfId && isset($networksById[$nconfId]))
|
|
? ($networksById[$nconfId]['vlan'] ?? null)
|
|
: null;
|
|
if ($vlan === null && ! empty($entry['vlan_id'])) {
|
|
$vlan = (int) $entry['vlan_id'];
|
|
}
|
|
$name = $entry['name'] ?? $entry['label'] ?? $entry['username'] ?? null;
|
|
// For anonymous PPSKs, use the associated network name as the default label
|
|
if (! $name && $nconfId && isset($networksById[$nconfId])) {
|
|
$name = $networksById[$nconfId]['name'] ?? null;
|
|
}
|
|
|
|
// Match in priority order:
|
|
// 1. by current unifi_id (already-synced row)
|
|
// 2. by name within this wlan (catches rotation: passphrase
|
|
// changed → synthetic id changed → row identity unchanged)
|
|
// 3. by passphrase among held rows (legacy fallback for
|
|
// cases where name wasn't ingested)
|
|
$record = UnifiPpsk::where('unifi_id', $uid)->first()
|
|
?? ($name
|
|
? UnifiPpsk::where('wlan_id', $wlanId)->where('name', $name)
|
|
->orderByRaw("FIELD(state, 'active', 'held')")
|
|
->first()
|
|
: null)
|
|
?? UnifiPpsk::where('wlan_id', $wlanId)
|
|
->where('x_passphrase', $pass)
|
|
->where('state', 'held')
|
|
->first();
|
|
|
|
if ($record) {
|
|
$upd = ['unifi_id' => $uid, 'state' => 'active'];
|
|
if ($name) $upd['name'] = $name;
|
|
if ($pass) $upd['x_passphrase'] = $pass;
|
|
if ($vlan !== null) $upd['vlan'] = $vlan;
|
|
$record->update($upd);
|
|
} else {
|
|
UnifiPpsk::create([
|
|
'wlan_id' => $wlanId,
|
|
'unifi_id' => $uid,
|
|
'name' => $name ?? 'PPSK',
|
|
'x_passphrase' => $pass ?? '',
|
|
'vlan' => $vlan,
|
|
'state' => 'active',
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Mark non-matching active rows as held — but ONLY if there's no
|
|
// other active row with the same name we just reconnected.
|
|
if (! empty($liveIds)) {
|
|
UnifiPpsk::where('wlan_id', $wlanId)
|
|
->where('state', 'active')
|
|
->whereNotNull('unifi_id')
|
|
->whereNotIn('unifi_id', $liveIds)
|
|
->update(['state' => 'held', 'unifi_id' => null]);
|
|
}
|
|
|
|
// For each active row, salvage any rotate_password / schedule
|
|
// settings from the held tombstones with the same name BEFORE
|
|
// we prune them. Otherwise a row that had rotate=on loses the
|
|
// flag every time a rotation changes its synthetic id.
|
|
$activeRows = UnifiPpsk::where('wlan_id', $wlanId)
|
|
->where('state', 'active')
|
|
->whereNotNull('name')
|
|
->get();
|
|
foreach ($activeRows as $active) {
|
|
$heldWithSettings = UnifiPpsk::where('wlan_id', $wlanId)
|
|
->where('state', 'held')
|
|
->where('name', $active->name)
|
|
->where(fn ($q) => $q
|
|
->where('rotate_password', true)
|
|
->orWhereNotNull('schedule'))
|
|
->orderByDesc('updated_at')
|
|
->first();
|
|
if (! $heldWithSettings) continue;
|
|
|
|
$patch = [];
|
|
if ($heldWithSettings->rotate_password && ! $active->rotate_password) {
|
|
$patch['rotate_password'] = true;
|
|
}
|
|
if ($heldWithSettings->schedule && ! $active->schedule) {
|
|
$patch['schedule'] = $heldWithSettings->schedule;
|
|
}
|
|
if ($patch) $active->update($patch);
|
|
}
|
|
|
|
// Prune obsolete held rows: any held row whose name matches an
|
|
// active row in the same wlan is a stale tombstone — its
|
|
// settings have been salvaged above, and its data has been
|
|
// superseded by the active one.
|
|
$activeNames = $activeRows->pluck('name')->filter()->unique();
|
|
if ($activeNames->isNotEmpty()) {
|
|
UnifiPpsk::where('wlan_id', $wlanId)
|
|
->where('state', 'held')
|
|
->whereIn('name', $activeNames)
|
|
->delete();
|
|
}
|
|
|
|
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
|
|
->orderByRaw("FIELD(state, 'active', 'held')")
|
|
->orderBy('name')
|
|
->get();
|
|
|
|
// Fallback: if DB still empty but live entries exist, return live entries directly.
|
|
// Applies the same synthetic-ID and networkconf logic so IDs are always non-null.
|
|
if ($dbRecords->isEmpty() && ! empty($liveEntries)) {
|
|
$entries = collect($liveEntries)->map(function ($e) use ($wlanId, $networksById) {
|
|
$pass = $e['x_passphrase'] ?? $e['password'] ?? null;
|
|
$uid = $e['_id'] ?? $e['id'] ?? null;
|
|
if (! $uid && $pass) {
|
|
$uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32);
|
|
}
|
|
$nconfId = $e['networkconf_id'] ?? null;
|
|
$vlan = ($nconfId && isset($networksById[$nconfId]))
|
|
? ($networksById[$nconfId]['vlan'] ?? null) : null;
|
|
if ($vlan === null && ! empty($e['vlan_id'])) {
|
|
$vlan = (int) $e['vlan_id'];
|
|
}
|
|
$name = $e['name'] ?? $e['label'] ?? $e['username'] ?? null;
|
|
if (! $name && $nconfId && isset($networksById[$nconfId])) {
|
|
$name = $networksById[$nconfId]['name'] ?? null;
|
|
}
|
|
return [
|
|
'id' => $uid,
|
|
'unifi_id' => $uid,
|
|
'name' => $name ?? 'PPSK',
|
|
'x_passphrase' => $pass,
|
|
'vlan' => $vlan,
|
|
'state' => 'active',
|
|
'rotate_password' => false,
|
|
'schedule' => null,
|
|
];
|
|
})->values();
|
|
} else {
|
|
$entries = $dbRecords->map(fn ($r) => $this->mapPpsk($r));
|
|
}
|
|
|
|
$networks = $networksById->values()->map(fn ($n) => [
|
|
'_id' => $n['_id'],
|
|
'name' => $n['name'] ?? 'Unnamed',
|
|
'vlan' => $n['vlan'] ?? null,
|
|
]);
|
|
|
|
return response()->json(['ok' => true, 'entries' => $entries, 'networks' => $networks]);
|
|
} catch (\Throwable $e) {
|
|
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
|
}
|
|
}
|
|
|
|
public function ppskStore(Request $request, string $wlanId, UnifiApiClient $unifi)
|
|
{
|
|
$data = $request->validate([
|
|
'name' => 'required|string|max:100',
|
|
'x_passphrase' => 'required|string|min:8|max:63',
|
|
'networkconf_id' => 'nullable|string',
|
|
'vlan' => 'nullable|integer',
|
|
]);
|
|
|
|
try {
|
|
$pushData = [
|
|
'name' => $data['name'],
|
|
'x_passphrase' => $data['x_passphrase'],
|
|
'wlan_id' => $wlanId,
|
|
];
|
|
if (! empty($data['networkconf_id'])) {
|
|
$pushData['networkconf_id'] = $data['networkconf_id'];
|
|
}
|
|
|
|
$result = $unifi->createPpsk($pushData);
|
|
$raw = $result[0] ?? $result;
|
|
$unifiId = $raw['_id'] ?? null;
|
|
|
|
$record = UnifiPpsk::create([
|
|
'wlan_id' => $wlanId,
|
|
'unifi_id' => $unifiId,
|
|
'name' => $data['name'],
|
|
'x_passphrase' => $data['x_passphrase'],
|
|
'vlan' => $data['vlan'] ?? null,
|
|
'state' => 'active',
|
|
]);
|
|
|
|
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]);
|
|
} catch (\Throwable $e) {
|
|
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
|
}
|
|
}
|
|
|
|
public function ppskUpdate(Request $request, string $wlanId, string $ppskId, UnifiApiClient $unifi)
|
|
{
|
|
$record = UnifiPpsk::findOrFail($ppskId);
|
|
|
|
$data = $request->validate([
|
|
'name' => 'sometimes|string|max:100',
|
|
'x_passphrase' => 'sometimes|string|min:8|max:63',
|
|
'networkconf_id' => 'nullable|string',
|
|
'vlan' => 'nullable|integer',
|
|
]);
|
|
|
|
try {
|
|
if ($record->unifi_id && $record->state === 'active') {
|
|
$unifiUpdate = array_filter(
|
|
array_intersect_key($data, array_flip(['name', 'x_passphrase', 'networkconf_id'])),
|
|
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.
|
|
// 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 grouped WLAN siblings (user-defined
|
|
// SSID groups, falling back to same-name).
|
|
foreach ($unifi->getGroupedWlans($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) {
|
|
// PPSK absent on this band is fine — just
|
|
// means it isn't mirrored. Anything else
|
|
// gets warning-logged.
|
|
$level = str_contains($e->getMessage(), 'not found') ? 'info' : 'warning';
|
|
\Illuminate\Support\Facades\Log::log($level, 'unifi.ppsk_sibling_update', [
|
|
'sibling_wlan' => $siblingWlanId,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
} else {
|
|
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
|
}
|
|
}
|
|
}
|
|
|
|
$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);
|
|
|
|
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]);
|
|
} catch (\Throwable $e) {
|
|
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
|
}
|
|
}
|
|
|
|
public function ppskDestroy(string $wlanId, string $ppskId, UnifiApiClient $unifi)
|
|
{
|
|
$record = UnifiPpsk::findOrFail($ppskId);
|
|
|
|
try {
|
|
if ($record->unifi_id) {
|
|
try { $unifi->kickClientsForPpsk($record->unifi_id); } catch (\Throwable) {}
|
|
// Embedded PPSKs (synthetic emb_ IDs) aren't deletable via the PPSK REST endpoint;
|
|
// skip the API call — the entry will disappear from UniFi when the WLAN is reconfigured.
|
|
if (! str_starts_with($record->unifi_id, 'emb_')) {
|
|
$unifi->deletePpsk($record->unifi_id);
|
|
}
|
|
}
|
|
$record->delete();
|
|
return response()->json(['ok' => true]);
|
|
} catch (\Throwable $e) {
|
|
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
|
}
|
|
}
|
|
|
|
public function ppskSchedule(Request $request, string $wlanId, string $ppskId)
|
|
{
|
|
$record = UnifiPpsk::findOrFail($ppskId);
|
|
$request->validate([
|
|
'schedule' => 'nullable|array',
|
|
'schedule.*' => 'boolean',
|
|
]);
|
|
$record->update(['schedule' => $request->schedule]);
|
|
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]);
|
|
}
|
|
|
|
public function ppskToggleRotation(Request $request, string $wlanId, string $ppskId)
|
|
{
|
|
$record = UnifiPpsk::findOrFail($ppskId);
|
|
$request->validate(['rotate_password' => 'required|boolean']);
|
|
$record->update(['rotate_password' => $request->boolean('rotate_password')]);
|
|
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]);
|
|
}
|
|
|
|
private function mapPpsk(UnifiPpsk $r): array
|
|
{
|
|
return [
|
|
'id' => $r->id,
|
|
'unifi_id' => $r->unifi_id,
|
|
'name' => $r->name,
|
|
'x_passphrase' => $r->x_passphrase,
|
|
'vlan' => $r->vlan,
|
|
'state' => $r->state,
|
|
'rotate_password' => $r->rotate_password,
|
|
'schedule' => $r->schedule,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Group/ungroup SSIDs. Groups are stored as { "GroupName": ["wlan_id_1", "wlan_id_2"] }
|
|
*/
|
|
public function saveGroups(Request $request)
|
|
{
|
|
$request->validate(['groups' => 'present']);
|
|
$groups = $request->input('groups', []);
|
|
if (is_array($groups) && array_is_list($groups)) $groups = (object) [];
|
|
Setting::set('unifi.ssid_groups', json_encode($groups ?: (object) []));
|
|
return back()->with('success', 'SSID groups saved.');
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private function mapWlan(array $w): array
|
|
{
|
|
return [
|
|
'id' => $w['_id'],
|
|
'name' => $w['name'],
|
|
'enabled' => $w['enabled'] ?? true,
|
|
'security' => $w['security'] ?? 'open',
|
|
'wpa_mode' => $w['wpa_mode'] ?? '',
|
|
'is_guest' => $w['is_guest'] ?? false,
|
|
'vlan_enabled' => $w['vlan_enabled'] ?? false,
|
|
'vlan' => $w['vlan'] ?? null,
|
|
'hide_ssid' => $w['hide_ssid'] ?? false,
|
|
'passphrase' => $w['x_passphrase'] ?? '',
|
|
'band' => $this->detectBand($w),
|
|
'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false,
|
|
'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny',
|
|
'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false)
|
|
|| ($w['ppsk'] ?? false)
|
|
|| ($w['private_preshared_keys_enabled'] ?? false)
|
|
|| ! empty($w['private_preshared_keys']),
|
|
];
|
|
}
|
|
|
|
private function detectBand(array $w): string
|
|
{
|
|
$band = $w['wlan_band'] ?? null;
|
|
if ($band === 'ng' || $band === '2g') return '2.4 GHz';
|
|
if ($band === 'na' || $band === '5g') return '5 GHz';
|
|
if ($band === 'a6' || $band === '6g' || $band === '6e') return '6 GHz';
|
|
if ($band === 'both' || $band === null) return 'All bands';
|
|
|
|
$name = strtolower($w['name'] ?? '');
|
|
if (preg_match('/2\.?4\s*g/i', $name)) return '2.4 GHz';
|
|
if (preg_match('/5\s*g/i', $name)) return '5 GHz';
|
|
if (preg_match('/6\s*g/i', $name)) return '6 GHz';
|
|
|
|
return 'All bands';
|
|
}
|
|
}
|