1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: /**
16: * Ghost management
17: *
18: * A blogging platform built on Node
19: *
20: * @package core
21: */
22: class Redis_Module extends Module_Skeleton
23: {
24: const PREF_KEY = 'redis';
25: protected $exportedFunctions = [
26: '*' => PRIVILEGE_SITE | PRIVILEGE_USER,
27: 'key_memusage' => PRIVILEGE_ADMIN,
28: 'system_info' => PRIVILEGE_ADMIN,
29: 'version' => PRIVILEGE_ALL
30: ];
31:
32: public function __construct()
33: {
34: parent::__construct();
35: if ($this->permission_level & (PRIVILEGE_USER | PRIVILEGE_SITE) && (!SSH_USER_DAEMONS || !$this->ssh_enabled())) {
36: $this->exportedFunctions['*'] = PRIVILEGE_NONE;
37: }
38: }
39:
40: /**
41: * Create a Redis service
42: *
43: * @param string $nickname
44: * @param array $options
45: * @return bool
46: */
47: public function create(string $nickname, array $options = []): bool
48: {
49: if (!IS_CLI) {
50: return $this->query('redis_create', $nickname, $options);
51: }
52:
53: if (strlen($nickname) < 3) {
54: return error('Minimum Redis nickname length is 3');
55: }
56:
57: if (!preg_match(Regex::REDIS_NICKNAME, $nickname)) {
58: return error('Invalid connection nickname');
59: }
60: if ($this->exists($nickname)) {
61: return error('Redis nickname already in use');
62: }
63: $options['port'] = $options['port'] ?? 0;
64: if (empty($options['unixsocket'])) {
65: $port = \Opcenter\Net\Port::firstFree($this->getAuthContext());
66: if (!$port) {
67: return error('Unable to locate free port to run Redis service');
68: }
69: $options['port'] = $port;
70: $options['bind'] = $options['bind'] ?? '127.0.0.1';
71: if ($options['bind'] !== '127.0.0.1' && empty($options['requirepass'])) {
72: return error('No password set for Redis connection and connection open to remote connections. A password must be set.');
73: }
74: if ($options['bind'] !== '127.0.0.1') {
75: return error('External Redis support not supported yet');
76: }
77: }
78: // always daemonize
79: $options['daemonize'] = 'yes';
80:
81: $home = $this->user_get_home();
82: $path = $home . '/.redis';
83:
84: if (!$this->file_exists($path)) {
85: $this->file_create_directory($path, 0700);
86: }
87:
88: if (!isset($options['dir'])) {
89: $options['dir'] = $path . '/' . $nickname;
90: }
91: if (!$this->file_exists($options['dir'])) {
92: $this->file_create_directory($options['dir'], 0700);
93: }
94:
95:
96: $fstpath = $this->domain_fs_path($this->getRedisConfiguration($nickname));
97: copy(resource_path('templates/redis/redis.conf'), $fstpath);
98: \Opcenter\Filesystem::chogp($fstpath, $this->user_id, $this->group_id, 0600);
99: $map = \Opcenter\Map::load($fstpath, 'r+', 'textfile');
100: if (empty($options['bind'])) {
101: unset($map['bind']);
102: }
103: $options['daemonize'] = $options['daemonize'] ?? 'yes';
104: $options['pidfile'] = $options['dir'] . '/redis.pid';
105:
106: foreach ($options as $k => $v) {
107: $map[$k] = $v;
108: }
109:
110: $map->save();
111:
112: $cfgfile = $this->getRedisConfiguration($nickname);
113: $ret = $this->pman_run('redis-server %(cfg)s', ['cfg' => $cfgfile]);
114: if (!$ret['success']) {
115: return error('Failed to start redis: %s', $ret['stderr']);
116: }
117: $prefs = \Preferences::factory($this->getAuthContext());
118: $data = array_get($prefs, self::PREF_KEY, []);
119: $data[$nickname] = [
120: 'port' => $options['port'] ?? null,
121: 'bind' => $options['bind'] ?? null,
122: 'unixsocket' => $options['unixsocket'] ?? null,
123: 'type' => isset($options['unixsocket']) ? 'unix' : 'tcp',
124: ];
125: $this->common_set_preference(static::PREF_KEY, $data);
126: unset($prefs);
127: if (!$this->crontab_enabled()) {
128: return warn('Cannot create redis-server job on reboot. Cron is not running');
129: }
130:
131: if (!$this->crontab_add_job('@reboot', null, null, null, null, 'redis-server ' . $cfgfile)) {
132: return warn('Failed to create redis-server job for reboot');
133: }
134:
135: return true;
136: }
137:
138: /**
139: * Redis nickname in use
140: *
141: * @param string $nickname
142: * @return bool
143: */
144: public function exists(string $nickname): bool
145: {
146: $prefs = \Preferences::factory($this->getAuthContext());
147: $pdata = array_get($prefs, self::PREF_KEY, []);
148:
149: return isset($pdata[$nickname]);
150: }
151:
152: /**
153: * Get configuration file from Redis file
154: *
155: * @param string $nickname
156: * @return array
157: */
158: protected function getRedisConfiguration(string $nickname): string
159: {
160: return $this->user_get_home() . '/.redis/' . $nickname . '.conf';
161: }
162:
163: public function delete(string $nickname): bool
164: {
165: if (!IS_CLI) {
166: return $this->query('redis_delete', $nickname);
167: }
168:
169: if (!$this->exists($nickname)) {
170: return error("Unknown Redis instance `%s'", $nickname);
171: }
172: if ($this->running($nickname) && !$this->stop($nickname)) {
173: return error("Failed to stop Redis instance `%s'", $nickname);
174: }
175: $prefs = \Preferences::factory($this->getAuthContext());
176: $key = static::PREF_KEY;
177: $prefs->unlock($this->getApnscpFunctionInterceptor());
178: $redispref = array_get($prefs, $key, []);
179: unset($redispref[$nickname]);
180: $prefs[$key] = $redispref;
181: unset($prefs);
182: $home = $this->user_get_home();
183:
184: $files = [
185: $cfgfile = $this->getRedisConfiguration($nickname),
186: "${home}/.redis/${nickname}"
187: ];
188:
189: foreach ($files as $f) {
190: $this->file_delete($f, true);
191: }
192: $this->crontab_delete_job('@reboot', null, null, null, null, 'redis-server ' . $cfgfile);
193:
194: return true;
195: }
196:
197: /**
198: * Instance is running
199: *
200: * @param string $name
201: * @return null|int
202: */
203: public function running(string $name): ?int
204: {
205: if (!$this->exists($name)) {
206: return null;
207: }
208:
209: $config = $this->config($name);
210: $pid = $config['pidfile'];
211: if (!$pid || !$this->file_exists($pid)) {
212: return null;
213: }
214: $pid = (int)$this->file_get_file_contents($pid);
215:
216: return \Opcenter\Process::pidMatches($pid, 'redis-server') ? $pid : null;
217: }
218:
219: /**
220: * Get configuration from instance
221: *
222: * @param string $nickname
223: * @return array|null
224: */
225: public function config(string $nickname): ?array
226: {
227: if (!IS_CLI) {
228: return $this->query('redis_config', $nickname);
229: }
230: $fstcfg = $this->domain_fs_path($this->getRedisConfiguration($nickname));
231: if (!file_exists($fstcfg)) {
232: warn("Redis configuration for `%s' missing", $nickname);
233:
234: return null;
235: }
236:
237: return \Opcenter\Map::load($fstcfg, 'r', 'textfile')->fetchAll();
238: }
239:
240: /**
241: * Stop Redis instance
242: *
243: * @param string $name
244: * @return bool
245: */
246: public function stop(string $name): bool
247: {
248: if (!IS_CLI) {
249: return $this->query('redis_stop', $name);
250: }
251: if (!$this->exists($name)) {
252: return error("Unknown redis instance `%s'", $name);
253: }
254: if (!$pid = $this->running($name)) {
255: return warn("Instance `%s' not running", $name);
256: }
257:
258: return $this->pman_kill($pid);
259:
260:
261: }
262:
263: /**
264: * Get all known instances
265: *
266: * @return array
267: */
268: public function list(): array
269: {
270: if ($this->permission_level & PRIVILEGE_SITE) {
271: $users = array_keys($this->user_get_users());
272: } else {
273: $users = [$this->username];
274:
275: }
276: $instances = [];
277: foreach ($users as $user) {
278: $prefs = \Preferences::factory(Auth::context($user, $this->site));
279: if (!$config = array_get($prefs, static::PREF_KEY, [])) {
280: continue;
281: }
282: $instances += $config;
283: }
284:
285: return $instances;
286: }
287:
288: /**
289: * Start Redis instance
290: *
291: * @param string $name
292: * @return bool
293: */
294: public function start(string $name): bool
295: {
296: if (!$this->exists($name)) {
297: return error("Unknown redis instance `%s'", $name);
298: } else if ($pid = $this->running($name)) {
299: return warn("Redis instance `%s' already running with PID `%s'", $name, $pid);
300: }
301: $file = $this->getRedisConfiguration($name);
302:
303: return $this->pman_run('redis-server %s', [$file])['success'] ?? false;
304: }
305:
306: /**
307: * Read INFO fields from system Redis database
308: *
309: * @return array|null
310: */
311: public function system_info(): ?array
312: {
313: if (!is_debug()) {
314: error('Debug mode must be enabled');
315:
316: return null;
317: }
318:
319: return \Cache_Global::spawn()->info();
320: }
321:
322: /**
323: * Get per-key memory usage
324: *
325: * @param int $db
326: * @return array
327: */
328: public function key_memusage(int $db = 0): ?array {
329: if (!is_debug()) {
330: error('Debug mode must be enabled');
331: return null;
332: }
333:
334: $cache = \Cache_Global::spawn();
335: if (!version_compare(array_get($cache->info(), 'redis_version', '0.0.0'), '4.0.0', '>=')) {
336: error('Key usage requires Redis 4');
337: return null;
338: }
339: if (!$cache->select($db)) {
340: error('Failed to access database %d', $db);
341: return null;
342: }
343:
344: $usage = [];
345: // use raw to avoid prefix
346: foreach ($cache->rawCommand('KEYS', '*') as $key) {
347: $usage[$key] = $cache->rawCommand('MEMORY', 'USAGE', $key);
348: }
349:
350: return $usage;
351: }
352:
353: /**
354: * Redis version
355: *
356: * @return string
357: */
358: public function version(): string
359: {
360: return strtok(\CLI\Yum\Synchronizer\Utils::getFullVersionFromPackage('redis'), '-');
361: }
362: }