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:
1317: if (null !== ($r = $this->getRecordFromCache(static::createRecord($zone,
1318: ['name' => $subdomain, 'rr' => $rr, 'parameter' => $parameter])))) {
1319: return !data_get($r, 'deleted', false);
1320: }
1321:
1322: // args passed as [zone:domain.com, subdomain:sub.domain.com]
1323: if ($subdomain && $subdomain[-1] === '.' && substr($subdomain, -\strlen($zone) - 1, -1) === $zone) {
1324: $subdomain = rtrim(substr($subdomain, 0, -\strlen($zone)-1), '.');
1325: }
1326: $record = trim($subdomain . '.' . $zone, '.');
1327: $rr = strtoupper($rr);
1328: if (null === static::record2const($rr)) {
1329: return error("unknown RR class `%s'", $rr);
1330: }
1331: $hostingns = array_diff($this->get_hosting_nameservers($zone), $failed);
1332: if (!$hostingns) {
1333: return warn("No hosting nameservers configured for `%s', cannot determine if record exists", $zone);
1334: }
1335: $status = Util_Process::exec(static::DIG_SHLOOKUP, [
1336: 'nameserver' => $ns = $hostingns[array_rand($hostingns)],
1337: 'hostname' => $record,
1338: 'rr' => array_key_exists($rr, static::$rec_2_const) ? $rr : 'ANY'
1339: ]
1340: );
1341: if ($status['return'] === 9) {
1342: // nameserver timed out
1343: $failed[] = $ns;
1344: return warn("Query to `%s' failed - disabling from future queries", $ns);
1345: }
1346: // make sure there is some data in the response
1347: if (!$parameter) {
1348: $parameter = '.';
1349: } else {
1350: $parameter = str_replace("'", "\\'", preg_quote($parameter, '!'));
1351: }
1352:
1353: return (bool)preg_match('!' . $parameter . '!i', $status['output']);
1354: }
1355:
1356: /**
1357: * Perform an atomic update of a record allowing reversion on failure
1358: *
1359: * @param string $zone
1360: * @param \Opcenter\Dns\Record $old
1361: * @param \Opcenter\Dns\Record $newdata
1362: * @return bool record succeeded
1363: */
1364: protected function atomicUpdate(string $zone, \Opcenter\Dns\Record $old, \Opcenter\Dns\Record $newdata): bool
1365: {
1366: // Throw an exception to halt reversion
1367: return false;
1368: }
1369:
1370: /**
1371: * Create DNS zone privileged mode
1372: *
1373: * @param string $domain
1374: * @param string $ip
1375: * @return bool
1376: */
1377: public function add_zone_backend(string $domain, string $ip): bool
1378: {
1379: if (!static::MASTER_NAMESERVER) {
1380: return error("rndc not configured in config.ini. Cannot add zone `%s'", $domain);
1381: }
1382: $buffer = Error_Reporter::flush_buffer();
1383: $res = $this->get_zone_data($domain);
1384:
1385: if (null !== $res) {
1386: Error_Reporter::set_buffer($buffer);
1387: warn("DNS for zone `%s' already exists, not overwriting", $domain);
1388:
1389: return true;
1390: }
1391: // make sure DNS does not exist yet for the parent
1392: [$a, $b] = explode('.', $domain, 2);
1393: $res = $this->get_zone_data($b);
1394: Error_Reporter::set_buffer($buffer);
1395: if (null !== $res) {
1396: warn("DNS for zone `%s' already exists, not overwriting", $b);
1397: return true;
1398: }
1399:
1400: if (is_array($ip)) {
1401: $ip = array_pop($ip);
1402: }
1403:
1404: if (inet_pton($ip) === false) {
1405: return error("`%s': invalid address", $ip);
1406: }
1407:
1408: info("Added domain `%s'", $domain);
1409:
1410: return true;
1411: }
1412:
1413: /**
1414: * Check whether IP is assigned
1415: *
1416: * Assigned IP addresses will have PTRs. Unassigned will be empty.
1417: *
1418: * @param $ip string ip address
1419: * @return bool
1420: */
1421: public function ip_allocated(string $ip): bool
1422: {
1423: return \Opcenter\Net\IpCommon::ip_allocated($ip);
1424: }
1425:
1426: /**
1427: * Reset domain to pristine DNS state
1428: *
1429: * @param string $domain
1430: * @return bool
1431: * @throws PostgreSQLError
1432: */
1433: public function reset(string $domain): bool
1434: {
1435: $wrapper = $this;
1436:
1437: if ($this->permission_level & PRIVILEGE_ADMIN) {
1438: if (null === ($site_id = \Auth::get_site_id_from_domain($domain))) {
1439: return error("Domain `%s' not located on server", $domain);
1440: }
1441: $wrapper = \apnscpFunctionInterceptor::instantiateContexted(\Auth::context(null, "site${site_id}"));
1442: }
1443: if ($wrapper->email_enabled() && !$wrapper->email_transport_exists($domain)) {
1444: $wrapper->email_add_virtual_transport($domain);
1445: }
1446:
1447: $newrecs = $wrapper->dns_provisioning_records($domain) +
1448: append_config($wrapper->email_provisioning_records($domain));
1449:
1450: $oldrecs = (array)$wrapper->dns_get_zone_information($domain);
1451: $ttl = $wrapper->dns_get_default('ttl');
1452: $ips = [];
1453: foreach (['A' => 'ip', 'AAAA' => 'ip6'] as $rr => $fn) {
1454: if (!($ip = $wrapper->{'dns_get_public_' . $fn}())) {
1455: continue;
1456: }
1457: $ips[$rr] = $ip;
1458: }
1459:
1460: // ignore NS/SOA records
1461: $ignore = ['SOA'];
1462: if ((empty($oldrecs['NS']) || !$wrapper->dns_get_default('apex-ns')) &&
1463: !in_array('NS', $this->permitted_records(), true))
1464: {
1465: $ignore[] = 'NS';
1466: } else if ($wrapper->dns_get_default('apex-ns')) {
1467: // NS records must be manually configured
1468: $newrecs = array_merge($newrecs, array_map(function ($ns) use ($domain) {
1469: return new Record($domain, [
1470: 'rr' => 'NS',
1471: 'ttl' => $this->dns_get_default('ttl'),
1472: 'parameter' => $ns
1473: ]);
1474: }, $wrapper->dns_get_hosting_nameservers($domain)));
1475: }
1476:
1477: array_forget($oldrecs, $ignore);
1478:
1479: foreach (array_keys($wrapper->web_list_subdomains()) as $subdomain) {
1480: if (false !== strpos($subdomain, '.')) {
1481: $split = $wrapper->web_split_host($subdomain);
1482: // fallthrough or local
1483: if (($split['subdomain'] && $split['domain'] !== $domain) || (!$split['subdomain'] && $split['domain'] !== $domain)) {
1484: continue;
1485: }
1486: // global subdomain
1487: $subdomain = !$split['subdomain'] && $split['domain'] === $domain ? '*' : $split['subdomain'];
1488: }
1489:
1490: foreach ($ips as $rr => $ip) {
1491: $newrecs[] = new Record($domain, [
1492: 'name' => $subdomain,
1493: 'ttl' => $ttl,
1494: 'rr' => $rr,
1495: 'parameter' => $ip
1496: ]);
1497: }
1498: }
1499: $keys = array_keys($oldrecs);
1500: foreach (array('A', 'AAAA') as $needle) {
1501: $pos = array_search($needle, $keys, true);
1502: if ($pos !== false) {
1503: unset($keys[$pos]);
1504: $keys[] = $needle;
1505: }
1506: }
1507:
1508: if (!$wrapper->dns_empty_zone($domain)) {
1509: return false;
1510: }
1511:
1512: foreach ($newrecs as $r) {
1513: $wrapper->dns_add_record($r->getZone(), (string)$r['name'], $r['rr'], $r['parameter'], $r['ttl']);
1514: }
1515:
1516: return \count($newrecs) > 0 ? info("Added %d records", count($newrecs)) : true;
1517: }
1518:
1519: /**
1520: * Flush DNS cache for domain
1521: *
1522: * @param string $domain
1523: * @return bool
1524: */
1525: public function flush(string $domain): bool
1526: {
1527: return debug("flush not implemented");
1528: }
1529:
1530: /**
1531: * Import zone data for domain, overwriting configuration on server
1532: *
1533: * @param string $domain
1534: * @param string $nameserver
1535: * @param string|null $key
1536: * @return bool
1537: */
1538: public function import_from_ns(string $domain, string $nameserver, $key = null): bool
1539: {
1540: $myip = Util_Conf::server_ip();
1541: $domain = strtolower($domain);
1542: if (!preg_match(Regex::DOMAIN, $domain)) {
1543: return error("invalid zone `%s' - not a domain name", $domain);
1544: }
1545:
1546: if (!preg_match(Regex::DOMAIN, $nameserver)) {
1547: return error("invalid nameserver to query `%s'", $nameserver);
1548: }
1549:
1550: if ($key && false === strpos($key, ':')) {
1551: return error("invalid dns key `%s', must be in format name:key", $key);
1552: }
1553: if ($key) {
1554: $key = '-y' . $key;
1555: }
1556:
1557: $cmd = 'dig %(key)s -b%(ip)s @%(nameserver)s %(zone)s +norecurse +nocmd +nonssearch +noadditional +nocomments +nostats AXFR ';
1558: $proc = Util_Process_Safe::exec($cmd, array(
1559: 'key' => $key,
1560: 'ip' => $myip,
1561: 'zone' => $domain,
1562: 'nameserver' => $nameserver
1563: ));
1564: $output = $proc['output'];
1565: if (!$proc['success'] || false !== strpos($output, '; Transfer failed')) {
1566: $output = $proc['stderr'] ?: $proc['output'];
1567:
1568: return error('axfr failed: %s', $output);
1569: }
1570:
1571: return $this->import($domain, output);
1572: }
1573:
1574: /**
1575: * Import records from hosted domain
1576: *
1577: * @param string $domain target domain to import records into
1578: * @param string $src hosted domain to derive records from
1579: * @return bool
1580: */
1581: public function import_from_domain(string $domain, string $src): bool
1582: {
1583: if ($domain === $src) {
1584: return error('Cannot import - target is same as source');
1585: }
1586: if (false === ($recs = $this->export($src))) {
1587: return false;
1588: }
1589: return $this->import(
1590: $domain,
1591: preg_replace('!(\b|^)' . preg_quote($src, '!') . '(\b|$)!m', $domain, $recs)
1592: );
1593: }
1594:
1595: /**
1596: * Import raw AXFR records into zone
1597: *
1598: * @param string $domain
1599: * @param string $axfr
1600: * @return bool
1601: */
1602: public function import(string $domain, string $axfr): bool
1603: {
1604: // empty out old zone first
1605:
1606: if (!$this->empty_zone($domain)) {
1607: return false;
1608: }
1609:
1610: $nrecs = 0;
1611: $regex = \Regex::compile(\Regex::DNS_AXFR_REC_DOMAIN,
1612: [
1613: 'rr' => implode('|', static::$permitted_records + [99999 => 'SOA']),
1614: 'domain' => str_replace('.', '\\.', $domain)
1615: ]);
1616: foreach (preg_split("/[\r\n]{1,2}/", $axfr) as $line) {
1617:
1618: if ('' === $line || $line[0] == ';') {
1619: continue;
1620: }
1621: if (!preg_match($regex, $line, $rec)) {
1622: continue;
1623: }
1624: $tmp = strtoupper($rec['rr']);
1625: // skip SOA + apex record
1626: if ($tmp === 'SOA' || ($tmp === 'NS' && !$rec['subdomain']) || ($rec['class'] ?? 'IN') !== 'IN') {
1627: continue;
1628: }
1629: $subdomain = trim($rec['subdomain'], '.');
1630: $rec['parameter'] = preg_replace('/\s+/', ' ', $rec['parameter']);
1631: if (!$this->add_record($domain, $subdomain, $rec['rr'], $rec['parameter'], (int)$rec['ttl'])) {
1632: warn('failed to add record `%s` -> `%s` (RR: %s, TTL: %s)',
1633: $subdomain,
1634: $rec['parameter'],
1635: $rec['rr'],
1636: $rec['ttl']
1637: );
1638: continue;
1639: }
1640: $nrecs++;
1641: }
1642:
1643: return info('imported %d records', $nrecs);
1644: }
1645:
1646: /**
1647: * Remove all records in zone except for SOA/NS
1648: *
1649: * @param string $domain
1650: * @return bool
1651: */
1652: public function empty_zone(string $domain): bool
1653: {
1654: $zoneinfo = $this->get_zone_information($domain);
1655: if (null === $zoneinfo) {
1656: return error('unable to get old zone information - is this domain added to your account?');
1657: }
1658:
1659: if ($this->parented($domain)) {
1660: return warn("Domain is parented - cannot empty");
1661: }
1662:
1663: $nrecs = 0;
1664: $permittedRecords = array_flip($this->permitted_records());
1665: $nameservers = $this->get_hosting_nameservers($domain);
1666: foreach ($zoneinfo as $rr => $recs) {
1667: foreach ($recs as $rec) {
1668: $tmp = strtoupper($rr);
1669: // skip SOA + apex record
1670: if ($tmp === 'SOA') {
1671: continue;
1672: }
1673: if ($tmp === 'NS' && (!$rec['subdomain'] && (!isset($permittedRecords['NS']) ||
1674: in_array(rtrim($rec['parameter'], '.'), $nameservers, true))))
1675: {
1676: continue;
1677: }
1678: if (!$this->remove_record($domain, $rec['subdomain'], $rr, $rec['parameter'])) {
1679: warn('failed to purge record `%s` %s %s %s',
1680: $rec['name'],
1681: $rr,
1682: $rec['ttl'],
1683: $rec['parameter']
1684: );
1685: continue;
1686: }
1687: $nrecs++;
1688: }
1689: }
1690:
1691: return $nrecs > 0 ? info('Purged %d old records', $nrecs) : true;
1692: }
1693:
1694: /**
1695: * Validate DNS template
1696: *
1697: * @param string $file
1698: * @param array $overrideParams additional params to pass to site creation
1699: * @return bool
1700: */
1701: public function validate_template(string $file, array $overrideParams = []): bool
1702: {
1703: $blade = BladeLite::factory('templates/dns');
1704: if (!$blade->exists($file)) {
1705: return error("Requested DNS template `%s' does not exist", $file);
1706: }
1707: $acct = \Error_Reporter::silence(static function() use ($overrideParams) {
1708: $site = Ephemeral::create($overrideParams);
1709:
1710: return $site ?? null;
1711: });
1712:
1713: if (null === $acct) {
1714: return error('Failed to create test account to evaluate DNS template');
1715: }
1716:
1717: $zoneTxt = $blade->render($file, [
1718: 'svc' => SiteConfiguration::shallow($acct->getContext()),
1719: 'ttl' => 1800,
1720: 'zone' => $acct->getContext()->domain,
1721: 'subdomain' => '',
1722: 'hostname' => $acct->getContext()->domain,
1723: 'ips' => array_filter([\Opcenter\Net\Ip4::my_ip(), \Opcenter\Net\Ip6::my_ip()])
1724: ]);
1725:
1726: defer($_, static fn() => \Error_Reporter::silence(static fn() => $acct->destroy()));
1727:
1728: $regex = \Regex::compile(\Regex::DNS_AXFR_REC_DOMAIN,
1729: [
1730: 'rr' => implode('|', static::$permitted_records + [99999 => 'SOA']),
1731: 'domain' => str_replace('.', '\\.', $acct->getContext()->domain),
1732: ]);
1733:
1734: foreach (explode("\n", $zoneTxt) as $line) {
1735: if (empty(trim($line))) {
1736: continue;
1737: }
1738: if (!preg_match($regex, $line)) {
1739: return error("Zone template `%s' failed on line: %s", $file, $line);
1740: }
1741: }
1742: return true;
1743: }
1744:
1745: /**
1746: * array get_zone_information (string)
1747: *
1748: * Reads zone information for a given domain on the nameservers.
1749: *
1750: * @param string|null $domain domain or current domain to check
1751: * @return null|array
1752: */
1753: public function get_zone_information(string $domain = null): ?array
1754: {
1755: $domain = $domain ?? $this->domain;
1756:
1757: if (!$this->permission_level & PRIVILEGE_ADMIN && !$this->owned_zone($domain)) {
1758: error('access denied - cannot view zone `' . $domain . "'");
1759:
1760: return null;
1761: }
1762: $rec = $this->get_zone_data($domain);
1763:
1764: return $rec ?? null;
1765: }
1766:
1767: /**
1768: * bool remove_record (string, string)
1769: * Removes a record from a zone.
1770: *
1771: * @param string $zone base domain
1772: * @param string $subdomain subdomain, leave blank for base domain
1773: * @param string $rr resource record type, possible values:
1774: * [MX, TXT, A, AAAA, NS, CNAME, DNAME, SRV]
1775: * @param string $param record context
1776: * @return bool operation completed successfully or not
1777: *
1778: */
1779: public function remove_record(string $zone, string $subdomain, string $rr, string $param = ''): bool
1780: {
1781: $subdomain = rtrim($subdomain, '.');
1782: if (!$zone) {
1783: $zone = $this->domain;
1784: }
1785: if (!$this->owned_zone($zone)) {
1786: return error($zone . ': not owned by account');
1787: }
1788:
1789: if (!$this->canonicalizeRecord($zone, $subdomain, $rr, $param)) {
1790: return false;
1791: }
1792: // only supply parameter if parameter is provided
1793:
1794:
1795: /**
1796: * Now purge the record
1797: */
1798:
1799: return true;
1800: }
1801:
1802: public function release_ip(string $ip): bool
1803: {
1804: deprecated_func('use ipinfo_release_ip');
1805:
1806: return $this->ipinfo_release_ip($ip);
1807: }
1808:
1809: /**
1810: * Get module default
1811: *
1812: * @param string $key
1813: * @return mixed|null
1814: */
1815: public function get_default(string $key)
1816: {
1817: switch (strtolower($key)) {
1818: case 'ttl':
1819: return static::DNS_TTL;
1820: case 'apex-ns':
1821: return static::SHOW_NS_APEX;
1822: default:
1823: return null;
1824: }
1825: }
1826:
1827: /**
1828: * Get DNS minimum TTL
1829: *
1830: * @XXX DO NOT USE FOR VALIDATING TTL. DEFER TO API ALWAYS.
1831: * Modules have special restrictions: CloudFlare allows "1" for automatic
1832: *
1833: * @return int
1834: */
1835: public function min_ttl(): int
1836: {
1837: return static::DNS_TTL_MIN;
1838: }
1839:
1840: public function _delete()
1841: {
1842: if (!$this->configured()) {
1843: return info("DNS not configured for `%s', bypassing DNS hooks", $this->domain);
1844: }
1845: if (!$this->getServiceValue('ipinfo', 'namebased')) {
1846: $ips = (array)$this->getServiceValue('ipinfo', 'ipaddrs');
1847: // pass the domain to verify the PTR isn't detached incorrectly
1848: // from another domain that has recycled it
1849: $domain = $this->getServiceValue('siteinfo', 'domain');
1850: foreach ($ips as $ip) {
1851: $this->__deleteIP($ip, $domain);
1852: }
1853: }
1854:
1855: return true;
1856: }
1857:
1858: /**
1859: * Release PTR assignment from an IP
1860: *
1861: * @param $ip
1862: * @param string $domain confirm PTR rDNS matches domain
1863: * @return bool
1864: */
1865: protected function __deleteIP(string $ip, string $domain = null): bool
1866: {
1867: // @todo move to ipinfo
1868: return true;
1869: }
1870:
1871: /**
1872: * Remove zone from nameserver
1873: *
1874: * @param string $domain
1875: * @return bool
1876: */
1877: public function remove_zone_backend(string $domain): bool
1878: {
1879: return warn("cannot remove zone - DNS provider `%s' not configured fully",
1880: $this->getServiceValue('dns', 'provider', 'builtin'));
1881: }
1882:
1883: public function _edit()
1884: {
1885: if (!$this->configured()) {
1886: return info("DNS not configured for `%s', skipping edit hook", $this->domain);
1887: }
1888: $ipconf_old = $this->getAuthContext()->conf('ipinfo', 'old');
1889: $ipconf_new = $this->getAuthContext()->conf('ipinfo', 'new');
1890: $domainold = \array_get($this->getAuthContext()->conf('siteinfo', 'old'), 'domain');
1891: $domainnew = \array_get($this->getAuthContext()->conf('siteinfo', 'new'), 'domain');
1892: $dnsconf_old = $this->getAuthContext()->conf('dns', 'old');
1893: $dnsconf_new = $this->getAuthContext()->conf('dns', 'new');
1894: if (\array_get($dnsconf_old, 'provider') !== array_get($dnsconf_new, 'provider') || array_get($dnsconf_new, 'enabled') && !array_get($dnsconf_old, 'enabled'))
1895: {
1896: $this->provisionProviderChange();
1897: }
1898: // domain name change via auth_change_domain()
1899: $ip = (array)$this->publicIpWrapper('new', 4);
1900:
1901: // ensure promoted domain is not removed
1902: $aliasesnew = array_merge(
1903: array_get($this->getAuthContext()->conf('aliases', 'new'), 'aliases', []),
1904: [$domainnew]
1905: );
1906: $aliasesold = array_merge(
1907: array_get($this->getAuthContext()->conf('aliases', 'old'), 'aliases', []),
1908: [$domainold]
1909: );
1910:
1911: $ip = $this->get_public_ip();
1912:
1913: $add = array_diff($aliasesnew, $aliasesold);
1914: $rem = array_diff($aliasesold, $aliasesnew);
1915: foreach ($add as $a) {
1916: $this->add_zone($a, $ip);
1917: }
1918:
1919: foreach ($rem as $r) {
1920: $this->remove_zone($r);
1921: }
1922:
1923: if ($domainold !== $domainnew && !$ipconf_new['namebased']) {
1924: $this->__changePTR($ip[0], $domainnew, $domainold);
1925: }
1926: // enable ip hosting
1927: if ($ipconf_new === $ipconf_old && $this->getAuthContext()->conf('dns', 'old') === $this->getAuthContext('dns', 'new')) {
1928: return;
1929: }
1930:
1931: $ipadd = $ipdel = [];
1932: $ipnew = $this->publicIpWrapper('new', 4, 'ipaddrs');
1933: $ipold = $this->publicIpWrapper('old', 4, 'ipaddrs');
1934: if ($ipconf_old['namebased'] && !$ipconf_new['namebased']) {
1935: // enable ip hosting
1936: $ipadd = $ipnew;
1937: } else if (!$ipconf_old['namebased'] && $ipconf_new['namebased']) {
1938: // disable ip hosting
1939: $ipdel = $ipold;
1940: } else {
1941: // add/remove ip hosting
1942: $ipdel = array_diff((array)$ipold, (array)$ipnew);
1943: $ipadd = array_diff((array)$ipnew, (array)$ipold);
1944: }
1945:
1946: foreach ($ipdel as $ip) {
1947: // NB __changePTR is called before to update domain on change
1948: $this->__deleteIP($ip, $domainnew);
1949: }
1950:
1951: foreach ($ipadd as $ip) {
1952: $this->__addIP($ip, $domainnew);
1953: }
1954:
1955: // update nbaddrs
1956: $ipnew = $this->publicIpWrapper('new', 4, 'nbaddrs');
1957: $ipold = $this->publicIpWrapper('old', 4, 'nbaddrs');
1958: if ($ipconf_old['namebased'] && !$ipconf_new['namebased']) {
1959: // added ip-based hosting
1960: $ipadd = $this->publicIpWrapper('new', 4, 'ipaddrs');
1961: $ipdel = $ipold;
1962: } else if (!$ipconf_old['namebased'] && $ipconf_new['namebased']) {
1963: // removed ip-based hosting
1964: $ipdel = $this->publicIpWrapper('old', 4, 'ipaddrs');
1965: $ipadd = $ipnew;
1966: } else if ($ipconf_old['namebased'] === $ipconf_new['namebased'] && $ipconf_new['namebased']) {
1967: // no namebased change
1968: $ipdel = array_diff(
1969: (array)$this->publicIpWrapper('old', 4, 'nbaddrs'),
1970: (array)$this->publicIpWrapper('new', 4, 'nbaddrs')
1971: );
1972: $ipadd = array_diff(
1973: (array)$this->publicIpWrapper('new', 4, 'nbaddrs'),
1974: (array)$this->publicIpWrapper('old', 4, 'nbaddrs')
1975: );
1976: }
1977:
1978: // change DNS
1979: // there will always be a 1:1 pairing for IP addresses
1980: $domains = array_keys($this->web_list_domains());
1981: foreach ($ipadd as $newip) {
1982: $oldip = array_pop($ipdel);
1983: $newparams = array('ttl' => static::DNS_TTL, 'parameter' => $newip);
1984: foreach ($domains as $domain) {
1985: $records = $this->get_records_by_rr('A', $domain);
1986: foreach ($records as $r) {
1987: if ($r['parameter'] !== $oldip) {
1988: continue;
1989: }
1990: if (!$this->modify_record($r['domain'], $r['subdomain'], 'A', $oldip, $newparams)) {
1991: $frag = ltrim($r['subdomain'] . '.' . $r['domain'], '.');
1992: error('failed to modify record for `' . $frag . "'");
1993: } else {
1994: $pieces = array($r['subdomain'], $r['domain']);
1995: $host = trim(implode('.', $pieces), '.');
1996: info("modified `%s'", $host);
1997: }
1998: }
1999: }
2000: }
2001:
2002: return;
2003: }
2004:
2005: public function _create()
2006: {
2007: return true;
2008: }
2009:
2010: /**
2011: * Provision fresh zone configuration on provider change
2012: *
2013: * @return bool|void
2014: */
2015: private function provisionProviderChange()
2016: {
2017: if (!$this->configured()) {
2018: return info("DNS not configured for `%s', bypassing DNS hooks", $this->domain);
2019: }
2020: $ipinfo = $this->getAuthContext()->conf('ipinfo');
2021: $siteinfo = $this->getAuthContext()->conf('siteinfo');
2022: $domain = $siteinfo['domain'];
2023: $ip = (array)$this->publicIpWrapper('cur');
2024: $this->add_zone($domain, $ip[0]);
2025:
2026: if (!$ipinfo['namebased']) {
2027: $ips = array_merge($ip, (array)$ipinfo['ipaddrs']);
2028: foreach(array_unique($ips) as $ip) {
2029: $this->__addIP($ip, $siteinfo['domain']);
2030: }
2031: }
2032: if (!$this->domain_uses_nameservers($domain)) {
2033: warn("Domain `%s' doesn't use assigned nameservers. Change nameservers to %s",
2034: $domain, implode(',', $this->get_hosting_nameservers($domain))
2035: );
2036: }
2037:
2038: return true;
2039: }
2040:
2041: /**
2042: * Helper function to fetch IP address from config
2043: *
2044: * @param string $which
2045: * @param int $class
2046: * @param string|null $svcvar class
2047: * @return array
2048: */
2049: protected function publicIpWrapper(string $which = 'cur', int $class = 4, string $svcvar = null): array
2050: {
2051: if ($class !== 4 && $class !== 6) {
2052: fatal("Unknown IP class `%s'", $class);
2053: }
2054: if ($which === 'current') {
2055: $which = 'cur';
2056: }
2057: $confctx = $this->getAuthContext()->conf('dns', $which);
2058: $proxyvar = 'proxy' . ($class === 4 ? '' : '6') . 'addr';
2059: if ($info = ($confctx[$proxyvar] ?? null)) {
2060: return $info;
2061: }
2062: $svccls = 'ipinfo' . ($class === 6 ? '6' : '');
2063: $confctx = $this->getAuthContext()->conf($svccls, $which);
2064: if (null === $svcvar) {
2065: $svcvar = $confctx['namebased'] ? 'nbaddrs' : 'ipaddrs';
2066: }
2067: return $confctx[$svcvar];
2068: }
2069:
2070: /**
2071: * Add zone to DNS server
2072: *
2073: * @param string $domain
2074: * @param string $ip
2075: * @return bool
2076: */
2077: public function add_zone(string $domain, string $ip): bool
2078: {
2079: $domain = rtrim($domain, '\.');
2080: if (!$this->configured()) {
2081: return warn("cannot create DNS zone for `%s' - DNS is not configured for account", $domain);
2082: }
2083:
2084: $buffer = Error_Reporter::flush_buffer();
2085: if (($this->permission_level & PRIVILEGE_SITE) && $this->getOldServices('dns') !== $this->getNewServices('dns')) {
2086: // flush cache to prevent false positives on provider change
2087: $this->zoneExistsCache[$domain] = null;
2088: }
2089:
2090: if (($parent = $this->getParent($domain)) || $this->zone_exists($domain)) {
2091: Error_Reporter::set_buffer($buffer);
2092: if (!$parent) {
2093: return warn("DNS for zone `%s' already exists, not overwriting", $domain);
2094: }
2095: warn("DNS for zone `%(domain)s' parented under `%(parent)s', not extending as separate zone",
2096: ['domain' => $domain, 'parent' => $parent]);
2097: return $this->provision_domain(
2098: $parent,
2099: substr($domain, 0, \strlen($domain) - \strlen($parent) - 1),
2100: false
2101: );
2102: }
2103:
2104: // breaks module swap unit testing :\
2105: if ((posix_getuid() && !$this->query('dns_add_zone_backend', $domain, $ip)) ||
2106: (!posix_getuid() && !$this->add_zone_backend($domain, $ip))) {
2107: return false;
2108: }
2109:
2110: if (!$this->verified($domain) && !$this->verify($domain)) {
2111: warn("Domain `%s' must be verified before DNS activates", $domain);
2112: }
2113:
2114: // verify zone present; this is often async
2115: // @todo extend or exponential backoff?
2116: $data = null;
2117: $totalWait = 0;
2118: for ($i = 0; $i < 100; $i++) {
2119: if (null !== ($data = $this->get_zone_data($domain))) {
2120: break;
2121: }
2122: $wait = 250000 * $i;
2123: $totalWait += ($wait/1000000);
2124: if ($totalWait >= DNS_VALIDATION_WAIT) {
2125: break;
2126: }
2127: usleep($wait);
2128: }
2129: $this->zoneExistsCache[$domain] = $data !== null;
2130: if (!$this->zoneExistsCache[$domain]) {
2131: warn("%s master not reporting authoritative for zone `%s' - continuing to add DNS records",
2132: ucwords($this->get_provider()),
2133: $domain
2134: );
2135: }
2136: if ($this->permission_level & PRIVILEGE_ADMIN) {
2137: return warn('Zone added as administrator - unable to provision records automatically');
2138: }
2139:
2140: $nameservers = $this->get_hosting_nameservers($domain);
2141:
2142: unset($data['SOA']);
2143:
2144: $cb = static function ($rec) use ($nameservers) {
2145: return $rec['subdomain'] || !in_array(rtrim($rec['parameter'], '.'), $nameservers, true);
2146: };
2147:
2148: if (array_first($data['NS'] ?? [], $cb) || count(array_diff_key($data, ['NS'])) > 0) {
2149: return $this->reset($domain);
2150: }
2151:
2152: return $this->provision_domain($domain, '', false);
2153: }
2154:
2155: /**
2156: * Provision records for domain
2157: *
2158: * @param string $domain domain
2159: * @param string $subdomain subdomain
2160: * @param bool $recordCheck thorough record check prior to provisioning
2161: * @return bool
2162: */
2163: private function provision_domain(string $domain, string $subdomain = '', bool $recordCheck = true): bool
2164: {
2165: dlog("Provisioning zone: %(zone)s, subdomain: %(sub)s using %(provider)s",
2166: [
2167: 'zone' => $domain,
2168: 'sub' => $subdomain,
2169: 'provider' => $this->get_provider()
2170: ]);
2171: $records = $this->provisioning_records($domain, $subdomain) +
2172: append_config($this->email_provisioning_records($domain, $subdomain));
2173: foreach ($records as $record) {
2174: if ($recordCheck && $this->record_exists($domain, $record['name'], $record['rr'], $record['parameter'])) {
2175: continue;
2176: }
2177:
2178: if (!$this->add_record($domain, $record['name'], $record['rr'], $record['parameter'],
2179: $record['ttl'])) {
2180: warn("Failed to add DNS record `%(hostname)s' on `%(name)s' (rr: %(rr)s, parameter: %(parameter)s)", [
2181: 'hostname' => ltrim('.', $subdomain . '.' . $domain),
2182: 'name' => $record['name'],
2183: 'rr' => $record['rr'],
2184: 'parameter' => $record['parameter']
2185: ]);
2186: }
2187: }
2188:
2189: return true;
2190: }
2191:
2192: /**
2193: * Hostname has parent
2194: *
2195: * @param string $hostname
2196: * @return bool
2197: */
2198: public function parented(string $hostname): bool
2199: {
2200: return $this->getParent($hostname) !== null;
2201: }
2202:
2203: /**
2204: * Get hostname parent
2205: *
2206: * @param string $hostname
2207: * @return string|null
2208: */
2209: protected function getParent(string $hostname): ?string
2210: {
2211: // let's assume admin knows what she's doing
2212: if ($this->permission_level & PRIVILEGE_ADMIN) {
2213: return null;
2214: }
2215: $count = substr_count($hostname, '.');
2216: if ($count < 2) {
2217: return null;
2218: }
2219: $pieces = explode('.', $hostname, $count);
2220: // aliases:list-shared-domains lists domains *attached* with a defined document root,
2221: // we need to inspect all aliases attached to the site as well as the domain itself to see if this
2222: // domain is explicitly defined or fallthrough to /var/www/html
2223: $domains = [$this->getConfig('siteinfo', 'domain')] + append_config($this->aliases_list_aliases());
2224:
2225: $tmp = array_pop($pieces);
2226: do {
2227: if (\in_array($tmp, $domains, true)) {
2228: return $tmp;
2229: }
2230: $chk = array_pop($pieces);
2231: $tmp = "$chk.$tmp";
2232: } while ($tmp !== $hostname);
2233:
2234: return null;
2235: }
2236:
2237: /**
2238: * Get public IPv4 address(es)
2239: *
2240: * @return string|array|null single IP, multiple IPs, or null if not configured
2241: */
2242: public function get_public_ip()
2243: {
2244: $addr = $this->getServiceValue('dns','proxyaddr') ?: $this->common_get_ip_address();
2245: return \count($addr) > 1 ? $addr : ($addr[0] ?? null);
2246: }
2247:
2248: /**
2249: * Get public IPv6 address(es)
2250: *
2251: * @return string|array|null single IP, multiple IPs, or null if not configured
2252: */
2253: public function get_public_ip6()
2254: {
2255: $addr = $this->getServiceValue('dns', 'proxy6addr') ?: $this->common_get_ip6_address();
2256:
2257: return \count($addr) > 1 ? $addr : ($addr[0] ?? null);
2258: }
2259:
2260: /**
2261: * Get base provisioning DNS records to setup automatically
2262: *
2263: * @param string $zone zone name
2264: * @param string $subdomain
2265: * @return \Opcenter\Dns\Record[]
2266: */
2267: public function provisioning_records(string $zone, string $subdomain = ''): array
2268: {
2269: if (!IS_CLI) {
2270: return $this->query('dns_provisioning_records', $zone);
2271: }
2272:
2273: if (!$this->getConfig('dns','enabled')) {
2274: return [];
2275: }
2276:
2277: $ttl = $this->dns_get_default('ttl');
2278: $ips = [];
2279: if ($this->getServiceValue('ipinfo', 'enabled')) {
2280: $ips += (array)$this->get_public_ip();
2281: }
2282: if ($this->getServiceValue('ipinfo6', 'enabled')) {
2283: $ips += append_config((array)$this->get_public_ip6());
2284: }
2285:
2286: $template = BladeLite::factory('templates/dns')->render('dns', [
2287: 'svc' => \Opcenter\SiteConfiguration::shallow($this->getAuthContext()),
2288: 'ttl' => $ttl,
2289: 'zone' => $zone,
2290: 'subdomain' => $subdomain,
2291: 'hostname' => ltrim(implode('.', [$subdomain, $zone]), '.'),
2292: 'ips' => (array)$ips
2293: ]);
2294: $regex = Regex::compile(Regex::DNS_AXFR_REC_DOMAIN, [
2295: 'rr' => implode('|', $this->dns_permitted_records() + [99999 => 'SOA']),
2296: 'domain' => $zone
2297: ]);
2298: if (!preg_match_all($regex, $template, $matches, PREG_SET_ORDER)) {
2299: debug('No provisioning records discovered from template');
2300:
2301: return [];
2302: }
2303: $records = [];
2304: foreach ($matches as $record) {
2305: $records[] = static::createRecord($zone, [
2306: 'ttl' => $record['ttl'],
2307: 'parameter' => $record['parameter'],
2308: 'rr' => $record['rr'],
2309: 'name' => rtrim($record['subdomain'], '.')
2310: ]);
2311: }
2312:
2313: return $records;
2314: }
2315:
2316: /**
2317: * Get DNS UUID for host
2318: *
2319: * @return null|string
2320: */
2321: public function uuid(): ?string
2322: {
2323: return DNS_UUID ?: null;
2324: }
2325:
2326: /**
2327: * Add an IP address to hosting
2328: *
2329: * @param string $ip
2330: * @param string $hostname
2331: * @return bool
2332: */
2333: protected function __addIP(string $ip, string $hostname = ''): bool
2334: {
2335: return true;
2336: }
2337:
2338: /**
2339: * Lookup and compare nameservers for domain to host
2340: *
2341: * @param string $domain
2342: * @return bool
2343: */
2344: public function domain_uses_nameservers(string $domain): bool
2345: {
2346: if (!preg_match(Regex::DOMAIN, $domain)) {
2347: return error("malformed domain `%s'", $domain);
2348: }
2349: $hostingns = $this->get_hosting_nameservers($domain);
2350: if (!$hostingns) {
2351: // not configured under [dns] hosting_ns in config.ini
2352: return true;
2353: }
2354: $dns = mute(function () use ($domain) {
2355: return $this->get_authns_from_host($domain);
2356: });
2357: $found = false;
2358: if (!$dns) {
2359: return $found;
2360: }
2361: if (DNS_VANITY_NS) {
2362: $hostingns += append_config(DNS_VANITY_NS);
2363: }
2364: foreach ($dns as $ns) {
2365: if (in_array($ns, $hostingns, true)) {
2366: return true;
2367: }
2368: }
2369:
2370: return false;
2371: }
2372:
2373: /**
2374: * Get configured hosting nameservers
2375: *
2376: * Toggled via config.ini > [dns] > hosting_ns
2377: *
2378: * @return array
2379: */
2380: public function get_hosting_nameservers(string $domain = null): array
2381: {
2382: return DNS_HOSTING_NS;
2383: }
2384:
2385: /**
2386: * Get authoritative nameservers for given hostname
2387: *
2388: * Example response:
2389: * Array
2390: * (
2391: * [0] => Array
2392: * (
2393: * [host] => ns2.apisnetworks.com
2394: * [type] => A
2395: * [ip] => 96.126.122.82
2396: * [class] => IN
2397: * [ttl] => 83137
2398: * )
2399: * )
2400: *
2401: * @param string $host hostname
2402: * @return array|null authoritative nameservers or resolver chain incomplete
2403: */
2404: public function get_authns_from_host($host): ?array
2405: {
2406: $nameservers = static::RECURSIVE_NAMESERVERS;
2407: $authns = silence(static function () use ($host, $nameservers) {
2408: return dns_get_record($host, static::record2const('ns'), $nameservers);
2409: });
2410: if ($authns) {
2411: // domain is properly delegated, nameserver returns affirmative
2412: $tmp = array();
2413: foreach ($authns as $a) {
2414: if ($a['type'] == 'NS') {
2415: $tmp[] = $a['target'];
2416: }
2417: }
2418:
2419: return $tmp;
2420: }
2421:
2422: // domain delegated to hosting nameservers, but hosting servers don't
2423: // have dns provisioned yet for domain
2424: //
2425: // crawl
2426: $resolver = new Net_DNS2_Resolver([
2427: 'nameservers' => $nameservers,
2428: 'recurse' => true
2429: ]);
2430: try {
2431: $nameservers = $this->get_authns_from_host_recursive($host, $resolver);
2432: } catch (Net_DNS2_Exception $e) {
2433: warn("NS lookup failed for `%s': %s", $host, $e->getMessage());
2434:
2435: return array();
2436: }
2437:
2438: return $nameservers;
2439: }
2440:
2441: /**
2442: * Fallback authoritative NS lookup
2443: *
2444: * Crawl the entire TLD hierarchy to find the last known nameserver
2445: *
2446: * @param string $host
2447: * @param Net_DNS2_Resolver $resolver
2448: * @param string $seen
2449: * @return array|null nameservers or null if resolve failed before reaching end
2450: */
2451: protected function get_authns_from_host_recursive($host, Net_DNS2_Resolver $resolver, $seen = ''): ?array
2452: {
2453: $components = explode('.', $host);
2454: $nameservers = null;
2455: try {
2456: $lookup = array_pop($components) . '.' . $seen;
2457: $res = silence(static function () use ($resolver, $lookup) {
2458: return $resolver->query($lookup, 'NS');
2459: });
2460: if ($res->answer) {
2461: $nameservers = array_filter(array_map(static function ($arr) {
2462: return gethostbyname($arr->nsdname);
2463: }, $res->answer));
2464: $resolver->setServers($nameservers);
2465: }
2466: } catch (Net_DNS2_Exception | \Error $e) {
2467: if ($components) {
2468: if (false !== strpos($e->getMessage(), 'member function open() on null')) {
2469: // invalid tld extension
2470: return null;
2471: }
2472: // resolver chain broken
2473: warn("failed to recurse on `%s': %s", $lookup, $e->getMessage());
2474: }
2475:
2476: return null;
2477: }
2478: if (!$components) {
2479: return array_map(static function ($a) {
2480: return $a->nsdname;
2481: }, $res->authority);
2482: }
2483: $resolver->recurse = 0;
2484:
2485: return $this->get_authns_from_host_recursive(implode('.', $components), $resolver, $lookup);
2486:
2487: }
2488:
2489: /**
2490: * Remove a zone from DNS management
2491: *
2492: * No direct access to method as site.
2493: *
2494: * Use EditDomain or aliases:remove-domain to manage zone attachments.
2495: * Certain DNS providers may also soft delete zones requiring manual removal.
2496: *
2497: * @param string $domain
2498: * @param bool $force remove zone as admin bypassing all validation checks
2499: * @return bool
2500: */
2501: public function remove_zone(string $domain, bool $force = false): bool
2502: {
2503: if ($this->permission_level & PRIVILEGE_SITE && !$this->owned_zone($domain)) {
2504: return error("Unusual call to remove_zone() with specified unowned zone `%s' blocked", $domain);
2505: }
2506:
2507: if (!$this->configured()) {
2508: return warn("cannot remove DNS zone for `%s' - DNS is not configured for account", $domain);
2509: }
2510:
2511: if ($force) {
2512: return $this->remove_zone_backend($domain);
2513: }
2514:
2515: if (null !== ($parent = $this->getParent($domain))) {
2516: // @TODO cleaning up a parented domain and its children will be messy
2517: return true;
2518: }
2519:
2520:
2521: if (!$this->zone_exists($domain)) {
2522: return true;
2523: }
2524: if (false === ($record = $this->get_records(static::UUID_RECORD, 'TXT', $domain))) {
2525: // zone axfr failed?
2526: return warn("Zone transfer failed - ignoring removal of `%s'", $domain);
2527: }
2528: if (null !== ($uuid = $this->uuid()) && $uuid !== ($record = array_get($record, '0.parameter', null))) {
2529: return warn("Bypassing DNS removal. DNS UUID for `%s' is `%s'. Server UUID is `%s'", $domain, $record,
2530: $uuid);
2531: }
2532: if ($ret = $this->query('dns_remove_zone_backend', $domain)) {
2533: unset($this->zoneExistsCache[$domain], $this->zoneCache[$domain]);
2534: }
2535:
2536: return $ret;
2537: }
2538:
2539: /**
2540: * Test credentials against configured module
2541: *
2542: * @param string $provider DNS provider to test
2543: * @param mixed $key authentication key
2544: * @return bool
2545: */
2546: public function auth_test(string $provider = DNS_PROVIDER_DEFAULT, $key = ''): bool {
2547: if (!is_debug()) {
2548: return error('Only available in debug mode');
2549: }
2550:
2551: if (!\Opcenter\Dns::providerValid($provider)) {
2552: return error("DNS provider `%s' invalid", $provider);
2553: }
2554:
2555: if (!\Opcenter\Dns::providerHasHelper($provider)) {
2556: return true;
2557: }
2558: $helper = \Opcenter\Dns::getProviderHelper($provider);
2559: $ctx = new \Opcenter\Service\ConfigurationContext('dns', new SiteConfiguration($this->site));
2560: return $helper->valid($ctx, $key);
2561: }
2562:
2563: /**
2564: * Change PTR name
2565: *
2566: * @param string $ip IP address to alter
2567: * @param string $hostname new PTR name
2568: * @param string $chk optional check hostname to verify
2569: * @return bool
2570: */
2571: protected function __changePTR(string $ip, string $hostname, string $chk = ''): bool
2572: {
2573: return true;
2574: }
2575:
2576: /**
2577: * Query hosting nameservers for DNS records of named category
2578: *
2579: * {@see get_zone_information()}
2580: *
2581: * example:
2582: * Account has two MX records assigned, first the
2583: * default MX on debug.com, and a second user-created MX on debug.debug.com.
2584: * debug.debug.com was designated an e-mail domain through Mail Routing
2585: * {@see Email_Module::add_virtual_transport()}
2586: *
2587: * apis> $c->dns_get_records_by_rr("MX");
2588: *
2589: * array(2)
2590: * apis>
2591: * array(2) {
2592: * [0]=>
2593: * array(4) {
2594: * ["name"]=>
2595: * string(10) "debug.com."
2596: * ["class"]=>
2597: * string(2) "IN"
2598: * ["ttl"]=>
2599: * string(5) "86400"
2600: * ["parameter"]=>
2601: * string(18) "10 mail.debug.com."
2602: * }
2603: * [1]=>
2604: * array(4) {
2605: * ["name"]=>
2606: * string(16) "debug.debug.com."
2607: * ["class"]=>
2608: * string(2) "IN"
2609: * ["ttl"]=>
2610: * string(5) "86400"
2611: * ["parameter"]=>
2612: * string(24) "10 mail.debug.debug.com."
2613: * }
2614: * }
2615: *
2616: * @param string $rr resource record [MX, A, AAAA, CNAME, DNAME, TXT, SRV]
2617: * @param string $zone
2618: * @return array|null resource records
2619: *
2620: */
2621: public function get_records_by_rr(string $rr, string $zone = null): ?array
2622: {
2623: if (null === $zone) {
2624: $zone = $this->domain;
2625: }
2626:
2627: if (!$this->owned_zone($zone)) {
2628: if (!$this->owned_zone($rr)) {
2629: error('access denied - cannot view zone `' . $zone . "'");
2630:
2631: return null;
2632: }
2633: // confusing half-assed backwards
2634: // accept arguments in either form
2635: $t = $rr;
2636: $rr = $zone;
2637: $zone = $t;
2638: }
2639:
2640: $rr = strtoupper($rr);
2641: if ($rr !== 'ANY' && !in_array($rr, static::$permitted_records, true)) {
2642: error('%s: invalid resource record type', $rr);
2643:
2644: return null;
2645: }
2646:
2647: $recs = $this->get_zone_information($zone);
2648: if (!$recs) {
2649: return array();
2650: }
2651: if ($rr == 'ANY') {
2652: return $recs;
2653: }
2654: if (!isset($recs[strtoupper($rr)])) {
2655: return array();
2656: }
2657:
2658: return $recs[strtoupper($rr)];
2659: }
2660:
2661: /**
2662: * Domain has been verified and permitted addition
2663: *
2664: * @param string $domain
2665: * @return bool
2666: */
2667: public function verified(string $domain): bool
2668: {
2669: return true;
2670: }
2671:
2672: /**
2673: * Perform verification on domain
2674: *
2675: * @param string $domain
2676: * @return bool
2677: */
2678: public function verify(string $domain): bool
2679: {
2680: return true;
2681: }
2682:
2683: /**
2684: * Get DNS zone challenges
2685: *
2686: * @param string $domain
2687: * @return array ns, txt correspond to nameserver delegation, TXT record presence
2688: */
2689: public function challenges(string $domain): array
2690: {
2691: return [];
2692: }
2693:
2694: /**
2695: * Send raw driver-specific command
2696: *
2697: * @param string $param
2698: * @param mixed $val
2699: * @return mixed
2700: */
2701: public function raw(string $param, $val)
2702: {
2703: return error("Command `%s' not understood", $param);
2704: }
2705:
2706: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
2707: {
2708: return true;
2709: }
2710:
2711: public function _create_user(string $user)
2712: {
2713: return;
2714: }
2715:
2716: public function _delete_user(string $user)
2717: {
2718: return;
2719: }
2720:
2721: public function _edit_user(string $userold, string $usernew, array $oldpwd)
2722: {
2723: return;
2724: }
2725: }