Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 274f210337 | |||
| c0f12ce931 | |||
| dd4e0ca564 | |||
| f533208b37 | |||
| bb74edf4c1 | |||
| e5cc075938 |
@@ -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.10.0",
|
"version": "1.12.0",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
@@ -80,17 +80,17 @@ class RotatePasswords extends Command
|
|||||||
$newPass = $passwords[array_rand($passwords)];
|
$newPass = $passwords[array_rand($passwords)];
|
||||||
try {
|
try {
|
||||||
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
|
if (str_starts_with((string) $ppsk->unifi_id, 'emb_')) {
|
||||||
// Embedded PPSK: update inside the parent WLAN object.
|
// Embedded PPSK: update inside the parent WLAN object,
|
||||||
// Match by name (most reliable) — falls back to
|
// matched by name (synthetic id changes with the
|
||||||
// passphrase if name is missing.
|
// passphrase, so it's not a stable matcher).
|
||||||
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
|
$unifi->updateEmbeddedPpsk($ppsk->wlan_id, $ppsk->x_passphrase, $newPass, $ppsk->name);
|
||||||
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
|
$newUid = 'emb_' . substr(hash('sha256', $ppsk->wlan_id . ':' . $newPass), 0, 32);
|
||||||
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
|
$ppsk->update(['x_passphrase' => $newPass, 'unifi_id' => $newUid]);
|
||||||
|
|
||||||
// Sibling WLANs (same SSID name on a different band):
|
// Update every grouped sibling (user-defined SSID
|
||||||
// rotate the matching-name PPSK in each so the
|
// groups take precedence; same-name fallback for
|
||||||
// SSID's 2.4/5GHz halves stay in sync.
|
// installs that haven't grouped manually).
|
||||||
foreach ($unifi->getWlanSiblings($ppsk->wlan_id) as $siblingWlanId) {
|
foreach ($unifi->getGroupedWlans($ppsk->wlan_id) as $siblingWlanId) {
|
||||||
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||||
->where('name', $ppsk->name)
|
->where('name', $ppsk->name)
|
||||||
->where('state', 'active')
|
->where('state', 'active')
|
||||||
@@ -104,10 +104,37 @@ class RotatePasswords extends Command
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
if (str_contains($e->getMessage(), 'not found')) {
|
||||||
|
\Illuminate\Support\Facades\Log::info('unifi.ppsk_sibling_skipped', [
|
||||||
|
'sibling_wlan' => $siblingWlanId,
|
||||||
|
'ppsk_name' => $ppsk->name,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
|
$this->error("Sibling rotate failed for wlan {$siblingWlanId}: {$e->getMessage()}");
|
||||||
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
|
$failedPpsks[] = ['name' => $ppsk->name . ' (sibling wlan ' . $siblingWlanId . ')', 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify that the new passphrase actually applied
|
||||||
|
// on every grouped WLAN. UniFi can 200 an update
|
||||||
|
// that doesn't stick (cluster sync race, etc).
|
||||||
|
// Anything we expected to rotate that didn't is a
|
||||||
|
// failure — surface it in the cron log.
|
||||||
|
$allWlanIds = array_merge([$ppsk->wlan_id], $unifi->getGroupedWlans($ppsk->wlan_id));
|
||||||
|
foreach ($allWlanIds as $checkWlanId) {
|
||||||
|
$result = $unifi->verifyEmbeddedPpsk($checkWlanId, $ppsk->name, $newPass);
|
||||||
|
if ($result['ok']) continue;
|
||||||
|
|
||||||
|
// 'not_found' on a sibling = PPSK isn't on that band — ignore
|
||||||
|
// (consistent with the skip in the update loop).
|
||||||
|
if ($result['reason'] === 'not_found' && $checkWlanId !== $ppsk->wlan_id) continue;
|
||||||
|
|
||||||
|
$failedPpsks[] = [
|
||||||
|
'name' => $ppsk->name . ' (verify wlan ' . $checkWlanId . ')',
|
||||||
|
'error' => 'verification ' . $result['reason'] . ($result['error'] ?? null ? ': ' . $result['error'] : ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
} else {
|
} 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]);
|
||||||
|
|||||||
@@ -149,8 +149,18 @@ class WifiController extends Controller
|
|||||||
$name = $networksById[$nconfId]['name'] ?? null;
|
$name = $networksById[$nconfId]['name'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match by unifi_id, or by passphrase for a held embedded record re-appearing
|
// 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()
|
$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)
|
?? UnifiPpsk::where('wlan_id', $wlanId)
|
||||||
->where('x_passphrase', $pass)
|
->where('x_passphrase', $pass)
|
||||||
->where('state', 'held')
|
->where('state', 'held')
|
||||||
@@ -174,8 +184,8 @@ class WifiController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only mark as held when we have confirmed live IDs —
|
// Mark non-matching active rows as held — but ONLY if there's no
|
||||||
// never wipe on an empty API response (prevents false-holds on API failures)
|
// other active row with the same name we just reconnected.
|
||||||
if (! empty($liveIds)) {
|
if (! empty($liveIds)) {
|
||||||
UnifiPpsk::where('wlan_id', $wlanId)
|
UnifiPpsk::where('wlan_id', $wlanId)
|
||||||
->where('state', 'active')
|
->where('state', 'active')
|
||||||
@@ -184,6 +194,47 @@ class WifiController extends Controller
|
|||||||
->update(['state' => 'held', 'unifi_id' => null]);
|
->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)
|
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
|
||||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||||
->orderBy('name')
|
->orderBy('name')
|
||||||
@@ -298,9 +349,9 @@ class WifiController extends Controller
|
|||||||
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
|
$unifi->updateEmbeddedPpsk($record->wlan_id, $record->x_passphrase, $newPass, $record->name);
|
||||||
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
|
$data['unifi_id'] = 'emb_' . substr(hash('sha256', $record->wlan_id . ':' . $newPass), 0, 32);
|
||||||
|
|
||||||
// Also update sibling WLANs (banded SSID — same name
|
// Also update grouped WLAN siblings (user-defined
|
||||||
// on 2.4 and 5GHz are separate wlanconf rows).
|
// SSID groups, falling back to same-name).
|
||||||
foreach ($unifi->getWlanSiblings($record->wlan_id) as $siblingWlanId) {
|
foreach ($unifi->getGroupedWlans($record->wlan_id) as $siblingWlanId) {
|
||||||
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
$sibling = UnifiPpsk::where('wlan_id', $siblingWlanId)
|
||||||
->where('name', $record->name)
|
->where('name', $record->name)
|
||||||
->where('state', 'active')
|
->where('state', 'active')
|
||||||
@@ -314,7 +365,11 @@ class WifiController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
\Illuminate\Support\Facades\Log::warning('unifi.ppsk_sibling_update_failed', [
|
// 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,
|
'sibling_wlan' => $siblingWlanId,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -312,6 +312,88 @@ class UnifiApiClient
|
|||||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find every other WLAN that should rotate/update together with this
|
||||||
|
* one. Authoritative source: the user-defined "SSID groups" setting
|
||||||
|
* (unifi.ssid_groups) from the WiFi Networks page, which lets the
|
||||||
|
* operator manually couple WLANs that may have different SSID names.
|
||||||
|
*
|
||||||
|
* Falls back to same-SSID-name siblings for installs that haven't
|
||||||
|
* configured groups yet.
|
||||||
|
*
|
||||||
|
* Returns an array of sibling wlan IDs (excludes $wlanId itself).
|
||||||
|
*/
|
||||||
|
public function getGroupedWlans(string $wlanId): array
|
||||||
|
{
|
||||||
|
$groupsJson = Setting::get('unifi.ssid_groups', '{}');
|
||||||
|
$groups = json_decode($groupsJson, true);
|
||||||
|
if (is_array($groups)) {
|
||||||
|
foreach ($groups as $wlanIds) {
|
||||||
|
if (! is_array($wlanIds)) continue;
|
||||||
|
if (in_array($wlanId, $wlanIds, true)) {
|
||||||
|
return array_values(array_filter($wlanIds, fn ($id) => $id !== $wlanId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->getWlanSiblings($wlanId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an embedded PPSK has the expected passphrase right now.
|
||||||
|
* Used after an update to confirm the change actually applied —
|
||||||
|
* UniFi sometimes 200s an update that didn't stick (cluster sync
|
||||||
|
* race, hot-restart in progress, etc.).
|
||||||
|
*
|
||||||
|
* Returns ['ok' => true] on a clean match, or
|
||||||
|
* ['ok' => false, 'reason' => 'fetch_failed'|'not_found'|'mismatch']
|
||||||
|
* with optional 'error' on fetch failures.
|
||||||
|
*/
|
||||||
|
public function verifyEmbeddedPpsk(string $wlanId, string $name, string $expectedPassphrase): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$entries = $this->getPpskEntries($wlanId);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'reason' => 'fetch_failed', 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$networkconfId = $this->findNetworkconfIdByName($name);
|
||||||
|
|
||||||
|
foreach ($entries as $e) {
|
||||||
|
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
||||||
|
$entryNetId = $e['networkconf_id'] ?? null;
|
||||||
|
$entryMatches = ($networkconfId !== null && $entryNetId === $networkconfId)
|
||||||
|
|| ($entryName !== null && $entryName === $name);
|
||||||
|
if (! $entryMatches) continue;
|
||||||
|
|
||||||
|
$entryPass = $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? null;
|
||||||
|
return $entryPass === $expectedPassphrase
|
||||||
|
? ['ok' => true]
|
||||||
|
: ['ok' => false, 'reason' => 'mismatch'];
|
||||||
|
}
|
||||||
|
return ['ok' => false, 'reason' => 'not_found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a networkconf (VLAN/network) by its display name. Embedded
|
||||||
|
* PPSKs on this controller use networkconf_id as their stable
|
||||||
|
* identifier — the human "name" the operator sees is actually the
|
||||||
|
* network's name.
|
||||||
|
*/
|
||||||
|
private function findNetworkconfIdByName(string $name): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$networks = $this->getNetworkConfs();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($networks as $n) {
|
||||||
|
if (($n['name'] ?? null) === $name) {
|
||||||
|
return $n['_id'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find sibling WLAN configs — same SSID name, different _id. UniFi
|
* Find sibling WLAN configs — same SSID name, different _id. UniFi
|
||||||
* splits a "banded" SSID (band-steering disabled) into one wlanconf
|
* splits a "banded" SSID (band-steering disabled) into one wlanconf
|
||||||
@@ -548,10 +630,13 @@ class UnifiApiClient
|
|||||||
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
|
throw new \RuntimeException('WLAN has no embedded PPSKs to update.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match in this order — most reliable first:
|
// Embedded PPSKs on this controller don't carry a name field —
|
||||||
// 1. by PPSK name (if provided) — survives passphrase drift
|
// the human label ("GUEST", "3DPrinters", …) is the *network's*
|
||||||
// caused by manual edits or previous out-of-sync rotations.
|
// name, and each entry references it via networkconf_id. So when
|
||||||
// 2. by current passphrase (legacy)
|
// the caller passes a name, first resolve it to a networkconf_id
|
||||||
|
// and match on that. Falls back to entry-level name (other
|
||||||
|
// controller versions DO put a name on the entry) and finally
|
||||||
|
// to current passphrase.
|
||||||
$applyUpdate = function (array &$e) use ($newPassphrase) {
|
$applyUpdate = function (array &$e) use ($newPassphrase) {
|
||||||
if (array_key_exists('x_passphrase', $e)) $e['x_passphrase'] = $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('password', $e)) $e['password'] = $newPassphrase;
|
||||||
@@ -561,8 +646,21 @@ class UnifiApiClient
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$networkconfId = ($name !== null && $name !== '') ? $this->findNetworkconfIdByName($name) : null;
|
||||||
|
|
||||||
$matched = false;
|
$matched = false;
|
||||||
if ($name !== null && $name !== '') {
|
if ($networkconfId !== null) {
|
||||||
|
foreach ($entries as &$e) {
|
||||||
|
if (($e['networkconf_id'] ?? null) === $networkconfId) {
|
||||||
|
$applyUpdate($e);
|
||||||
|
$matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $matched && $name !== null && $name !== '') {
|
||||||
foreach ($entries as &$e) {
|
foreach ($entries as &$e) {
|
||||||
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
$entryName = $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null;
|
||||||
if ($entryName === $name) {
|
if ($entryName === $name) {
|
||||||
@@ -589,7 +687,7 @@ class UnifiApiClient
|
|||||||
if (! $matched) {
|
if (! $matched) {
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
'Embedded PPSK not found' .
|
'Embedded PPSK not found' .
|
||||||
($name !== null ? " by name \"{$name}\"" : '') .
|
($name !== null ? " for network \"{$name}\"" : '') .
|
||||||
' or by current passphrase.'
|
' or by current passphrase.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,7 +181,11 @@ class WebhookCheckService
|
|||||||
$prev = DeviceState::where('device_mac', $mac)->first();
|
$prev = DeviceState::where('device_mac', $mac)->first();
|
||||||
if (! $prev) continue;
|
if (! $prev) continue;
|
||||||
|
|
||||||
// Skip planned reboots — these are intentional, not alerts
|
// Skip planned reboots — these are intentional, not alerts.
|
||||||
|
// Two layers: a global suppression window set by RebootAllAps
|
||||||
|
// (Setting, survives any cache driver), plus the per-MAC
|
||||||
|
// cache keys for finer granularity.
|
||||||
|
if ($this->inGlobalRebootSuppression()) continue;
|
||||||
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
|
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
|
||||||
|
|
||||||
if ($comingOnline) {
|
if ($comingOnline) {
|
||||||
@@ -509,6 +513,8 @@ class WebhookCheckService
|
|||||||
private function checkReboot($aps, array $filter): array
|
private function checkReboot($aps, array $filter): array
|
||||||
{
|
{
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
|
if ($this->inGlobalRebootSuppression()) return $alerts;
|
||||||
|
|
||||||
foreach ($aps as $ap) {
|
foreach ($aps as $ap) {
|
||||||
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
||||||
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
|
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue;
|
||||||
@@ -584,6 +590,24 @@ class WebhookCheckService
|
|||||||
return self::buildPlatformPayload($url, $message, $fullPayload);
|
return self::buildPlatformPayload($url, $message, $fullPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is a fleet reboot in progress right now? RebootAllAps stamps a
|
||||||
|
* suppression-until timestamp as a Setting; while that timestamp
|
||||||
|
* is in the future, we skip all device-offline / device-online /
|
||||||
|
* unexpected-reboot alerts to avoid flooding webhooks during the
|
||||||
|
* known maintenance window.
|
||||||
|
*/
|
||||||
|
private function inGlobalRebootSuppression(): bool
|
||||||
|
{
|
||||||
|
$until = \App\Models\Setting::get('unifi.reboot_suppression_until');
|
||||||
|
if (! $until) return false;
|
||||||
|
try {
|
||||||
|
return \Carbon\Carbon::parse($until)->isFuture();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public/static helper so the test-webhook endpoint produces the
|
* Public/static helper so the test-webhook endpoint produces the
|
||||||
* same per-platform payload shape that real events do.
|
* same per-platform payload shape that real events do.
|
||||||
|
|||||||
Reference in New Issue
Block a user