1: <?php
2: declare(strict_types=1);
3:
4: /**
5: * +------------------------------------------------------------+
6: * | apnscp |
7: * +------------------------------------------------------------+
8: * | Copyright (c) Apis Networks |
9: * +------------------------------------------------------------+
10: * | Licensed under Artistic License 2.0 |
11: * +------------------------------------------------------------+
12: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
13: * +------------------------------------------------------------+
14: */
15:
16: use Daphnie\Collector;
17: use Daphnie\Metrics\Apache as ApacheMetrics;
18: use Module\Skeleton\Contracts\Hookable;
19: use Module\Skeleton\Contracts\Reactive;
20: use Module\Skeleton\Contracts\Tasking;
21: use Module\Support\Webapps\App\UIPanel;
22: use Module\Support\Webapps\MetaManager;
23: use Opcenter\Filesystem;
24: use Opcenter\Http\Apache;
25: use Opcenter\Http\Apache\Map;
26: use Opcenter\Http\Apache\Maps\HSTS\Mode as HSTSMode;
27: use Opcenter\Http\Apache\Maps\Tls as TlsMap;
28: use Opcenter\Provisioning\ConfigurationWriter;
29:
30: /**
31: * Web server and package management
32: *
33: * @package core
34: */
35: class Web_Module extends Module_Skeleton implements Hookable, Reactive, Tasking
36: {
37: const DEPENDENCY_MAP = [
38: 'ipinfo',
39: 'ipinfo6',
40: 'siteinfo',
41: 'dns',
42: // required for PHP-FPM cgroup binding
43: 'cgroup'
44: ];
45:
46: // primary domain document root
47: const MAIN_DOC_ROOT = '/var/www/html';
48: const WEB_USERNAME = APACHE_USER;
49: const WEB_GROUPID = APACHE_GID;
50: const PROTOCOL_MAP = '/etc/httpd/conf/http10';
51: const SUBDOMAIN_ROOT = '/var/subdomain';
52:
53: protected $pathCache = [];
54: protected $service_cache;
55: protected $exportedFunctions = [
56: '*' => PRIVILEGE_SITE,
57: 'add_subdomain_raw' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC,
58: 'host_html_dir' => PRIVILEGE_SITE | PRIVILEGE_USER,
59: 'reload' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
60: 'status' => PRIVILEGE_ADMIN,
61: 'get_sys_user' => PRIVILEGE_ALL,
62: 'capture' => PRIVILEGE_SERVER_EXEC | PRIVILEGE_SITE,
63: 'inventory_capture' => PRIVILEGE_ADMIN
64: ];
65: protected $hostCache = [];
66:
67: /**
68: * void __construct(void)
69: *
70: * @ignore
71: */
72: public function __construct()
73: {
74: parent::__construct();
75:
76: if (!DAV_APACHE) {
77: $this->exportedFunctions += [
78: 'bind_dav' => PRIVILEGE_NONE,
79: 'unbind_dav' => PRIVILEGE_NONE,
80: 'list_dav_locations' => PRIVILEGE_NONE,
81: ];
82: }
83:
84: }
85:
86: public function __wakeup()
87: {
88: $this->pathCache = [];
89: }
90:
91: /**
92: * User capability is enabled for web service
93: *
94: * Possible values subdomain, cgi
95: *
96: * @param string $user user
97: * @param string $svc service name possible values subdomain, cgi
98: * @return bool
99: */
100: public function user_service_enabled(string $user, string $svc): bool
101: {
102: if (!IS_CLI) {
103: return $this->query('web_user_service_enabled',
104: array($user, $svc));
105: }
106: if ($svc != 'cgi' && $svc != 'subdomain') {
107: return error('Invalid service name `%s\'', $svc);
108: }
109:
110: return true;
111: }
112:
113: /**
114: * Sweep all subdomains to confirm accessibility
115: *
116: * @return array list of subdomains invalid
117: */
118: public function validate_subdomains(): array
119: {
120: $prefix = $this->domain_fs_path();
121: $invalid = array();
122: foreach (glob($prefix . self::SUBDOMAIN_ROOT . '/*/') as $entry) {
123: $subdomain = basename($entry);
124: if ((is_link($entry . '/html') || is_dir($entry . '/html')) && file_exists($entry . '/html')) {
125: continue;
126: }
127: warn("inaccessible subdomain `%s' detected", $subdomain);
128: $file = Opcenter\Filesystem::rel2abs($entry . '/html',
129: readlink($entry . '/html'));
130: $invalid[$subdomain] = substr($file, strlen($prefix));
131: }
132:
133: return $invalid;
134: }
135:
136: /**
137: * Check if hostname is a subdomain
138: *
139: * @param string $hostname
140: * @return bool
141: */
142: public function is_subdomain(string $hostname): bool
143: {
144: if (false !== strpos($hostname, '.') && !preg_match(Regex::SUBDOMAIN, $hostname)) {
145: return false;
146: }
147:
148: return is_dir($this->domain_fs_path(self::SUBDOMAIN_ROOT . "/$hostname"));
149: }
150:
151: public function subdomain_accessible($subdomain)
152: {
153: if ($subdomain[0] == '*') {
154: $subdomain = substr($subdomain, 2);
155: }
156:
157: return file_exists($this->domain_fs_path(self::SUBDOMAIN_ROOT . "/$subdomain/html")) &&
158: is_executable($this->domain_fs_path(self::SUBDOMAIN_ROOT . "/$subdomain/html"));
159: }
160:
161: /**
162: * Get assigned web user for host/docroot
163: *
164: * @see get_sys_user() for HTTPD user
165: *
166: * @param string $hostname hostname or path
167: * @param string $path
168: * @return string
169: */
170: public function get_user(string $hostname, string $path = ''): string
171: {
172: // @TODO gross oversimplification of path check,
173: // assert path is readable and if not default to apache,webuser
174: if ($hostname[0] === '/' && $path) {
175: warn('$path variable should be omitted when specifying docroot');
176: }
177: return $this->getServiceValue('apache', 'webuser', static::WEB_USERNAME);
178: }
179:
180: /**
181: * Get HTTP system user
182: *
183: * HTTP user has limited requirements except read.
184: * In a PHP-FPM environment, no write access is permitted by this user.
185: *
186: * @return string
187: */
188: public function get_sys_user(): string
189: {
190: return static::WEB_USERNAME;
191: }
192:
193: /**
194: * Retrieve document root for given host
195: *
196: * Doubly useful to evaluate where documents
197: * will be served given a particular domain
198: *
199: * @param string $hostname HTTP host
200: * @param string $path optional path component
201: * @return string document root path
202: */
203: public function normalize_path(string $hostname, string $path = ''): ?string
204: {
205: if (!IS_CLI && isset($this->pathCache[$hostname][$path])) {
206: return $this->pathCache[$hostname][$path];
207: }
208: $prefix = $this->domain_fs_path();
209: if (false === ($docroot = $this->get_docroot($hostname, $path))) {
210: return null;
211: }
212:
213: $checkpath = $prefix . DIRECTORY_SEPARATOR . $docroot;
214: clearstatcache(true, $checkpath);
215: if (\Util_PHP::is_link($checkpath)) {
216: // take the referent unless the path doesn't exist
217: // let the API figure out what to do with it
218: if (false === ($checkpath = realpath($checkpath))) {
219: return $docroot;
220: }
221: if (0 !== strpos($checkpath, $prefix)) {
222: error("docroot for `%s/%s' exceeds site root", $hostname, $path);
223:
224: return null;
225: }
226: $docroot = substr($checkpath, strlen($prefix));
227: }
228: if (!file_exists($checkpath)) {
229: $subpath = dirname($checkpath);
230: if (!file_exists($subpath)) {
231: error("invalid domain `%s', docroot `%s' does not exist", $hostname, $docroot);
232:
233: return null;
234: }
235: }
236: if (!isset($this->pathCache[$hostname])) {
237: $this->pathCache[$hostname] = [];
238: }
239:
240: $this->pathCache[$hostname][$path] = $docroot;
241:
242: return $docroot ?: null;
243: }
244:
245: /**
246: * Get document root from hostname
247: *
248: * @param string $hostname
249: * @param string $path
250: * @return bool|string
251: */
252: public function get_docroot(string $hostname, string $path = '')
253: {
254: $domains = $this->list_domains();
255: $path = ltrim($path, '/');
256: if (isset($domains[$hostname])) {
257: return rtrim($domains[$hostname] . '/' . $path, '/');
258: }
259:
260: $domains = $this->list_subdomains();
261: if (array_key_exists($hostname, $domains)) {
262: // missing symlink will report as NULL
263: if (null !== $domains[$hostname]) {
264: return rtrim($domains[$hostname] . '/' . $path, '/');
265: }
266: $info = $this->subdomain_info($hostname);
267:
268: return rtrim($info['path'] . '/' . $path, '/');
269: }
270:
271: if (0 === strncmp($hostname, "www.", 4)) {
272: $tmp = substr($hostname, 4);
273:
274: return $this->get_docroot($tmp, $path);
275: }
276: if (false !== strpos($hostname, '.')) {
277: $host = $this->split_host($hostname);
278: if (!empty($host['subdomain']) && $this->subdomain_exists($host['subdomain'])) {
279: return $this->get_docroot($host['subdomain'], $path);
280: }
281:
282: }
283:
284: return error("unknown domain `$hostname'");
285: }
286:
287: /**
288: * Import subdomains from domain
289: *
290: * @param string $target target domain to import into
291: * @param string $src source domain
292: * @return bool
293: */
294: public function import_subdomains_from_domain(string $target, string $src): bool
295: {
296: $domains = $this->web_list_domains();
297: foreach ([$target, $src] as $chk) {
298: if (!isset($domains[$chk])) {
299: return error("Unknown domain `%s'", $chk);
300: }
301: }
302: if ($target === $src) {
303: return error('Cannot import - target is same as source');
304: }
305: foreach ($this->list_subdomains('local', $target) as $subdomain => $path) {
306: $this->remove_subdomain($subdomain);
307: }
308: foreach ($this->list_subdomains('local', $src) as $subdomain => $path) {
309: if ($src !== substr($subdomain, -\strlen($src))) {
310: warn("Subdomain attached to `%s' does not match target domain `%s'??? Skipping", $subdomain, $target);
311: continue;
312: }
313: $subdomain = substr($subdomain, 0, -\strlen($src)) . $target;
314: $this->add_subdomain($subdomain, $path);
315: }
316:
317: return true;
318: }
319:
320: /**
321: * List subdomains on the account
322: *
323: * Array format- subdomain => path
324: *
325: * @param string $filter filter by "global", "local", "path"
326: * @param string|array $domains only show subdomains bound to domain or re for path
327: * @return array|false matching subdomains
328: */
329: public function list_subdomains(string $filter = '', $domains = array())
330: {
331: if ($filter && $filter != 'local' && $filter != 'global' && $filter != 'path') {
332: return error("invalid filter mode `%s'", $filter);
333: }
334: $subdomains = array();
335: if ($filter == 'path') {
336: $re = $domains;
337: if ($re && $re[0] !== $re[-1]) {
338: $re = '!' . preg_quote($re, '!') . '!';
339: }
340: } else {
341: $re = null;
342: }
343: if ($domains && !is_array($domains)) {
344: $domains = array($domains);
345: }
346: foreach (glob($this->domain_fs_path() . self::SUBDOMAIN_ROOT . '/*', GLOB_NOSORT) as $entry) {
347: $subdomain = basename($entry);
348: $path = '';
349: if (is_link($entry . '/html') || is_dir($entry . '/html') /* smh... */) {
350: if (!is_link($entry . '/html')) {
351: warn(':subdomain_is_dir', "subdomain `%s' doc root is directory", $subdomain);
352: $path = Opcenter\Http\Apache::makeSubdomainPath($subdomain);
353: } else {
354: $path = (string)substr(Opcenter\Filesystem::rel2abs($entry . '/html',
355: readlink($entry . '/html')),
356: strlen($this->domain_fs_path()));
357: }
358: }
359: if ($filter && ($filter == 'local' && !strpos($subdomain, '.') ||
360: $filter == 'global' && strpos($subdomain, '.'))
361: ) {
362: continue;
363: }
364: if ($filter == 'path' && !preg_match($re, $path)) {
365: continue;
366: }
367:
368: if ($filter !== 'path' && strpos($subdomain, '.') && $domains) {
369: $skip = 0;
370: foreach ($domains as $domain) {
371: $lendomain = strlen($domain);
372: if (substr($subdomain, -$lendomain) != $domain) {
373: $skip = 1;
374: break;
375: }
376: }
377: if ($skip) {
378: continue;
379: }
380: }
381:
382: $subdomains[$subdomain] = $path;
383: }
384:
385: asort($subdomains, SORT_LOCALE_STRING);
386:
387: return $subdomains;
388: }
389:
390: /**
391: * Get detailed information on a subdomain
392: *
393: * Response:
394: * path (string): filesystem location
395: * active (bool): subdomain references accessible directory
396: * user (string): owner of subdomain
397: * type (string): local, global, or fallthrough
398: *
399: * @param string $subdomain
400: * @return array
401: */
402: public function subdomain_info(string $subdomain): array
403: {
404: if ($subdomain[0] == '*') {
405: $subdomain = substr($subdomain, 2);
406: }
407:
408: if (!$subdomain) {
409: return error('no subdomain provided');
410: }
411: if (!$this->subdomain_exists($subdomain)) {
412: return error($subdomain . ': subdomain does not exist');
413: }
414:
415: $info = array(
416: 'path' => null,
417: 'active' => false,
418: 'user' => null,
419: 'type' => null
420: );
421:
422: $fs_location = $this->domain_fs_path() . self::SUBDOMAIN_ROOT . "/$subdomain";
423:
424: if (!strpos($subdomain, '.')) {
425: $type = 'global';
426: } else if (!array_key_exists($subdomain, $this->list_domains())) {
427: $type = 'local';
428: } else {
429: $type = 'fallthrough';
430: }
431:
432: $info['type'] = $type;
433: $link = $fs_location . '/html';
434: /**
435: * link does not exist
436: * test first if no symlink referent is present,
437: * then verify (is_link()) that the $link is not present
438: * file_exists() checks the referent
439: */
440: if (!file_exists($link) && !is_link($link)) {
441: return $info;
442: }
443: // case when <subdomain>/html is directory instead of symlink
444: if (!is_link($link)) {
445: $path = $link;
446: } else {
447: clearstatcache(true, $link);
448: $path = Opcenter\Filesystem::rel2abs($link, readlink($link));
449: }
450: $info['path'] = $this->file_canonicalize_site($path);
451:
452: $info['active'] = file_exists($link) && is_readable($link);
453: $stat = $this->file_stat($info['path']);
454: if (!$stat) {
455: return $info;
456: }
457: $info['user'] = $stat['owner'];
458:
459: return $info;
460: }
461:
462: /**
463: * Check if named subdomain exists
464: *
465: * Fallthrough, local, and global subdomain patterns
466: * are valid
467: *
468: * @see add_subdomain()
469: *
470: * @param string $subdomain
471: * @return bool
472: */
473: public function subdomain_exists(string $subdomain): bool
474: {
475: if ($subdomain[0] === '*') {
476: $subdomain = substr($subdomain, 2);
477: }
478: $path = $this->domain_fs_path(self::SUBDOMAIN_ROOT . "/$subdomain");
479:
480: return file_exists($path);
481: }
482:
483: public function list_domains(): array
484: {
485: $domains = array_merge(
486: array($this->getConfig('siteinfo', 'domain') => self::MAIN_DOC_ROOT),
487: $this->aliases_list_shared_domains()
488: );
489:
490: return $domains + array_fill_keys(
491: array_keys(array_diff_key(array_flip($this->aliases_list_aliases()), $domains)),
492: self::MAIN_DOC_ROOT
493: );
494:
495: }
496:
497: /**
498: * Split hostname into subdomain + domain components
499: *
500: * @param string $hostname
501: * @return array|bool components or false on error
502: */
503: public function split_host(string $host)
504: {
505: if (!preg_match(Regex::HTTP_HOST, $host)) {
506: return error([':err_split_host_invalid', "can't split, invalid host `%s'"], $host);
507: }
508: $split = array(
509: 'subdomain' => '',
510: 'domain' => $host
511: );
512: $domain_lookup = $this->list_domains();
513: if (!$host || isset($domain_lookup[$host])) {
514: return $split;
515: }
516:
517: $offset = 0;
518: $level_sep = strpos($host, '.');
519: do {
520: $subdomain = substr($host, $offset, $level_sep - $offset);
521: $domain = substr($host, $level_sep + 1);
522: if (isset($domain_lookup[$domain])) {
523: break;
524: }
525:
526: $offset = $level_sep + 1;
527: $level_sep = strpos($host, '.', $offset + 1);
528: } while ($level_sep !== false);
529:
530: if (!isset($domain_lookup[$domain])) {
531: return $split;
532: }
533: $split['subdomain'] = (string)substr($host, 0, $offset) . $subdomain;
534: $split['domain'] = $domain;
535:
536: return $split;
537: }
538:
539: /**
540: * Get the normalized hostname from a global subdomain
541: *
542: * @param string $host
543: * @return string
544: */
545: public function normalize_hostname(string $host): string
546: {
547: if (false !== strpos($host, '.')) {
548: return $host;
549: }
550:
551: // @todo track domain/entry_domain better in contexted roles
552: return $host . '.' .
553: ($this->inContext() ? $this->domain : \Session::get('entry_domain', $this->domain));
554: }
555:
556: // {{{ remove_user_subdomain()
557:
558: /**
559: * Get information on a domain
560: *
561: * Info elements
562: * path (string): filesystem path
563: * active (bool): domain is active and readable
564: * user (string): owner of directory
565: *
566: * @param string $domain
567: * @return array|false domain information
568: */
569: public function domain_info(string $domain)
570: {
571: if (!$this->domain_exists($domain)) {
572: return error($domain . ': domain does not exist');
573: }
574:
575: $path = self::MAIN_DOC_ROOT;
576: $info = array(
577: 'path' => $path,
578: 'active' => false,
579: 'user' => null
580: );
581:
582: if ($domain !== $this->getConfig('siteinfo', 'domain')) {
583: $domains = $this->aliases_list_shared_domains();
584: // domain attached directly via aliases,aliases service value, not present in domain_map
585: $path = $domains[$domain] ?? self::MAIN_DOC_ROOT;
586: }
587: $info['path'] = $path;
588: $info['active'] = is_readable($this->domain_fs_path() . $path);
589:
590: $stat = $this->file_stat($path);
591: if (!$stat) {
592: return $stat;
593: }
594: $info['user'] = $stat['owner'];
595:
596: return $info;
597: }
598:
599: /**
600: * Test if domain is attached to account
601: *
602: * aliases:list-aliases is used to check site configuration for fixed aliases
603: * aliases:list-shared-domains checks presence in info/domain_map
604: *
605: * @see aliases_domain_exists() to check against domain_map
606: *
607: * @param string $domain
608: * @return bool
609: */
610: public function domain_exists(string $domain): bool
611: {
612: return $domain == $this->getConfig('siteinfo', 'domain') ||
613: in_array($domain, $this->aliases_list_aliases(), true);
614:
615: }
616:
617: // }}}
618:
619: /**
620: * Get hostname from location
621: *
622: * @param string $docroot document root as seen by server (does not resolve symlinks!)
623: * @return string|null
624: */
625: public function get_hostname_from_docroot(string $docroot): ?string
626: {
627: $docroot = rtrim($docroot, '/');
628: if ($docroot === static::MAIN_DOC_ROOT) {
629: return $this->getServiceValue('siteinfo', 'domain');
630: }
631: $aliases = $this->aliases_list_shared_domains();
632: if (false !== ($domain = array_search($docroot, $aliases, true))) {
633: return $domain;
634: }
635:
636: if ($subdomain = $this->list_subdomains('path', $docroot)) {
637: return (string)key($subdomain);
638: }
639:
640: return null;
641: }
642:
643: /**
644: * Given a docroot, find all hostnames that serve from here
645: *
646: * @xxx expensive lookup
647: *
648: * @param string $docroot
649: * @return array
650: */
651: public function get_all_hostnames_from_path(string $docroot): array
652: {
653: $hosts = [];
654: if ($docroot === static::MAIN_DOC_ROOT) {
655: $hosts[] = $this->getServiceValue('siteinfo', 'domain');
656: }
657: foreach ($this->aliases_list_shared_domains() as $domain => $path) {
658: if ($docroot === $path) {
659: $hosts[] = $domain;
660: }
661: }
662:
663: return array_merge($hosts, array_keys($this->list_subdomains('path', '!' . preg_quote($docroot, '!') . '$!')));
664: }
665:
666: /**
667: * Decompose a path into its hostname/path components
668: *
669: * @param string $docroot
670: * @return null|array
671: */
672: public function extract_components_from_path(string $docroot): ?array
673: {
674: $path = [];
675: do {
676: if (null !== ($hostname = $this->get_hostname_from_docroot($docroot))) {
677: return [
678: 'hostname' => $hostname,
679: 'path' => implode('/', $path)
680: ];
681: }
682: array_unshift($path, \basename($docroot));
683: $docroot = \dirname($docroot);
684: } while ($docroot !== '/');
685:
686: return null;
687: }
688:
689: /**
690: * Assign a path as a DAV-aware location
691: *
692: * @param string $location filesystem location
693: * @param string $provider DAV provider
694: * @return bool
695: */
696: public function bind_dav(string $location, string $provider): bool
697: {
698: if (!IS_CLI) {
699: return $this->query('web_bind_dav', $location, $provider);
700: }
701:
702: if (!$this->verco_svn_enabled() && (strtolower($provider) == 'svn')) {
703: return error('Cannot use Subversion provider when not enabled');
704: } else if (!\in_array($provider, ['on', 'dav', 'svn'])) {
705: return error("Unknown dav provider `%s'", $provider);
706: }
707: if ($provider === 'dav') {
708: $provider = 'on';
709: }
710: if ($location[0] != '/') {
711: return error("DAV location `%s' is not absolute", $location);
712: }
713: if (!file_exists($this->domain_fs_path() . $location)) {
714: return error('DAV location `%s\' does not exist', $location);
715: }
716:
717: $stat = $this->file_stat($location);
718: if (!$stat) {
719: return false;
720: }
721:
722: if ($stat['file_type'] != 'dir') {
723: return error("bind_dav: `$location' is not directory");
724: } else if (!$stat['can_write']) {
725: return error("`%s': cannot write to directory", $location);
726: }
727:
728: $this->query('file_fix_apache_perms_backend', $location);
729: $file = $this->site_config_dir() . '/dav';
730:
731: $locations = $this->parse_dav($file);
732: if (null !== ($chk = $locations[$location] ?? null) && $chk === $provider) {
733: return warn("DAV already enabled for `%s'", $location);
734: }
735: $locations[$location] = $provider;
736:
737: return $this->write_dav($file, $locations);
738: }
739:
740: /**
741: * Parse DAV configuration
742: *
743: * @param string $path
744: * @return array
745: */
746: private function parse_dav(string $path): array
747: {
748: $locations = [];
749: if (!file_exists($path)) {
750: return [];
751: }
752: $dav_config = trim(file_get_contents($path));
753:
754: if (preg_match_all(\Regex::DAV_CONFIG, $dav_config, $matches, PREG_SET_ORDER)) {
755: foreach ($matches as $match) {
756: $cfgpath = $this->file_unmake_path($match['path']);
757: $locations[$cfgpath] = $match['provider'];
758: }
759: }
760: return $locations;
761: }
762:
763: /**
764: * Convert DAV to text representation
765: *
766: * @param string $path
767: * @param array $cfg
768: * @return bool
769: */
770: private function write_dav(string $path, array $cfg): bool
771: {
772: if (!$cfg) {
773: if (file_exists($path)) {
774: unlink($path);
775: }
776: return true;
777: }
778: $template = (new \Opcenter\Provisioning\ConfigurationWriter('apache.dav-provider',
779: \Opcenter\SiteConfiguration::shallow($this->getAuthContext())))
780: ->compile([
781: 'prefix' => $this->domain_fs_path(),
782: 'locations' => $cfg
783: ]);
784: return file_put_contents($path, $template) !== false;
785: }
786:
787: public function site_config_dir(): string
788: {
789: return Apache::siteStoragePath($this->site);
790: }
791:
792: /**
793: * Permit a disallowed protocol access to hostname
794: *
795: * @param string $hostname
796: * @param string $proto only http10 is valid
797: * @return bool
798: */
799: public function allow_protocol(string $hostname, string $proto = 'http10'): bool
800: {
801: if (!IS_CLI) {
802: return $this->query('web_allow_protocol', $hostname, $proto);
803: }
804: if ($proto !== 'http10') {
805: return error("protocol `%s' not known, only http10 accepted", $proto);
806: }
807: if (!$this->protocol_disallowed($hostname, $proto)) {
808: return true;
809: }
810: if (!$this->split_host($hostname)) {
811: // unowned domain/subdomain
812: return error("Invalid hostname `%s'", $hostname);
813: }
814: $map = Map::open(self::PROTOCOL_MAP, Map::MODE_WRITE);
815: $map[$hostname] = $this->site_id;
816:
817: return $map->sync();
818: }
819:
820: /**
821: * Specified protocol is disallowed
822: *
823: * @param string $hostname
824: * @param string $proto
825: * @return bool
826: */
827: public function protocol_disallowed(string $hostname, string $proto = 'http10'): bool
828: {
829: if ($proto !== 'http10') {
830: return error("protocol `%s' not known, only http10 accepted", $proto);
831: }
832: $map = Map::open(self::PROTOCOL_MAP);
833:
834: return !isset($map[$hostname]);
835: }
836:
837: /**
838: * Disallow protocol
839: *
840: * @param string $hostname
841: * @param string $proto
842: * @return bool
843: */
844: public function disallow_protocol(string $hostname, string $proto = 'http10'): bool
845: {
846: if (!IS_CLI) {
847: return $this->query('web_disallow_protocol', $hostname, $proto);
848: }
849: if ($proto !== 'http10') {
850: return error("protocol `%s' not known, only http10 accepted", $proto);
851: }
852:
853: if (!$this->get_docroot($hostname)) {
854: return false;
855: }
856:
857: if ($this->protocol_disallowed($hostname, $proto)) {
858: return true;
859: }
860: $map = Map::open(self::PROTOCOL_MAP, Map::MODE_WRITE);
861: if ((int)($map[$hostname] ?? -1) !== $this->site_id) {
862: return warn("Site `%s' not found in map", $hostname);
863: }
864:
865: unset($map[$hostname]);
866:
867: return $map->sync();
868:
869: }
870:
871: public function unbind_dav(string $location): bool
872: {
873: if (!IS_CLI) {
874: return $this->query('web_unbind_dav', $location);
875: }
876: $file = $this->site_config_dir() . '/dav';
877: $locations = $this->parse_dav($file);
878: if (!isset($locations[$location])) {
879: return warn("DAV not enabled for `%s'", $location);
880: }
881: unset($locations[$location]);
882:
883: return $this->write_dav($file, $locations);
884:
885: }
886:
887: public function list_dav_locations(): array
888: {
889: $file = $this->site_config_dir() . '/dav';
890: $locations = [];
891: foreach ($this->parse_dav($file) as $path => $type) {
892: $locations[] = [
893: 'path' => $path,
894: 'provider' => $type === 'on' ? 'dav' : $type
895: ];
896: }
897: return $locations;
898: }
899:
900: public function _edit()
901: {
902: $conf_new = $this->getAuthContext()->getAccount()->new;
903: $conf_old = $this->getAuthContext()->getAccount()->old;
904: // change to web config or ipconfig
905: $ssl = \Opcenter\SiteConfiguration::getModuleRemap('openssl');
906: if ($conf_new['apache'] != $conf_old['apache'] ||
907: $conf_new['ipinfo'] != $conf_old['ipinfo'] ||
908: $conf_new[$ssl] != $conf_old[$ssl] ||
909: $conf_new['aliases'] != $conf_old['aliases']
910: ) {
911: Apache::activate();
912: }
913:
914: }
915:
916: public function _edit_user(string $userold, string $usernew, array $oldpwd)
917: {
918: if ($userold === $usernew) {
919: return;
920: }
921: /**
922: * @TODO
923: * Assert that all users are stored under /home/username
924: * edit_user hook is called after user is changed, so
925: * this is lost without passing user pwd along
926: */
927: $userhome = $this->user_get_user_home($usernew);
928: $re = '!^' . $oldpwd['home'] . '!';
929: mute_warn();
930: $subdomains = $this->list_subdomains('path', $re);
931: unmute_warn();
932: foreach ($subdomains as $subdomain => $path) {
933: $newpath = preg_replace('!' . DIRECTORY_SEPARATOR . $userold . '!',
934: DIRECTORY_SEPARATOR . $usernew, $path, 1);
935: if ($subdomain === $userold) {
936: $newsubdomain = $usernew;
937: } else {
938: $newsubdomain = $subdomain;
939: }
940: if ($this->rename_subdomain($subdomain, $newsubdomain, $newpath)) {
941: info("moved subdomain `%s' from `%s' to `%s'", $subdomain, $path, $newpath);
942: }
943: }
944:
945: return true;
946: }
947:
948: /**
949: * Rename a subdomain and/or change its path
950: *
951: * @param string $subdomain source subdomain
952: * @param string $newsubdomain new subdomain
953: * @param string $newpath
954: * @return bool
955: */
956: public function rename_subdomain(string $subdomain, string $newsubdomain = null, string $newpath = null): bool
957: {
958: if (!$this->subdomain_exists($subdomain)) {
959: $parts = array_values($this->split_host($subdomain));
960: if ($parts[0] && $this->subdomain_exists($parts[0])) {
961: // global subdomain
962: warn("Requested subdomain %(local)s is global subdomain %(global)s. Retrying %(fn)s.", [
963: 'local' => $subdomain,
964: 'global' => $parts[0],
965: 'fn' => 'web_' . __FUNCTION__
966: ]);
967: return $this->rename_subdomain($parts[0], $newsubdomain, $newpath);
968: }
969: return error('%s: subdomain does not exist', $subdomain);
970: }
971: if ($newsubdomain && $subdomain !== $newsubdomain && $this->subdomain_exists($newsubdomain)) {
972: return error("destination subdomain `%s' already exists", $newsubdomain);
973: }
974: if (!$newsubdomain && !$newpath) {
975: return error('no rename operation specified');
976: }
977: if ($newpath && ($newpath[0] != '/' && $newpath[0] != '.')) {
978: return error("invalid path `%s', subdomain path must " .
979: 'be relative or absolute', $newpath);
980: }
981:
982: if (!$newsubdomain) {
983: $newsubdomain = $subdomain;
984: } else {
985: $newsubdomain = strtolower($newsubdomain);
986: }
987:
988: unset($this->hostCache[$subdomain], $this->hostCache[$newsubdomain]);
989: $sdpath = Opcenter\Http\Apache::makeSubdomainPath($subdomain);
990: $stat = $this->file_stat($sdpath);
991: $old_path = $stat['link'] ? $stat['referent'] : $sdpath;
992:
993: if (!$newpath) {
994: $newpath = $old_path;
995: }
996: if (!$newsubdomain) {
997: $newsubdomain = $subdomain;
998: }
999:
1000: // add_subdomain() creates index.html placeholder
1001: if (!$this->file_exists($newpath . '/index.html')) {
1002: defer($_, fn() => $this->file_delete($newpath . '/index.html'));
1003: }
1004:
1005: if ($subdomain !== $newsubdomain) {
1006: if (!$this->remove_subdomain($subdomain) || !$this->add_subdomain($newsubdomain, $newpath)) {
1007: error("changing subdomain `%s' to `%s' failed", $subdomain, $newsubdomain);
1008: if (!$this->add_subdomain($subdomain, $old_path)) {
1009: error("critical: could not reassign subdomain `%(sub)s' to `%(path)s' after failed rename", [
1010: 'sub' => $subdomain,
1011: 'path' => $old_path
1012: ]);
1013: }
1014:
1015: return false;
1016: }
1017: } else if (!$this->remove_subdomain($subdomain) || !$this->add_subdomain($subdomain, $newpath)) {
1018: error("failed to change path for `%s' from `%s' to `%s'",
1019: $subdomain,
1020: $old_path,
1021: $newpath);
1022: if (!$this->add_subdomain($subdomain, $old_path)) {
1023: error("failed to restore subdomain `%s' to old path `%s'",
1024: $subdomain,
1025: $old_path);
1026: }
1027:
1028: return false;
1029: }
1030:
1031: if ($subdomain !== $newsubdomain) {
1032: MetaManager::instantiateContexted($this->getAuthContext())
1033: ->merge($newpath, ['hostname' => $newsubdomain])->sync();
1034: }
1035: return true;
1036: }
1037:
1038: /**
1039: * Remove a subdomain
1040: *
1041: * @param string $subdomain fully or non-qualified subdomain
1042: * @param bool $keepdns preserve DNS records for subdomain
1043: * @return bool
1044: */
1045: public function remove_subdomain(string $subdomain, bool $keepdns = false): bool
1046: {
1047: // clear both ends
1048: $this->purge();
1049: if (!IS_CLI) {
1050: // remove Web App first
1051: $docroot = $this->get_docroot($subdomain);
1052: if (false && $docroot) {
1053: // @TODO rename_subdomain calls remove + add, which would break renaming meta
1054: // this is how it should be done, but can't implement *yet*
1055: $mm = MetaManager::factory($this->getAuthContext());
1056: $app = \Module\Support\Webapps\App\Loader::fromDocroot(
1057: array_get($mm->get($docroot), 'type', 'unknown'),
1058: $docroot,
1059: $this->getAuthContext()
1060: );
1061: $app->uninstall();
1062: }
1063:
1064: if (!$this->query('web_remove_subdomain', $subdomain)) {
1065: return false;
1066: }
1067:
1068: if (false && $docroot) {
1069: $mm->forget($docroot)->sync();
1070: }
1071: return true;
1072: }
1073:
1074: $subdomain = strtolower((string)$subdomain);
1075:
1076: if (!preg_match(Regex::SUBDOMAIN, $subdomain) &&
1077: 0 !== strncmp($subdomain, '*.', 2) &&
1078: !preg_match(Regex::DOMAIN, preg_replace('/^\*\./', '', $subdomain))
1079: )
1080: {
1081: return error('%s: invalid subdomain', $subdomain);
1082: }
1083: if ($subdomain[0] === '*') {
1084: $subdomain = substr($subdomain, 2);
1085: }
1086: if (!$this->subdomain_exists($subdomain)) {
1087: return warn('%s: subdomain does not exist', $subdomain);
1088: }
1089:
1090: $this->map_subdomain('delete', $subdomain);
1091: $path = $this->domain_fs_path() . self::SUBDOMAIN_ROOT . "/$subdomain";
1092: if (is_link($path)) {
1093: return unlink($path) && warn("subdomain `%s' path `%s' corrupted, removing reference",
1094: $subdomain,
1095: $this->file_unmake_path($path)
1096: );
1097: }
1098:
1099: $dh = opendir($path);
1100: while (false !== ($entry = readdir($dh))) {
1101: if ($entry === '..' || $entry === '.') {
1102: continue;
1103: }
1104: if (!is_link($path . '/' . $entry) && is_dir($path . '/' . $entry)) {
1105: warn("directory found in subdomain `%s'", $entry);
1106: Filesystem::rmdir($path . '/' . $entry);
1107: } else {
1108: unlink($path . '/' . $entry);
1109: }
1110: }
1111: closedir($dh);
1112: rmdir($path);
1113: if (!$this->dns_configured() || $keepdns) {
1114: return true;
1115: }
1116: $hostcomponents = [
1117: 'subdomain' => $subdomain,
1118: 'domain' => ''
1119: ];
1120: if (false !== strpos($subdomain, '.')) {
1121: $hostcomponents = $this->split_host($subdomain);
1122: }
1123: if (!$hostcomponents['subdomain']) {
1124: return true;
1125: }
1126: if (!$hostcomponents['domain']) {
1127: $hostcomponents['domain'] = array_keys($this->list_domains());
1128: }
1129: $ret = true;
1130:
1131: $ips = [];
1132: if ($tmp = $this->dns_get_public_ip()) {
1133: $ips = (array)$tmp;
1134: }
1135: if ($tmp = $this->dns_get_public_ip6()) {
1136: $ips = array_merge($ips, (array)$tmp);
1137: }
1138:
1139: $components = [''];
1140: if (DNS_SUBDOMAIN_IMPLICIT_WWW) {
1141: $components[] = 'www';
1142: }
1143: foreach ((array)$hostcomponents['domain'] as $domain) {
1144: foreach ($components as $component) {
1145: $subdomain = ltrim("{$component}." . $hostcomponents['subdomain'], '.');
1146: foreach ($ips as $ip) {
1147: $rr = false === strpos($ip, ':') ? 'A' : 'AAAA';
1148: if ($this->dns_record_exists($domain, $subdomain, $rr, $ip)) {
1149: $oldex = \Error_Reporter::exception_upgrade(\Error_Reporter::E_ERROR);
1150: try {
1151: $ret &= $this->dns_remove_record($domain, $subdomain, $rr, $ip);
1152: } catch (\apnscpException $e) {
1153: warn($e->getMessage());
1154: } finally {
1155: \Error_Reporter::exception_upgrade($oldex);
1156: }
1157: }
1158: }
1159: }
1160: }
1161:
1162: return (bool)$ret;
1163: }
1164:
1165: /**
1166: * Clear path cache
1167: *
1168: * @return void
1169: */
1170: public function purge(): void
1171: {
1172: $this->pathCache = [];
1173: $this->hostCache = [];
1174: if (!IS_CLI) {
1175: $this->query('web_purge');
1176: }
1177: }
1178:
1179: /**
1180: * Manage subdomain symlink mapping
1181: *
1182: * @todo merge from Web_Module::map_domain()
1183: * @param string $mode add/delete
1184: * @param string $subdomain subdomain to add/remove
1185: * @param string $path domain path
1186: * @param string $user user to assign mapping
1187: * @return bool
1188: */
1189: public function map_subdomain(string $mode, string $subdomain, string $path = null, string $user = null): bool
1190: {
1191: if (!IS_CLI) {
1192: return $this->query('web_map_subdomain',
1193: $mode,
1194: $subdomain,
1195: $path,
1196: $user);
1197: }
1198:
1199: $mode = substr($mode, 0, 3);
1200: if (!preg_match(Regex::SUBDOMAIN, $subdomain) &&
1201: !preg_match(Regex::DOMAIN, $subdomain))
1202: {
1203: return error($subdomain . ': invalid subdomain');
1204: }
1205: if ($mode != 'add' && $mode != 'del') {
1206: return error($mode . ': invalid mapping operation');
1207: }
1208: if ($mode == 'del') {
1209: $docroot = $this->get_docroot($subdomain);
1210: if ($docroot) {
1211: MetaManager::factory($this->getAuthContext())->forget($docroot)->sync();
1212: }
1213:
1214: return $this->file_delete('/home/*/all_subdomains/' . $subdomain);
1215: }
1216: if ($mode == 'add') {
1217: if (!$user) {
1218: $stat = $this->file_stat($path);
1219: if (!$stat) {
1220: return error("Cannot map subdomain - failed to determine user from `%s'", $path);
1221: }
1222:
1223: $user = $stat['uid'] >= \User_Module::MIN_UID ? $this->user_get_username_from_uid($stat['uid']) : $this->username;
1224:
1225: }
1226: $user_home = '/home/' . $user;
1227: $user_home_abs = $this->domain_fs_path() . $user_home;
1228:
1229: if (!file_exists($this->domain_fs_path() . $path)) {
1230: warn($path . ': path does not exist, creating link');
1231: }
1232: if (!file_exists($user_home_abs . '/all_subdomains')) {
1233: $this->file_create_directory($user_home . '/all_subdomains');
1234: $this->file_chown($user_home . '/all_subdomains', $user);
1235: }
1236: $this->file_symlink($path, $user_home . '/all_subdomains/' . $subdomain) &&
1237: $this->file_chown_symlink($user_home . '/all_subdomains/' . $subdomain, $user);
1238: }
1239:
1240: return true;
1241: }
1242:
1243: /**
1244: * Add subdomain to account
1245: *
1246: * There are 3 types of subdomains:
1247: * Local- subdomain includes subdomain + domain - foo.bar.com
1248: * Fallthrough- subdomain is named after domain - bar.com
1249: * Global- subdomain excludes domain - foo
1250: *
1251: * @param string $subdomain
1252: * @param string $docroot document root of the subdomain
1253: * @return bool
1254: */
1255: public function add_subdomain(string $subdomain, string $docroot): bool
1256: {
1257: if (!IS_CLI) {
1258: return $this->query('web_add_subdomain', $subdomain, $docroot);
1259: }
1260:
1261: $subdomain = strtolower(trim((string)$subdomain));
1262: if ($subdomain === 'www') {
1263: return error('illegal subdomain name');
1264: }
1265: $subdomain = preg_replace('/^www\./', '', strtolower($subdomain));
1266: if (!$subdomain) {
1267: return error('Missing subdomain');
1268: }
1269:
1270: if (!preg_match(Regex::SUBDOMAIN, $subdomain) &&
1271: 0 !== strncmp($subdomain, '*.', 2) &&
1272: !preg_match(Regex::DOMAIN, preg_replace('/^\*\./', '', $subdomain))
1273: ) {
1274: return error($subdomain . ': invalid subdomain');
1275: }
1276: if ($this->subdomain_exists($subdomain)) {
1277: return error($subdomain . ': subdomain exists');
1278: } else if ($subdomain === gethostname()) {
1279: warn("Subdomain duplicates system hostname `%s'. Supplied document root will " .
1280: 'never have precedence over system document root.', $subdomain);
1281: }
1282:
1283: $components = filter(fn() => $this->split_host(
1284: preg_replace('/^\*\./', '', $subdomain)
1285: ), ':err_split_host_invalid');
1286:
1287: if (str_contains($subdomain, '.') &&
1288: (false === $components || !$this->domain_exists($components['domain'])))
1289: {
1290: return error("Global subdomain subdomains are unsupported");
1291: }
1292:
1293: if ($docroot[0] != '/' && $docroot[0] != '.') {
1294: return error("invalid path `%s', subdomain path must " .
1295: 'be relative or absolute', $docroot);
1296: }
1297: /**
1298: * This is particularly nasty because add_subdomain can provide
1299: * either the subdomain or the subdomain + domain as the $subdomain
1300: * argument. We need to (1) loop through each domain to determine if
1301: * a FQDN or subdomain, (2) query each DNS record to ensure
1302: * it is provisioned correctly, (3) add missing records.
1303: *
1304: * A FQDN for a hostname on the other hand is a bit easier; just
1305: * add the record. First we check to see if it's FQDN or not. If
1306: * FQDN, check DNS and add.
1307: */
1308: $domains = array_keys($this->list_domains());
1309: if ($subdomain[0] === '*') {
1310: $subdomain = substr($subdomain, 2);
1311: $domain = '';
1312: if (!in_array($subdomain, $domains, true)) {
1313: return error("domain `%s' not attached to account (DNS > Addon Domains)", $domain);
1314: }
1315: }
1316: if ( null !== ($limit = $this->getConfig('apache', 'subnum', null) ) && ($limit <= count($this->list_subdomains())) ) {
1317: return error('Subdomain limit %d has been reached - cannot add %s', $limit, $subdomain);
1318: }
1319: // is it a fully-qualified domain name? i.e. www.apisnetworks.com or
1320: // a subdomain? e.g. "www"
1321: $FQDN = false;
1322:
1323: // hostnames to query and setup DNS records for
1324: $recs_to_add = array();
1325: foreach ($domains as $domain) {
1326: if (preg_match('/\.' . $domain . '$/', $subdomain)) {
1327: // local subdomain
1328: $FQDN = true;
1329: $recs_to_add = array(
1330: array(
1331: 'subdomain' => substr($subdomain, 0, -strlen($domain) - 1),
1332: 'domain' => $domain
1333: )
1334: );
1335: break;
1336: } else if ($subdomain === $domain) {
1337: // subdomain is fallthrough
1338: $recs_to_add[] = array(
1339: 'subdomain' => '*',
1340: 'domain' => $domain
1341: );
1342:
1343: }
1344: }
1345: if (!$recs_to_add) {
1346: // domain is global subdomain
1347: foreach ($domains as $domain) {
1348: $recs_to_add[] = array(
1349: 'subdomain' => $subdomain,
1350: 'domain' => $domain
1351: );
1352: }
1353: }
1354:
1355: $ips = [];
1356: if ($tmp = $this->dns_get_public_ip()) {
1357: $ips = (array)$tmp;
1358: }
1359: if ($tmp = $this->dns_get_public_ip6()) {
1360: $ips = array_merge($ips, (array)$tmp);
1361: }
1362:
1363: foreach ($recs_to_add as $record) {
1364: foreach ($ips as $ip) {
1365: $rr = false === strpos($ip, ':') ? 'A' : 'AAAA';
1366: $this->dns_add_record_conditionally($record['domain'], $record['subdomain'], $rr, $ip);
1367: if ($record['subdomain'] !== '*') {
1368: $this->dns_add_record_conditionally(
1369: $record['domain'],
1370: 'www.' . $record['subdomain'],
1371: $rr,
1372: $ip
1373: );
1374: }
1375: }
1376: }
1377:
1378: /**
1379: * Home directories without subdomains explicitly enabled
1380: * are created with 700. This bears the side-effect of Apache
1381: * being unable to descend past /home/<user>/. Fix by giving
1382: * the execute bit
1383: */
1384: $realpath = $this->file_unmake_path(realpath($this->file_make_path($docroot)));
1385:
1386: if (preg_match('!^/home/([^/]+)!', $realpath, $user_home)) {
1387: $user = $user_home[1];
1388: $stat = $this->file_stat('/home/' . $user);
1389: if (!$stat) {
1390: return error("user `%s' does not exist", $user);
1391: }
1392: // give Apache access
1393: if (!$this->file_chmod("/home/{$user}", decoct($stat['permissions']) | 001)) {
1394: return false;
1395: }
1396:
1397: if ($this->php_jailed()) {
1398: // and FPM, DACs will match group rather than world
1399: $this->file_set_acls("/home/{$user}", $this->get_user($subdomain,''), 'x');
1400: }
1401:
1402: } else {
1403: $user = $this->getServiceValue('siteinfo', 'admin_user');
1404: }
1405:
1406: $prefix = $this->domain_fs_path();
1407: if (!$this->file_exists($docroot) || empty($this->file_get_directory_contents($docroot))) {
1408: if (\Util_PHP::is_link($prefix . $docroot)) {
1409: // fix cases where a client links the doc root to an absolute symlink outside the scope
1410: // of apache, e.g. /var/www/html -> /foo, apache would see <fst>/foo, not /foo
1411: $newlink = Opcenter\Filesystem::abs2rel($docroot, readlink($prefix . $docroot));
1412: warn('converted unfollowable absolute symlink to relative (document root): %s -> %s', $docroot,
1413: $newlink);
1414: unlink($prefix . $docroot);
1415: $ret = $this->file_symlink($newlink, $docroot);
1416: } else {
1417: $ret = $this->file_create_directory($docroot, 0755, true);
1418: }
1419:
1420: if (!$ret) {
1421: return $ret;
1422: }
1423: $this->file_chown($docroot, $user);
1424: $index = $prefix . $docroot . '/index.html';
1425: $template = (string)(new ConfigurationWriter('apache.placeholder',
1426: \Opcenter\SiteConfiguration::shallow($this->getAuthContext())))->compile([
1427: 'hostname' => $subdomain,
1428: 'docroot' => $docroot,
1429: 'user' => $user
1430: ]);
1431: file_put_contents($index, $template) &&
1432: Filesystem::chogp($index, (int)$this->user_get_uid_from_username($user), $this->group_id, 0644);
1433: }
1434: $subdomainpath = Opcenter\Http\Apache::makeSubdomainPath($subdomain);
1435:
1436: defer($_, function () use($subdomain) {
1437: UIPanel::instantiateContexted($this->getAuthContext())->freshen($subdomain, "",
1438: (bool)SCREENSHOTS_ENABLED);
1439: });
1440:
1441: return $this->add_subdomain_raw($subdomain,
1442: Opcenter\Filesystem::abs2rel($subdomainpath, $docroot)) &&
1443: $this->map_subdomain('add', $subdomain, $docroot);
1444: }
1445:
1446: public function add_subdomain_raw(string $subdomain, string $docroot): bool
1447: {
1448:
1449: $prefix = $this->domain_fs_path();
1450: $subdomain_path = Opcenter\Http\Apache::makeSubdomainPath($subdomain);
1451: $subdomain_parent = dirname($prefix . $subdomain_path);
1452: if (!file_exists($subdomain_parent)) {
1453: \Opcenter\Filesystem::mkdir($subdomain_parent, $this->user_id, $this->group_id);
1454: }
1455: $tmp = $docroot;
1456: if ($docroot[0] === '.' && $docroot[1] == '.') {
1457: $tmp = $subdomain_parent . DIRECTORY_SEPARATOR . $docroot;
1458: }
1459: clearstatcache(true, $tmp);
1460: $user = fileowner($tmp);
1461: if (!file_exists($tmp)) {
1462: Error_Reporter::print_debug_bt();
1463: }
1464:
1465: return symlink($docroot, $prefix . $subdomain_path) &&
1466: Util_PHP::lchown($prefix . $subdomain_path, $user) &&
1467: Util_PHP::lchgrp($prefix . $subdomain_path, $this->group_id);
1468: }
1469:
1470: /**
1471: * Get Apache service status
1472: *
1473: * @return array
1474: */
1475: public function status(): array
1476: {
1477: return Apache::getReportedServiceStatus();
1478: }
1479:
1480: /**
1481: * Account created
1482: */
1483: public function _create()
1484: {
1485: Apache::activate();
1486: }
1487:
1488: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
1489: {
1490: return true;
1491: }
1492:
1493: public function _reload(string $why = '', array $args = [])
1494: {
1495: if (in_array($why, ['', 'php', 'aliases', Ssl_Module::SYS_RHOOK, Ssl_Module::USER_RHOOK], true)) {
1496: return Apache::activate();
1497: }
1498: }
1499:
1500: public function _housekeeping() {
1501: // kill chromedriver if persisting between startups
1502: $class = new ReflectionClass(\Service\CaptureDevices\Chromedriver::class);
1503: $instance = $class->newInstanceWithoutConstructor();
1504: if ($instance->running()) {
1505: $instance->stop(true);
1506: }
1507: }
1508:
1509: public function _cron(Cronus $c) {
1510: if (SCREENSHOTS_ENABLED && !APNSCPD_HEADLESS) {
1511: $c->schedule(60 * 60, 'screenshots', static function () {
1512: // need Laravel 6+ for closure serialization support to Horizon
1513: $n = (int)sprintf('%u', SCREENSHOTS_BATCH);
1514: $job = (new \Service\BulkCapture(new \Service\CaptureDevices\Chromedriver));
1515: $job->batch($n);
1516:
1517: });
1518: }
1519:
1520:
1521: if (TELEMETRY_ENABLED) {
1522: $collector = new Collector(PostgreSQL::pdo());
1523:
1524: if ( !($status = $this->status()) ) {
1525: // service down, zero fill
1526: // gap filling via TSDB would lend itself to false positives
1527: // if the cron interval ever changes
1528: $status = array_fill_keys(array_values(ApacheMetrics::ATTRVAL_MAP), 0);
1529: }
1530:
1531: foreach (ApacheMetrics::ATTRVAL_MAP as $attr => $metric) {
1532: $collector->add("apache-{$attr}", null, (int)$status[$metric]);
1533: }
1534: }
1535: }
1536:
1537: public function _delete()
1538: {
1539:
1540: }
1541:
1542: public function http_config_dir(): string
1543: {
1544: deprecated_func('use site_config_dir');
1545:
1546: return $this->site_config_dir();
1547: }
1548:
1549: public function config_dir(): string
1550: {
1551: return Apache::CONFIG_PATH;
1552: }
1553:
1554: public function _delete_user(string $user)
1555: {
1556: $this->remove_user_subdomain($user);
1557: }
1558:
1559: /**
1560: * Removes all subdomains associated with a named user
1561: *
1562: * @param string $user
1563: * @return bool
1564: */
1565: public function remove_user_subdomain(string $user): bool
1566: {
1567: $ret = true;
1568: $home = $this->user_get_user_home($user) . '/';
1569: foreach ($this->list_subdomains() as $subdomain => $dir) {
1570: if (!str_starts_with($dir, $home)) {
1571: continue;
1572: }
1573: $ret &= $this->remove_subdomain($subdomain);
1574: }
1575:
1576: return (bool)$ret;
1577: }
1578:
1579: /**
1580: * Enable SSL/TLS redirection on hostname
1581: *
1582: * @param string $hostname
1583: * @param int|HSTSMode $redirection_mode value between 0 and 4
1584: * @return bool
1585: */
1586: public function set_ssl(string $hostname, int|HSTSMode $redirection_mode = HSTSMode::disabled): bool
1587: {
1588: if (!IS_CLI) {
1589: return $this->query('web_set_ssl', $hostname, $redirection_mode);
1590: }
1591:
1592: if (!($parts = filter(fn() => $this->split_host($hostname), ':err_split_host_invalid')) ||
1593: !$this->domain_exists($parts['domain']))
1594: {
1595: return error("Invalid hostname");
1596: }
1597:
1598: if (is_int($redirection_mode)) {
1599: try {
1600: $redirection_mode = HSTSMode::from($redirection_mode);
1601: } catch (ValueError) {
1602: return error("Invalid mode");
1603: }
1604: }
1605:
1606: if (!empty($parts['subdomain']) && $redirection_mode > HSTSMode::enabled) {
1607: return error("HSTS subdomain settings may not contain a subdomain");
1608: }
1609:
1610: $map = TlsMap::open(TlsMap::DOMAIN_MAP, TlsMap::MODE_WRITE);
1611: $map["{$this->site_id}:{$hostname}"] = $redirection_mode->value;
1612: $map->sync();
1613:
1614: return true;
1615: }
1616:
1617: /**
1618: * Remove SSL/TLS redirection on hostname
1619: */
1620: public function remove_ssl(string $hostname): bool
1621: {
1622: if (!IS_CLI) {
1623: return $this->query('web_remove_ssl', $hostname);
1624: }
1625:
1626: if (!filter(fn() => $this->split_host($hostname), ':err_split_host_invalid')) {
1627: return error("Invalid hostname");
1628: }
1629:
1630: $key = "{$this->site_id}:{$hostname}";
1631:
1632: $map = TlsMap::open(TlsMap::DOMAIN_MAP, TlsMap::MODE_WRITE);
1633: if (!$map->offsetExists($key)) {
1634: return warn("Hostname %(host)s does not exist", $hostname);
1635: }
1636: unset($map[$key]);
1637:
1638: return $map->sync();
1639: }
1640:
1641: /**
1642: * List SSL-TLS upgrade hostnames
1643: *
1644: * @return array<string $hostname, int $mode>
1645: */
1646: public function list_ssl(): array
1647: {
1648: $hosts = [];
1649: $map = TlsMap::open(TlsMap::DOMAIN_MAP, TlsMap::MODE_READ);
1650: $prefix = "{$this->site_id}:";
1651: foreach ($map as $key => $mode) {
1652: if (str_starts_with($key, $prefix)) {
1653: $hosts[substr($key, strlen($prefix))] = (int)$mode;
1654: }
1655: }
1656:
1657: return $hosts;
1658: }
1659:
1660: /**
1661: * Bulk screenshot of all sites
1662: *
1663: * @param array|null $sites
1664: * @return void
1665: */
1666: public function inventory_capture(array $sites = null): void {
1667: if (!$sites) {
1668: $sites = \Opcenter\Account\Enumerate::sites();
1669: }
1670: $driver = new \Service\BulkCapture(new \Service\CaptureDevices\Chromedriver);
1671: foreach ($sites as $site) {
1672: $oldex = \Error_Reporter::exception_upgrade(\Error_Reporter::E_FATAL|\Error_Reporter::E_ERROR);
1673: try {
1674: $ctx = \Auth::context(null, $site);
1675: $afi = \apnscpFunctionInterceptor::factory($ctx);
1676: } catch (\apnscpException $e) {
1677: continue;
1678: } finally {
1679: \Error_Reporter::exception_upgrade($oldex);
1680: }
1681:
1682: if (!posix_getuid()) {
1683: // hold ref to allow suspension at garbage collection
1684: $serviceRef = new \Opcenter\Http\Php\Fpm\StateRestore($ctx->site);
1685: }
1686:
1687: foreach ((new \Module\Support\Webapps\Finder($ctx))->getAllApplicationRoots() as $meta) {
1688: if (empty($meta['hostname'])) {
1689: continue;
1690: }
1691:
1692: debug('%(site)s: Capturing %(url)s (IP: %(ip)s)', [
1693: 'site' => $ctx->site,
1694: 'url' => rtrim(implode('/', [$meta['hostname'], $meta['path']]), '/'),
1695: 'ip' => $afi->site_ip_address()
1696: ]);
1697: $driver->snap($meta['hostname'], $meta['path'], $afi->site_ip_address());
1698: }
1699:
1700: }
1701: }
1702:
1703: /**
1704: * Capture screenshot of site
1705: *
1706: * @XXX Restricted from backend.
1707: *
1708: * @param string $hostname
1709: * @param string $path
1710: * @param \Service\BulkCapture|null $service optional BulkCapture service
1711: * @return bool
1712: */
1713: public function capture(string $hostname, string $path = '', \Service\BulkCapture $service = null): bool
1714: {
1715: if (APNSCPD_HEADLESS) {
1716: return warn('Panel in headless mode');
1717: }
1718:
1719: $hostname = strtolower($hostname);
1720: if (!$this->normalize_path($hostname, $path)) {
1721: return error("Site `%s/%s' is not hosted on account", $hostname, $path);
1722: }
1723: if (!$service) {
1724: $service = new \Service\BulkCapture(new \Service\CaptureDevices\Chromedriver);
1725: }
1726: return $service->snap($hostname, $path, $this->site_ip_address());
1727: }
1728:
1729: public function _create_user(string $user)
1730: {
1731: // TODO: Implement _create_user() method.
1732: }
1733: }