1:   2:   3:   4:   5:   6:   7:   8:   9:  10:  11:  12:  13:  14:  15:  16:  17:  18:  19:  20:  21:  22:  23:  24:  25:  26:  27:  28:  29:  30:  31:  32:  33:  34:  35:  36:  37:  38:  39:  40:  41:  42:  43:  44:  45:  46:  47:  48:  49:  50:  51:  52:  53:  54:  55:  56:  57:  58:  59:  60:  61:  62:  63:  64:  65:  66:  67:  68:  69:  70:  71:  72:  73:  74:  75:  76:  77:  78:  79:  80:  81:  82:  83:  84:  85:  86:  87:  88:  89:  90:  91:  92:  93:  94:  95:  96:  97:  98:  99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122: 123: 124: 125: 126: 127: 128: 129: 130: 131: 132: 133: 134: 135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148: 149: 150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173: 174: 175: 176: 177: 178: 179: 180: 181: 182: 183: 184: 185: 186: 187: 188: 189: 190: 191: 192: 193: 194: 195: 196: 197: 198: 199: 200: 201: 202: 203: 204: 205: 206: 207: 208: 209: 210: 211: 212: 213: 214: 215: 216: 217: 218: 219: 220: 221: 222: 223: 224: 225: 226: 227: 228: 229: 230: 231: 232: 233: 234: 235: 236: 237: 238: 239: 240: 241: 242: 243: 244: 245: 246: 247: 248: 249: 250: 251: 252: 253: 254: 255: 256: 257: 258: 259: 260: 261: 262: 263: 264: 265: 266: 267: 268: 269: 270: 271: 272: 273: 274: 275: 276: 277: 278: 279: 280: 281: 282: 283: 284: 285: 286: 287: 288: 289: 290: 291: 292: 293: 294: 295: 296: 297: 298: 299: 300: 301: 302: 303: 304: 305: 306: 307: 308: 309: 310: 311: 312: 313: 314: 315: 316: 317: 318: 319: 320: 321: 322: 323: 324: 325: 326: 327: 328: 329: 330: 331: 332: 333: 334: 335: 336: 337: 338: 339: 340: 341: 342: 343: 344: 345: 346: 347: 348: 349: 350: 351: 352: 353: 354: 355: 356: 357: 358: 359: 360: 361: 362: 363: 364: 365: 366: 367: 368: 369: 370: 371: 372: 373: 374: 375: 376: 377: 378: 379: 380: 381: 382: 383: 384: 385: 386: 387: 388: 389: 390: 391: 392: 393: 394: 395: 396: 397: 398: 399: 400: 401: 402: 403: 404: 405: 406: 407: 408: 409: 410: 411: 412: 413: 414: 415: 416: 417: 418: 419: 420: 421: 422: 423: 424: 425: 426: 427: 428: 429: 
<?php
    declare(strict_types=1);
    /**
     *  +------------------------------------------------------------+
     *  | apnscp                                                     |
     *  +------------------------------------------------------------+
     *  | Copyright (c) Apis Networks                                |
     *  +------------------------------------------------------------+
     *  | Licensed under Artistic License 2.0                        |
     *  +------------------------------------------------------------+
     *  | Author: Matt Saladna (msaladna@apisnetworks.com)           |
     *  +------------------------------------------------------------+
     */

    /**
     * Logfile manipulation and management
     *
     * @package core
     */
    class Logs_Module extends Module_Skeleton
    {
        const DEPENDENCY_MAP = [
            'apache'
        ];

        /**
         * {{{ void __construct(void)
         *
         * @ignore
         */
        public function __construct()
        {
            parent::__construct();
            $this->exportedFunctions = array(
                '*' => PRIVILEGE_SITE
            );
        }

        /* }}} */

        /**
         * Get webserver log usage
         *
         * @return int|bool size in KB
         */
        public function get_webserver_log_usage()
        {
            // v7.5 platforms lock /var/log/httpd access from other
            if (!IS_CLI) {
                return $this->query('logs_get_webserver_log_usage');
            }
            if (!file_exists($this->domain_fs_path() . '/var/log/httpd/')) {
                return 0;
            }

            if (false === ($dh = opendir($this->domain_fs_path() . '/var/log/httpd/'))) {
                return error('failed to open /var/log/httpd');
            }
            $size = 0;
            while (($file = readdir($dh)) !== false) {
                if ($file == '.' || $file == '..') {
                    continue;
                }
                $path = $this->domain_fs_path() . '/var/log/httpd/' . $file;
                if (!$this->file_exists($path)) {
                    continue;
                }
                $size += filesize($path) / 1024;
            }
            closedir($dh);

            return (int)$size;
        }

        /**
         * array list_logfiles()
         *
         * @return array
         */
        public function list_logfiles()
        {
            $logs = array();
            $path = $this->web_site_config_dir() . '/custom_logs';
            if (!file_exists($path)) {
                $logs['*']['*'] = 'access_log';

                return $logs;
            }
            $logdata = file_get_contents($path);
            $logs = $this->render_log_data_as_array($logdata);

            return $logs;
        }

        private function render_log_data_as_array($data)
        {
            $logs = $envmap = array();
            $lines = explode("\n", $data);
            $domains = $this->web_list_domains();
            for ($i = 0, $n = sizeof($lines); $i < $n; $i++) {
                $line = $lines[$i];
                $tok = strtok($line, ' ');
                if (!$tok) {
                    continue;
                }
                $directive = strtolower($tok);

                if ($directive == 'setenvifnocase') {
                    preg_match('/^\s*SetEnvIfNoCase\s+Host\s+\(?(\.?[^\.]+)\.\)?\??([\S]+)\s+(.+)$/i', $line,
                        $lineCapture);
                    $subdomain = str_replace(array('.*', '\\'), array('*', ''), $lineCapture[1]);
                    $domain = str_replace(array('.*', '\\'), array('*', ''), $lineCapture[2]);
                    $env = $lineCapture[3];
                    $envmap[$env] = array('subdomain' => $subdomain, 'domain' => $domain);
                } else {
                    if ($directive == 'customlog') {
                        $logpath = strtok(' ');
                        $logfile = substr($logpath, strrpos($logpath, '/') + 1);
                        $logtype = strtok(' ');
                        $env = strtok(' ');
                        if (!$env) {
                            $logs['*'] = array('*' => $logfile);
                            continue;
                        }
                        $pos = strpos($env, '=');
                        if ($pos !== false) {
                            $env = substr($env, $pos + 1);
                        }
                        if (isset($envmap[$env])) {
                            $subdomain = $envmap[$env]['subdomain'];
                            $domain = $envmap[$env]['domain'];
                        } else {
                            if (substr($env, 0, 2) == 'L-') {

                                $subdomain = str_replace('_', '.', substr($env, 2));
                                if ($subdomain[0] == '.' || strpos($subdomain, '.') !== false) {
                                    // domain fall-through or local subdomain

                                    $components = $this->web_split_host(ltrim($subdomain, '.'));
                                    $domain = $components['domain'];
                                    if ($subdomain[0] == '.') {
                                        // domain fall-through
                                        $subdomain = '*';
                                    } else {
                                        // local subdomain
                                        if ($components) {
                                            $subdomain = $components['subdomain'];
                                        } else {
                                            $domain = substr($subdomain, strpos($subdomain, '.') + 1);
                                            $subdomain = substr($subdomain, 0, strpos($subdomain, '.'));
                                        }
                                    }
                                } else {
                                    // global subdomain
                                    $domain = '*';
                                }
                            } else {
                                error("Unknown log identifier `$env'");
                                continue;
                            }
                        }
                        if (!isset($logs[$domain])) {
                            $logs[$domain] = array();
                        }
                        $logs[$domain][$subdomain] = $logfile;
                    }
                }
            }

            return $logs;
        }

        /**
         * bool add_logfile(string, string, string)
         *
         * @param string $domain
         * @param string $subdomain
         * @param string $file
         * @return bool
         */
        public function add_logfile($domain, $subdomain, $file)
        {
            if (!IS_CLI) {
                return $this->query('logs_add_logfile', $domain, $subdomain, $file);
            }
            if ($domain != '*' && !preg_match(Regex::HTTP_HOST, $domain)) {
                return error($domain . ': invalid domain');
            } else {
                if ($subdomain && $subdomain != '*' && !preg_match(Regex::SUBDOMAIN, $subdomain)) {
                    return error($subdomain . ': invalid subdomain');
                } else {
                    if (!preg_match(Regex::HTTP_LOG_FILE, $file)) {
                        return error($file . ': Invalid logfile');
                    }
                }
            }

            $data = array();
            $path = $this->web_site_config_dir() . '/custom_logs';
            if (!file_exists($path)) {
                $data['*']['*'] = 'access_log';
            } else {
                $logdata = file_get_contents($path);
                $data = $this->render_log_data_as_array($logdata);
            }
            if (isset($data[$domain]) && isset($data[$domain][$subdomain])) {
                // @BUG warn generates error on pb when going from backend to gui
                return warn('profile for ' . $subdomain . ($subdomain ? '.' : '') . $domain . ' exists');
            }
            $data[$domain][$subdomain] = $file;

            return file_put_contents($logdata, $this->render_array_as_log_data($data), LOCK_EX) &&
                touch($this->domain_fs_path() . '/var/log/httpd/' . $file) &&
                chown($this->domain_fs_path() . '/var/log/httpd/' . $file, $this->user_id) &&
                chgrp($this->domain_fs_path() . '/var/log/httpd/' . $file, $this->group_id) &&
                chown($this->domain_fs_path() . '/etc/logrotate.d/apache', 'root') &&
                chgrp($this->domain_fs_path() . '/etc/logrotate.d/apache', $this->group_id) &&
                $this->add_log_rotation_profile('/var/log/httpd/' . $file, 'apache');
        }

        /**
         * The expected format is as follows:
         * Numerically indexed array, which gives log position, each
         * element is an array itself with the indexes subdomain, domain, and file
         */
        private function render_array_as_log_data(array $data)
        {
            /**
             * logfile is just created, once we do this we lose the wildcard
             * piped logging feature, so make a case to catch the rest
             */
            $txt = '<IfDefine !SLAVE>' . "\n";

            foreach ($data as $domain => $logs) {
                foreach ($logs as $subdomain => $file) {
                    /**
                     * SetEnvIfNoCase Host <subdomain>.<domain> <subdomain>_<domain>
                     * Substitute [*.] with _ so the env variable name doesn't puke
                     */
                    $env = 'env=L-';
                    list($subdomain, $domain) = str_replace('.', '_', array($subdomain, $domain));
                    if ($subdomain == '*') {
                        if ($domain == '*') {
                            $env = '';
                        } else {
                            $env .= '_' . $domain;
                        }
                    } else {
                        if ($subdomain) {
                            $env .= $subdomain . '.';
                        }
                        if ($domain != '*') {
                            $env .= $domain;
                        }
                    }

                    $env = str_replace(
                        array('*', '.'),
                        '_',
                        $env
                    );
                    $txt .= 'CustomLog ' . $this->domain_fs_path() . '/var/log/httpd/' . $file . ' combined ' . $env . "\n";
                }
            }

            return $txt . 'ErrorLog ' . $this->domain_fs_path() . '/var/log/httpd/error_log' . "\n</IfDefine>";
        }

        /**
         * bool add_log_rotation_profile(string)
         *
         * @param string $mLog log name, relative to /var/log/httpd/
         * @return bool
         */
        public function add_log_rotation_profile($log, $profile = 'apache')
        {
            if (!IS_CLI) {
                return $this->query('logs_add_log_rotation_profile', $log, $profile);
            }
            $log = str_replace('..', '', $log);
            if (!preg_match(Regex::HTTP_LOG_FILE, $log)) {
                return error("Invalid logfile `$log'");
            } else {
                if (!preg_match('/^[A-Z0-9_]+$/i', $profile) ||
                    !file_exists($this->domain_fs_path() . '/etc/logrotate.d/' . $profile)
                ) {
                    return error("Invalid service `$profile'");
                }
            }

            $data = file_get_contents($this->domain_fs_path() . '/etc/logrotate.d/' . $profile);
            if (preg_match('!\s*' . $log . '\s*(?:\s|{)!', $data)) {
                return true;
            }

            // TODO: Raise a warning instead if duplicate log rotation profile exists
            // return new FileError("Rotation profile for ".$log." already exists");
            $data = rtrim($data) . "\n" . $log . " {\n\tmissingok\n}";
            file_put_contents($this->domain_fs_path() . '/etc/logrotate.d/' . $profile, $data, LOCK_EX);

            return true;

        }

        /**
         * bool remove_logfile(string, string)
         *
         * @param string $domain
         * @param string $subdomain
         * @return bool
         */
        public function remove_logfile($domain, $subdomain)
        {
            if (!IS_CLI) {
                return $this->query('logs_remove_logfile', $domain, $subdomain);
            }
            $path = $this->web_site_config_dir() . '/custom_logs';
            $data = file_get_contents($path);
            $data = $this->render_log_data_as_array($data);

            if (!isset($data[$domain]) && !isset($data[$domain][$subdomain])) {
                return warn('Log profile not found for ' . $subdomain . '.' . $domain);
            }
            $log_file = '/var/log/httpd/' . $data[$domain][$subdomain];

            unset($data[$domain][$subdomain]);
            // no more logs left on the domain
            if (sizeof($data[$domain]) == 0) {
                unset($data[$domain]);
            }
            file_put_contents($path, $this->render_array_as_log_data($data), LOCK_EX);

            $this->remove_log_rotation_profile($log_file, 'apache');

            foreach (glob($this->domain_fs_path() . $log_file . '{,.gz,.[1-4],.[1-4].gz}', GLOB_BRACE) as $log) {
                unlink($log);
            }

            return true;
        }

        /**
         * bool remove_log_rotation_profile(string)
         *
         * @param string $mLog log name, relative to /var/log/httpd/
         * @return bool
         */
        public function remove_log_rotation_profile($log, $profile = 'apache')
        {
            if (!IS_CLI) {
                return $this->query('logs_remove_log_rotation_profile', $log, $profile);
            }
            $log = str_replace('..', '', $log);
            if (!preg_match(Regex::HTTP_LOG_FILE, $log)) {
                return error('Invalid logfile');
            } else {
                if (!preg_match('/^[A-Z0-9_]+$/i', $profile) ||
                    !file_exists($this->domain_fs_path() . '/etc/logrotate.d/' . $profile)
                ) {
                    return error('Invalid service type');
                }
            }

            $data = file_get_contents($this->domain_fs_path() . '/etc/logrotate.d/' . $profile);
            $data_new = preg_replace('!^\s*' . $log . '\s*{[^}]+[\r\n]+}$!m', '', $data);
            if ($data == $data_new) {
                return warn('no such log `' . basename($log) . "' found for service " . $profile);
            }
            file_put_contents($this->domain_fs_path() . '/etc/logrotate.d/' . $profile,
                $data_new,
                LOCK_EX);

            return true;

        }

        public function set_logrotate($data)
        {
            if (!IS_CLI) {
                return $this->query('logs_set_logrotate', $data);
            }

            $file = $this->domain_fs_path() . '/etc/logrotate.conf';
            $old = file_get_contents($file);
            file_put_contents($file, $data);
            if (!$this->validate_config()) {
                file_put_contents($file, $old);

                return false;
            }

            return true;
        }

        /**
         * Validate logrotate configuration
         *
         * @return int 0 on err, 1 on clean syntax, -1 on clean but invalid syntax
         */
        public function validate_config()
        {
            if (!IS_CLI) {
                return $this->query('logs_validate_config');
            }
            $proc = new Util_Process_Chroot($this->domain_fs_path());
            $ret = $proc->run('/usr/sbin/logrotate %s %s', ['-d', '/etc/logrotate.conf']);
            /**
             * additional non-fatal markup can appear in logrotate config, logrotate -d
             * returns 0 irrespective on v6 platforms, 1 on error on v6.5+ platforms...
             * including case below; parse debug output
             */
            $errs = array();
            foreach (explode("\n", $ret['stderr']) as $line) {
                if (0 !== strncmp($line, "error:", 6)) {
                    continue;
                } else if (0 === strncmp($line, "error: error opening /", 22)) {
                    /**
                     * even if missingok is set, logrotate will complain
                     * if a file to be removed is missing in dry-run mode
                     */
                    continue;
                }
                $errs[] = $line;
                warn($line);
            }

            return count($errs) === 0;
        }
    }