|null Null = not loaded yet; an array = resolved settings */ private static $cached = null; /** * Force a reload on next get(). * * Primary use case: the addon's _output() page calls this before re-fetching * config so a test-connection click after saving settings sees the saved values. * Most other code should NOT call this — the request-scoped cache is there for * good performance reasons. */ public static function reset(): void { self::$cached = null; } /** * Return the fully-resolved configuration array with decrypted apiKey. * * Keys: enabled(bool), endpoint(string), apiKey(string), serverId(string), * defaultTtl(int), cacheTtl(int). */ public static function get(): array { if (self::$cached !== null) { return self::$cached; } $config = [ 'enabled' => false, 'endpoint' => '', 'apiKey' => '', 'serverId' => 'localhost', 'defaultTtl' => 3600, 'cacheTtl' => 60, ]; try { // pluck('value', 'setting') returns a Collection keyed by 'setting' with // 'value' as the values — so $rows['enabled'] reads the row where // setting='enabled'. Efficient: one query regardless of how many // settings exist. $rows = DB::table('tbladdonmodules') ->where('module', self::MODULE_NAME) ->pluck('value', 'setting') ->toArray(); // WHMCS yesno fields store either "on"/"" or "1"/"0" depending on version // and form handling. Accept all common truthy representations rather than // relying on a single literal. $enabledRaw = $rows['enabled'] ?? ''; $config['enabled'] = in_array(strtolower((string) $enabledRaw), ['on', 'yes', '1', 'true'], true); // Trim trailing slash from endpoint so Client::base() can safely concatenate // "/api/v1/..." without producing doubled slashes. $config['endpoint'] = rtrim((string) ($rows['endpoint'] ?? ''), '/'); $config['serverId'] = (string) ($rows['serverId'] ?? 'localhost'); // Floor at 60s for defaultTtl and 10s for cacheTtl. Prevents a foot-gun // where an operator accidentally saves "0" and causes PowerDNS to treat // PTRs as non-cacheable (which some resolvers refuse) or this module to // hammer PowerDNS on every call. $config['defaultTtl'] = max(60, (int) ($rows['defaultTtl'] ?? 3600)); $config['cacheTtl'] = max(10, (int) ($rows['cacheTtl'] ?? 60)); if (! empty($rows['apiKey'])) { $raw = (string) $rows['apiKey']; $decrypted = ''; try { // decrypt() is WHMCS's global helper — matches how the VirtFusion // bearer token is handled in Module::getCP(). $decrypted = (string) decrypt($raw); } catch (\Throwable $e) { // Even when decrypt throws, we try the raw value so a diagnostic // path exists. Operator sees the decrypt error in the module log // but isn't locked out of using the addon while they investigate. Log::insert('PowerDns:Config', 'decrypt threw', $e->getMessage()); } // WHMCS addon module password-type fields are stored PLAINTEXT in // tbladdonmodules.value (unlike tblservers.password which IS encrypted). // When fed a plaintext input, WHMCS's decrypt() doesn't return empty // or unchanged — it returns a short binary garbage string. If we used // that as the API key we'd produce a baffling 401 from PowerDNS. // // Heuristic: an API key is printable ASCII by definition. If // decrypt() produced non-printable output, we know it mangled a // plaintext value and we should stick with raw. If decrypt() // produced a different-but-printable string, it's a genuine // decryption of an actually-encrypted value (unusual for addons, // but some third-party setups do encrypt at rest). // // trim() handles another common foot-gun: admin UIs silently // appending a newline on paste, which would land in the // X-API-Key: header verbatim and also produce a 401. $candidate = $raw; if ($decrypted !== '' && $decrypted !== $raw && ctype_print($decrypted)) { $candidate = $decrypted; } elseif ($decrypted !== '' && $decrypted !== $raw) { // Decrypt output wasn't printable — it's garbage from mangling // a plaintext input. Log once so the diagnostic trail is clear // but don't expose key material. Log::insert( 'PowerDns:Config', 'decrypt produced non-printable output; using raw', ['raw_len' => strlen($raw), 'dec_len' => strlen($decrypted)], ); } $config['apiKey'] = trim($candidate); } } catch (\Throwable $e) { // Any DB-level failure (table doesn't exist, connection dropped, etc.) // leaves $config at its safe defaults — isEnabled() returns false, // nothing gets written to PowerDNS, and the server module continues // to provision as if the addon weren't installed. Log::insert('PowerDns:Config', 'load failed', $e->getMessage()); } self::$cached = $config; return $config; } /** True only when the addon is activated, configured, and has both endpoint and key. */ public static function isEnabled(): bool { $c = self::get(); return $c['enabled'] && $c['endpoint'] !== '' && $c['apiKey'] !== ''; } public static function endpoint(): string { return self::get()['endpoint']; } public static function apiKey(): string { return self::get()['apiKey']; } public static function serverId(): string { return self::get()['serverId']; } public static function defaultTtl(): int { return self::get()['defaultTtl']; } public static function cacheTtl(): int { return self::get()['cacheTtl']; } }