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