1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Module\Support\Dns;
16: use Opcenter\Account\Ephemeral;
17: use Opcenter\Dns\Record;
18: use Opcenter\SiteConfiguration;
19:
20: /**
21: * Provides DNS functions to apnscp.
22: *
23: * @package core
24: */
25: class Dns_Module extends Dns
26: {
27: const DEPENDENCY_MAP = [
28: 'siteinfo'
29: ];
30: /**
31: * apex markers are marked with @
32: */
33: protected const HAS_ORIGIN_MARKER = false;
34: /**
35: * Maximum contiguous name length is 255 per RFC 1035 2.3.4
36: */
37: protected const HAS_CONTIGUOUS_LIMIT = false;
38:
39: /** primary nameserver */
40: const MASTER_NAMESERVER = DNS_INTERNAL_MASTER;
41: /**
42: * @var array 1 or more authoritative nameservers
43: */
44: const AUTHORITATIVE_NAMESERVERS = DNS_AUTHORITATIVE_NS;
45: /**
46: * @var array 1 or more recursive nameservers
47: */
48: const RECURSIVE_NAMESERVERS = DNS_RECURSIVE_NS;
49: const UUID_RECORD = DNS_UUID_NAME;
50:
51: // default DNS TTL for records modified via update()
52: const DYNDNS_TTL = 300;
53:
54: // @var int minimum module TTL
55: const DNS_TTL_MIN = 5;
56:
57: // @var int default DNS TTL
58: const DNS_TTL = DNS_DEFAULT_TTL;
59:
60: // standard hosts file location
61: const HOSTS_FILE = '/etc/hosts';
62:
63: // dig command for short lookup
64: const DIG_SHLOOKUP = ['dig', '+norec', '+time=3', '+tcp', '+short', '@%(nameserver)s', '%(hostname)s', '%(rr)s'];
65:
66: // NS apex is modifiable and thus shall be displayed
67: public const SHOW_NS_APEX = true;
68:
69: /** @var array quick lookup */
70: protected $zoneExistsCache = [];
71: /**
72: * @var string TSIG key
73: * @ignore
74: */
75: protected static $dns_key = DNS_TSIG_KEY;
76: /** mapping of RR types to constants */
77: protected static $rec_2_const = array(
78: 'ANY' => DNS_ANY,
79: 'A' => DNS_A,
80: 'AAAA' => DNS_AAAA,
81: 'MX' => DNS_MX,
82: 'NS' => DNS_NS,
83: 'SOA' => DNS_SOA,
84: 'TXT' => DNS_TXT,
85: 'CNAME' => DNS_CNAME,
86: 'SRV' => DNS_SRV,
87: 'PTR' => DNS_PTR,
88: 'HINFO' => DNS_HINFO,
89: 'A6' => DNS_A6,
90: 'NAPTR' => DNS_NAPTR,
91: 'CAA' => DNS_CAA,
92: 'TLSA' => 0,
93: 'DS' => 0,
94: 'RP' => 0,
95: 'SSHFP' => 0,
96: 'SPF' => 0,
97: 'URI' => 0,
98: 'CERT' => 0
99:
100: );
101: /** array of 1 or more nameservers used */
102: protected static $nameservers;
103:
104: /**
105: * Legal DNS resource records permitted by provider
106: * A
107: * AAAA
108: * MX
109: * CNAME
110: * DNAME
111: * HINFO
112: * TXT
113: * NS
114: * SRV
115: *
116: * @var array
117: */
118: protected static $permitted_records = array(
119: 'A',
120: 'AAAA',
121: 'A6',
122: 'CAA',
123: 'CNAME',
124: 'DNAME',
125: 'HINFO',
126: 'MX',
127: 'NAPTR',
128: 'NS',
129: 'SOA',
130: 'SRV',
131: 'TXT',
132: );
133:
134: protected $exportedFunctions = [
135: '*' => PRIVILEGE_SITE,
136: 'get_public_ip' => PRIVILEGE_SITE | PRIVILEGE_USER,
137: 'get_public_ip6' => PRIVILEGE_SITE | PRIVILEGE_USER,
138: 'enabled' => PRIVILEGE_SITE | PRIVILEGE_USER,
139: 'configured' => PRIVILEGE_ALL,
140: 'get_whois_record' => PRIVILEGE_ALL,
141: 'get_records_by_rr' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
142: 'get_records' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
143: 'record_exists' => PRIVILEGE_ALL,
144: 'modify_record' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
145: 'remove_record' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
146: 'add_record' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
147: 'empty_zone' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
148: 'get_zone_information' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
149: 'get_all_domains' => PRIVILEGE_ADMIN,
150: 'zones' => PRIVILEGE_ADMIN | PRIVILEGE_SITE,
151: 'get_parent_domain' => PRIVILEGE_ADMIN,
152: 'get_server_from_domain' => PRIVILEGE_ADMIN,
153: 'release_ip' => PRIVILEGE_ADMIN,
154: 'ip_allocated' => PRIVILEGE_ADMIN,
155: 'gethostbyaddr_t' => PRIVILEGE_ALL,
156: 'gethostbyname_t' => PRIVILEGE_ALL,
157: 'get_provider' => PRIVILEGE_ALL,
158: 'uuid' => PRIVILEGE_ALL,
159: 'get_default' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
160: 'provisioning_records' => PRIVILEGE_SITE,
161: 'remove_zone' => PRIVILEGE_ADMIN,
162: 'remove_zone_backend' => PRIVILEGE_ADMIN | PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
163: 'add_zone' => PRIVILEGE_ADMIN,
164: 'add_zone_backend' => PRIVILEGE_ADMIN | PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
165: 'providers' => PRIVILEGE_ADMIN,
166: 'zone_exists' => PRIVILEGE_ADMIN | PRIVILEGE_SITE,
167: 'validate_template' => PRIVILEGE_ADMIN,
168: 'import_from_domain' => PRIVILEGE_ADMIN | PRIVILEGE_SITE,
169: 'export' => PRIVILEGE_ADMIN | PRIVILEGE_SITE,
170: 'import' => PRIVILEGE_ADMIN | PRIVILEGE_SITE,
171: 'auth_test' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
172: 'raw' => PRIVILEGE_ADMIN
173: ];
174:
175: /**
176: * @var string zone ownership check
177: */
178: private array $ownershipCache = [];
179:
180: /**
181: * Provider loader
182: *
183: * @return Module_Skeleton
184: */
185: public function _proxy(): \Module_Skeleton
186: {
187: $provider = $this->get_provider();
188:
189: if ($provider === \Opcenter\Service\Contracts\DefaultNullable::NULLABLE_MARKER) {
190: // BUG. An account's provider is substituted with the provider default at creation
191: // Check for an in-place upgrade. If plan wasn't substituted because no prior def exists
192: // the marker is used.
193: $provider = \Opcenter\Dns::default();
194: }
195:
196: if ($provider === 'builtin') {
197: return $this;
198: }
199:
200: return \Module\Provider::get('dns', $provider, $this->getAuthContext());
201: }
202:
203: /**
204: * Get DNS provider
205: *
206: * @return string
207: */
208: public function get_provider(): string
209: {
210: if ($this->permission_level & (PRIVILEGE_SITE|PRIVILEGE_USER)) {
211: if (!$this->enabled()) {
212: return 'null';
213: }
214: return $this->getServiceValue('dns', 'provider', DNS_PROVIDER_DEFAULT);
215: }
216: return \Opcenter\Dns::default();
217: }
218:
219: /**
220: * Get known mail providers
221: *
222: * @return array
223: */
224: public function providers(): array
225: {
226: return \Opcenter\Dns::providers();
227: }
228:
229: /**
230: * Get DNS UUID record name
231: *
232: * @return string
233: */
234: public function uuid_name(): string
235: {
236: return static::UUID_RECORD;
237: }
238:
239: /**
240: * Query database for domain expiration
241: *
242: * On multi-server lookups that perform DNS lookups independent,
243: * perform batch lookups and pull those records from the database
244: *
245: *
246: * A return of 0 indicates failure
247: * null indicates unknown expiration
248: *
249: * @param string $domain domain owned by the account
250: * @return null|int expiration as unix timestamp
251: */
252: public function domain_expiration(string $domain): ?int
253: {
254: return null;
255: }
256:
257: /**
258: * Fetches all domains
259: *
260: * Context-aware, returns zones for account or all DNS domains
261: *
262: * @return array
263: */
264: public function get_all_domains(): array
265: {
266: if (!Auth_Lookup::extendedAvailable()) {
267: return array_keys(\Opcenter\Map::load(\Opcenter\Map::DOMAIN_MAP)->fetchAll());
268: }
269:
270: return \Auth_Lookup::all();
271: }
272:
273: /**
274: * Fetch all hosted zones
275: *
276: * @return array
277: */
278: public function zones(): array
279: {
280: if ($this->permission_level & PRIVILEGE_ADMIN) {
281: return $this->get_all_domains();
282: }
283:
284: $domains = [
285: -1 => $this->getServiceValue('siteinfo', 'domain')
286: ] + $this->aliases_list_aliases();
287:
288: return array_values(array_filter($domains, function ($domain) {
289: return !$this->parented($domain);
290: }));
291: }
292:
293: /**
294: * Get server on which a domain is hosted
295: *
296: * @param string $domain
297: * @param bool $all show all server matches, $all = true: array of all servers, else server
298: * @param bool $extended when $all, show all metadata
299: * @return string|array
300: */
301: public function get_server_from_domain(string $domain, bool $all = false, bool $extended = false)
302: {
303: if (!Auth_Lookup::extendedAvailable()) {
304: return SERVER_NAME_SHORT;
305: }
306:
307: $servers = Auth_Lookup::serverLocator($domain, $all);
308:
309: if (!$all && count($servers) > 1) {
310: warn("domain `%s' present on `%d' servers",
311: $domain, count($servers));
312: }
313:
314: if ($all) {
315: return $extended ? $servers : array_column($servers, 'server');
316: }
317:
318: return array_shift($servers);
319:
320: }
321:
322: /**
323: * Get primary domain affiliated with account
324: *
325: * In multi-server setups query the master DB to find
326: * domains whose invoice matches the parent invoice or domains
327: * that share the same site ID
328: *
329: * @param string $domain
330: * @return bool|string primary domain or false on error
331: */
332: public function get_parent_domain(string $domain)
333: {
334: if (false !== ($id = \Opcenter\Map::load(\Opcenter\Map::DOMAIN_MAP)->fetch($domain))) {
335: return \Auth::get_domain_from_site_id((int)substr($id, 4));
336: }
337:
338: if (!Auth_Lookup::extendedAvailable()) {
339: return false;
340: }
341:
342: return \Auth_Lookup::parent($domain) ?? false;
343: }
344:
345: /**
346: * Query WHOIS server for record
347: *
348: * @param string $domain domain name to look up the whois record for
349: * @return string|bool whois data
350: *
351: */
352: public function get_whois_record(string $domain)
353: {
354: Error_Reporter::suppress_php_error('require_once');
355: if (!preg_match(Regex::DOMAIN, $domain)) {
356: return error('%s: invalid domain', $domain);
357: }
358: if (!class_exists('Net_Whois', false) &&
359: !include 'Net/Whois.php'
360: ) {
361: return error('Unable to include Whois module');
362: }
363:
364: $whois = new Net_Whois();
365: $data = $whois->query($domain);
366: if (PEAR::isError($data)) {
367: return error("Failed to lookup whois data: `%s'", $data->message);
368: }
369:
370: return $data;
371: }
372:
373: /**
374: * DNS zone exists
375: *
376: * @param string $zone
377: * @return bool
378: */
379: public function zone_exists(string $zone): bool
380: {
381: if (!$this->configured()) {
382: return false;
383: }
384: if (!$this->owned_zone($zone)) {
385: return false;
386: }
387: if (!isset($this->zoneExistsCache[$zone])) {
388: $resp = ($this->zoneAxfr($zone));
389: $this->zoneExistsCache[$zone] = (null !== $resp);
390: }
391: return $this->zoneExistsCache[$zone];
392: }
393:
394: /**
395: * DNS handler is configured for account
396: *
397: * Weaker form of enabled()
398: *
399: * @return bool
400: */
401: public function configured(): bool
402: {
403: return static::class !== self::class;
404: }
405:
406: /**
407: * DNS is enabled for account
408: *
409: * Stricter version of configured().
410: * Requires module to faithfully implement DNS functions.
411: *
412: * @return bool
413: */
414: public function enabled(): bool
415: {
416: return (bool)$this->getServiceValue('dns', 'enabled');
417: }
418:
419: /**
420: * Requested domain is manageable by the account
421: *
422: * @param string $zone zone name
423: * @return bool
424: */
425: protected function owned_zone(string $zone): bool
426: {
427: if ($this->getAuthContext()->level & PRIVILEGE_ADMIN) {
428: return true;
429: }
430:
431: if (isset($this->ownershipCache[$zone])) {
432: return $this->ownershipCache[$zone];
433: }
434:
435: if ($zone === $this->domain || $this->parented($zone) ||
436: in_array($zone, $this->aliases_list_aliases(), true) ||
437: $zone === array_get((array)$this->getOldServices('siteinfo'), 'domain', null))
438: {
439: return $this->ownershipCache[$zone] = true;
440: }
441:
442: $res = \in_array($zone, array_get((array)$this->getOldServices('aliases'), 'aliases', []), true);
443:
444: return $res ? ($this->ownershipCache[$zone] = true) : false;
445: }
446:
447: /**
448: * Perform a full zone transfer
449: *
450: * @param string $domain
451: * @return null|string
452: */
453: protected function zoneAxfr(string $domain): ?string
454: {
455: if (!static::MASTER_NAMESERVER) {
456: error("Cannot fetch zone information for `%s': no master nameserver configured in config.ini",
457: $domain);
458:
459: return null;
460: }
461: $data = Util_Process::exec(['dig', '-t', 'AXFR', '-y', '%s', '@%s', '%s'],
462: $this->getTsigKey($domain), static::MASTER_NAMESERVER, $domain, [-1, 0]);
463:
464: // AXFR can fail yet return a success RC
465: if (false !== strpos($data['output'], '; Transfer failed.')) {
466: return null;
467: }
468:
469: return $data['success'] ? $data['output'] : null;
470: }
471:
472: /**
473: * Get AXFR key for domain
474: *
475: * @param string $domain
476: * @return null|string
477: */
478: private function getTsigKey(string $domain): ?string
479: {
480: return static::$dns_key;
481: }
482:
483: /**
484: * Export zone configuration in BIND-friendly notation
485: *
486: * @param string|null $zone
487: * @return bool|string
488: */
489: public function export(string $zone = null)
490: {
491: if (null === $zone) {
492: $zone = $this->domain;
493: }
494:
495: if (!$this->permission_level & PRIVILEGE_ADMIN && !$this->owned_zone($zone)) {
496: return error("access denied - cannot view zone `%s'", $zone);
497: }
498: $recs = $this->get_zone_data($zone);
499: if (null === $recs) {
500: return error("failed to export zone `%s'", $zone);
501: }
502: if (!$recs) {
503: return '';
504: }
505: $soa = $recs['SOA'][0];
506: $soadata = preg_split('/\s+/', $soa['parameter']);
507: $format = ';; ' . "\n" .
508: ";; Domain:\t" . $zone . "\n" .
509: ";; Exported:\t" . date('r') . "\n" .
510: ';; ' . "\n" .
511: '$ORIGIN . ' . "\n" .
512: "@\t" . $soa['ttl'] . "\tIN\tSOA\t$zone.\t" . $soadata[1] . ' ( ' . "\n" .
513: "\t" . $soadata[2] . "\t; serial" . "\n" .
514: "\t" . $soadata[3] . "\t; refresh" . "\n" .
515: "\t" . $soadata[4] . "\t; retry" . "\n" .
516: "\t" . $soadata[5] . "\t; expire" . "\n" .
517: "\t" . $soadata[6] . ")\t; minimum" . "\n\n";
518: $buffer = array();
519:
520: if (empty($recs['NS'])) {
521: $recs['NS'] = array_build($this->get_hosting_nameservers($zone), function ($idx, $ns) {
522: return [$idx, [
523: 'name' => '@',
524: 'ttl' => $this->get_default('ttl'),
525: 'parameter' => $ns
526: ]];
527: });
528: }
529:
530: $buffer[] = ';; NS Records (YOU MUST CHANGE THIS)';
531: foreach ($recs['NS'] as $ns) {
532: $ns['parameter'] = 'YOU_MUST_CHANGE_THIS_VALUE';
533: $buffer[] = $ns['name'] . "\t" . $ns['ttl'] .
534: "\t" . "IN NS\t" . $ns['parameter'];
535: }
536: $buffer[] = '';
537: $ignore = array('SOA', 'NS', 'TSIG');
538: foreach (static::$permitted_records as $rr) {
539: $rr = strtoupper($rr);
540: if (!isset($recs[$rr]) || in_array($rr, $ignore, true)) {
541: continue;
542: }
543: $buffer[] = ';; ' . $rr . ' Records';
544: foreach ($recs[$rr] as $r) {
545: $buffer[] = $r['name'] . "\t" . $r['ttl'] .
546: "\t" . 'IN ' . $rr . "\t" . $r['parameter'];
547: }
548: $buffer[] = "\n";
549: }
550: $format .= implode("\n", $buffer);
551:
552: return $format;
553: }
554:
555: /**
556: * Get all zone records parsed
557: *
558: * @param string $domain
559: * @return array|null zone data or null if zone not present
560: */
561: protected function get_zone_data(string $domain): ?array
562: {
563: if (!$this->owned_zone($domain)) {
564: return nerror("Domain `%s' not owned by account", $domain);
565: }
566:
567: if ($this->parented($domain)) {
568: return nerror("Domain `%s' is parented", $domain);
569: }
570:
571: if (null === ($data = $this->zoneAxfr($domain))) {
572: return nerror('Non-authoritative for zone %s', $domain);
573: }
574:
575: $zoneData = array();
576: $offset = strlen($domain) + 1; // domain.com.
577: $regexp = \Regex::compile(\Regex::DNS_AXFR_REC,
578: ['rr' => implode('|', static::$permitted_records + [99999 => 'SOA'])]);
579:
580: foreach (explode("\n", $data) as $line) {
581: if (false !== strpos($line, 'Transfer failed.')) {
582: return null;
583: }
584: if (!preg_match($regexp, $line, $match)) {
585: continue;
586: }
587: [$name, $ttl, $class, $rr, $parameter] = array_slice($match, 1);
588: $rr = strtoupper($rr);
589: // TXT records should always be balanced with quotes
590: // assume this to be the case if " present
591: // don't pretty-print if more than 1 quote pair present
592: if ($rr == 'TXT' && $parameter[0] == '"') {
593: $end = strlen($parameter) - 1;
594: if (strpos($parameter, '"', 1) === $end && strpos($parameter, '"', 1) === $end) {
595: // parameter formatted as "foobar"
596: // preserve TXT records formatted as '"foo" "bar" "baz"'
597: $parameter = substr($parameter, 1, -1);
598: }
599: }
600: $zoneData[$rr][] = array(
601: 'name' => $name,
602: 'subdomain' => rtrim(substr($name, 0, strlen($name) - $offset), '.'),
603: 'domain' => $domain,
604: 'class' => $class,
605: 'ttl' => (int)$ttl,
606: 'parameter' => $parameter,
607: 'rr' => $rr
608: );
609: }
610:
611: return $zoneData;
612: }
613:
614: /**
615: * Get permitted records for DNS provider
616: *
617: * @return array
618: */
619: public function permitted_records(): array
620: {
621: return static::$permitted_records;
622: }
623:
624: /**
625: * Get DNS record(s) from a third-party nameserver
626: *
627: * When using same nameservers as hosting ns, ensure ns uses split-view
628: *
629: * @param string $subdomain optional subdomain
630: * @param string $rr optional RR type
631: * @param string $domain optional domain
632: * @param array $nameservers optional nameserver to query
633: * @return array|bool false on error
634: */
635: public function get_records_external(
636: string $subdomain = '',
637: string $rr = 'any',
638: string $domain = null,
639: array $nameservers = null
640: ) {
641: if (!$domain) {
642: $domain = $this->domain;
643: }
644: $host = $domain;
645: if ($subdomain) {
646: $host = $subdomain . '.' . $host;
647: }
648: $rr = strtoupper($rr);
649: if (null === self::record2const($rr)) {
650: return error("unknown rr record type `%s'", $rr);
651: }
652: if (!$nameservers) {
653: $nameservers = static::RECURSIVE_NAMESERVERS;
654: }
655: $resolvers = [];
656: foreach ($nameservers as $ns) {
657: if (strspn($ns, '1234567890.') === \strlen($ns)) {
658: $resolvers[] = $ns;
659: } else if ($ip = Net_Gethost::gethostbyname_t($ns)) {
660: $resolvers[] = $ip;
661: }
662: }
663:
664: if (!$resolvers) {
665: return error("No resolvers could be found");
666: }
667:
668: $resolver = new Net_DNS2_Resolver([
669: 'nameservers' => $resolvers,
670: 'ns_random' => true
671: ]);
672:
673: for ($i = 0; $i < 5; $i++) {
674: // @todo remove dependency on dig
675: $recraw = Error_Reporter::silence(static function () use ($host, $rr, $resolver) {
676: try {
677: return $resolver->query($host, $rr);
678: } catch (Net_DNS2_Exception $e) {
679: return false;
680: }
681: });
682: if (!empty($recraw->answer)) {
683: break;
684: }
685: usleep(50000);
686: }
687: if (empty($recraw->answer)) {
688: $host = ltrim(implode('.', array($subdomain, $domain)), '.');
689: warn("failed to get external raw records for `%s' on `%s'", $rr, $host);
690:
691: return [];
692: }
693:
694: $records = array();
695: foreach ($recraw->answer as $r) {
696: $target = null;
697:
698: // most records
699: if (isset($r->target)) {
700: $target = $r->target;
701: }
702: // A
703: if (isset($r->ip)) {
704: $target = $r->ip;
705: } else if (isset($r->address)) {
706: $target = $r->address;
707: }
708: // SRV
709: if (isset($r->weight)) {
710: // ignore PRI that comes before WEIGHT
711: // it is handled next
712: $target = $r->weight . ' ' . $target .
713: $r->port;
714: }
715: // MX, SRV
716: if (isset($r->pri)) {
717: $target = $r->pri . ' ' . $target;
718: }
719: // TXT
720: if (isset($r->txt)) {
721: $target = $r->txt;
722: }
723: // HINFO
724: if (isset($r->cpu)) {
725: $target = $r->cpu . ' ' . $r->os;
726: }
727: // SOA
728: if (isset($r->mname)) {
729: $target = $r->mname . ' ' . $r->rname . ' ' .
730: $r->serial . ' ' . $r->refresh . ' ' .
731: $r->retry . ' ' . $r->expire . ' ' .
732: $r->minimum;
733: }
734: // AAAA, A6
735: if (isset($r->ipv6)) {
736: $target = $r->ipv6;
737: }
738: // A6
739: if (isset($r->masklen)) {
740: $target = $r->masklen . ' ' . $target . ' ' .
741: $r->chain;
742: }
743: // NAPTR
744: if (isset($r->order)) {
745: $target = $r->order . ' ' . $r->pref . ' ' .
746: $r->flags . ' ' . $r->services . ' ' .
747: $r->regex . ' ' . $r->replacement;
748: }
749: $records[] = array(
750: 'name' => $host,
751: 'subdomain' => $subdomain,
752: 'domain' => $domain,
753: 'class' => 'IN',
754: 'type' => $rr,
755: 'ttl' => $r->ttl,
756: 'parameter' => $target
757: );
758: }
759:
760: return $records;
761: }
762:
763: /**
764: * Translate RR into PHP constant
765: *
766: * NB Used by DNS Manager
767: *
768: * @param string $rr
769: * @return int
770: */
771: public static function record2const(string $rr): ?int
772: {
773: $rr = strtoupper($rr);
774:
775: return static::$rec_2_const[$rr] ?? null;
776: }
777:
778: /**
779: * Returns the host name of the Internet host specified by $ip with timeout
780: *
781: * @param string $ip
782: * @param int $timeout
783: * @return string|null|false false on error, null on missing record
784: */
785: public function gethostbyaddr_t(string $ip, int $timeout = DNS_LOOKUP_TIMEOUT)
786: {
787: return Net_Gethost::gethostbyaddr_t($ip, $timeout);
788: }
789:
790: /**
791: * Returns the IP of the Internet host specified by $host with timeout
792: *
793: * @param string $name
794: * @param int $timeout
795: * @return bool|null|string
796: */
797: public function gethostbyname_t(string $name, int $timeout = DNS_LOOKUP_TIMEOUT)
798: {
799: return Net_Gethost::gethostbyname_t($name, $timeout);
800: }
801:
802: /**
803: * Check whether a domain is hosted on any server
804: *
805: * In multi-server setups, ensure all servers are named
806: * SERVER_NAME_SHORT (first component of "hostname" command)
807: *
808: * @param string $domain
809: * @param bool $ignore_on_account domains hosted on account ignored
810: * @return bool
811: */
812: public function domain_hosted(string $domain, bool $ignore_on_account = false): bool
813: {
814: $domain = strtolower($domain);
815: if (str_starts_with($domain, "www.")) {
816: $domain = substr($domain, 4);
817: }
818:
819: $site_id = \Auth::get_site_id_from_domain($domain);
820: // check if on same server
821: if ($ignore_on_account && $site_id) {
822: return $site_id !== $this->site_id;
823: }
824:
825: // domain exists on server
826: if ($ignore_on_account && $this->domain_on_account($domain)) {
827: return false;
828: }
829:
830: $server = $this->get_server_from_domain($domain);
831: return $server && $server !== SERVER_NAME_SHORT;
832: }
833:
834: /**
835: * Domain exists and is under the account on a multi-server instance
836: *
837: * Useful when doing cross-server transfers to ensure the domain to add
838: * is part of the account, which will allow the domain to be added via
839: * the aliases_add_domain @{see Aliases_Module::add_domain}
840: *
841: * Implementation details are available on github.com/apisnetworks/apnscp-modules
842: *
843: * @param string $domain
844: * @return bool
845: */
846: public function domain_on_account(string $domain): bool
847: {
848: if (!Auth_Lookup::extendedAvailable()) {
849: return false;
850: }
851:
852: if (str_starts_with($domain, 'www.')) {
853: $domain = substr($domain, 4);
854: }
855:
856: $servers = \Auth_Lookup::serverLocator($domain);
857:
858: // check if elsewhere
859: foreach ($servers as $s) {
860: if ($s['invoice'] !== $this->billing_get_invoice())
861: {
862: return false;
863: }
864: }
865:
866: return (bool)$servers;
867: }
868:
869: /**
870: * Get recently expiring domains
871: *
872: * Sample response:
873: * Array(
874: * [0] => Array(
875: * 'domain' => 'apnscp.com',
876: * 'ts' => 1469937612
877: * )
878: * )
879: *
880: * @param int $days lookahead n days
881: * @param bool $showExpired show domains expired within the last 10 days
882: *
883: * @return array
884: */
885: public function get_pending_expirations(int $days = 30, bool $showExpired = true): array
886: {
887: return [];
888: }
889:
890: /**
891: * Check zone data with bind
892: *
893: * @param string $zone
894: * @param Record[] $recs
895: * @return bool
896: */
897: public function check_zone(string $zone, array $recs = []): bool
898: {
899: if (!file_exists('/usr/sbin/named-checkzone')) {
900: return warn('bind package is not installed - cannot check zone');
901: }
902: $tmpfile = tempnam('/tmp', 'f');
903: if (null === ($axfr = $this->zoneAxfr($zone))) {
904: return error("Failed to transfer zone `%s' - cannot check", $zone);
905: }
906:
907: if ($recs) {
908: $axfr .= "\n" . implode("\n", array_filter(array_map(static function ($r) {
909: if (!$r instanceof \Opcenter\Dns\Record) {
910: return $r[0] . ' ' . $r[1] . ' ' . $r[2] . ' ' . $r[3];
911: }
912: return (string)$r;
913: }, $recs)));
914:
915: }
916: file_put_contents($tmpfile, $axfr);
917: $status = Util_Process_Safe::exec('/usr/sbin/named-checkzone %s %s', [$zone, $tmpfile]);
918: unlink($tmpfile);
919:
920: return $status['success'];
921: }
922:
923: /**
924: * Update hostname with caller's IP4 address
925: *
926: * @param string $hostname fqdn
927: * @param string $ip optional ip address to skip detection
928: * @return string|bool ip address or false on failure
929: */
930: public function update(string $hostname, string $ip = null)
931: {
932: $chunk = $this->web_split_host($hostname);
933: $domain = $chunk['domain'];
934: $subdomain = $chunk['subdomain'];
935: if (!$this->owned_zone($domain)) {
936: return error("restricted zone `%s' specified", $domain);
937: }
938: if (!$ip) {
939: $ip = Auth::client_ip();
940: }
941: if (false === ip2long($ip)) {
942: return error('cannot detect ip!');
943: }
944: $record = $this->get_records($subdomain, 'A', $domain);
945: if (count($record) > 1) {
946: warn("%d records found for `%s'", count($record), $hostname);
947: }
948: if (!$record) {
949: // no record set
950: warn("no DNS record exists, setting new record for `%s'", $hostname);
951: $add = $this->add_record($domain, $subdomain, 'A', $ip, static::DYNDNS_TTL);
952: if (!$add) {
953: return $add;
954: }
955:
956: return $ip;
957: }
958:
959: $newparams = array('ttl' => static::DYNDNS_TTL, 'parameter' => $ip);
960: $ret = true;
961: foreach ($record as $r) {
962: if (!$this->modify_record($domain, $subdomain, $r['rr'], $r['parameter'], $newparams)) {
963: $ret = false;
964: error("record modification failed for `%s'", $hostname);
965: }
966: }
967:
968: return $ret ? $ip : $ret;
969: }
970:
971: /**
972: * Get DNS record(s)
973: *
974: * @param string $subdomain optional subdomain
975: * @param string $rr optional RR type
976: * @param string $domain optional domain
977: * @return array|false
978: */
979: public function get_records(?string $subdomain = '', string $rr = 'any', string $domain = null)
980: {
981: if (!$domain) {
982: $domain = $this->domain;
983: }
984: if (!$this->owned_zone($domain)) {
985: return error('cannot view DNS information for unaffiliated domain `' . $domain . "'");
986: }
987: if (null === $subdomain) {
988: $records = $this->get_zone_information($domain);
989: $rr = strtoupper($rr);
990: if ($rr === 'ANY' || !$records) {
991: return is_array($records) ? $records : false;
992: }
993:
994: return $records[$rr] ?? [];
995: }
996: if (false === ($recs = $this->get_records_raw($subdomain, $rr, $domain))) {
997: // provisioning/internal error
998: return $recs;
999: }
1000:
1001: return (array)$recs;
1002: }
1003:
1004: /**
1005: * get_records() unauthenticated DNS wrapper
1006: *
1007: * @param string $subdomain optional subdomain
1008: * @param string $rr optional RR type
1009: * @param string $domain optional domain
1010: * @return array|bool records or false on failure
1011: */
1012: protected function get_records_raw(string $subdomain = '', string $rr = 'ANY', string $domain = null)
1013: {
1014: if ($subdomain == '@') {
1015: $subdomain = '';
1016: warn("record `@' alias for domain - record stripped");
1017: }
1018: $rr = strtoupper($rr);
1019: if ($rr !== 'ANY' && !in_array($rr, static::$permitted_records, true)) {
1020: return error('%s: invalid resource record type', $rr);
1021: }
1022: $rr = strtoupper($rr);
1023: $recs = $this->get_zone_data($domain);
1024: // zone error, Transfer failed, i.e. zone not provisioned
1025: if (null === $recs) {
1026: return false;
1027: }
1028: $domain .= '.';
1029: if ($subdomain !== '') {
1030: $domain = $subdomain . '.' . $domain;
1031: }
1032:
1033: $newrecs = [];
1034: $keys = [$rr];
1035: if ($rr === 'ANY') {
1036: $keys = array_keys($recs);
1037: } else if (!isset($recs[$rr])) {
1038: return $newrecs;
1039: }
1040: foreach ($keys as $tmp) {
1041: foreach ($recs[$tmp] as $rec) {
1042: $rec['rr'] = $tmp;
1043: if ($rec['name'] === $domain) {
1044: $newrecs[] = $rec;
1045: }
1046: }
1047: }
1048:
1049: return $newrecs;
1050: }
1051:
1052: /**
1053: * Add a DNS record to a domain
1054: *
1055: * @param string $zone zone name (normally domain name)
1056: * @param string $subdomain name of the record to add
1057: * @param string $rr resource record type [MX, A, AAAA, CNAME, NS, TXT, DNAME]
1058: * @param string $param parameter value
1059: * @param int $ttl TTL value, default value 86400
1060: *
1061: * @return bool
1062: */
1063: public function add_record(
1064: string $zone,
1065: string $subdomain,
1066: string $rr,
1067: string $param,
1068: int $ttl = self::DNS_TTL
1069: ): bool {
1070: if (!$this->owned_zone($zone)) {
1071: return error('%s not owned by account', $zone);
1072: }
1073: if (!$this->canonicalizeRecord($zone, $subdomain, $rr, $param, $ttl)) {
1074: return false;
1075: }
1076:
1077: /**
1078: * Implement your own!
1079: */
1080: return true;
1081: }
1082:
1083: /**
1084: * Add a DNS record to a domain if no other record exists
1085: *
1086: * Purposed for Lararia\Jobs\SimpleCommandJob
1087: *
1088: * @param string $zone zone name (normally domain name)
1089: * @param string $subdomain name of the record to add
1090: * @param string $rr resource record type [MX, A, AAAA, CNAME, NS, TXT, DNAME]
1091: * @param string $param parameter value
1092: * @param int $ttl TTL value, default value 86400
1093: *
1094: * @return bool
1095: */
1096: public function add_record_conditionally(
1097: string $zone,
1098: string $subdomain,
1099: string $rr,
1100: string $param,
1101: int $ttl = self::DNS_TTL
1102: ): bool
1103: {
1104: if (!$this->owned_zone($zone)) {
1105: return error('%s not owned by account', $zone);
1106: }
1107: if (!$this->configured()) {
1108: return info('DNS not configured for account - cannot add record');
1109: }
1110: if (!$this->zone_exists($zone)) {
1111: return warn("DNS zone `%s' does not exist, skipping", $zone);
1112: }
1113: if (!$this->canonicalizeRecord($zone, $subdomain, $rr, $param, $ttl)) {
1114: return false;
1115: }
1116: if ($this->record_exists($zone, $subdomain, $rr)) {
1117: return info('Record %s%s already exists - not overwriting', ltrim($subdomain . '.', '.'), $zone);
1118: }
1119: if ($rr === 'CNAME' && $this->record_exists($zone, $subdomain, 'ANY')) {
1120: return info('Record %s%s already has existing record - not adding CNAME', ltrim($subdomain . '.', '.'), $zone);
1121: }
1122: if (($rr === 'A' || $rr === 'AAAA') && $this->record_exists($zone, $subdomain, 'CNAME')) {
1123: return info('Record %(subdomain)s%(domain)s already exists as CNAME - not adding %(rr)s',
1124: [
1125: 'subdomain' => ltrim($subdomain . '.', '.'),
1126: 'domain' => $zone,
1127: 'rr' => $rr
1128: ]
1129: );
1130: }
1131: if (!$this->add_record($zone, $subdomain, $rr, $param)) {
1132: return error('%s%s: DNS master returned bad value', ltrim($subdomain . '.', '.'), $zone);
1133: }
1134: return true;
1135: }
1136:
1137: /**
1138: * Fixup a DNS record before submitting
1139: *
1140: * @param string $zone
1141: * @param string $subdomain
1142: * @param string $rr
1143: * @param string $param
1144: * @param int $ttl
1145: * @return bool
1146: */
1147: protected function canonicalizeRecord(
1148: string &$zone,
1149: string &$subdomain,
1150: string &$rr,
1151: string &$param,
1152: int &$ttl = null
1153: ): bool {
1154:
1155: $rr = strtoupper($rr);
1156: $subdomain = rtrim($subdomain, '.');
1157: if ($rr == 'CNAME') {
1158: if (!$subdomain && $this->hasCnameApexRestriction()) {
1159: return error('CNAME record cannot coexist with zone root, see RFC 1034 section 3.6.2');
1160: }
1161: if (0 === strncmp($subdomain, 'http:', 5) || 0 === strncmp($subdomain, 'https:', 6)) {
1162: return error('CNAME must be a hostname. A protocol was specified (http://, https://)');
1163: }
1164: }
1165:
1166: if ($rr !== 'ANY' && !\in_array($rr, static::$permitted_records, true)) {
1167: return error('%s: invalid resource record type', $rr);
1168: }
1169:
1170: if (false !== strpos($subdomain, ' ')) {
1171: return error("DNS record `%s' must not contain any spaces", $subdomain);
1172: }
1173: if (!static::HAS_ORIGIN_MARKER && substr($subdomain, -\strlen($zone)) === $zone) {
1174: $subdomain = substr($subdomain, 0, -\strlen($zone));
1175: }
1176:
1177: if (!static::HAS_ORIGIN_MARKER && $subdomain === '@') {
1178: $subdomain = '';
1179: warn("record `@' alias for domain - record stripped");
1180: }
1181:
1182: // many DNS services do not allow hosting a child as a standalone, work up the
1183: // chain to see if user added a subdomain as an addon domain (for "miscellaneous reasons")
1184: if (null !== ($parent = $this->getParent($zone))) {
1185: $subdomain = ltrim($subdomain . '.' . substr($zone, 0, -strlen($parent) - 1 /* period */), '.');
1186: $zone = $parent;
1187: }
1188:
1189: if (static::HAS_ORIGIN_MARKER && $subdomain === '') {
1190: $subdomain = '@';
1191: }
1192:
1193: if (!$param) {
1194: return true;
1195: }
1196:
1197: if ($rr == 'MX' && preg_match('/(\S+) (\d+)$/', $param, $mx_flip)) {
1198: // user entered MX record in reverse, e.g. mail.apisnetworks.com 10
1199: $param = $mx_flip[2] . ' ' . $mx_flip[1];
1200: }
1201:
1202: // per RFC 4408 section 3.1.3,
1203: // TXT records limits contiguous length to 255 characters, but
1204: // may also concatenate
1205: if ($rr === 'TXT') {
1206: if ($param[0] !== '"' && $param[strlen($param) - 1] !== '"') {
1207: $param = '"' . str_replace('"', '\\"', $param) . '"';
1208: }
1209: if (static::HAS_CONTIGUOUS_LIMIT) {
1210: $param = '"' . implode('" "', str_split(trim($param, '"'), 253)) . '"';
1211: }
1212: }
1213:
1214: return true;
1215: }
1216:
1217: /**
1218: * Abides by DNS RFC that restricts DNS records from having an apex CNAME
1219: *
1220: * See also RFC 1034 section 3.6.2
1221: *
1222: * @return bool
1223: */
1224: protected function hasCnameApexRestriction(): bool
1225: {
1226: return true;
1227: }
1228:
1229: /**
1230: * Modify a DNS record
1231: *
1232: * @param string $zone
1233: * @param string $subdomain
1234: * @param string $rr
1235: * @param string $parameter
1236: * @param array $newdata new zone data (name, rr, ttl, parameter)
1237: * @return bool
1238: */
1239: public function modify_record(
1240: string $zone,
1241: string $subdomain,
1242: string $rr,
1243: string $parameter,
1244: array $newdata
1245: ): bool {
1246: if (!$this->owned_zone($zone)) {
1247: return error("Domain `%s' not owned by account", $zone);
1248: }
1249:
1250: $ttl = (int)$this->get_default('ttl');
1251: if (!$this->canonicalizeRecord($zone, $subdomain, $rr, $parameter, $ttl)) {
1252: return false;
1253: }
1254:
1255: $newdata = static::createRecord($zone, array_merge([
1256: 'name' => $subdomain,
1257: 'rr' => $rr,
1258: 'ttl' => null,
1259: 'parameter' => $parameter
1260: ], $newdata));
1261:
1262: if (!$this->canonicalizeRecord($zone, $newdata['name'], $newdata['rr'], $newdata['parameter'],
1263: $newdata['ttl'])) {
1264: return false;
1265: }
1266:
1267: $rectmp = preg_replace('/\.?' . $zone . '\.?$/', '', $newdata['name']);
1268:
1269: if ($newdata['name'] !== $subdomain && $newdata['rr'] !== $rr &&
1270: $this->record_exists($zone, $rectmp, $newdata['rr'], $parameter)
1271: ) {
1272: return error('Target record `' . $newdata['name'] . "' exists");
1273: }
1274:
1275: $old = static::createRecord($zone, [
1276: 'name' => $subdomain,
1277: 'rr' => $rr,
1278: 'parameter' => $parameter,
1279: 'ttl' => null
1280: ]);
1281:
1282: if (false === ($ret = $this->atomicUpdate($zone, $old, $newdata))) {
1283: // nsUpdate failed, rollback records
1284: warn('record update failed');
1285:
1286: return (($subdomain === $newdata['name'] && $rr === $newdata['rr']) ||
1287: !$this->record_exists($zone, $subdomain, $rr, $parameter)) &&
1288: $this->record_exists($zone, $newdata['name'], $newdata['rr'], $newdata['parameter']);
1289: }
1290:
1291: return (bool)$ret;
1292: }
1293:
1294: /**
1295: * DNS record exists
1296: *
1297: * @param string $zone
1298: * @param string $subdomain
1299: * @param string $rr
1300: * @param string $parameter
1301: * @return bool
1302: */
1303: public function record_exists(
1304: string $zone,
1305: string $subdomain,
1306: string $rr = 'ANY',
1307: string $parameter = ''
1308: ): bool {
1309: static $failed = [];
1310: if ($subdomain == '@') {
1311: $subdomain = '';
1312: if (!static::HAS_ORIGIN_MARKER) {
1313: warn("record `@' alias for domain - record stripped");
1314: }
1315: }
1316: // args passed as [zone:domain.com, subdomain:sub.domain.com]
1317: if ($subdomain && $subdomain[-1] === '.' && substr($subdomain, -\strlen($zone) - 1, -1) === $zone) {
1318: $subdomain = rtrim(substr($subdomain, 0, -\strlen($zone)-1), '.');
1319: }
1320: $record = trim($subdomain . '.' . $zone, '.');
1321: $rr = strtoupper($rr);
1322: if (null === static::record2const($rr)) {
1323: return error("unknown RR class `%s'", $rr);
1324: }
1325: $hostingns = array_diff($this->get_hosting_nameservers($zone), $failed);
1326: if (!$hostingns) {
1327: return warn("No hosting nameservers configured for `%s', cannot determine if record exists", $zone);
1328: }
1329: $status = Util_Process::exec(static::DIG_SHLOOKUP, [
1330: 'nameserver' => $ns = $hostingns[array_rand($hostingns)],
1331: 'hostname' => $record,
1332: 'rr' => array_key_exists($rr, static::$rec_2_const) ? $rr : 'ANY'
1333: ]
1334: );
1335: if ($status['return'] === 9) {
1336: // nameserver timed out
1337: $failed[] = $ns;
1338: return warn("Query to `%s' failed - disabling from future queries", $ns);
1339: }
1340: // make sure there is some data in the response
1341: if (!$parameter) {
1342: $parameter = '.';
1343: } else {
1344: $parameter = str_replace("'", "\\'", preg_quote($parameter, '!'));
1345: }
1346:
1347: return (bool)preg_match('!' . $parameter . '!i', $status['output']);
1348: }
1349:
1350: /**
1351: * Perform an atomic update of a record allowing reversion on failure
1352: *
1353: * @param string $zone
1354: * @param \Opcenter\Dns\Record $old
1355: * @param \Opcenter\Dns\Record $newdata
1356: * @return bool record succeeded
1357: */
1358: protected function atomicUpdate(string $zone, \Opcenter\Dns\Record $old, \Opcenter\Dns\Record $newdata): bool
1359: {
1360: // Throw an exception to halt reversion
1361: return false;
1362: }
1363:
1364: /**
1365: * Create DNS zone privileged mode
1366: *
1367: * @param string $domain
1368: * @param string $ip
1369: * @return bool
1370: */
1371: public function add_zone_backend(string $domain, string $ip): bool
1372: {
1373: if (!static::MASTER_NAMESERVER) {
1374: return error("rndc not configured in config.ini. Cannot add zone `%s'", $domain);
1375: }
1376: $buffer = Error_Reporter::flush_buffer();
1377: $res = $this->get_zone_data($domain);
1378:
1379: if (null !== $res) {
1380: Error_Reporter::set_buffer($buffer);
1381: warn("DNS for zone `%s' already exists, not overwriting", $domain);
1382:
1383: return true;
1384: }
1385: // make sure DNS does not exist yet for the parent
1386: [$a, $b] = explode('.', $domain, 2);
1387: $res = $this->get_zone_data($b);
1388: Error_Reporter::set_buffer($buffer);
1389: if (null !== $res) {
1390: warn("DNS for zone `%s' already exists, not overwriting", $b);
1391: return true;
1392: }
1393:
1394: if (is_array($ip)) {
1395: $ip = array_pop($ip);
1396: }
1397:
1398: if (inet_pton($ip) === false) {
1399: return error("`%s': invalid address", $ip);
1400: }
1401:
1402: info("Added domain `%s'", $domain);
1403:
1404: return true;
1405: }
1406:
1407: /**
1408: * Check whether IP is assigned
1409: *
1410: * Assigned IP addresses will have PTRs. Unassigned will be empty.
1411: *
1412: * @param $ip string ip address
1413: * @return bool
1414: */
1415: public function ip_allocated(string $ip): bool
1416: {
1417: return \Opcenter\Net\IpCommon::ip_allocated($ip);
1418: }
1419:
1420: /**
1421: * Reset domain to pristine DNS state
1422: *
1423: * @param string $domain
1424: * @return bool
1425: * @throws PostgreSQLError
1426: */
1427: public function reset(string $domain): bool
1428: {
1429: $wrapper = $this;
1430:
1431: if ($this->permission_level & PRIVILEGE_ADMIN) {
1432: if (null === ($site_id = \Auth::get_site_id_from_domain($domain))) {
1433: return error("Domain `%s' not located on server", $domain);
1434: }
1435: $wrapper = \apnscpFunctionInterceptor::instantiateContexted(\Auth::context(null, "site${site_id}"));
1436: }
1437: if ($wrapper->email_enabled() && !$wrapper->email_transport_exists($domain)) {
1438: $wrapper->email_add_virtual_transport($domain);
1439: }
1440:
1441: $newrecs = $wrapper->dns_provisioning_records($domain) +
1442: append_config($wrapper->email_provisioning_records($domain));
1443:
1444: $oldrecs = (array)$wrapper->dns_get_zone_information($domain);
1445: $ttl = $wrapper->dns_get_default('ttl');
1446: $ips = [];
1447: foreach (['A' => 'ip', 'AAAA' => 'ip6'] as $rr => $fn) {
1448: if (!($ip = $wrapper->{'dns_get_public_' . $fn}())) {
1449: continue;
1450: }
1451: $ips[$rr] = $ip;
1452: }
1453:
1454: // ignore NS/SOA records
1455: $ignore = ['SOA'];
1456: if ((empty($oldrecs['NS']) || !$wrapper->dns_get_default('apex-ns')) &&
1457: !in_array('NS', $this->permitted_records(), true))
1458: {
1459: $ignore[] = 'NS';
1460: } else if ($wrapper->dns_get_default('apex-ns')) {
1461: // NS records must be manually configured
1462: $newrecs = array_merge($newrecs, array_map(function ($ns) use ($domain) {
1463: return new Record($domain, [
1464: 'rr' => 'NS',
1465: 'ttl' => $this->dns_get_default('ttl'),
1466: 'parameter' => $ns
1467: ]);
1468: }, $wrapper->dns_get_hosting_nameservers($domain)));
1469: }
1470:
1471: array_forget($oldrecs, $ignore);
1472:
1473: foreach (array_keys($wrapper->web_list_subdomains()) as $subdomain) {
1474: if (false !== strpos($subdomain, '.')) {
1475: $split = $wrapper->web_split_host($subdomain);
1476: // fallthrough or local
1477: if (($split['subdomain'] && $split['domain'] !== $domain) || (!$split['subdomain'] && $split['domain'] !== $domain)) {
1478: continue;
1479: }
1480: // global subdomain
1481: $subdomain = !$split['subdomain'] && $split['domain'] === $domain ? '*' : $split['subdomain'];
1482: }
1483:
1484: foreach ($ips as $rr => $ip) {
1485: $newrecs[] = new Record($domain, [
1486: 'name' => $subdomain,
1487: 'ttl' => $ttl,
1488: 'rr' => $rr,
1489: 'parameter' => $ip
1490: ]);
1491: }
1492: }
1493: $keys = array_keys($oldrecs);
1494: foreach (array('A', 'AAAA') as $needle) {
1495: $pos = array_search($needle, $keys, true);
1496: if ($pos !== false) {
1497: unset($keys[$pos]);
1498: $keys[] = $needle;
1499: }
1500: }
1501:
1502: if (!$wrapper->dns_empty_zone($domain)) {
1503: return false;
1504: }
1505:
1506: foreach ($newrecs as $r) {
1507: $wrapper->dns_add_record($r->getZone(), (string)$r['name'], $r['rr'], $r['parameter'], $r['ttl']);
1508: }
1509:
1510: return \count($newrecs) > 0 ? info("Added %d records", count($newrecs)) : true;
1511: }
1512:
1513: /**
1514: * Flush DNS cache for domain
1515: *
1516: * @param string $domain
1517: * @return bool
1518: */
1519: public function flush(string $domain): bool
1520: {
1521: return debug("flush not implemented");
1522: }
1523:
1524: /**
1525: * Import zone data for domain, overwriting configuration on server
1526: *
1527: * @param string $domain
1528: * @param string $nameserver
1529: * @param string|null $key
1530: * @return bool
1531: */
1532: public function import_from_ns(string $domain, string $nameserver, $key = null): bool
1533: {
1534: $myip = Util_Conf::server_ip();
1535: $domain = strtolower($domain);
1536: if (!preg_match(Regex::DOMAIN, $domain)) {
1537: return error("invalid zone `%s' - not a domain name", $domain);
1538: }
1539:
1540: if (!preg_match(Regex::DOMAIN, $nameserver)) {
1541: return error("invalid nameserver to query `%s'", $nameserver);
1542: }
1543:
1544: if ($key && false === strpos($key, ':')) {
1545: return error("invalid dns key `%s', must be in format name:key", $key);
1546: }
1547: if ($key) {
1548: $key = '-y' . $key;
1549: }
1550:
1551: $cmd = 'dig %(key)s -b%(ip)s @%(nameserver)s %(zone)s +norecurse +nocmd +nonssearch +noadditional +nocomments +nostats AXFR ';
1552: $proc = Util_Process_Safe::exec($cmd, array(
1553: 'key' => $key,
1554: 'ip' => $myip,
1555: 'zone' => $domain,
1556: 'nameserver' => $nameserver
1557: ));
1558: $output = $proc['output'];
1559: if (!$proc['success'] || false !== strpos($output, '; Transfer failed')) {
1560: $output = $proc['stderr'] ?: $proc['output'];
1561:
1562: return error('axfr failed: %s', $output);
1563: }
1564:
1565: return $this->import($domain, output);
1566: }
1567:
1568: /**
1569: * Import records from hosted domain
1570: *
1571: * @param string $domain target domain to import records into
1572: * @param string $src hosted domain to derive records from
1573: * @return bool
1574: */
1575: public function import_from_domain(string $domain, string $src): bool
1576: {
1577: if ($domain === $src) {
1578: return error('Cannot import - target is same as source');
1579: }
1580: if (false === ($recs = $this->export($src))) {
1581: return false;
1582: }
1583: return $this->import(
1584: $domain,
1585: preg_replace('!(\b|^)' . preg_quote($src, '!') . '(\b|$)!m', $domain, $recs)
1586: );
1587: }
1588:
1589: /**
1590: * Import raw AXFR records into zone
1591: *
1592: * @param string $domain
1593: * @param string $axfr
1594: * @return bool
1595: */
1596: public function import(string $domain, string $axfr): bool
1597: {
1598: // empty out old zone first
1599:
1600: if (!$this->empty_zone($domain)) {
1601: return false;
1602: }
1603:
1604: $nrecs = 0;
1605: $regex = \Regex::compile(\Regex::DNS_AXFR_REC_DOMAIN,
1606: [
1607: 'rr' => implode('|', static::$permitted_records + [99999 => 'SOA']),
1608: 'domain' => str_replace('.', '\\.', $domain)
1609: ]);
1610: foreach (preg_split("/[\r\n]{1,2}/", $axfr) as $line) {
1611:
1612: if ('' === $line || $line[0] == ';') {
1613: continue;
1614: }
1615: if (!preg_match($regex, $line, $rec)) {
1616: continue;
1617: }
1618: $tmp = strtoupper($rec['rr']);
1619: // skip SOA + apex record
1620: if ($tmp === 'SOA' || ($tmp === 'NS' && !$rec['subdomain']) || ($rec['class'] ?? 'IN') !== 'IN') {
1621: continue;
1622: }
1623: $subdomain = trim($rec['subdomain'], '.');
1624: $rec['parameter'] = preg_replace('/\s+/', ' ', $rec['parameter']);
1625: if (!$this->add_record($domain, $subdomain, $rec['rr'], $rec['parameter'], (int)$rec['ttl'])) {
1626: warn('failed to add record `%s` -> `%s` (RR: %s, TTL: %s)',
1627: $subdomain,
1628: $rec['parameter'],
1629: $rec['rr'],
1630: $rec['ttl']
1631: );
1632: continue;
1633: }
1634: $nrecs++;
1635: }
1636:
1637: return info('imported %d records', $nrecs);
1638: }
1639:
1640: /**
1641: * Remove all records in zone except for SOA/NS
1642: *
1643: * @param string $domain
1644: * @return bool
1645: */
1646: public function empty_zone(string $domain): bool
1647: {
1648: $zoneinfo = $this->get_zone_information($domain);
1649: if (null === $zoneinfo) {
1650: return error('unable to get old zone information - is this domain added to your account?');
1651: }
1652:
1653: if ($this->parented($domain)) {
1654: return warn("Domain is parented - cannot empty");
1655: }
1656:
1657: $nrecs = 0;
1658: $permittedRecords = array_flip($this->permitted_records());
1659: $nameservers = $this->get_hosting_nameservers($domain);
1660: foreach ($zoneinfo as $rr => $recs) {
1661: foreach ($recs as $rec) {
1662: $tmp = strtoupper($rr);
1663: // skip SOA + apex record
1664: if ($tmp === 'SOA') {
1665: continue;
1666: }
1667: if ($tmp === 'NS' && (!$rec['subdomain'] && (!isset($permittedRecords['NS']) ||
1668: in_array(rtrim($rec['parameter'], '.'), $nameservers, true))))
1669: {
1670: continue;
1671: }
1672: if (!$this->remove_record($domain, $rec['subdomain'], $rr, $rec['parameter'])) {
1673: warn('failed to purge record `%s` %s %s %s',
1674: $rec['name'],
1675: $rr,
1676: $rec['ttl'],
1677: $rec['parameter']
1678: );
1679: continue;
1680: }
1681: $nrecs++;
1682: }
1683: }
1684:
1685: return $nrecs > 0 ? info('Purged %d old records', $nrecs) : true;
1686: }
1687:
1688: /**
1689: * Validate DNS template
1690: *
1691: * @param string $file
1692: * @param array $overrideParams additional params to pass to site creation
1693: * @return bool
1694: */
1695: public function validate_template(string $file, array $overrideParams = []): bool
1696: {
1697: $blade = BladeLite::factory('templates/dns');
1698: if (!$blade->exists($file)) {
1699: return error("Requested DNS template `%s' does not exist", $file);
1700: }
1701: $acct = \Error_Reporter::silence(static function() use ($overrideParams) {
1702: $site = Ephemeral::create($overrideParams);
1703:
1704: return $site ?? null;
1705: });
1706:
1707: if (null === $acct) {
1708: return error('Failed to create test account to evaluate DNS template');
1709: }
1710:
1711: $zoneTxt = $blade->render($file, [
1712: 'svc' => SiteConfiguration::shallow($acct->getContext()),
1713: 'ttl' => 1800,
1714: 'zone' => $acct->getContext()->domain,
1715: 'subdomain' => '',
1716: 'hostname' => $acct->getContext()->domain,
1717: 'ips' => array_filter([\Opcenter\Net\Ip4::my_ip(), \Opcenter\Net\Ip6::my_ip()])
1718: ]);
1719:
1720: defer($_, static fn() => \Error_Reporter::silence(static fn() => $acct->destroy()));
1721:
1722: $regex = \Regex::compile(\Regex::DNS_AXFR_REC_DOMAIN,
1723: [
1724: 'rr' => implode('|', static::$permitted_records + [99999 => 'SOA']),
1725: 'domain' => str_replace('.', '\\.', $acct->getContext()->domain),
1726: ]);
1727:
1728: foreach (explode("\n", $zoneTxt) as $line) {
1729: if (empty(trim($line))) {
1730: continue;
1731: }
1732: if (!preg_match($regex, $line)) {
1733: return error("Zone template `%s' failed on line: %s", $file, $line);
1734: }
1735: }
1736: return true;
1737: }
1738:
1739: /**
1740: * array get_zone_information (string)
1741: *
1742: * Reads zone information for a given domain on the nameservers.
1743: *
1744: * @param string|null $domain domain or current domain to check
1745: * @return null|array
1746: */
1747: public function get_zone_information(string $domain = null): ?array
1748: {
1749: $domain = $domain ?? $this->domain;
1750:
1751: if (!$this->permission_level & PRIVILEGE_ADMIN && !$this->owned_zone($domain)) {
1752: error('access denied - cannot view zone `' . $domain . "'");
1753:
1754: return null;
1755: }
1756: $rec = $this->get_zone_data($domain);
1757:
1758: return $rec ?? null;
1759: }
1760:
1761: /**
1762: * bool remove_record (string, string)
1763: * Removes a record from a zone.
1764: *
1765: * @param string $zone base domain
1766: * @param string $subdomain subdomain, leave blank for base domain
1767: * @param string $rr resource record type, possible values:
1768: * [MX, TXT, A, AAAA, NS, CNAME, DNAME, SRV]
1769: * @param string $param record context
1770: * @return bool operation completed successfully or not
1771: *
1772: */
1773: public function remove_record(string $zone, string $subdomain, string $rr, string $param = ''): bool
1774: {
1775: $subdomain = rtrim($subdomain, '.');
1776: if (!$zone) {
1777: $zone = $this->domain;
1778: }
1779: if (!$this->owned_zone($zone)) {
1780: return error($zone . ': not owned by account');
1781: }
1782:
1783: if (!$this->canonicalizeRecord($zone, $subdomain, $rr, $param)) {
1784: return false;
1785: }
1786: // only supply parameter if parameter is provided
1787:
1788:
1789: /**
1790: * Now purge the record
1791: */
1792:
1793: return true;
1794: }
1795:
1796: public function release_ip(string $ip): bool
1797: {
1798: deprecated_func('use ipinfo_release_ip');
1799:
1800: return $this->ipinfo_release_ip($ip);
1801: }
1802:
1803: /**
1804: * Get module default
1805: *
1806: * @param string $key
1807: * @return mixed|null
1808: */
1809: public function get_default(string $key)
1810: {
1811: switch (strtolower($key)) {
1812: case 'ttl':
1813: return static::DNS_TTL;
1814: case 'apex-ns':
1815: return static::SHOW_NS_APEX;
1816: default:
1817: return null;
1818: }
1819: }
1820:
1821: /**
1822: * Get DNS minimum TTL
1823: *
1824: * @XXX DO NOT USE FOR VALIDATING TTL. DEFER TO API ALWAYS.
1825: * Modules have special restrictions: CloudFlare allows "1" for automatic
1826: *
1827: * @return int
1828: */
1829: public function min_ttl(): int
1830: {
1831: return static::DNS_TTL_MIN;
1832: }
1833:
1834: public function _delete()
1835: {
1836: if (!$this->configured()) {
1837: return info("DNS not configured for `%s', bypassing DNS hooks", $this->domain);
1838: }
1839: if (!$this->getServiceValue('ipinfo', 'namebased')) {
1840: $ips = (array)$this->getServiceValue('ipinfo', 'ipaddrs');
1841: // pass the domain to verify the PTR isn't detached incorrectly
1842: // from another domain that has recycled it
1843: $domain = $this->getServiceValue('siteinfo', 'domain');
1844: foreach ($ips as $ip) {
1845: $this->__deleteIP($ip, $domain);
1846: }
1847: }
1848:
1849: return true;
1850: }
1851:
1852: /**
1853: * Release PTR assignment from an IP
1854: *
1855: * @param $ip
1856: * @param string $domain confirm PTR rDNS matches domain
1857: * @return bool
1858: */
1859: protected function __deleteIP(string $ip, string $domain = null): bool
1860: {
1861: // @todo move to ipinfo
1862: return true;
1863: }
1864:
1865: /**
1866: * Remove zone from nameserver
1867: *
1868: * @param string $domain
1869: * @return bool
1870: */
1871: public function remove_zone_backend(string $domain): bool
1872: {
1873: return warn("cannot remove zone - DNS provider `%s' not configured fully",
1874: $this->getServiceValue('dns', 'provider', 'builtin'));
1875: }
1876:
1877: public function _edit()
1878: {
1879: if (!$this->configured()) {
1880: return info("DNS not configured for `%s', skipping edit hook", $this->domain);
1881: }
1882: $ipconf_old = $this->getAuthContext()->conf('ipinfo', 'old');
1883: $ipconf_new = $this->getAuthContext()->conf('ipinfo', 'new');
1884: $domainold = \array_get($this->getAuthContext()->conf('siteinfo', 'old'), 'domain');
1885: $domainnew = \array_get($this->getAuthContext()->conf('siteinfo', 'new'), 'domain');
1886: $dnsconf_old = $this->getAuthContext()->conf('dns', 'old');
1887: $dnsconf_new = $this->getAuthContext()->conf('dns', 'new');
1888: if (\array_get($dnsconf_old, 'provider') !== array_get($dnsconf_new, 'provider') || array_get($dnsconf_new, 'enabled') && !array_get($dnsconf_old, 'enabled'))
1889: {
1890: $this->provisionProviderChange();
1891: }
1892: // domain name change via auth_change_domain()
1893: $ip = (array)$this->publicIpWrapper('new', 4);
1894:
1895: // ensure promoted domain is not removed
1896: $aliasesnew = array_merge(
1897: array_get($this->getAuthContext()->conf('aliases', 'new'), 'aliases', []),
1898: [$domainnew]
1899: );
1900: $aliasesold = array_merge(
1901: array_get($this->getAuthContext()->conf('aliases', 'old'), 'aliases', []),
1902: [$domainold]
1903: );
1904:
1905: $ip = $this->get_public_ip();
1906:
1907: $add = array_diff($aliasesnew, $aliasesold);
1908: $rem = array_diff($aliasesold, $aliasesnew);
1909: foreach ($add as $a) {
1910: $this->add_zone($a, $ip);
1911: }
1912:
1913: foreach ($rem as $r) {
1914: $this->remove_zone($r);
1915: }
1916:
1917: if ($domainold !== $domainnew && !$ipconf_new['namebased']) {
1918: $this->__changePTR($ip[0], $domainnew, $domainold);
1919: }
1920: // enable ip hosting
1921: if ($ipconf_new === $ipconf_old && $this->getAuthContext()->conf('dns', 'old') === $this->getAuthContext('dns', 'new')) {
1922: return;
1923: }
1924:
1925: $ipadd = $ipdel = [];
1926: $ipnew = $this->publicIpWrapper('new', 4, 'ipaddrs');
1927: $ipold = $this->publicIpWrapper('old', 4, 'ipaddrs');
1928: if ($ipconf_old['namebased'] && !$ipconf_new['namebased']) {
1929: // enable ip hosting
1930: $ipadd = $ipnew;
1931: } else if (!$ipconf_old['namebased'] && $ipconf_new['namebased']) {
1932: // disable ip hosting
1933: $ipdel = $ipold;
1934: } else {
1935: // add/remove ip hosting
1936: $ipdel = array_diff((array)$ipold, (array)$ipnew);
1937: $ipadd = array_diff((array)$ipnew, (array)$ipold);
1938: }
1939:
1940: foreach ($ipdel as $ip) {
1941: // NB __changePTR is called before to update domain on change
1942: $this->__deleteIP($ip, $domainnew);
1943: }
1944:
1945: foreach ($ipadd as $ip) {
1946: $this->__addIP($ip, $domainnew);
1947: }
1948:
1949: // update nbaddrs
1950: $ipnew = $this->publicIpWrapper('new', 4, 'nbaddrs');
1951: $ipold = $this->publicIpWrapper('old', 4, 'nbaddrs');
1952: if ($ipconf_old['namebased'] && !$ipconf_new['namebased']) {
1953: // added ip-based hosting
1954: $ipadd = $this->publicIpWrapper('new', 4, 'ipaddrs');
1955: $ipdel = $ipold;
1956: } else if (!$ipconf_old['namebased'] && $ipconf_new['namebased']) {
1957: // removed ip-based hosting
1958: $ipdel = $this->publicIpWrapper('old', 4, 'ipaddrs');
1959: $ipadd = $ipnew;
1960: } else if ($ipconf_old['namebased'] === $ipconf_new['namebased'] && $ipconf_new['namebased']) {
1961: // no namebased change
1962: $ipdel = array_diff(
1963: (array)$this->publicIpWrapper('old', 4, 'nbaddrs'),
1964: (array)$this->publicIpWrapper('new', 4, 'nbaddrs')
1965: );
1966: $ipadd = array_diff(
1967: (array)$this->publicIpWrapper('new', 4, 'nbaddrs'),
1968: (array)$this->publicIpWrapper('old', 4, 'nbaddrs')
1969: );
1970: }
1971:
1972: // change DNS
1973: // there will always be a 1:1 pairing for IP addresses
1974: $domains = array_keys($this->web_list_domains());
1975: foreach ($ipadd as $newip) {
1976: $oldip = array_pop($ipdel);
1977: $newparams = array('ttl' => static::DNS_TTL, 'parameter' => $newip);
1978: foreach ($domains as $domain) {
1979: $records = $this->get_records_by_rr('A', $domain);
1980: foreach ($records as $r) {
1981: if ($r['parameter'] !== $oldip) {
1982: continue;
1983: }
1984: if (!$this->modify_record($r['domain'], $r['subdomain'], 'A', $oldip, $newparams)) {
1985: $frag = ltrim($r['subdomain'] . '.' . $r['domain'], '.');
1986: error('failed to modify record for `' . $frag . "'");
1987: } else {
1988: $pieces = array($r['subdomain'], $r['domain']);
1989: $host = trim(implode('.', $pieces), '.');
1990: info("modified `%s'", $host);
1991: }
1992: }
1993: }
1994: }
1995:
1996: return;
1997: }
1998:
1999: public function _create()
2000: {
2001: return true;
2002: }
2003:
2004: /**
2005: * Provision fresh zone configuration on provider change
2006: *
2007: * @return bool|void
2008: */
2009: private function provisionProviderChange()
2010: {
2011: if (!$this->configured()) {
2012: return info("DNS not configured for `%s', bypassing DNS hooks", $this->domain);
2013: }
2014: $ipinfo = $this->getAuthContext()->conf('ipinfo');
2015: $siteinfo = $this->getAuthContext()->conf('siteinfo');
2016: $domain = $siteinfo['domain'];
2017: $ip = (array)$this->publicIpWrapper('cur');
2018: $this->add_zone($domain, $ip[0]);
2019:
2020: if (!$ipinfo['namebased']) {
2021: $ips = array_merge($ip, (array)$ipinfo['ipaddrs']);
2022: foreach(array_unique($ips) as $ip) {
2023: $this->__addIP($ip, $siteinfo['domain']);
2024: }
2025: }
2026: if (!$this->domain_uses_nameservers($domain)) {
2027: warn("Domain `%s' doesn't use assigned nameservers. Change nameservers to %s",
2028: $domain, implode(',', $this->get_hosting_nameservers($domain))
2029: );
2030: }
2031:
2032: return true;
2033: }
2034:
2035: /**
2036: * Helper function to fetch IP address from config
2037: *
2038: * @param string $which
2039: * @param int $class
2040: * @param string|null $svcvar class
2041: * @return array
2042: */
2043: protected function publicIpWrapper(string $which = 'cur', int $class = 4, string $svcvar = null): array
2044: {
2045: if ($class !== 4 && $class !== 6) {
2046: fatal("Unknown IP class `%s'", $class);
2047: }
2048: if ($which === 'current') {
2049: $which = 'cur';
2050: }
2051: $confctx = $this->getAuthContext()->conf('dns', $which);
2052: $proxyvar = 'proxy' . ($class === 4 ? '' : '6') . 'addr';
2053: if ($info = ($confctx[$proxyvar] ?? null)) {
2054: return $info;
2055: }
2056: $svccls = 'ipinfo' . ($class === 6 ? '6' : '');
2057: $confctx = $this->getAuthContext()->conf($svccls, $which);
2058: if (null === $svcvar) {
2059: $svcvar = $confctx['namebased'] ? 'nbaddrs' : 'ipaddrs';
2060: }
2061: return $confctx[$svcvar];
2062: }
2063:
2064: /**
2065: * Add zone to DNS server
2066: *
2067: * @param string $domain
2068: * @param string $ip
2069: * @return bool
2070: */
2071: public function add_zone(string $domain, string $ip): bool
2072: {
2073: $domain = rtrim($domain, '\.');
2074: if (!$this->configured()) {
2075: return warn("cannot create DNS zone for `%s' - DNS is not configured for account", $domain);
2076: }
2077:
2078: $buffer = Error_Reporter::flush_buffer();
2079: if (($this->permission_level & PRIVILEGE_SITE) && $this->getOldServices('dns') !== $this->getNewServices('dns')) {
2080: // flush cache to prevent false positives on provider change
2081: $this->zoneExistsCache[$domain] = null;
2082: }
2083:
2084: if (($parent = $this->getParent($domain)) || $this->zone_exists($domain)) {
2085: Error_Reporter::set_buffer($buffer);
2086: if (!$parent) {
2087: return warn("DNS for zone `%s' already exists, not overwriting", $domain);
2088: }
2089: warn("DNS for zone `%(domain)s' parented under `%(parent)s', not extending as separate zone",
2090: ['domain' => $domain, 'parent' => $parent]);
2091: return $this->provision_domain(
2092: $parent,
2093: substr($domain, 0, \strlen($domain) - \strlen($parent) - 1),
2094: false
2095: );
2096: }
2097:
2098: // breaks module swap unit testing :\
2099: if ((posix_getuid() && !$this->query('dns_add_zone_backend', $domain, $ip)) ||
2100: (!posix_getuid() && !$this->add_zone_backend($domain, $ip))) {
2101: return false;
2102: }
2103:
2104: if (!$this->verified($domain) && !$this->verify($domain)) {
2105: warn("Domain `%s' must be verified before DNS activates", $domain);
2106: }
2107:
2108: // verify zone present; this is often async
2109: // @todo extend or exponential backoff?
2110: $data = null;
2111: $totalWait = 0;
2112: for ($i = 0; $i < 100; $i++) {
2113: if (null !== ($data = $this->get_zone_data($domain))) {
2114: break;
2115: }
2116: $wait = 250000 * $i;
2117: $totalWait += ($wait/1000000);
2118: if ($totalWait >= DNS_VALIDATION_WAIT) {
2119: break;
2120: }
2121: usleep($wait);
2122: }
2123: $this->zoneExistsCache[$domain] = $data !== null;
2124: if (!$this->zoneExistsCache[$domain]) {
2125: warn("%s master not reporting authoritative for zone `%s' - continuing to add DNS records",
2126: ucwords($this->get_provider()),
2127: $domain
2128: );
2129: }
2130: if ($this->permission_level & PRIVILEGE_ADMIN) {
2131: return warn('Zone added as administrator - unable to provision records automatically');
2132: }
2133:
2134: $nameservers = $this->get_hosting_nameservers($domain);
2135:
2136: unset($data['SOA']);
2137:
2138: $cb = static function ($rec) use ($nameservers) {
2139: return $rec['subdomain'] || !in_array(rtrim($rec['parameter'], '.'), $nameservers, true);
2140: };
2141:
2142: if (array_first($data['NS'] ?? [], $cb) || count(array_diff_key($data, ['NS'])) > 0) {
2143: return $this->reset($domain);
2144: }
2145:
2146: return $this->provision_domain($domain, '', false);
2147: }
2148:
2149: /**
2150: * Provision records for domain
2151: *
2152: * @param string $domain domain
2153: * @param string $subdomain subdomain
2154: * @param bool $recordCheck thorough record check prior to provisioning
2155: * @return bool
2156: */
2157: private function provision_domain(string $domain, string $subdomain = '', bool $recordCheck = true): bool
2158: {
2159: dlog("Provisioning zone: %(zone)s, subdomain: %(sub)s using %(provider)s",
2160: [
2161: 'zone' => $domain,
2162: 'sub' => $subdomain,
2163: 'provider' => $this->get_provider()
2164: ]);
2165: $records = $this->provisioning_records($domain, $subdomain) +
2166: append_config($this->email_provisioning_records($domain, $subdomain));
2167: foreach ($records as $record) {
2168: if ($recordCheck && $this->record_exists($domain, $record['name'], $record['rr'], $record['parameter'])) {
2169: continue;
2170: }
2171:
2172: if (!$this->add_record($domain, $record['name'], $record['rr'], $record['parameter'],
2173: $record['ttl'])) {
2174: warn("Failed to add DNS record `%(hostname)s' on `%(name)s' (rr: %(rr)s, parameter: %(parameter)s)", [
2175: 'hostname' => ltrim('.', $subdomain . '.' . $domain),
2176: 'name' => $record['name'],
2177: 'rr' => $record['rr'],
2178: 'parameter' => $record['parameter']
2179: ]);
2180: }
2181: }
2182:
2183: return true;
2184: }
2185:
2186: /**
2187: * Hostname has parent
2188: *
2189: * @param string $hostname
2190: * @return bool
2191: */
2192: public function parented(string $hostname): bool
2193: {
2194: return $this->getParent($hostname) !== null;
2195: }
2196:
2197: /**
2198: * Get hostname parent
2199: *
2200: * @param string $hostname
2201: * @return string|null
2202: */
2203: protected function getParent(string $hostname): ?string
2204: {
2205: // let's assume admin knows what she's doing
2206: if ($this->permission_level & PRIVILEGE_ADMIN) {
2207: return null;
2208: }
2209: $count = substr_count($hostname, '.');
2210: if ($count < 2) {
2211: return null;
2212: }
2213: $pieces = explode('.', $hostname, $count);
2214: // aliases:list-shared-domains lists domains *attached* with a defined document root,
2215: // we need to inspect all aliases attached to the site as well as the domain itself to see if this
2216: // domain is explicitly defined or fallthrough to /var/www/html
2217: $domains = [$this->getConfig('siteinfo', 'domain')] + append_config($this->aliases_list_aliases());
2218:
2219: $tmp = array_pop($pieces);
2220: do {
2221: if (\in_array($tmp, $domains, true)) {
2222: return $tmp;
2223: }
2224: $chk = array_pop($pieces);
2225: $tmp = "$chk.$tmp";
2226: } while ($tmp !== $hostname);
2227:
2228: return null;
2229: }
2230:
2231: /**
2232: * Get public IPv4 address(es)
2233: *
2234: * @return string|array|null single IP, multiple IPs, or null if not configured
2235: */
2236: public function get_public_ip()
2237: {
2238: $addr = $this->getServiceValue('dns','proxyaddr') ?: $this->common_get_ip_address();
2239: return \count($addr) > 1 ? $addr : ($addr[0] ?? null);
2240: }
2241:
2242: /**
2243: * Get public IPv6 address(es)
2244: *
2245: * @return string|array|null single IP, multiple IPs, or null if not configured
2246: */
2247: public function get_public_ip6()
2248: {
2249: $addr = $this->getServiceValue('dns', 'proxy6addr') ?: $this->common_get_ip6_address();
2250:
2251: return \count($addr) > 1 ? $addr : ($addr[0] ?? null);
2252: }
2253:
2254: /**
2255: * Get base provisioning DNS records to setup automatically
2256: *
2257: * @param string $zone zone name
2258: * @param string $subdomain
2259: * @return \Opcenter\Dns\Record[]
2260: */
2261: public function provisioning_records(string $zone, string $subdomain = ''): array
2262: {
2263: if (!IS_CLI) {
2264: return $this->query('dns_provisioning_records', $zone);
2265: }
2266:
2267: if (!$this->getConfig('dns','enabled')) {
2268: return [];
2269: }
2270:
2271: $ttl = $this->dns_get_default('ttl');
2272: $ips = [];
2273: if ($this->getServiceValue('ipinfo', 'enabled')) {
2274: $ips += (array)$this->get_public_ip();
2275: }
2276: if ($this->getServiceValue('ipinfo6', 'enabled')) {
2277: $ips += append_config((array)$this->get_public_ip6());
2278: }
2279:
2280: $template = BladeLite::factory('templates/dns')->render('dns', [
2281: 'svc' => \Opcenter\SiteConfiguration::shallow($this->getAuthContext()),
2282: 'ttl' => $ttl,
2283: 'zone' => $zone,
2284: 'subdomain' => $subdomain,
2285: 'hostname' => ltrim(implode('.', [$subdomain, $zone]), '.'),
2286: 'ips' => (array)$ips
2287: ]);
2288: $regex = Regex::compile(Regex::DNS_AXFR_REC_DOMAIN, [
2289: 'rr' => implode('|', $this->dns_permitted_records() + [99999 => 'SOA']),
2290: 'domain' => $zone
2291: ]);
2292: if (!preg_match_all($regex, $template, $matches, PREG_SET_ORDER)) {
2293: debug('No provisioning records discovered from template');
2294:
2295: return [];
2296: }
2297: $records = [];
2298: foreach ($matches as $record) {
2299: $records[] = static::createRecord($zone, [
2300: 'ttl' => $record['ttl'],
2301: 'parameter' => $record['parameter'],
2302: 'rr' => $record['rr'],
2303: 'name' => rtrim($record['subdomain'], '.')
2304: ]);
2305: }
2306:
2307: return $records;
2308: }
2309:
2310: /**
2311: * Get DNS UUID for host
2312: *
2313: * @return null|string
2314: */
2315: public function uuid(): ?string
2316: {
2317: return DNS_UUID ?: null;
2318: }
2319:
2320: /**
2321: * Add an IP address to hosting
2322: *
2323: * @param string $ip
2324: * @param string $hostname
2325: * @return bool
2326: */
2327: protected function __addIP(string $ip, string $hostname = ''): bool
2328: {
2329: return true;
2330: }
2331:
2332: /**
2333: * Lookup and compare nameservers for domain to host
2334: *
2335: * @param string $domain
2336: * @return bool
2337: */
2338: public function domain_uses_nameservers(string $domain): bool
2339: {
2340: if (!preg_match(Regex::DOMAIN, $domain)) {
2341: return error("malformed domain `%s'", $domain);
2342: }
2343: $hostingns = $this->get_hosting_nameservers($domain);
2344: if (!$hostingns) {
2345: // not configured under [dns] hosting_ns in config.ini
2346: return true;
2347: }
2348: $dns = mute(function () use ($domain) {
2349: return $this->get_authns_from_host($domain);
2350: });
2351: $found = false;
2352: if (!$dns) {
2353: return $found;
2354: }
2355: if (DNS_VANITY_NS) {
2356: $hostingns += append_config(DNS_VANITY_NS);
2357: }
2358: foreach ($dns as $ns) {
2359: if (in_array($ns, $hostingns, true)) {
2360: return true;
2361: }
2362: }
2363:
2364: return false;
2365: }
2366:
2367: /**
2368: * Get configured hosting nameservers
2369: *
2370: * Toggled via config.ini > [dns] > hosting_ns
2371: *
2372: * @return array
2373: */
2374: public function get_hosting_nameservers(string $domain = null): array
2375: {
2376: return DNS_HOSTING_NS;
2377: }
2378:
2379: /**
2380: * Get authoritative nameservers for given hostname
2381: *
2382: * Example response:
2383: * Array
2384: * (
2385: * [0] => Array
2386: * (
2387: * [host] => ns2.apisnetworks.com
2388: * [type] => A
2389: * [ip] => 96.126.122.82
2390: * [class] => IN
2391: * [ttl] => 83137
2392: * )
2393: * )
2394: *
2395: * @param string $host hostname
2396: * @return array|null authoritative nameservers or resolver chain incomplete
2397: */
2398: public function get_authns_from_host($host): ?array
2399: {
2400: $nameservers = static::RECURSIVE_NAMESERVERS;
2401: $authns = silence(static function () use ($host, $nameservers) {
2402: return dns_get_record($host, static::record2const('ns'), $nameservers);
2403: });
2404: if ($authns) {
2405: // domain is properly delegated, nameserver returns affirmative
2406: $tmp = array();
2407: foreach ($authns as $a) {
2408: if ($a['type'] == 'NS') {
2409: $tmp[] = $a['target'];
2410: }
2411: }
2412:
2413: return $tmp;
2414: }
2415:
2416: // domain delegated to hosting nameservers, but hosting servers don't
2417: // have dns provisioned yet for domain
2418: //
2419: // crawl
2420: $resolver = new Net_DNS2_Resolver([
2421: 'nameservers' => $nameservers,
2422: 'recurse' => true
2423: ]);
2424: try {
2425: $nameservers = $this->get_authns_from_host_recursive($host, $resolver);
2426: } catch (Net_DNS2_Exception $e) {
2427: warn("NS lookup failed for `%s': %s", $host, $e->getMessage());
2428:
2429: return array();
2430: }
2431:
2432: return $nameservers;
2433: }
2434:
2435: /**
2436: * Fallback authoritative NS lookup
2437: *
2438: * Crawl the entire TLD hierarchy to find the last known nameserver
2439: *
2440: * @param string $host
2441: * @param Net_DNS2_Resolver $resolver
2442: * @param string $seen
2443: * @return array|null nameservers or null if resolve failed before reaching end
2444: */
2445: protected function get_authns_from_host_recursive($host, Net_DNS2_Resolver $resolver, $seen = ''): ?array
2446: {
2447: $components = explode('.', $host);
2448: $nameservers = null;
2449: try {
2450: $lookup = array_pop($components) . '.' . $seen;
2451: $res = silence(static function () use ($resolver, $lookup) {
2452: return $resolver->query($lookup, 'NS');
2453: });
2454: if ($res->answer) {
2455: $nameservers = array_filter(array_map(static function ($arr) {
2456: return gethostbyname($arr->nsdname);
2457: }, $res->answer));
2458: $resolver->setServers($nameservers);
2459: }
2460: } catch (Net_DNS2_Exception | \Error $e) {
2461: if ($components) {
2462: if (false !== strpos($e->getMessage(), 'member function open() on null')) {
2463: // invalid tld extension
2464: return null;
2465: }
2466: // resolver chain broken
2467: warn("failed to recurse on `%s': %s", $lookup, $e->getMessage());
2468: }
2469:
2470: return null;
2471: }
2472: if (!$components) {
2473: return array_map(static function ($a) {
2474: return $a->nsdname;
2475: }, $res->authority);
2476: }
2477: $resolver->recurse = 0;
2478:
2479: return $this->get_authns_from_host_recursive(implode('.', $components), $resolver, $lookup);
2480:
2481: }
2482:
2483: /**
2484: * Remove a zone from DNS management
2485: *
2486: * No direct access to method as site.
2487: *
2488: * Use EditDomain or aliases:remove-domain to manage zone attachments.
2489: * Certain DNS providers may also soft delete zones requiring manual removal.
2490: *
2491: * @param string $domain
2492: * @param bool $force remove zone as admin bypassing all validation checks
2493: * @return bool
2494: */
2495: public function remove_zone(string $domain, bool $force = false): bool
2496: {
2497: if ($this->permission_level & PRIVILEGE_SITE && !$this->owned_zone($domain)) {
2498: return error("Unusual call to remove_zone() with specified unowned zone `%s' blocked", $domain);
2499: }
2500:
2501: if (!$this->configured()) {
2502: return warn("cannot remove DNS zone for `%s' - DNS is not configured for account", $domain);
2503: }
2504:
2505: if ($force) {
2506: return $this->remove_zone_backend($domain);
2507: }
2508:
2509: if (null !== ($parent = $this->getParent($domain))) {
2510: // @TODO cleaning up a parented domain and its children will be messy
2511: return true;
2512: }
2513:
2514:
2515: if (!$this->zone_exists($domain)) {
2516: return true;
2517: }
2518: if (false === ($record = $this->get_records(static::UUID_RECORD, 'TXT', $domain))) {
2519: // zone axfr failed?
2520: return warn("Zone transfer failed - ignoring removal of `%s'", $domain);
2521: }
2522: if (null !== ($uuid = $this->uuid()) && $uuid !== ($record = array_get($record, '0.parameter', null))) {
2523: return warn("Bypassing DNS removal. DNS UUID for `%s' is `%s'. Server UUID is `%s'", $domain, $record,
2524: $uuid);
2525: }
2526: if ($ret = $this->query('dns_remove_zone_backend', $domain)) {
2527: unset($this->zoneExistsCache[$domain], $this->zoneCache[$domain]);
2528: }
2529:
2530: return $ret;
2531: }
2532:
2533: /**
2534: * Test credentials against configured module
2535: *
2536: * @param string $provider DNS provider to test
2537: * @param mixed $key authentication key
2538: * @return bool
2539: */
2540: public function auth_test(string $provider = DNS_PROVIDER_DEFAULT, $key = ''): bool {
2541: if (!is_debug()) {
2542: return error('Only available in debug mode');
2543: }
2544:
2545: if (!\Opcenter\Dns::providerValid($provider)) {
2546: return error("DNS provider `%s' invalid", $provider);
2547: }
2548:
2549: if (!\Opcenter\Dns::providerHasHelper($provider)) {
2550: return true;
2551: }
2552: $helper = \Opcenter\Dns::getProviderHelper($provider);
2553: $ctx = new \Opcenter\Service\ConfigurationContext('dns', new SiteConfiguration($this->site));
2554: return $helper->valid($ctx, $key);
2555: }
2556:
2557: /**
2558: * Change PTR name
2559: *
2560: * @param string $ip IP address to alter
2561: * @param string $hostname new PTR name
2562: * @param string $chk optional check hostname to verify
2563: * @return bool
2564: */
2565: protected function __changePTR(string $ip, string $hostname, string $chk = ''): bool
2566: {
2567: return true;
2568: }
2569:
2570: /**
2571: * Query hosting nameservers for DNS records of named category
2572: *
2573: * {@see get_zone_information()}
2574: *
2575: * example:
2576: * Account has two MX records assigned, first the
2577: * default MX on debug.com, and a second user-created MX on debug.debug.com.
2578: * debug.debug.com was designated an e-mail domain through Mail Routing
2579: * {@see Email_Module::add_virtual_transport()}
2580: *
2581: * apis> $c->dns_get_records_by_rr("MX");
2582: *
2583: * array(2)
2584: * apis>
2585: * array(2) {
2586: * [0]=>
2587: * array(4) {
2588: * ["name"]=>
2589: * string(10) "debug.com."
2590: * ["class"]=>
2591: * string(2) "IN"
2592: * ["ttl"]=>
2593: * string(5) "86400"
2594: * ["parameter"]=>
2595: * string(18) "10 mail.debug.com."
2596: * }
2597: * [1]=>
2598: * array(4) {
2599: * ["name"]=>
2600: * string(16) "debug.debug.com."
2601: * ["class"]=>
2602: * string(2) "IN"
2603: * ["ttl"]=>
2604: * string(5) "86400"
2605: * ["parameter"]=>
2606: * string(24) "10 mail.debug.debug.com."
2607: * }
2608: * }
2609: *
2610: * @param string $rr resource record [MX, A, AAAA, CNAME, DNAME, TXT, SRV]
2611: * @param string $zone
2612: * @return array|null resource records
2613: *
2614: */
2615: public function get_records_by_rr(string $rr, string $zone = null): ?array
2616: {
2617: if (null === $zone) {
2618: $zone = $this->domain;
2619: }
2620:
2621: if (!$this->owned_zone($zone)) {
2622: if (!$this->owned_zone($rr)) {
2623: error('access denied - cannot view zone `' . $zone . "'");
2624:
2625: return null;
2626: }
2627: // confusing half-assed backwards
2628: // accept arguments in either form
2629: $t = $rr;
2630: $rr = $zone;
2631: $zone = $t;
2632: }
2633:
2634: $rr = strtoupper($rr);
2635: if ($rr !== 'ANY' && !in_array($rr, static::$permitted_records, true)) {
2636: error('%s: invalid resource record type', $rr);
2637:
2638: return null;
2639: }
2640:
2641: $recs = $this->get_zone_information($zone);
2642: if (!$recs) {
2643: return array();
2644: }
2645: if ($rr == 'ANY') {
2646: return $recs;
2647: }
2648: if (!isset($recs[strtoupper($rr)])) {
2649: return array();
2650: }
2651:
2652: return $recs[strtoupper($rr)];
2653: }
2654:
2655: /**
2656: * Domain has been verified and permitted addition
2657: *
2658: * @param string $domain
2659: * @return bool
2660: */
2661: public function verified(string $domain): bool
2662: {
2663: return true;
2664: }
2665:
2666: /**
2667: * Perform verification on domain
2668: *
2669: * @param string $domain
2670: * @return bool
2671: */
2672: public function verify(string $domain): bool
2673: {
2674: return true;
2675: }
2676:
2677: /**
2678: * Get DNS zone challenges
2679: *
2680: * @param string $domain
2681: * @return array ns, txt correspond to nameserver delegation, TXT record presence
2682: */
2683: public function challenges(string $domain): array
2684: {
2685: return [];
2686: }
2687:
2688: /**
2689: * Send raw driver-specific command
2690: *
2691: * @param string $param
2692: * @param mixed $val
2693: * @return mixed
2694: */
2695: public function raw(string $param, $val)
2696: {
2697: return error("Command `%s' not understood", $param);
2698: }
2699:
2700: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
2701: {
2702: return true;
2703: }
2704:
2705: public function _create_user(string $user)
2706: {
2707: return;
2708: }
2709:
2710: public function _delete_user(string $user)
2711: {
2712: return;
2713: }
2714:
2715: public function _edit_user(string $userold, string $usernew, array $oldpwd)
2716: {
2717: return;
2718: }
2719: }