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