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