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: 
<?php
    declare(strict_types=1);
    /**
     *  +------------------------------------------------------------+
     *  | apnscp                                                     |
     *  +------------------------------------------------------------+
     *  | Copyright (c) Apis Networks                                |
     *  +------------------------------------------------------------+
     *  | Licensed under Artistic License 2.0                        |
     *  +------------------------------------------------------------+
     *  | Author: Matt Saladna (msaladna@apisnetworks.com)           |
     *  +------------------------------------------------------------+
     */

    /**
     * Ghost management
     *
     * A blogging platform built on Node
     *
     * @package core
     */
    class Redis_Module extends Module_Skeleton
    {
        const PREF_KEY = 'redis';
        protected $exportedFunctions = [
            '*'            => PRIVILEGE_SITE | PRIVILEGE_USER,
            'key_memusage' => PRIVILEGE_ADMIN
        ];

        public function __construct()
        {
            parent::__construct();
            if ($this->permission_level & (PRIVILEGE_USER | PRIVILEGE_SITE) && (!SSH_USER_DAEMONS || !$this->ssh_enabled())) {
                $this->exportedFunctions['*'] = PRIVILEGE_NONE;
            }
        }

        /**
         * Create a Redis service
         *
         * @param string $nickname
         * @param array  $options
         * @return bool
         */
        public function create(string $nickname, array $options = []): bool
        {
            if (!IS_CLI) {
                return $this->query('redis_create', $nickname, $options);
            }

            if (strlen($nickname) < 3) {
                return error('Minimum Redis nickname length is 3');
            }

            if (!preg_match(Regex::REDIS_NICKNAME, $nickname)) {
                return error('Invalid connection nickname');
            }
            if ($this->exists($nickname)) {
                return error('Redis nickname already in use');
            }

            if (empty($options['unixsocket'])) {
                $port = \Opcenter\Net\Port::firstFree($this->getAuthContext());
                if (!$port) {
                    return error('Unable to locate free port to run Redis service');
                }
                $options['port'] = $port;
                $options['bind'] = $options['bind'] ?? '127.0.0.1';
                if ($options['bind'] !== '127.0.0.1' && empty($options['requirepass'])) {
                    return error('No password set for Redis connection and connection open to remote connections. A password must be set.');
                }
                if ($options['bind'] !== '127.0.0.1') {
                    return error('External Redis support not supported yet');
                }
            }
            // always daemonize
            $options['daemonize'] = 'yes';

            $home = $this->user_get_home();
            $path = $home . '/.redis';

            if (!$this->file_exists($path)) {
                $this->file_create_directory($path, 0700);
            }

            if (!isset($options['dir'])) {
                $options['dir'] = $path . '/' . $nickname;
            }
            if (!$this->file_exists($options['dir'])) {
                $this->file_create_directory($options['dir'], 0700);
            }


            $fstpath = $this->domain_fs_path($this->getRedisConfiguration($nickname));
            copy(resource_path('templates/redis/redis.conf'), $fstpath);
            \Opcenter\Filesystem::chogp($fstpath, $this->user_id, $this->group_id, 0600);
            $map = \Opcenter\Map::load($fstpath, 'r+', 'textfile');
            if (empty($options['bind'])) {
                unset($map['bind'], $map['port']);
            }
            $options['daemonize'] = $options['daemonize'] ?? 'yes';
            $options['pidfile'] = $options['dir'] . '/redis.pid';

            foreach ($options as $k => $v) {
                $map[$k] = $v;
            }

            $map->save();

            $cfgfile = $this->getRedisConfiguration($nickname);
            $ret = $this->pman_run('redis-server %(cfg)s', ['cfg' => $cfgfile]);
            if (!$ret['success']) {
                return error('Failed to start redis: %s', $ret['stderr']);
            }
            $prefs = \Preferences::factory($this->getAuthContext());
            $data = array_get($prefs, self::PREF_KEY, []);
            $data[$nickname] = [
                'port'       => $options['port'] ?? null,
                'bind'       => $options['bind'] ?? null,
                'unixsocket' => $options['unixsocket'] ?? null,
                'type'       => isset($options['unixsocket']) ? 'unix' : 'tcp',
            ];
            $prefs->unlock($this->getApnscpFunctionInterceptor());
            $prefs[self::PREF_KEY] = $data;
            unset($prefs);
            if (!$this->crontab_enabled()) {
                return warn('Cannot create redis-server job on reboot. Cron is not running');
            }

            if (!$this->crontab_add_job('@reboot', null, null, null, null, 'redis-server ' . $cfgfile)) {
                return warn('Failed to create redis-server job for reboot');
            }

            return true;
        }

        /**
         * Redis nickname in use
         *
         * @param string $nickname
         * @return bool
         */
        public function exists(string $nickname): bool
        {
            $prefs = \Preferences::factory($this->getAuthContext());
            $pdata = array_get($prefs, self::PREF_KEY, []);

            return isset($pdata[$nickname]);
        }

        /**
         * Get configuration file from Redis file
         *
         * @param string $nickname
         * @return array
         */
        protected function getRedisConfiguration(string $nickname): string
        {
            return $this->user_get_home() . '/.redis/' . $nickname . '.conf';
        }

        public function delete(string $nickname): bool
        {
            if (!IS_CLI) {
                return $this->query('redis_delete', $nickname);
            }

            if (!$this->exists($nickname)) {
                return error("Unknown Redis instance `%s'", $nickname);
            }
            if ($this->running($nickname) && !$this->stop($nickname)) {
                return error("Failed to stop Redis instance `%s'", $nickname);
            }
            $prefs = \Preferences::factory($this->getAuthContext());
            $key = static::PREF_KEY;
            $prefs->unlock($this->getApnscpFunctionInterceptor());
            $redispref = array_get($prefs, $key, []);
            unset($redispref[$nickname]);
            $prefs[$key] = $redispref;
            unset($prefs);
            $home = $this->user_get_home();

            $files = [
                $cfgfile = $this->getRedisConfiguration($nickname),
                "${home}/.redis/${nickname}"
            ];

            foreach ($files as $f) {
                $this->file_delete($f, true);
            }
            $this->crontab_delete_job('@reboot', null, null, null, null, 'redis-server ' . $cfgfile);

            return true;
        }

        /**
         * Instance is running
         *
         * @param string $name
         * @return null|int
         */
        public function running(string $name): ?int
        {
            if (!$this->exists($name)) {
                return null;
            }

            $config = $this->config($name);
            $pid = $config['pidfile'];
            if (!$pid || !$this->file_exists($pid)) {
                return null;
            }
            $pid = (int)$this->file_get_file_contents($pid);

            return \Opcenter\Process::pidMatches($pid, 'redis-server') ? $pid : null;
        }

        /**
         * Get configuration from instance
         *
         * @param string $nickname
         * @return array|null
         */
        public function config(string $nickname): ?array
        {
            if (!IS_CLI) {
                return $this->query('redis_config', $nickname);
            }
            $fstcfg = $this->domain_fs_path($this->getRedisConfiguration($nickname));
            if (!file_exists($fstcfg)) {
                warn("Redis configuration for `%s' missing", $nickname);

                return null;
            }

            return \Opcenter\Map::load($fstcfg, 'r', 'textfile')->fetchAll();
        }

        /**
         * Stop Redis instance
         *
         * @param string $name
         * @return bool
         */
        public function stop(string $name): bool
        {
            if (!IS_CLI) {
                return $this->query('redis_stop', $name);
            }
            if (!$this->exists($name)) {
                return error("Unknown redis instance `%s'", $name);
            }
            if (!$pid = $this->running($name)) {
                return warn("Instance `%s' not running", $name);
            }

            return $this->pman_kill($pid);


        }

        /**
         * Get all known instances
         *
         * @return array
         */
        public function list(): array
        {
            if ($this->permission_level & PRIVILEGE_SITE) {
                $users = array_keys($this->user_get_users());
            } else {
                $users = [$this->username];

            }
            $instances = [];
            foreach ($users as $user) {
                $prefs = \Preferences::factory(Auth::context($user, $this->site));
                if (!$config = array_get($prefs, static::PREF_KEY, [])) {
                    continue;
                }
                $instances += $config;
            }

            return $instances;
        }

        /**
         * Start Redis instance
         *
         * @param string $name
         * @return bool
         */
        public function start(string $name): bool
        {
            if (!$this->exists($name)) {
                return error("Unknown redis instance `%s'", $name);
            } else if ($pid = $this->running($name)) {
                return warn("Redis instance `%s' already running with PID `%s'", $name, $pid);
            }
            $file = $this->getRedisConfiguration($name);

            return $this->pman_run('redis-server %s', $file)['success'] ?? false;
        }

        /**
         * Get per-key memory usage
         *
         * @param int $db
         * @return array
         */
        public function key_memusage(int $db = 0): ?array {
            if (!is_debug()) {
                error('Debug mode must be enabled');
                return null;
            }

            $cache = \Cache_Global::spawn();
            if (!version_compare(array_get($cache->info(), 'redis_version', '0.0.0'), '4.0.0', '>=')) {
                error('Key usage requires Redis 4');
                return null;
            }
            if (!$cache->select($db)) {
                error('Failed to access database %d', $db);
                return null;
            }

            $usage = [];
            // use raw to avoid prefix
            foreach ($cache->rawCommand('KEYS', '*') as $key) {
                $usage[$key] = $cache->rawCommand('MEMORY', 'USAGE', $key);
            }

            return $usage;
        }
    }