diff --git a/automation/cds_scanner.php b/automation/cds_scanner.php new file mode 100644 index 0000000..adc60b7 --- /dev/null +++ b/automation/cds_scanner.php @@ -0,0 +1,229 @@ +info('job started.'); + +$pool = new Swoole\Database\PDOPool( + (new Swoole\Database\PDOConfig()) + ->withDriver($c['db_type']) + ->withHost($c['db_host']) + ->withPort($c['db_port']) + ->withDbName($c['db_database']) + ->withUsername($c['db_username']) + ->withPassword($c['db_password']) + ->withCharset('utf8mb4') +); + +Co\run(function () use ($pool, $log) { + $concurrency = 20; + $chan = new Channel($concurrency); + $wg = new WaitGroup(); + $failedDomains = []; + + $pdo = $pool->get(); + $stmt = $pdo->query("SELECT id, name FROM domain"); + $domains = $stmt->fetchAll(PDO::FETCH_ASSOC); + $pool->put($pdo); + + foreach ($domains as $domain) { + $chan->push(true); + $wg->add(); + + go(function () use ($domain, $pool, $chan, $wg, $log, &$failedDomains) { + defer(function () use ($chan, $wg) { + $chan->pop(); + $wg->done(); + }); + + $pdo = $pool->get(); + + // Get NS hosts + $stmt = $pdo->prepare("SELECT host_id FROM domain_host_map WHERE domain_id = ?"); + $stmt->execute([$domain['id']]); + $host_ids = $stmt->fetchAll(PDO::FETCH_COLUMN); + + if (empty($host_ids)) { + $pool->put($pdo); + return; + } + + $host_stmt = $pdo->prepare("SELECT name FROM host WHERE id = ?"); + foreach ($host_ids as $host_id) { + $host_stmt->execute([$host_id]); + $ns = $host_stmt->fetchColumn(); + if (!$ns) continue; + + // Prefer CDS + $cds = digRecords($domain['name'], $ns, 'CDS', $failedDomains); + if (!empty($cds) && validateCDS($cds)) { + try { + $log->info("Valid CDS for {$domain['name']} via $ns: " . json_encode($cds, JSON_THROW_ON_ERROR)); + } catch (Throwable $e) { + $log->warning("CDS found for {$domain['name']} via $ns but failed to log JSON: " . $e->getMessage()); + } + foreach ($cds as $rr) { + insertSecdnsDS($pdo, $domain['id'], $rr); + } + break; + } + + // Fallback to CDNSKEY + generate DS + $cdnskey = digRecords($domain['name'], $ns, 'CDNSKEY', $failedDomains); + if (!empty($cdnskey)) { + $valid = array_filter($cdnskey, fn($k) => validateCDNSKEY($k)); + foreach ($valid as $k) { + $ds = generateDSFromDNSKEY($domain['name'], $k); + if ($ds) insertSecdnsDS($pdo, $domain['id'], $ds); + } + break; + } + } + + $pool->put($pdo); + }); + } + + $wg->wait(); + if (!empty($failedDomains)) { + $log->info('--- DIG ERRORS SUMMARY ---'); + foreach ($failedDomains as $line) { + $log->info($line); + } + } + $log->info('job finished successfully.'); +}); + +// Run dig and parse +function digRecords(string $domain, string $ns, string $type, &$failedDomains): array { + static $logged = []; + + $cmd = "dig @$ns +short $domain $type 2>&1"; + $output = shell_exec($cmd); + + if (!is_string($output)) return []; + + $output = trim($output); + if ($output === '') return []; + + $key = "$domain|$type"; + $err = null; + + if (stripos($output, "couldn't get address") !== false) { + $err = "NS unreachable"; + } elseif (preg_match('/(connection timed out|SERVFAIL|refused|not found)/i', $output)) { + $err = "DNS error"; + } + + if ($err !== null && !isset($logged[$key])) { + $logged[$key] = true; + $failedDomains[] = "$domain ($type) via $ns - $err"; + } + + if ($err !== null) return []; + + $results = []; + + foreach (explode("\n", $output) as $line) { + if (empty($line)) continue; + $parts = preg_split('/\s+/', trim($line)); + + if ($type === 'CDS' && count($parts) >= 4) { + $results[] = [ + 'keytag' => (int)($parts[0] ?? 0), + 'alg' => (int)($parts[1] ?? 0), + 'digesttype' => (int)($parts[2] ?? 0), + 'digest' => $parts[3] ?? '', + ]; + } elseif ($type === 'CDNSKEY' && count($parts) >= 4) { + $results[] = [ + 'flags' => (int)($parts[0] ?? 0), + 'protocol' => (int)($parts[1] ?? 0), + 'alg' => (int)($parts[2] ?? 0), + 'pubkey' => implode('', array_slice($parts, 3)), + ]; + } + } + + return $results; +} + +function validateCDS(array $rr): bool { + return isset($rr['alg'], $rr['digesttype'], $rr['digest']) && + $rr['alg'] >= 1 && + $rr['digesttype'] >= 1 && + strlen($rr['digest']) > 20; +} + +function validateCDNSKEY(array $rr): bool { + return isset($rr['protocol'], $rr['alg'], $rr['pubkey']) && + $rr['protocol'] === 3 && + $rr['alg'] >= 1 && + !empty($rr['pubkey']); +} + +function generateDSFromDNSKEY(string $domain, array $rr): ?array { + if (!isset($rr['flags'], $rr['protocol'], $rr['alg'], $rr['pubkey'])) { + return null; + } + + try { + $rdata = pack('nCC', $rr['flags'], $rr['protocol'], $rr['alg']) . base64_decode($rr['pubkey']); + $keytag = keyTag($rdata); + + $digest_sha256 = strtolower(hash('sha256', canonicalDNSName($domain) . $rdata)); + + return [ + 'keytag' => $keytag, + 'alg' => $rr['alg'], + 'digesttype' => 2, + 'digest' => $digest_sha256 + ]; + } catch (Throwable $e) { + return null; + } +} + +function canonicalDNSName(string $name): string { + $labels = explode('.', strtolower($name)); + $out = ''; + foreach ($labels as $label) { + $len = strlen($label); + $out .= chr($len) . $label; + } + return $out . chr(0); +} + +function keyTag(string $rdata): int { + $ac = 0; + $len = strlen($rdata); + for ($i = 0; $i < $len; $i++) { + $ac += ($i & 1) ? ord($rdata[$i]) : ord($rdata[$i]) << 8; + } + $ac += ($ac >> 16) & 0xFFFF; + return $ac & 0xFFFF; +} + +function insertSecdnsDS(Swoole\Database\PDOProxy $pdo, int $domain_id, array $r) { + $stmt = $pdo->prepare(" + INSERT IGNORE INTO secdns + (domain_id, interface, keytag, alg, digesttype, digest) + VALUES (?, 'dsData', ?, ?, ?, ?) + "); + $stmt->execute([ + $domain_id, + $r['keytag'], + $r['alg'], + $r['digesttype'], + $r['digest'], + ]); +} \ No newline at end of file diff --git a/automation/cron.php b/automation/cron.php index 714bab2..33975bb 100644 --- a/automation/cron.php +++ b/automation/cron.php @@ -17,6 +17,7 @@ // 'gtld_mode' => false, // Enable or disable gTLD mode // 'spec11' => false, // Enable or disable Spec 11 checks // 'exchange_rates' => false, // Enable or disable exchange rate download +// 'cds_scanner' => false, // Enable or disable CDS/CDNSKEY scanning and DS publishing to the zone // ]; // // Any keys omitted in cron_config.php will fall back to the defaults @@ -31,6 +32,7 @@ $defaultConfig = [ 'gtld_mode' => false, // Set to true to enable 'spec11' => false, // Set to true to enable 'exchange_rates' => false, // Set to true to enable + 'cds_scanner' => false, // Set to true to enable ]; // Load External Config if Exists @@ -90,5 +92,9 @@ if ($cronJobConfig['exchange_rates']) { $scheduler->php('/opt/registry/automation/exchange-rates.php')->at('0 1 * * *'); } +if ($cronJobConfig['cds_scanner']) { + $scheduler->php('/opt/registry/automation/cds_scanner.php')->at('0 */6 * * *'); +} + // Run Scheduled Tasks $scheduler->run(); diff --git a/docs/configuration.md b/docs/configuration.md index c548ae7..cfecf92 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -261,6 +261,7 @@ return [ 'gtld_mode' => false, // Enable or disable gTLD mode 'spec11' => false, // Enable or disable Spec 11 checks 'exchange_rates' => false, // Enable or disable exchange rate download + 'cds_scanner' => false, // Enable or disable CDS/CDNSKEY scanning and DS publishing to the zone ]; ```