1: | <?php |
2: | declare(strict_types=1); |
3: | |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
11: | |
12: | |
13: | |
14: | |
15: | use Daphnie\Collector; |
16: | use Daphnie\Metrics\Mysql as MysqlMetrics; |
17: | use Module\Skeleton\Contracts\Tasking; |
18: | use Module\Support\Sql; |
19: | use Opcenter\Database\MySQL\DefinerRemap; |
20: | use Opcenter\Net\IpCommon; |
21: | |
22: | |
23: | |
24: | |
25: | |
26: | |
27: | class Mysql_Module extends Sql implements Tasking |
28: | { |
29: | const DEPENDENCY_MAP = [ |
30: | 'siteinfo', |
31: | 'diskquota' |
32: | ]; |
33: | const MYSQL_DATADIR = '/var/lib/mysql'; |
34: | |
35: | const NEW_API_VERSION = 50720; |
36: | |
37: | const DEFAULT_CONCURRENCY_LIMIT = 10; |
38: | |
39: | const MAX_CONCURRENCY_LIMIT = MYSQL_CONCURRENCY_LIMIT ?: DATABASE_CONCURRENCY_LIMIT; |
40: | |
41: | |
42: | const EXPORT_CMD = '/usr/bin/mysqldump --quick --single-transaction --add-drop-table --add-drop-trigger --triggers --events -q -R'; |
43: | |
44: | protected $exportedFunctions = array( |
45: | '*' => PRIVILEGE_SITE, |
46: | 'version' => PRIVILEGE_ALL, |
47: | 'get_elevated_password_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC, |
48: | 'create_database_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
49: | 'delete_database_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
50: | 'get_uptime' => PRIVILEGE_ALL, |
51: | 'assert_permissions' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
52: | 'set_option' => PRIVILEGE_ALL, |
53: | 'get_option' => PRIVILEGE_ALL, |
54: | 'export_pipe_real' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
55: | 'enabled' => PRIVILEGE_SITE | PRIVILEGE_USER, |
56: | 'repair_database' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
57: | 'get_prefix' => PRIVILEGE_SITE | PRIVILEGE_USER, |
58: | |
59: | |
60: | 'get_database_size' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
61: | 'database_exists' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
62: | '_export_old' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
63: | 'resolve_site_from_database' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
64: | 'recover_innodb_from_disk' => PRIVILEGE_ADMIN, |
65: | 'stats' => PRIVILEGE_ALL |
66: | ); |
67: | |
68: | public function __destruct() |
69: | { |
70: | foreach ($this->_tempUsers as $user) { |
71: | if (!$this->user_exists($user)) { |
72: | continue; |
73: | } |
74: | $this->_delete_temp_user($user); |
75: | } |
76: | } |
77: | |
78: | public function user_exists($user, $host = 'localhost') |
79: | { |
80: | if (!$user) { |
81: | return false; |
82: | } |
83: | $conn = $this->_connect_root(); |
84: | $prefix = $this->get_prefix(); |
85: | if (!($this->permission_level & PRIVILEGE_ADMIN) && $user !== $this->databaseAdmin() && |
86: | 0 !== strpos($user, $prefix) |
87: | ) { |
88: | $user = $prefix . $user; |
89: | } |
90: | |
91: | $q = $conn->query("SELECT user FROM user WHERE user = '" . |
92: | $conn->escape_string($user) . "' AND host = '" . $conn->escape_string($host) . "'"); |
93: | |
94: | return !$q || $q->num_rows > 0; |
95: | } |
96: | |
97: | public function get_prefix() |
98: | { |
99: | return $this->getServiceValue('mysql', 'dbaseprefix'); |
100: | } |
101: | |
102: | |
103: | |
104: | |
105: | |
106: | |
107: | |
108: | |
109: | private function _delete_temp_user($user) |
110: | { |
111: | if (!Opcenter\Database\MySQL::deleteUser($user, 'localhost')) { |
112: | return false; |
113: | } |
114: | |
115: | |
116: | $idx = array_search($user, $this->_tempUsers, true); |
117: | if ($idx !== false) { |
118: | unset($this->_tempUsers[$idx]); |
119: | } |
120: | |
121: | return true; |
122: | } |
123: | |
124: | |
125: | |
126: | |
127: | |
128: | |
129: | |
130: | |
131: | public function delete_user($user, $host) |
132: | { |
133: | if ($user === $this->databaseAdmin() && !Util_Account_Hooks::is_mode('delete')) { |
134: | return error('Cannot remove main user'); |
135: | } else if (!$this->user_exists($user, $host)) { |
136: | return error("user `%s' on `%s' does not exist", $user, $host); |
137: | } |
138: | $prefix = $this->get_prefix(); |
139: | if ($user !== $this->databaseAdmin() && strpos($user, $prefix) !== 0) { |
140: | $user = $prefix . $user; |
141: | } |
142: | if (\Opcenter\Database\MySQL::version() >= static::NEW_API_VERSION) { |
143: | return Opcenter\Database\MySQL::deleteUser($user, $host); |
144: | } |
145: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
146: | $conn->select_db('mysql'); |
147: | $stmt = $conn->prepare('DELETE FROM user WHERE user = ? AND host = ?'); |
148: | $stmt->bind_param('ss', $user, $host); |
149: | $stmt->execute(); |
150: | if ($stmt->error) { |
151: | return new MySQLError('Invalid query, ' . $stmt->error); |
152: | } |
153: | |
154: | $stmt2 = $conn->prepare('DELETE FROM db WHERE user = ? AND host = ?'); |
155: | $stmt2->bind_param('ss', $user, $host); |
156: | $stmt2->execute(); |
157: | if (!$stmt2->error) { |
158: | $conn->query('FLUSH PRIVILEGES'); |
159: | } else { |
160: | return new MySQLError('Invalid query, ' . $stmt2->error); |
161: | } |
162: | |
163: | return ($stmt->affected_rows > 0); |
164: | |
165: | } |
166: | |
167: | |
168: | |
169: | |
170: | |
171: | |
172: | |
173: | public function store_password($sqlpasswd) |
174: | { |
175: | return $this->set_option('password', $sqlpasswd, 'client'); |
176: | } |
177: | |
178: | |
179: | |
180: | |
181: | |
182: | |
183: | |
184: | |
185: | |
186: | |
187: | |
188: | |
189: | public function set_option(string $option, string $value = null, string $group = 'client') |
190: | { |
191: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
192: | return error("Demo may not alter ~/.my.cnf"); |
193: | } |
194: | |
195: | if (!IS_CLI) { |
196: | return $this->query('mysql_set_option', $option, $value, $group); |
197: | } |
198: | $home = $this->user_get_user_home(); |
199: | $path = $this->domain_fs_path() . "{$home}/.my.cnf"; |
200: | if (is_link($path) && ( |
201: | false === ($link = readlink($path)) || |
202: | 0 !== strpos($this->domain_fs_path(), realpath($link))) |
203: | ) |
204: | { |
205: | unlink($path); |
206: | } |
207: | if (!file_exists($path)) { |
208: | \Opcenter\Filesystem::touch($path, $this->user_id, $this->group_id, 0600); |
209: | } |
210: | |
211: | return \Opcenter\Database\MySQL::setUserConfigurationField($path, $option, $value, $group); |
212: | } |
213: | |
214: | |
215: | |
216: | |
217: | |
218: | |
219: | public function get_password() |
220: | { |
221: | return $this->get_option('password'); |
222: | } |
223: | |
224: | |
225: | |
226: | |
227: | |
228: | |
229: | |
230: | |
231: | public function get_option($option, $group = 'client') |
232: | { |
233: | if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) { |
234: | return error("Demo may not alter ~/.my.cnf"); |
235: | } |
236: | |
237: | if (!IS_CLI) { |
238: | return $this->query('mysql_get_option', $option, $group); |
239: | } |
240: | $home = $this->user_get_user_home(); |
241: | $paths = [ |
242: | $this->domain_fs_path() . "{$home}/.my.cnf", |
243: | '/etc/my.cnf' |
244: | ]; |
245: | foreach ($paths as $path) { |
246: | if (is_link($path) && 0 !== strpos($this->domain_fs_path(), realpath(readlink($path)))) { |
247: | |
248: | continue; |
249: | } |
250: | if (null !== ($val = array_get(\Opcenter\Database\MySQL::getUserConfiguration($path), |
251: | "{$group}.{$option}", null))) { |
252: | return $val; |
253: | } |
254: | } |
255: | |
256: | return null; |
257: | } |
258: | |
259: | public function get_elevated_password_backend() |
260: | { |
261: | if (!IS_CLI) { |
262: | fatal('needs execution from backend'); |
263: | } |
264: | |
265: | return Opcenter\Database\MySQL::rootPassword(); |
266: | } |
267: | |
268: | |
269: | |
270: | |
271: | |
272: | |
273: | |
274: | |
275: | |
276: | |
277: | public function import($db, $file) |
278: | { |
279: | if (!IS_CLI) { |
280: | return $this->query('mysql_import', $db, $file); |
281: | } |
282: | |
283: | $prefix = $this->get_prefix(); |
284: | |
285: | if (strncmp($db, $prefix, strlen($prefix))) { |
286: | $db = $prefix . $db; |
287: | } |
288: | |
289: | $dbs = $this->list_databases(); |
290: | if (false === array_search($db, $dbs, true)) { |
291: | return error("database `%s' does not exist", $db); |
292: | } |
293: | $unlink = null; |
294: | if (false === ($realfile = $this->_preImport($file, $unlink))) { |
295: | return false; |
296: | } |
297: | |
298: | $tempUser = $this->_create_temp_user($db); |
299: | if (!$tempUser) { |
300: | $this->_postImport($unlink); |
301: | return error('unable to import database'); |
302: | } |
303: | |
304: | |
305: | |
306: | |
307: | |
308: | |
309: | |
310: | |
311: | |
312: | |
313: | |
314: | $bulk = []; |
315: | |
316: | if (filetype($realfile) === 'file') { |
317: | $ret = Util_Process_Safe::exec('sed -i %s %s', [ |
318: | '{ |
319: | /DEFINER=[^* ]*\(\*\| \)/w /dev/stdout |
320: | s/DEFINER=[^* ]*\(\*\| \)/\1/g |
321: | }', |
322: | $realfile |
323: | ]); |
324: | |
325: | if (!$ret['success']) { |
326: | return error("Aborting import from `%s'. Failed to perform DEFINER pass: %s", $file, $ret['error']); |
327: | } |
328: | |
329: | |
330: | if (preg_match_all(Regex::SQL_MYSQL_DEFINER, $ret['stdout'], $matches, PREG_SET_ORDER)) { |
331: | |
332: | $bulkChangeCredentials = []; |
333: | $prefix = $this->get_prefix(); |
334: | $users = []; |
335: | foreach ($this->list_users() as $user => $hosts) { |
336: | if (0 === strpos($user, $prefix)) { |
337: | $user = substr($user, \strlen($prefix)); |
338: | } |
339: | $users[$user] = []; |
340: | foreach (array_keys($hosts) as $host) { |
341: | $users[$user][$host] = 1; |
342: | } |
343: | } |
344: | |
345: | foreach ($matches as $gm) { |
346: | |
347: | if ($gm['type'] === 'FUNCTION') { |
348: | $gm['type'] = 'PROCEDURE'; |
349: | } |
350: | |
351: | if (false !== ($pos = strpos($gm['user'], '_'))) { |
352: | $gm['user'] = substr($gm['user'], ++$pos); |
353: | } |
354: | |
355: | if (!isset($users[$gm['user']])) { |
356: | warn( |
357: | 'User %s not found in user table. Converting %s grant for %s to %s', |
358: | $gm['user'], $gm['type'], $gm['name'], $this->databaseAdmin() |
359: | ); |
360: | $gm['user'] = $this->databaseAdmin(); |
361: | $gm['host'] = 'localhost'; |
362: | } else if (!isset($users[$gm['user']][$gm['host']])) { |
363: | warn( |
364: | 'Host %s not found in user table. Converting %s grant for %s to localhost', |
365: | $gm['host'], $gm['type'], $gm['user'] |
366: | ); |
367: | $gm['host'] = 'localhost'; |
368: | } |
369: | |
370: | if (empty($bulkChangeCredentials)) { |
371: | $bulkChangeCredentials = [ |
372: | $gm['user'], |
373: | $gm['host'] |
374: | ]; |
375: | } else if ($bulkChangeCredentials !== [$gm['user'], $gm['host']]) { |
376: | warn( |
377: | 'Import only supports 1 DEFINER adjustment. Converting %s (type: %s) from `%s`@`%s` to `%s`@`%s`', |
378: | $gm['name'], $gm['type'], $gm['user'], $gm['host'], $bulkChangeCredentials[0], |
379: | $bulkChangeCredentials[1] |
380: | ); |
381: | } |
382: | if (empty($bulk[$gm['type']])) { |
383: | $bulk[$gm['type']] = []; |
384: | } |
385: | $bulk[$gm['type']][] = $gm['name']; |
386: | } |
387: | |
388: | if (isset($bulkChangeCredentials[0]) && $bulkChangeCredentials[0] !== $this->databaseAdmin()) { |
389: | $bulkChangeCredentials[0] = $this->getConfig('mysql', |
390: | 'dbaseprefix') . $bulkChangeCredentials[0]; |
391: | } |
392: | } |
393: | } |
394: | |
395: | |
396: | $safe = new Util_Process_Safe(); |
397: | $safe->setDescriptor(0, 'file', [$realfile, 'rb'], null, ['mute_stdin' => false]); |
398: | $safe->setPriority(19); |
399: | $safe->addCallback(function() use ($unlink, $tempUser) { |
400: | $this->_postImport($unlink); |
401: | $this->_delete_temp_user($tempUser); |
402: | return true; |
403: | }, 'close'); |
404: | |
405: | $status = $safe->run('mysql -u %s %s', $tempUser, $db); |
406: | if (!$status['success']) { |
407: | return false; |
408: | } |
409: | if ($bulk) { |
410: | $handler = new DefinerRemap($db, $this->_connect_root(true)); |
411: | |
412: | $handler->remapAll(...$bulkChangeCredentials); |
413: | } |
414: | |
415: | return $status['success']; |
416: | } |
417: | |
418: | |
419: | |
420: | |
421: | |
422: | |
423: | |
424: | public function resolve_site_from_database(string $db): ?string |
425: | { |
426: | $db = strtok($db, '/'); |
427: | $prefix = strtok($db, '_') . '_'; |
428: | |
429: | if ($this->site) { |
430: | return $this->database_exists($db) ? $this->site : null; |
431: | } |
432: | |
433: | if (!preg_match(Regex::SQL_DATABASE, $db)) { |
434: | error("Invalid database name"); |
435: | return null; |
436: | } |
437: | |
438: | if ($db === $prefix) { |
439: | $path = realpath(self::MYSQL_DATADIR . '/' . $prefix); |
440: | if (!$path || !str_starts_with($path, FILESYSTEM_VIRTBASE)) { |
441: | return null; |
442: | } |
443: | $site = strtok(substr($path, strlen(FILESYSTEM_VIRTBASE)), '/'); |
444: | } else { |
445: | $site = Opcenter\Map::read(\Opcenter\Database\MySQL::PREFIX_MAP)[$prefix]; |
446: | } |
447: | |
448: | if (!$site && $this->site && !str_starts_with($db, $prefix)) { |
449: | $db = $this->get_prefix() . $db; |
450: | |
451: | return $this->resolve_site_from_database($db); |
452: | } |
453: | |
454: | return $site ?: null; |
455: | } |
456: | |
457: | |
458: | |
459: | |
460: | |
461: | |
462: | |
463: | |
464: | |
465: | |
466: | |
467: | public function recover_innodb_from_disk(string $db, string $srcdir, bool $force = false): bool |
468: | { |
469: | if (!IS_CLI) { |
470: | return $this->query('mysql_recover_innodb_from_disk', $db, $srcdir, $force); |
471: | } |
472: | |
473: | if (!\Opcenter\Database\MySQL::databaseExists($db)) { |
474: | return error("Database `%s' does not exist", $db); |
475: | } |
476: | if (!is_dir($srcdir)) { |
477: | return error("Source path `%s' is not a directory", $srcdir); |
478: | } |
479: | |
480: | $glob = array_map( |
481: | static function ($file) { return basename($file, '.ibd'); }, |
482: | glob("{$srcdir}/*.ibd", GLOB_NOESCAPE) |
483: | ); |
484: | |
485: | if (empty($glob)) { |
486: | return error("No matching *.ibd files found in `%s'", $srcdir); |
487: | } |
488: | $tables = \Opcenter\Database\MySQL::getTablesFromDatabase($db); |
489: | |
490: | |
491: | $missingSource = array_values(array_diff($tables, $glob)); |
492: | |
493: | $missingSchema = array_values(array_diff($glob, $tables)); |
494: | |
495: | $user = $this->_create_temp_user($db); |
496: | |
497: | $datadir = realpath(self::MYSQL_DATADIR . '/' . \Opcenter\Database\MySQL::canonicalize($db)); |
498: | if (!$datadir || !($stat = stat($datadir))) { |
499: | return error( |
500: | "Unable to stat `%s'", |
501: | self::MYSQL_DATADIR . '/' . \Opcenter\Database\MySQL::canonicalize($db) |
502: | ); |
503: | } |
504: | |
505: | $conn = MySQL::stub(); |
506: | $conn->connect('localhost', $user, null, $db); |
507: | $conn->query('SET FOREIGN_KEY_CHECKS=0'); |
508: | |
509: | $importer = static function($table) use ($conn, $srcdir, $datadir, $stat): bool { |
510: | $ex = \Error_Reporter::exception_upgrade(\Error_Reporter::E_FATAL); |
511: | try { |
512: | $conn->query("LOCK TABLES `{$table}` WRITE"); |
513: | } catch (apnscpException $e) { |
514: | |
515: | } finally { |
516: | \Error_Reporter::exception_upgrade($ex); |
517: | } |
518: | |
519: | $conn->query("ALTER TABLE `{$table}` DISCARD TABLESPACE"); |
520: | |
521: | if (!copy("{$srcdir}/{$table}.ibd", "{$datadir}/{$table}.ibd")) { |
522: | return false; |
523: | } |
524: | if (!\Opcenter\Filesystem::chogp("{$datadir}/{$table}.ibd", $stat['uid'], $stat['gid'], 0600)) { |
525: | return false; |
526: | } |
527: | return (bool)$conn->query("ALTER TABLE `{$table}` IMPORT TABLESPACE"); |
528: | }; |
529: | |
530: | try { |
531: | |
532: | foreach (array_intersect($glob, $tables) as $table) { |
533: | if (!$force && file_exists("{$datadir}/{$table}.ibd")) { |
534: | info("InnoDB file `%(path)s/%(table)s.ibd' already exists - skipping %(db)s.%(table)s", |
535: | ['path' => $datadir, 'table' => $table, 'db' => $db]); |
536: | continue; |
537: | } |
538: | if (!$importer($table)) { |
539: | warn('Failed to replace table %(table)s in %(db)s', |
540: | ['table' => $table, 'db' => $db]); |
541: | } else { |
542: | info('Restored %(db)s.%(table)s', ['db' => $db, 'table' => $table]); |
543: | } |
544: | } |
545: | |
546: | |
547: | foreach ($missingSchema as $table) { |
548: | if (!file_exists("$srcdir/{$table}.frm")) { |
549: | |
550: | continue; |
551: | } |
552: | warn('%s.%s present in backup, .frm present in data directory, but .ibd missing - continuing import', $db, $table); |
553: | foreach (['ibd', 'frm'] as $ext) { |
554: | if (!$force && file_exists("{$datadir}/{$table}.{$ext}")) { |
555: | info("InnoDB file `%s/%s.%s' already exists - skipping %s.%s", |
556: | $datadir, $table, $ext, $db, $table); |
557: | continue; |
558: | } |
559: | $ret = copy("{$srcdir}/{$table}.{$ext}", "{$datadir}/{$table}.{$ext}") && |
560: | \Opcenter\Filesystem::chogp("{$datadir}/{$table}.{$ext}", $stat['uid'], $stat['gid'], 0600); |
561: | |
562: | if (!$ret) { |
563: | warn('Failed to replace table %s in %s', $table, $db); |
564: | continue 2; |
565: | } |
566: | } |
567: | |
568: | info('Restored %s.%s', $db, $table); |
569: | } |
570: | |
571: | foreach ($missingSource as $table) { |
572: | if (file_exists("{$datadir}/{$table}.ibd") || file_exists("{$datadir}/{$table}.MYI")) { |
573: | continue; |
574: | } |
575: | warn("%(db)s.%(table)s reported in `%(path)s' but no backup in %(srcdir)s", |
576: | ['db' => $db, 'table' => $table, 'path' => $datadir, 'srcdir' => $srcdir]); |
577: | } |
578: | } catch (\Throwable $e) { |
579: | return error("Failed to restore backup into `%(db)s': %(err)s", |
580: | ['db' => $db, 'err' => $e->getMessage()]); |
581: | } finally { |
582: | $conn->query('SET FOREIGN_KEY_CHECKS=1'); |
583: | } |
584: | |
585: | $conn->query('UNLOCK TABLES'); |
586: | return true; |
587: | } |
588: | |
589: | |
590: | |
591: | |
592: | |
593: | |
594: | |
595: | public function list_databases() |
596: | { |
597: | $prefix = $this->get_prefix(); |
598: | if (!$prefix) { |
599: | |
600: | if (!$this->enabled()) { |
601: | return []; |
602: | } |
603: | report('Prefixless site - bug'); |
604: | return []; |
605: | } |
606: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
607: | $conn->select_db('mysql'); |
608: | $q = $conn->query("SELECT DISTINCT(REPLACE(db,'\\_','_')) AS db FROM db WHERE db LIKE '" . str_replace('_', '\\_', $prefix) . "%' OR db LIKE '" . str_replace('_', '\\\\_', $prefix) . "%' OR user = '" . $this->databaseAdmin() . "'"); |
609: | $dbs = array(); |
610: | while (null !== ($row = $q->fetch_object())) { |
611: | $dbs[] = $row->db; |
612: | } |
613: | |
614: | $conn->close(); |
615: | |
616: | return $dbs; |
617: | } |
618: | |
619: | |
620: | |
621: | |
622: | |
623: | |
624: | |
625: | private function _create_temp_user($db) |
626: | { |
627: | |
628: | $prefix = (string)$this->get_prefix(); |
629: | $maxlen = \Opcenter\Database\MySQL::fieldLength('user') - strlen($prefix); |
630: | if ($maxlen < 1) { |
631: | warn('temp mysql user exceeds field length, cannot create user'); |
632: | return false; |
633: | } |
634: | $chars = array( |
635: | 'a', |
636: | 'b', |
637: | 'c', |
638: | 'd', |
639: | 'e', |
640: | 'f', |
641: | '0', |
642: | '1', |
643: | '2', |
644: | '3', |
645: | '4', |
646: | '5', |
647: | '6', |
648: | '7', |
649: | '8', |
650: | '9', |
651: | ); |
652: | $maxlen = min(8, $maxlen); |
653: | |
654: | $user = $prefix; |
655: | for ($i = 0; $i < $maxlen; $i++) { |
656: | $n = random_int(0, 15); |
657: | $user .= $chars[$n]; |
658: | } |
659: | |
660: | |
661: | $sqldb = $this->_connect_root(); |
662: | $q = "SELECT user FROM user WHERE user = '" . $user . "'"; |
663: | $rs = $sqldb->query($q); |
664: | if ($rs->num_rows > 0) { |
665: | return error('cannot create temp mysql user'); |
666: | } |
667: | $q = "CREATE USER '" . $user . "'@'localhost' IDENTIFIED BY ''"; |
668: | $rs = $sqldb->query($q); |
669: | if (!$rs) { |
670: | return error('failed to create temp mysql user'); |
671: | } |
672: | $q = 'GRANT ALL ON `' . $db . "`.* to '" . $sqldb->escape_string($user) . "'@localhost"; |
673: | $rs = $sqldb->query($q); |
674: | if (!$rs) { |
675: | return error('failed to create temp mysql user'); |
676: | } |
677: | $q = "GRANT SELECT ON mysql.proc TO '" . $sqldb->escape_string($user) . "'@localhost"; |
678: | $sqldb->query($q); |
679: | $this->_register_temp_user($user); |
680: | |
681: | return $user; |
682: | } |
683: | |
684: | |
685: | |
686: | |
687: | |
688: | |
689: | |
690: | public function change_prefix($prefix) |
691: | { |
692: | return error('use sql_change_prefix'); |
693: | } |
694: | |
695: | public function get_sql_prefix() |
696: | { |
697: | deprecated('use sql_get_prefix'); |
698: | |
699: | return $this->get_prefix(); |
700: | } |
701: | |
702: | |
703: | |
704: | |
705: | |
706: | public function list_users() |
707: | { |
708: | |
709: | if (!$prefix = $this->getServiceValue('mysql', 'dbaseprefix')) { |
710: | return []; |
711: | } |
712: | |
713: | $prefix = str_replace('_', '\_', $prefix); |
714: | if (!$prefix) { |
715: | report('Prefixless site - bug'); |
716: | |
717: | return []; |
718: | } |
719: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
720: | $conn->select_db('mysql'); |
721: | $q = $conn->query("SELECT host, |
722: | user, |
723: | password, |
724: | ssl_type, |
725: | ssl_cipher, |
726: | x509_issuer, |
727: | x509_subject, |
728: | max_questions, |
729: | max_updates, |
730: | max_connections, |
731: | max_user_connections FROM user WHERE user = '" . $this->databaseAdmin() . "' OR user LIKE '" . $prefix . "%'"); |
732: | $users = array(); |
733: | while (null !== ($row = $q->fetch_array(MYSQLI_ASSOC))) { |
734: | $row = array_change_key_case($row, CASE_LOWER); |
735: | $user = $row['user']; $host = $row['host']; |
736: | $users[$user][$host] = array( |
737: | 'ssl_type' => $row['ssl_type'], |
738: | 'ssl_cipher' => $row['ssl_cipher'], |
739: | 'x509_issuer' => $row['x509_issuer'], |
740: | 'x509_subject' => $row['x509_subject'], |
741: | 'max_questions' => (int)$row['max_questions'], |
742: | 'max_updates' => (int)$row['max_updates'], |
743: | 'max_user_connections' => (int)$row['max_user_connections'], |
744: | 'max_connections' => (int)$row['max_connections'], |
745: | 'password' => $row['password'], |
746: | ); |
747: | } |
748: | |
749: | return $users; |
750: | } |
751: | |
752: | |
753: | |
754: | |
755: | |
756: | |
757: | |
758: | |
759: | |
760: | |
761: | |
762: | |
763: | |
764: | |
765: | |
766: | |
767: | public function add_user( |
768: | $user, |
769: | $host, |
770: | $password, |
771: | $maxconn = self::DEFAULT_CONCURRENCY_LIMIT, |
772: | $maxupdates = 0, |
773: | $maxquery = 0, |
774: | $ssl = '', |
775: | $cipher = '', |
776: | $issuer = '', |
777: | $subject = '' |
778: | ) { |
779: | if (!$this->enabled()) { |
780: | return error("%(service)s is disabled", 'mysql'); |
781: | } |
782: | |
783: | if (!$user) { |
784: | return error('no username specified'); |
785: | } |
786: | $dbaseadmin = $this->databaseAdmin(); |
787: | if ($user === $dbaseadmin && !IS_SOAP) { |
788: | return error("cannot name user after primary account user, `%s'", $dbaseadmin); |
789: | } |
790: | |
791: | $ssl = strtoupper($ssl); |
792: | if (!$maxconn) { |
793: | $maxconn = self::DEFAULT_CONCURRENCY_LIMIT; |
794: | } |
795: | $host = trim($host); |
796: | if ($host != 'localhost' && !IpCommon::valid($host) && !preg_match(Regex::SQL_MYSQL_IP_WILDCARD, $host)) { |
797: | return error("rejected host `%s': only numeric IP addresses are permitted, not hostnames", $host); |
798: | } |
799: | if (strlen($password) < self::MIN_PASSWORD_LENGTH) { |
800: | return error('Password must be at least %d characters', self::MIN_PASSWORD_LENGTH); |
801: | } else if ($ssl !== '' && $ssl != 'ANY' && $ssl != 'X509' && $ssl != 'SPECIFIED') { |
802: | return error('Invalid SSL type'); |
803: | } else if ($maxconn < 1 || $maxquery < 0 || $maxupdates < 0) { |
804: | return error('Max connections, queries, and updates must be greater than 0'); |
805: | } else if ($maxconn > static::MAX_CONCURRENCY_LIMIT) { |
806: | return error('Max concurrent connections cannot exceed %d. ' . |
807: | 'Contact support if you need more than %d.', |
808: | static::MAX_CONCURRENCY_LIMIT, static::MAX_CONCURRENCY_LIMIT); |
809: | } else if ($this->user_exists($user, $host)) { |
810: | return error("mysql user `$user' on `$host' exists"); |
811: | } |
812: | $conn = $this->_connect_root(); |
813: | $prefix = $this->get_prefix(); |
814: | if ($user !== $this->databaseAdmin() && 0 !== strpos($user, $prefix)) { |
815: | |
816: | |
817: | $user = $prefix . $user; |
818: | } |
819: | if (Opcenter\Database\MySQL::version() >= static::NEW_API_VERSION) { |
820: | return \Opcenter\Database\MySQL::createUser( |
821: | $user, |
822: | $password, |
823: | $host, |
824: | [ |
825: | 'ssl' => $ssl, |
826: | 'cipher' => $cipher, |
827: | 'issuer' => $issuer, |
828: | 'subject' => $subject |
829: | ], |
830: | [ |
831: | 'connections' => (int)$maxconn, |
832: | 'query' => (int)$maxquery, |
833: | 'updates' => (int)$maxupdates |
834: | ] |
835: | ) ?: error("User creation failed on `%s@%s'", $user, $host); |
836: | } |
837: | $pwclause = 'password(?)'; |
838: | |
839: | if ((\strlen($password) === 16 && ctype_xdigit($password)) || |
840: | ($password[0] == '*' && \strlen($password) === 41 |
841: | && ctype_xdigit(substr($password, 1)))) |
842: | { |
843: | $pwclause = '?'; |
844: | } |
845: | $needAuth = $conn->columnExists('authentication_string', 'user'); |
846: | $query = 'INSERT INTO user |
847: | (host, |
848: | user, |
849: | password, |
850: | ssl_type, |
851: | ssl_cipher, |
852: | x509_issuer, |
853: | x509_subject, |
854: | max_questions, |
855: | max_updates, |
856: | max_user_connections' . ($needAuth ? ', authentication_string' : '') . ') |
857: | VALUES |
858: | (?, |
859: | ?, |
860: | ' . $pwclause . ', |
861: | ?, |
862: | ?, |
863: | ?, |
864: | ?, |
865: | ?, |
866: | ?, |
867: | ?' . ($needAuth ? ',""' : '') . ');'; |
868: | $stmt = $conn->prepare($query); |
869: | $stmt->bind_param('sssssssiii', $host, $user, $password, $ssl, $cipher, |
870: | $issuer, $subject, $maxquery, $maxupdates, $maxconn); |
871: | $stmt->execute(); |
872: | if ($stmt->error) { |
873: | return new MySQLError('Invalid query, ' . $stmt->error); |
874: | } |
875: | $conn->query('FLUSH PRIVILEGES;'); |
876: | if ($stmt->affected_rows < 1) { |
877: | return error("user creation `%s@%s' failed", $user, $host); |
878: | } |
879: | |
880: | return true; |
881: | } |
882: | |
883: | |
884: | |
885: | |
886: | |
887: | |
888: | |
889: | public function get_database_charset(string $db): ?string |
890: | { |
891: | if ($this->permission_level & (PRIVILEGE_USER|PRIVILEGE_SITE)) { |
892: | $prefix = $this->getServiceValue('mysql', 'dbaseprefix'); |
893: | if (0 !== strpos($db, $prefix)) { |
894: | $db = $prefix . $db; |
895: | } |
896: | } |
897: | if (!\in_array($db, $this->list_databases(), true)) { |
898: | error('Invalid database %s', $db); |
899: | return null; |
900: | } |
901: | $conn = $this->_connect_root(); |
902: | $q = "SELECT default_character_set_name FROM information_schema.SCHEMATA |
903: | WHERE schema_name = '" . $conn->escape_string($db). "';"; |
904: | $rs = $conn->query($q); |
905: | |
906: | if (!$rs->num_rows) { |
907: | return null; |
908: | } |
909: | return $rs->fetch_object()->default_character_set_name; |
910: | } |
911: | |
912: | |
913: | |
914: | |
915: | |
916: | |
917: | |
918: | |
919: | |
920: | |
921: | public function create_database($db, $charset = 'latin1', $collation = 'latin1_swedish_ci') |
922: | { |
923: | if (!IS_CLI) { |
924: | return $this->query('mysql_create_database', $db, $charset, $collation); |
925: | } |
926: | |
927: | if (!$this->enabled()) { |
928: | return error("%(service)s is disabled", 'mysql'); |
929: | } |
930: | |
931: | $charset = strtolower($charset); |
932: | $collation = strtolower($collation); |
933: | |
934: | if (!preg_match(Regex::SQL_DATABASE, $db)) { |
935: | return error("invalid database name `%s'", $db); |
936: | } |
937: | if (!$this->charset_valid($charset)) { |
938: | return error("unrecognized mysql charset `%s'", $charset); |
939: | } |
940: | if (!$this->collation_valid($collation)) { |
941: | return error("invalid mysql collation `%s'", $collation); |
942: | } else if (!$this->collation_compatible($collation, $charset)) { |
943: | warn("collation `%s' for charset `%s' not sensible", $collation, $charset); |
944: | } |
945: | |
946: | $prefix = $this->get_prefix(); |
947: | |
948: | |
949: | if (0 !== strpos($db, $prefix)) { |
950: | $db = $prefix . $db; |
951: | } |
952: | |
953: | if ($this->database_exists($db)) { |
954: | return error("database `$db' exists"); |
955: | } |
956: | |
957: | if (null !== ($limit = $this->getConfig('mysql', 'dbasenum', null)) && $limit >= 0) { |
958: | $count = \count($this->list_databases()); |
959: | if ($count >= $limit) { |
960: | return error("Database limit `%d' reached - cannot create additional databases", $limit); |
961: | } |
962: | } |
963: | |
964: | $status = $this->query('mysql_create_database_backend', $db, $charset, $collation); |
965: | if (!$status) { |
966: | return $status; |
967: | } |
968: | |
969: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
970: | $conn->select_db('mysql'); |
971: | $conn->query('GRANT ALL ON `' . str_replace('_', '\\_', $db) . "`.* to '" . $conn->escape_string($this->databaseAdmin()) . "'@localhost;"); |
972: | if ($conn->error) { |
973: | \Opcenter\Database\MySQL::dropDatabase($db); |
974: | |
975: | return error("failed to create db `%s'. Error while applying grants: `%s' " . |
976: | "- is control user `%s' missing?", |
977: | $db, |
978: | $conn->error, |
979: | $this->databaseAdmin() |
980: | ); |
981: | } |
982: | |
983: | return info("created database `%s'", $db); |
984: | |
985: | } |
986: | |
987: | public function charset_valid($charset): bool |
988: | { |
989: | $charset = strtolower($charset); |
990: | $charsets = $this->get_supported_charsets(); |
991: | |
992: | return array_key_exists($charset, $charsets); |
993: | } |
994: | |
995: | public function get_supported_charsets(): array |
996: | { |
997: | $cache = Cache_Global::spawn(); |
998: | $key = 's:mysql.char'; |
999: | $charsets = $cache->get($key); |
1000: | if ($charsets) { |
1001: | return $charsets; |
1002: | } |
1003: | $db = MySQL::initialize(); |
1004: | $rs = $db->query('SELECT CHARACTER_SET_NAME AS charset, DESCRIPTION AS `desc` FROM INFORMATION_SCHEMA.character_sets'); |
1005: | $charsets = array(); |
1006: | while (null !== ($row = $rs->fetch_object())) { |
1007: | $charsets[$row->charset] = $row->desc; |
1008: | } |
1009: | $cache->set($key, $charsets); |
1010: | |
1011: | return $charsets; |
1012: | } |
1013: | |
1014: | |
1015: | |
1016: | |
1017: | |
1018: | |
1019: | |
1020: | public function collation_valid($collation): bool |
1021: | { |
1022: | $collations = $this->get_supported_collations(); |
1023: | $collation = strtolower($collation); |
1024: | foreach ($collations as $c) { |
1025: | if ($c['collation'] === $collation) { |
1026: | return true; |
1027: | } |
1028: | } |
1029: | |
1030: | return false; |
1031: | } |
1032: | |
1033: | public function get_supported_collations() |
1034: | { |
1035: | $cache = Cache_Global::spawn(); |
1036: | $key = 's:mysql.coll'; |
1037: | $collations = $cache->get($key); |
1038: | if ($collations) { |
1039: | return $collations; |
1040: | } |
1041: | $collations = []; |
1042: | $db = MySQL::initialize(); |
1043: | $q = 'SELECT collation_name, character_set_name FROM ' . |
1044: | "INFORMATION_SCHEMA.collations WHERE is_compiled = 'Yes'"; |
1045: | $rs = $db->query($q); |
1046: | if (!$rs) { |
1047: | return $collations; |
1048: | } |
1049: | |
1050: | while (null !== ($row = $rs->fetch_object())) { |
1051: | $collations[] = array( |
1052: | 'collation' => $row->collation_name, |
1053: | 'charset' => $row->character_set_name |
1054: | ); |
1055: | } |
1056: | $cache->set($key, $collations); |
1057: | |
1058: | return $collations; |
1059: | } |
1060: | |
1061: | |
1062: | |
1063: | |
1064: | |
1065: | |
1066: | |
1067: | |
1068: | |
1069: | public function collation_compatible($collation, $charset): bool |
1070: | { |
1071: | $db = MySQL::initialize(); |
1072: | $q = 'SELECT 1 FROM INFORMATION_SCHEMA.COLLATION_CHARACTER_SET_APPLICABILITY ' . |
1073: | "WHERE collation_name = '" . $db->escape($collation) . "' AND " . |
1074: | "character_set_name = '" . $db->escape($charset) . "'"; |
1075: | $rs = $db->query($q); |
1076: | if (!$rs) { |
1077: | return false; |
1078: | } |
1079: | |
1080: | return $rs->num_rows > 0; |
1081: | } |
1082: | |
1083: | |
1084: | |
1085: | |
1086: | |
1087: | |
1088: | |
1089: | public function database_exists($db): bool |
1090: | { |
1091: | if (!IS_CLI) { |
1092: | return $this->query('mysql_database_exists', $db); |
1093: | } |
1094: | if (!$db) { |
1095: | return false; |
1096: | } |
1097: | $prefix = ''; |
1098: | if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) { |
1099: | $sqlroot = $this->domain_shadow_path() . self::MYSQL_DATADIR; |
1100: | $normal = \Opcenter\Database\MySQL::canonicalize($db); |
1101: | $prefix = $this->get_prefix(); |
1102: | if (!file_exists($sqlroot . '/' . $normal)) { |
1103: | |
1104: | $db = $prefix . $db; |
1105: | } |
1106: | } |
1107: | if (\Opcenter\Database\MySQL::databaseExists($db)) { |
1108: | return true; |
1109: | } else if ($this->permission_level & PRIVILEGE_ADMIN) { |
1110: | |
1111: | |
1112: | return false; |
1113: | } |
1114: | $user = $this->databaseAdmin(); |
1115: | |
1116: | if (0 === strpos($db, $prefix . $prefix)) { |
1117: | $db = (string)substr($db, strlen($prefix)); |
1118: | } |
1119: | return \Opcenter\Database\MySQL::databaseExists($db, $user); |
1120: | } |
1121: | |
1122: | |
1123: | |
1124: | |
1125: | |
1126: | public function create_database_backend($db, $charset, $collation) |
1127: | { |
1128: | $dboptData = 'default-character-set=' . $charset . "\n" . |
1129: | 'default-collation=' . $collation; |
1130: | |
1131: | |
1132: | |
1133: | |
1134: | |
1135: | |
1136: | $path = $this->domain_shadow_path(); |
1137: | if (!Opcenter\Database\MySQL::prepBackend($path, $db)) { |
1138: | return error('Failed to prepare database backend storage'); |
1139: | } |
1140: | $dbRoot = $path . self::MYSQL_DATADIR . '/' . \Opcenter\Database\MySQL::canonicalize($db); |
1141: | $fp = fopen($dbRoot . '/db.opt', 'w'); |
1142: | fwrite($fp, $dboptData); |
1143: | fclose($fp); |
1144: | \Opcenter\Filesystem::chogp($dbRoot . '/db.opt', 'mysql', (int)$this->group_id, 0640); |
1145: | |
1146: | return file_exists(self::MYSQL_DATADIR . '/' . Opcenter\Database\MySQL::canonicalize($db)) && file_exists($path); |
1147: | } |
1148: | |
1149: | |
1150: | |
1151: | |
1152: | |
1153: | |
1154: | |
1155: | |
1156: | |
1157: | |
1158: | |
1159: | |
1160: | |
1161: | public function add_user_permissions($user, $host, $db, array $opts) |
1162: | { |
1163: | deprecated_func('use set_mysql_privileges()'); |
1164: | |
1165: | return $this->set_privileges($user, $host, $db, $opts); |
1166: | } |
1167: | |
1168: | |
1169: | |
1170: | |
1171: | |
1172: | |
1173: | |
1174: | |
1175: | |
1176: | |
1177: | public function set_privileges(string $user, string $host, string $db, array $privileges): bool |
1178: | { |
1179: | if (!$host) { |
1180: | return error("invalid hostname `$host'"); |
1181: | } |
1182: | |
1183: | $privileges = array_change_key_case($privileges); |
1184: | $prefix = $this->get_prefix(); |
1185: | if ($user != $this->databaseAdmin() && strncmp($user, $prefix, strlen($prefix))) { |
1186: | $user = $prefix . $user; |
1187: | } |
1188: | if ($user != $this->databaseAdmin() && !preg_match('/^' . $prefix . '/', $user)) { |
1189: | return error("invalid user `%s'", $user); |
1190: | } |
1191: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
1192: | $conn->select_db('mysql'); |
1193: | |
1194: | if ($db === '%') { |
1195: | if ($user === $this->databaseAdmin()) { |
1196: | return warn('User %s already has permission to all databases - ignoring', $this->databaseAdmin()); |
1197: | } |
1198: | |
1199: | $db = $prefix . '%'; |
1200: | } else if (0 === strpos($db, $prefix)) { |
1201: | $rs = $conn->query("SELECT 1 FROM db WHERE user = '" . $this->databaseAdmin() . "' AND (db = '" . $db . "' OR db = '" . str_replace('_', '\\_', $db) . "')"); |
1202: | if ($rs->num_rows < 1) { |
1203: | return error("No grants found in database on `%s' for user `%s'", $db, $this->databaseAdmin()); |
1204: | } |
1205: | } |
1206: | |
1207: | $valid_opts = array( |
1208: | 'select' => false, |
1209: | 'insert' => false, |
1210: | 'update' => false, |
1211: | 'delete' => false, |
1212: | 'create' => false, |
1213: | 'drop' => false, |
1214: | 'grant' => false, |
1215: | 'references' => false, |
1216: | 'index' => false, |
1217: | 'alter' => false, |
1218: | 'create_tmp_table' => false, |
1219: | 'show_view' => false, |
1220: | 'create_view' => false, |
1221: | 'create_routine' => false, |
1222: | 'alter_routine' => false, |
1223: | 'lock_tables' => false, |
1224: | 'execute' => false, |
1225: | 'event' => false, |
1226: | 'trigger' => false |
1227: | ); |
1228: | |
1229: | if (count($privileges) <= 2 && (isset($privileges['read']) || isset($privileges['write']))) { |
1230: | |
1231: | $tmp = array(); |
1232: | if (!empty($privileges['read'])) { |
1233: | $tmp['select'] = $tmp['show_view'] = $tmp['execute'] = true; |
1234: | } |
1235: | |
1236: | if (!empty($privileges['write'])) { |
1237: | $write = array_diff(array_keys($valid_opts), array('select', 'show_view', 'execute')); |
1238: | $tmp2 = array_fill_keys($write, true); |
1239: | $tmp = array_merge($tmp, $tmp2); |
1240: | } |
1241: | $privileges = $tmp; |
1242: | } |
1243: | |
1244: | $opts_copy = $valid_opts; |
1245: | foreach ($valid_opts as $opt => $enabled) { |
1246: | if (isset($privileges[($opt)]) && $privileges[$opt]) { |
1247: | $valid_opts[$opt] = $opt . '_priv'; |
1248: | } else { |
1249: | unset($valid_opts[$opt]); |
1250: | } |
1251: | } |
1252: | |
1253: | $revoke_opts = array_diff_key($opts_copy, $valid_opts); |
1254: | foreach (array_keys($opts_copy) as $name) { |
1255: | $opts[] = (isset($valid_opts[$name])) ? 'Y' : 'N'; |
1256: | } |
1257: | |
1258: | array_walk($opts_copy, static function (&$key, $val) { |
1259: | $key = $val . '_priv'; |
1260: | }); |
1261: | if ($db !== $prefix . '%') { |
1262: | $rs = $conn->query("SELECT db FROM db WHERE user = '" . $user . "' AND host = '" . $host . "' AND db = '" . str_replace('_', '\\_', $prefix) . '%' . "'"); |
1263: | if ($rs->num_rows > 0) { |
1264: | return error('User %s@%s has wildcard grants applied. Per-database grants cannot be applied over wildcard grant. User may only be deleted.', $user, $host); |
1265: | } |
1266: | } |
1267: | |
1268: | $rs = $conn->query("SELECT 1 FROM db where user = '" . $user . "' AND host = '" . $host . "' AND db = '" . str_replace('_', |
1269: | '\\_', $db) . "'"); |
1270: | if ($db === $prefix . '%' || $rs->num_rows > 0) { |
1271: | $db = str_replace('_', '\\_', $db); |
1272: | } |
1273: | |
1274: | $conn->query('REPLACE INTO db (' . implode(', ', |
1275: | $opts_copy) . ", `host`, `db`, `user`) VALUES ('" . implode("', '", $opts) . |
1276: | "', '" . $host . "', '" . $db . "', '" . $user . "');"); |
1277: | $ar = $conn->affected_rows; |
1278: | |
1279: | if ($conn->error) { |
1280: | return error('Error when applying grants, ' . $conn->error); |
1281: | } |
1282: | |
1283: | $conn->query('FLUSH PRIVILEGES;'); |
1284: | |
1285: | return $ar > 0; |
1286: | } |
1287: | |
1288: | |
1289: | |
1290: | |
1291: | |
1292: | |
1293: | public function delete_user_permissions($user, $host, $db) |
1294: | { |
1295: | deprecated_func('use revoke_from_mysql_db()'); |
1296: | |
1297: | return $this->revoke_privileges($user, $host, $db); |
1298: | } |
1299: | |
1300: | |
1301: | |
1302: | |
1303: | |
1304: | |
1305: | |
1306: | |
1307: | |
1308: | public function revoke_privileges($user, $host, $db) |
1309: | { |
1310: | $prefix = $this->get_prefix(); |
1311: | if ($user != $this->databaseAdmin() && strncmp($user, $prefix, strlen($prefix))) { |
1312: | $user = $prefix . $user; |
1313: | } |
1314: | if ($user != $this->databaseAdmin() && !preg_match('/^' . $prefix . '/', $user)) { |
1315: | return error("invalid user `$user'"); |
1316: | } |
1317: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
1318: | $conn->select_db('mysql'); |
1319: | $stmt = $conn->prepare('DELETE FROM db WHERE user = ? AND host = ? AND (db = ? OR db = ?)'); |
1320: | $canonical = str_replace('_', '\_', $db); |
1321: | $stmt->bind_param('ssss', $user, $host, $db, $canonical); |
1322: | $res = $stmt->execute(); |
1323: | if (!$res) { |
1324: | return error('Query error while dropping permissions, ' . $stmt->error); |
1325: | } |
1326: | |
1327: | $conn->query('FLUSH PRIVILEGES;'); |
1328: | |
1329: | return $stmt->affected_rows > 0; |
1330: | } |
1331: | |
1332: | |
1333: | |
1334: | public function get_user_permissions($user, $host, $db) |
1335: | { |
1336: | deprecated_func('use get_privileges()'); |
1337: | |
1338: | return $this->get_privileges($user, $host, $db); |
1339: | } |
1340: | |
1341: | |
1342: | |
1343: | |
1344: | |
1345: | |
1346: | |
1347: | |
1348: | |
1349: | |
1350: | |
1351: | public function get_privileges($user, $host, $db) |
1352: | { |
1353: | $prefix = $this->get_prefix(); |
1354: | if ($user != $this->databaseAdmin() && strncmp($user, $prefix, strlen($prefix))) { |
1355: | $user = $prefix . $user; |
1356: | } |
1357: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
1358: | $conn->select_db('mysql'); |
1359: | |
1360: | $stmt = $conn->prepare('SELECT Select_priv, Insert_priv, Update_priv, DELETE_PRIV, |
1361: | CREATE_PRIV, DROP_PRIV, GRANT_PRIV, REFERENCES_PRIV, |
1362: | INDEX_PRIV, ALTER_PRIV, CREATE_TMP_TABLE_PRIV, |
1363: | LOCK_TABLES_PRIV, CREATE_VIEW_PRIV, SHOW_VIEW_PRIV, |
1364: | CREATE_ROUTINE_PRIV, ALTER_ROUTINE_PRIV, EXECUTE_PRIV, |
1365: | EVENT_PRIV, TRIGGER_PRIV FROM db WHERE user = ? AND db IN (?,?,?) AND host = ?'); |
1366: | $dbSafe = str_replace('_', '\\_', $db); |
1367: | $wcSafe = str_replace('_', '\\_', $prefix . '%'); |
1368: | $stmt->bind_param('sssss', $user, $db, $dbSafe, $wcSafe, $host); |
1369: | $stmt->execute(); |
1370: | $select = $insert = $update = $delete = $create = $drop = $domain = |
1371: | $grant = $references = $index = $alter = $create_tmp_table = |
1372: | $lock_tables = $create_view = $show_view = $create_routine = |
1373: | $alter_routine = $execute = $event = $trigger = null; |
1374: | $stmt->bind_result($select, $insert, $update, $delete, $create, $drop, $grant, |
1375: | $references, $index, $alter, $create_tmp_table, $lock_tables, |
1376: | $create_view, $show_view, $create_routine, $alter_routine, |
1377: | $execute, $event, $trigger); |
1378: | |
1379: | if ($stmt->fetch()) { |
1380: | $priv = array( |
1381: | 'select' => $select, |
1382: | 'insert' => $insert, |
1383: | 'update' => $update, |
1384: | 'delete' => $delete, |
1385: | 'create' => $create, |
1386: | 'drop' => $drop, |
1387: | 'grant' => $grant, |
1388: | 'references' => $references, |
1389: | 'index' => $index, |
1390: | 'alter' => $alter, |
1391: | 'create_tmp_table' => $create_tmp_table, |
1392: | 'lock_tables' => $lock_tables, |
1393: | 'create_view' => $create_view, |
1394: | 'show_view' => $show_view, |
1395: | 'create_routine' => $create_routine, |
1396: | 'alter_routine' => $alter_routine, |
1397: | 'execute' => $execute, |
1398: | 'trigger' => $trigger, |
1399: | 'event' => $event |
1400: | ); |
1401: | array_walk($priv, static function (&$key, $val) { |
1402: | $key = $key == 'Y'; |
1403: | }); |
1404: | $stmt->close(); |
1405: | } else { |
1406: | $priv = array( |
1407: | 'select' => false, |
1408: | 'insert' => false, |
1409: | 'update' => false, |
1410: | 'delete' => false, |
1411: | 'create' => false, |
1412: | 'drop' => false, |
1413: | 'grant' => false, |
1414: | 'references' => false, |
1415: | 'index' => false, |
1416: | 'alter' => false, |
1417: | 'create_tmp_table' => false, |
1418: | 'lock_tables' => false, |
1419: | 'create_view' => false, |
1420: | 'show_view' => false, |
1421: | 'create_routine' => false, |
1422: | 'alter_routine' => false, |
1423: | 'execute' => false, |
1424: | ); |
1425: | } |
1426: | return $priv; |
1427: | } |
1428: | |
1429: | |
1430: | |
1431: | |
1432: | |
1433: | |
1434: | |
1435: | |
1436: | |
1437: | |
1438: | |
1439: | |
1440: | public function version($pretty = false) |
1441: | { |
1442: | $version = \Opcenter\Database\MySQL::version(); |
1443: | if (!$pretty) { |
1444: | return $version; |
1445: | } |
1446: | |
1447: | $mysqlver = array(); |
1448: | foreach (array('patch', 'minor', 'major') as $v) { |
1449: | $mysqlver[$v] = (int)$version % 100; |
1450: | $version /= 100; |
1451: | } |
1452: | |
1453: | return $mysqlver['major'] . '.' . $mysqlver['minor'] . '.' . |
1454: | $mysqlver['patch']; |
1455: | |
1456: | } |
1457: | |
1458: | |
1459: | |
1460: | |
1461: | |
1462: | |
1463: | |
1464: | public function delete_database($db) |
1465: | { |
1466: | $db = str_replace('\\\\', '\\', $db); |
1467: | $prefix = $this->get_prefix(); |
1468: | if (!$prefix) { |
1469: | report('Prefixless site - bug'); |
1470: | |
1471: | return false; |
1472: | } |
1473: | $admin = $this->databaseAdmin(); |
1474: | $prefixwc = str_replace('_', '\_', $prefix) . '%'; |
1475: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
1476: | $conn->select_db('mysql'); |
1477: | $stmt = $conn->prepare('SELECT user FROM db WHERE (user = ? OR db LIKE ?) AND (db = ? OR db = ?)'); |
1478: | $canonical = str_replace('_', '\_', $db); |
1479: | $stmt->bind_param('ssss', $admin, $prefixwc, $db, $canonical); |
1480: | $stmt->execute(); |
1481: | $stmt->store_result(); |
1482: | if ($stmt->num_rows < 1) { |
1483: | $stmt->free_result(); |
1484: | |
1485: | |
1486: | if (strncmp($db, $prefix, strlen($prefix))) { |
1487: | $db = $prefix . $db; |
1488: | |
1489: | return $this->delete_database($db); |
1490: | } |
1491: | if (!in_array($db, $this->list_databases(), true)) { |
1492: | |
1493: | return error("Unknown database `%s'", $db); |
1494: | } |
1495: | } |
1496: | $stmt->free_result(); |
1497: | $this->query('mysql_assert_permissions'); |
1498: | \Opcenter\Database\MySQL::dropDatabaseGrants($db); |
1499: | |
1500: | |
1501: | |
1502: | \Opcenter\Database\MySQL::flush(); |
1503: | \Opcenter\Database\MySQL::dropDatabase($db); |
1504: | |
1505: | $this->query('mysql_delete_database_backend', $db); |
1506: | |
1507: | if (in_array($db, $this->list_backups(), true)) { |
1508: | $this->delete_backup($db); |
1509: | } |
1510: | |
1511: | |
1512: | if ($conn->error) { |
1513: | return error("error while removing database `$db' - " . $conn->error); |
1514: | } |
1515: | |
1516: | return true; |
1517: | } |
1518: | |
1519: | |
1520: | |
1521: | |
1522: | |
1523: | |
1524: | |
1525: | public function delete_backup($db) |
1526: | { |
1527: | return parent::delete_backup_real('mysql', $db); |
1528: | } |
1529: | |
1530: | |
1531: | |
1532: | |
1533: | public function assert_permissions() |
1534: | { |
1535: | if (!file_exists($this->domain_fs_path() . self::MYSQL_DATADIR)) { |
1536: | return false; |
1537: | } |
1538: | |
1539: | chown($this->domain_fs_path() . self::MYSQL_DATADIR, 'mysql'); |
1540: | chgrp($this->domain_fs_path() . self::MYSQL_DATADIR, $this->group_id); |
1541: | |
1542: | return true; |
1543: | } |
1544: | |
1545: | public function delete_database_backend($db) |
1546: | { |
1547: | $db = \Opcenter\Database\MySQL::canonicalize($db); |
1548: | if (is_link(self::MYSQL_DATADIR . '/' . $db)) { |
1549: | @unlink(self::MYSQL_DATADIR . '/' . $db); |
1550: | } |
1551: | |
1552: | return true; |
1553: | |
1554: | } |
1555: | |
1556: | |
1557: | |
1558: | |
1559: | |
1560: | |
1561: | |
1562: | |
1563: | |
1564: | |
1565: | |
1566: | |
1567: | |
1568: | |
1569: | |
1570: | |
1571: | |
1572: | |
1573: | |
1574: | |
1575: | |
1576: | public function edit_user(string $user, string $host, array $opts): bool |
1577: | { |
1578: | $prefix = $this->get_prefix(); |
1579: | if ($user !== $this->databaseAdmin() && 0 !== strpos($user, $prefix)) { |
1580: | $user = $prefix . $user; |
1581: | } |
1582: | if (!is_array($opts)) { |
1583: | return error('Options must be an array'); |
1584: | } |
1585: | if (isset($opts['ssl_type'])) { |
1586: | $opts['ssl_type'] = strtoupper($opts['ssl_type']); |
1587: | if ($opts['ssl_type'] != '' && $opts['ssl_type'] != 'ANY' && |
1588: | $opts['ssl_type'] != 'SPECIFIED' && $opts['ssl_type'] != 'X509' |
1589: | ) { |
1590: | return error('Invalid cipher type'); |
1591: | } |
1592: | } |
1593: | |
1594: | if (isset($opts['host']) && $opts['host'] != 'localhost') { |
1595: | |
1596: | if (!IpCommon::valid($opts['host']) && !preg_match(Regex::SQL_MYSQL_IP_WILDCARD, $opts['host'])) { |
1597: | return error("rejected host `%s': only numeric IP addresses are permitted, not hostnames", |
1598: | $opts['host']); |
1599: | } |
1600: | } |
1601: | |
1602: | $defaults = array( |
1603: | 'host' => $host, |
1604: | 'password' => null, |
1605: | 'max_user_connections' => self::DEFAULT_CONCURRENCY_LIMIT, |
1606: | 'max_updates' => 0, |
1607: | 'max_questions' => 0, |
1608: | 'use_ssl' => false, |
1609: | 'ssl_type' => '', |
1610: | 'ssl_cipher' => '', |
1611: | 'x509_subject' => '', |
1612: | 'x509_issuer' => '' |
1613: | ); |
1614: | |
1615: | |
1616: | |
1617: | $mergeopts = $opts; |
1618: | foreach ($defaults as $def_nam => $def_val) { |
1619: | if (!isset($mergeopts[$def_nam])) { |
1620: | $mergeopts[$def_nam] = $def_val; |
1621: | } |
1622: | } |
1623: | |
1624: | if (!$mergeopts['use_ssl']) { |
1625: | $mergeopts['x509_subject'] = $mergeopts['x509_issuer'] = $mergeopts['ssl_cipher'] = $mergeopts['ssl_type'] = ''; |
1626: | } else if (!isset($mergeopts['ssl_type'])) { |
1627: | $mergeopts['ssl_type'] = 'ANY'; |
1628: | } |
1629: | |
1630: | if ($mergeopts['max_user_connections'] < 1) { |
1631: | $mergeopts['max_user_connections'] = self::DEFAULT_CONCURRENCY_LIMIT; |
1632: | } |
1633: | |
1634: | if ($mergeopts['max_questions'] < 0 || $mergeopts['max_updates'] < 0) { |
1635: | return error('Max queries and updates must be greater than 0'); |
1636: | } |
1637: | if (isset($opts['max_user_connections']) && $opts['max_user_connections'] > static::MAX_CONCURRENCY_LIMIT) { |
1638: | return error('Max connection limit %d. Must file a ticket justifying need. ' . |
1639: | 'Check index placements first.', static::MAX_CONCURRENCY_LIMIT); |
1640: | } |
1641: | if (!is_null($mergeopts['password']) && strlen($mergeopts['password']) < self::MIN_PASSWORD_LENGTH) { |
1642: | return error('password must be at least %d characters long', self::MIN_PASSWORD_LENGTH); |
1643: | } |
1644: | |
1645: | if (!empty($mergeopts['use_ssl']) && $user === $this->databaseAdmin() && $host === 'localhost') { |
1646: | warn("Enabling SSL for localhost will break phpMyAdmin access on this account"); |
1647: | } |
1648: | |
1649: | $conn = $this->_connect_root(); |
1650: | |
1651: | $stmt = $conn->prepare('SELECT user FROM user WHERE user = ? AND host = ?'); |
1652: | $stmt->bind_param('ss', $user, $host); |
1653: | $stmt->execute(); |
1654: | $stmt->store_result(); |
1655: | if ($stmt->num_rows < 1) { |
1656: | $stmt->free_result(); |
1657: | return error('invalid user@host specified: %s@%s', $user, $host); |
1658: | } |
1659: | $stmt->free_result(); |
1660: | |
1661: | if ($host === 'localhost' && |
1662: | $user === $this->databaseAdmin() && ($mergeopts['host'] ?: $host) !== $host) |
1663: | { |
1664: | return error("Cannot modify the hostname attribute for %(user)s on %(host)s", ['user' => $user, 'host' => $host]); |
1665: | } |
1666: | |
1667: | if (Opcenter\Database\MySQL::version() >= self::NEW_API_VERSION) { |
1668: | $params = [ |
1669: | $user, |
1670: | $host, |
1671: | [ |
1672: | 'ssl' => $mergeopts['use_ssl'] ?: false, |
1673: | 'subject' => $mergeopts['x509_subject'] ?: null, |
1674: | 'issuer' => $mergeopts['x509_issuer'] ?: null, |
1675: | 'ssl_type' => $mergeopts['ssl_type'] ?: null, |
1676: | 'password' => $mergeopts['password'] ?: null, |
1677: | 'connections' => (int)($mergeopts['max_user_connections'] ?: 0), |
1678: | 'updates' => (int)($mergeopts['max_updates'] ?: 0), |
1679: | 'query' => (int)($mergeopts['max_questions'] ?: 0), |
1680: | 'host' => $mergeopts['host'] ?: null |
1681: | ] |
1682: | ]; |
1683: | if (!Opcenter\Database\MySQL::alterUser(...$params)) { |
1684: | return false; |
1685: | } |
1686: | } else { |
1687: | $stmt = $conn->prepare('UPDATE user |
1688: | SET |
1689: | host = ?, |
1690: | ssl_type = ?, |
1691: | ssl_cipher = ?, |
1692: | x509_issuer = ? , |
1693: | x509_subject = ?, |
1694: | max_questions = ?, |
1695: | max_updates = ?, |
1696: | max_user_connections = ? |
1697: | WHERE |
1698: | user = ? |
1699: | AND |
1700: | host = ?'); |
1701: | |
1702: | $stmt->bind_param('sssssiiiss', |
1703: | $mergeopts['host'], |
1704: | $mergeopts['ssl_type'], |
1705: | $mergeopts['ssl_cipher'], |
1706: | $mergeopts['x509_issuer'], |
1707: | $mergeopts['x509_subject'], |
1708: | $mergeopts['max_questions'], |
1709: | $mergeopts['max_updates'], |
1710: | $mergeopts['max_user_connections'], |
1711: | $user, |
1712: | $host |
1713: | ); |
1714: | $stmt->execute(); |
1715: | if ($stmt->error) { |
1716: | return new MySQLError('Invalid query, ' . $stmt->error); |
1717: | } |
1718: | |
1719: | if ($mergeopts['host'] !== $defaults['host']) { |
1720: | $stmt = $conn->prepare('UPDATE db SET host = ? WHERE user = ? AND host = ?'); |
1721: | $stmt->bind_param('sss', $mergeopts['host'], $user, $defaults['host']); |
1722: | $stmt->execute(); |
1723: | if ($stmt->error) { |
1724: | return error('error while updating DB grants, %s', $stmt->error); |
1725: | } |
1726: | } |
1727: | |
1728: | if ($mergeopts['password']) { |
1729: | $pwclause = 'password(?)'; |
1730: | $password = $mergeopts['password']; |
1731: | |
1732: | if ($password[0] == '*' && strlen($password) == 41 |
1733: | && ctype_xdigit(substr($password, 1)) || |
1734: | |
1735: | strlen($password) == 16 && ctype_xdigit($password) && version_compare(platform_version(), '6', |
1736: | '<') |
1737: | ) { |
1738: | $pwclause = '?'; |
1739: | } |
1740: | $stmt2 = $conn->prepare('UPDATE user SET password = ' . $pwclause . ' WHERE user = ? AND host = ?;'); |
1741: | |
1742: | $stmt2->bind_param('sss', $password, $user, $mergeopts['host']); |
1743: | $stmt2->execute(); |
1744: | if ($stmt2->error) { |
1745: | return new MySQLError('Query error while updating password, ' . $stmt2->error); |
1746: | } |
1747: | } |
1748: | } |
1749: | |
1750: | $conn->query('FLUSH PRIVILEGES'); |
1751: | |
1752: | if ($user === $this->databaseAdmin() && $mergeopts['password']) { |
1753: | $this->set_option('user', $this->databaseAdmin(), 'client'); |
1754: | $this->set_option('password', |
1755: | str_replace(array('"'), array('\"'), $mergeopts['password']), |
1756: | 'client' |
1757: | ); |
1758: | } |
1759: | |
1760: | return true; |
1761: | } |
1762: | |
1763: | |
1764: | |
1765: | |
1766: | |
1767: | |
1768: | |
1769: | |
1770: | |
1771: | |
1772: | public function service_enabled() |
1773: | { |
1774: | deprecated('use enabled()'); |
1775: | |
1776: | return $this->enabled(); |
1777: | } |
1778: | |
1779: | |
1780: | |
1781: | |
1782: | |
1783: | |
1784: | |
1785: | |
1786: | public function enabled() |
1787: | { |
1788: | return parent::svc_enabled('mysql'); |
1789: | } |
1790: | |
1791: | public function truncate_database($db) |
1792: | { |
1793: | |
1794: | return $this->_mysql_empty_truncate_wrapper($db, 'truncate'); |
1795: | } |
1796: | |
1797: | private function _mysql_empty_truncate_wrapper($db, $mode) |
1798: | { |
1799: | if ($mode != 'truncate' && $mode != 'empty') { |
1800: | return error("unknown mode `%s'", $mode); |
1801: | } |
1802: | if ($mode == 'empty') { |
1803: | |
1804: | $mode = 'drop'; |
1805: | } |
1806: | |
1807: | $prefix = $this->getServiceValue('mysql', 'dbaseprefix'); |
1808: | if (0 !== strpos($db, $prefix)) { |
1809: | $db = $prefix . $db; |
1810: | } |
1811: | |
1812: | if (!$this->database_exists($db)) { |
1813: | return error("unknown database, `%s'", $db); |
1814: | } |
1815: | |
1816: | $user = $this->_create_temp_user($db); |
1817: | if (!$user) { |
1818: | return error("failed to %s db `%s'", $mode, $db); |
1819: | } |
1820: | $conn = new mysqli('localhost', $user); |
1821: | $conn->set_charset('utf8mb4'); |
1822: | if (!$conn->select_db($db)) { |
1823: | return error("unable to establish db connection for user `%s' on db `%s'", $user, $db); |
1824: | } |
1825: | |
1826: | $conn->query('SET FOREIGN_KEY_CHECKS=0'); |
1827: | |
1828: | $q = "SELECT CONCAT('" . strtoupper($mode) . " TABLE ','`', table_schema,'`','.','`',TABLE_NAME,'`', ';') |
1829: | FROM INFORMATION_SCHEMA.TABLES where table_schema in ('" . $conn->escape_string($db) . "');"; |
1830: | $res = $conn->query($q); |
1831: | while (null !== ($rs = $res->fetch_row())) { |
1832: | if (!$conn->query($rs[0])) { |
1833: | warn("failed to %s table `%s'", $mode, $rs[0]); |
1834: | } |
1835: | } |
1836: | |
1837: | $conn->query('SET @@FOREIGN_KEY_CHECKS=1;'); |
1838: | if (!$res) { |
1839: | return error("%s failed on database `%s': `%s'", $mode, $db, $conn->error); |
1840: | } |
1841: | $this->_delete_temp_user($user); |
1842: | |
1843: | return true; |
1844: | } |
1845: | |
1846: | |
1847: | |
1848: | |
1849: | |
1850: | |
1851: | |
1852: | public function empty($db) { |
1853: | if (!$this->database_exists($db)) { |
1854: | return true; |
1855: | } |
1856: | |
1857: | return \count(\Opcenter\Database\MySQL::getTablesFromDatabase($db)) === 0; |
1858: | } |
1859: | |
1860: | public function empty_database($db) |
1861: | { |
1862: | return $this->_mysql_empty_truncate_wrapper($db, 'empty'); |
1863: | } |
1864: | |
1865: | |
1866: | |
1867: | |
1868: | |
1869: | |
1870: | |
1871: | |
1872: | public function export($db, $file = null) |
1873: | { |
1874: | if (!IS_CLI) { |
1875: | return $this->query('mysql_export', $db, $file); |
1876: | } |
1877: | |
1878: | if (is_null($file)) { |
1879: | $file = $db . '.sql'; |
1880: | } |
1881: | if (!in_array($db, $this->list_databases(), true)) { |
1882: | return error('Invalid database ' . $db); |
1883: | } |
1884: | if ($file[0] !== '/' && $file[0] !== '.' && $file[0] !== '~') { |
1885: | $file = '/tmp/' . $file; |
1886: | } |
1887: | $pdir = dirname($file); |
1888: | if (!$this->file_exists($pdir) && !$this->file_create_directory($pdir, 0755, true)) { |
1889: | return error("failed to create parent directory, `%s'", $pdir); |
1890: | } |
1891: | $path = $this->file_make_path($file); |
1892: | if (!$path) { |
1893: | return error("invalid file `%s'", $file); |
1894: | } |
1895: | |
1896: | if (file_exists($path) && |
1897: | (filesize($path) > 0 || realpath($path) !== $path || fileowner($path) < USER_MIN_UID)) |
1898: | { |
1899: | return error('%s: file exists, cannot overwrite', $file); |
1900: | } |
1901: | $user = $this->_create_temp_user($db); |
1902: | $cmd = 'umask 077 && ' . self::EXPORT_CMD . ' -u %s %s > %s'; |
1903: | if (!$user) { |
1904: | $user = self::MASTER_USER; |
1905: | $rootpw = escapeshellarg($this->_get_elevated_password()); |
1906: | $cmd = str_replace('-u %s', '-u %s -p' . $rootpw, $cmd); |
1907: | } |
1908: | |
1909: | $fsizelimit = Util_Ulimit::get('fsize'); |
1910: | if ($this->get_database_size($db) > $fsizelimit / self::DB_BIN2TXT_MULT) { |
1911: | |
1912: | Util_Ulimit::set('fsize', 'unlimited'); |
1913: | } else { |
1914: | |
1915: | $fsizelimit = null; |
1916: | } |
1917: | $proc = new Util_Process_Safe(); |
1918: | |
1919: | $proc->setSgid($this->group_id); |
1920: | $proc->setSuid($this->user_id); |
1921: | $proc->setPriority(19); |
1922: | |
1923: | $status = $proc->run($cmd, |
1924: | $user, |
1925: | $db, |
1926: | $path |
1927: | ); |
1928: | |
1929: | if ($user != self::MASTER_USER) { |
1930: | $this->_delete_temp_user($user); |
1931: | } |
1932: | |
1933: | if (!is_null($fsizelimit)) { |
1934: | Util_Ulimit::set('fsize', $fsizelimit); |
1935: | } |
1936: | |
1937: | if (!$status['success'] || !file_exists($path)) { |
1938: | return error('export failed: %s', $status['stderr']); |
1939: | } |
1940: | |
1941: | if (!$status['success']) { |
1942: | return error('export failed: %s', $status['stderr']); |
1943: | } |
1944: | |
1945: | return $this->file_unmake_path($path); |
1946: | } |
1947: | |
1948: | |
1949: | |
1950: | |
1951: | |
1952: | |
1953: | |
1954: | |
1955: | public function clone(string $from, string $to): bool |
1956: | { |
1957: | if ($this->database_exists($to) && !$this->empty($to)) { |
1958: | return error("Database `%s' already exists", $to); |
1959: | } |
1960: | if (!$this->database_exists($from)) { |
1961: | return error("Database `%s' does not exist", $from); |
1962: | } |
1963: | if (!$this->database_exists($to) && !$this->create_database($to)) { |
1964: | return false; |
1965: | } |
1966: | $pipe = $this->export_pipe($from); |
1967: | defer($_, static function () use ($pipe) { |
1968: | if (file_exists($pipe)) { |
1969: | unlink($pipe); |
1970: | } |
1971: | }); |
1972: | return $this->import($to, $this->file_unmake_path($pipe)); |
1973: | } |
1974: | |
1975: | |
1976: | |
1977: | |
1978: | |
1979: | |
1980: | |
1981: | |
1982: | |
1983: | public function get_database_size($db) |
1984: | { |
1985: | if (!IS_CLI) { |
1986: | $resp = $this->query('mysql_get_database_size', $db); |
1987: | |
1988: | return (int)$resp; |
1989: | } |
1990: | |
1991: | |
1992: | $dir = self::MYSQL_DATADIR . '/' . \Opcenter\Database\MySQL::canonicalize($db); |
1993: | |
1994: | |
1995: | if (($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) && !is_link($dir)) { |
1996: | $this->_move_db($db); |
1997: | } |
1998: | if (!file_exists($dir) || (is_link($dir) && !readlink($dir))) { |
1999: | warn($db . ': database does not exist'); |
2000: | |
2001: | return 0; |
2002: | } |
2003: | |
2004: | $space = 0; |
2005: | $dh = opendir($dir); |
2006: | if (!$dh) { |
2007: | error("failed to open database directory `%s'", $dir); |
2008: | |
2009: | return 0; |
2010: | } |
2011: | while (($file = readdir($dh)) !== false) { |
2012: | if ($file == '.' || $file == '..') { |
2013: | continue; |
2014: | } |
2015: | $space += filesize($dir . '/' . $file); |
2016: | } |
2017: | closedir($dh); |
2018: | |
2019: | return $space; |
2020: | } |
2021: | |
2022: | |
2023: | |
2024: | private function _move_db($db) |
2025: | { |
2026: | |
2027: | |
2028: | |
2029: | |
2030: | |
2031: | $prefix = $this->databaseAdmin(); |
2032: | if (strncmp($db, $prefix, strlen($prefix))) { |
2033: | return true; |
2034: | } |
2035: | $dbfs = \Opcenter\Database\MySQL::canonicalize($db); |
2036: | $src = self::MYSQL_DATADIR . '/' . $dbfs; |
2037: | $dest = $this->domain_fs_path() . self::MYSQL_DATADIR . '/' . $dbfs; |
2038: | if (is_dir($dest)) { |
2039: | report('dest db exists - %s', $dest); |
2040: | |
2041: | return false; |
2042: | } |
2043: | \Opcenter\Filesystem::mkdir($dest, 'mysql', $this->group_id, 02750); |
2044: | |
2045: | |
2046: | |
2047: | |
2048: | |
2049: | |
2050: | |
2051: | |
2052: | |
2053: | $dh = opendir($src); |
2054: | if (!$dh) { |
2055: | return error("could not relocate database `%s'", $db); |
2056: | } |
2057: | |
2058: | while (false !== ($file = readdir($dh))) { |
2059: | if ($file == '.' || $file == '..') { |
2060: | continue; |
2061: | } |
2062: | rename($src . '/' . $file, $dest . '/' . $file); |
2063: | chown($dest . '/' . $file, 'mysql'); |
2064: | chgrp($dest . '/' . $file, $this->group_id); |
2065: | } |
2066: | |
2067: | rmdir($src); |
2068: | symlink($dest, $src); |
2069: | warn("database `%s' relocated under account filesystem root", $db); |
2070: | $db = $this->_connect_root(); |
2071: | $db->query('FLUSH TABLES'); |
2072: | |
2073: | return true; |
2074: | } |
2075: | |
2076: | |
2077: | |
2078: | |
2079: | |
2080: | |
2081: | |
2082: | |
2083: | |
2084: | |
2085: | public function export_pipe($db) |
2086: | { |
2087: | if (version_compare(platform_version(), '4.5', '<=')) { |
2088: | return error('platform version too old to support download feature'); |
2089: | } |
2090: | |
2091: | $dbs = $this->list_databases(); |
2092: | if (!\in_array($db, $dbs, true)) { |
2093: | $originalDb = $db; |
2094: | $db = $this->get_prefix() . $db; |
2095: | if (!\in_array($db, $dbs, true)) { |
2096: | return error('Invalid database %s', $originalDb); |
2097: | } |
2098: | } |
2099: | |
2100: | $user = $this->_create_temp_user($db); |
2101: | |
2102: | return $this->query('mysql_export_pipe_real', $db, $user); |
2103: | } |
2104: | |
2105: | |
2106: | |
2107: | |
2108: | |
2109: | |
2110: | |
2111: | |
2112: | |
2113: | |
2114: | |
2115: | |
2116: | |
2117: | |
2118: | |
2119: | public function export_pipe_real($db, $user) |
2120: | { |
2121: | if (!IS_CLI) { |
2122: | return $this->query('mysql_export_pipe_real', $db, $user); |
2123: | } |
2124: | |
2125: | |
2126: | |
2127: | |
2128: | $cmd = self::EXPORT_CMD . ' -u %s %s -r%s'; |
2129: | if (!$user) { |
2130: | $user = self::MASTER_USER; |
2131: | $rootpw = escapeshellarg($this->_get_elevated_password()); |
2132: | $cmd = str_replace('-u %s', '-u %s --password=' . $rootpw, $cmd); |
2133: | } |
2134: | |
2135: | |
2136: | $fifo = tempnam($this->domain_fs_path('/tmp'), 'id-' . $this->site); |
2137: | unlink($fifo); |
2138: | if (!posix_mkfifo($fifo, 0600)) { |
2139: | return error('failed to ready pipe for export'); |
2140: | } |
2141: | chown($fifo, File_Module::UPLOAD_UID); |
2142: | $proc = new Util_Process_Fork(); |
2143: | |
2144: | |
2145: | $proc->setPriority(19); |
2146: | $proc->setUser(APNSCP_SYSTEM_USER); |
2147: | |
2148: | $status = $proc->run($cmd, |
2149: | $user, |
2150: | $db, |
2151: | $fifo |
2152: | ); |
2153: | |
2154: | if (!$status['success'] || !file_exists($fifo)) { |
2155: | return error('export failed: %s', $status['stderr']); |
2156: | } |
2157: | register_shutdown_function(static function () use ($fifo) { |
2158: | if (file_exists($fifo)) { |
2159: | unlink($fifo); |
2160: | } |
2161: | |
2162: | }); |
2163: | |
2164: | return $fifo; |
2165: | } |
2166: | |
2167: | |
2168: | |
2169: | |
2170: | |
2171: | |
2172: | |
2173: | |
2174: | public function get_uptime(): int |
2175: | { |
2176: | $db = MySQL::initialize(); |
2177: | |
2178: | return $db->query("SHOW status LIKE 'uptime'")->fetch_object()->value; |
2179: | |
2180: | } |
2181: | |
2182: | |
2183: | |
2184: | public function add_backup( |
2185: | string $db, |
2186: | string $extension = DATABASE_BACKUP_EXTENSION, |
2187: | int $span = DATABASE_BACKUP_SPAN, |
2188: | int $preserve = DATABASE_BACKUP_PRESERVE, |
2189: | $email = '' |
2190: | ) |
2191: | { |
2192: | return parent::add_backup_real('mysql', $db, $extension, $span, $preserve, $email); |
2193: | } |
2194: | |
2195: | public function edit_backup( |
2196: | string $db, |
2197: | string $extension = DATABASE_BACKUP_EXTENSION, |
2198: | int $span = DATABASE_BACKUP_SPAN, |
2199: | int $preserve = DATABASE_BACKUP_PRESERVE, |
2200: | $email = '' |
2201: | ) |
2202: | { |
2203: | return $this->edit_backup_real('mysql', $db, $extension, $span, $preserve, $email); |
2204: | } |
2205: | |
2206: | public function list_backups() |
2207: | { |
2208: | return parent::list_backups_real('mysql'); |
2209: | } |
2210: | |
2211: | |
2212: | |
2213: | |
2214: | |
2215: | |
2216: | |
2217: | |
2218: | |
2219: | |
2220: | |
2221: | |
2222: | |
2223: | public function get_backup_config($db) |
2224: | { |
2225: | return parent::get_backup_config_real('mysql', $db); |
2226: | } |
2227: | |
2228: | public function repair_database($db) |
2229: | { |
2230: | if (!IS_CLI) { |
2231: | return $this->query('mysql_repair_database', $db); |
2232: | } |
2233: | |
2234: | if (!$this->database_exists($db)) { |
2235: | return error("unknown database `%s'", $db); |
2236: | } |
2237: | |
2238: | $sqlroot = $this->domain_fs_path() . self::MYSQL_DATADIR; |
2239: | if (!file_exists($sqlroot . '/' . $db)) { |
2240: | |
2241: | $prefix = $this->get_prefix(); |
2242: | $db = $prefix . $db; |
2243: | } |
2244: | |
2245: | $files = glob($sqlroot . '/' . $db . '/*'); |
2246: | if (count($files) < 2) { |
2247: | return true; |
2248: | } |
2249: | |
2250: | |
2251: | $quota = $this->site_get_account_quota(); |
2252: | $conn = $this->_connect_root(); |
2253: | $q = 'SELECT MAX(Data_length) AS max FROM ' . |
2254: | "information_schema.tables WHERE table_schema = '" . |
2255: | $conn->real_escape_string($db) . "'"; |
2256: | $rs = $conn->query($q); |
2257: | $row = $rs->fetch_object(); |
2258: | $tblsz = $row->max / 1024 * 1.25; |
2259: | |
2260: | $qfree = $quota['qhard'] - $quota['qused']; |
2261: | $cmd = 'env HOME=/root mysqlcheck --auto-repair %s'; |
2262: | if ($tblsz > $qfree) { |
2263: | warn('not enough storage to safely use mysqlcheck (need %d KB have %d KB free): reverting to direct IO', |
2264: | $tblsz, $qfree |
2265: | ); |
2266: | $cmd = 'myisamchk -r -c ' . $sqlroot . '/%s/*.MYI'; |
2267: | } |
2268: | $fsizelimit = Util_Ulimit::get('fsize'); |
2269: | Util_Ulimit::set('fsize', 'unlimited'); |
2270: | $ret = Util_Process_Safe::exec($cmd, array($db), ['mute_stderr' => true]); |
2271: | Util_Ulimit::set('fsize', $fsizelimit); |
2272: | if (!$ret['success'] && false === strpos($ret['stderr'], "doesn't exist")) { |
2273: | return error("`%s' repair failed:\n%s", $db, $ret['stderr']); |
2274: | } |
2275: | |
2276: | return info("`%s' repair succeeded:\n%s", $db, $ret['output']); |
2277: | } |
2278: | |
2279: | |
2280: | |
2281: | |
2282: | |
2283: | |
2284: | |
2285: | |
2286: | public function kill($id) |
2287: | { |
2288: | $db = $this->_connect_root(); |
2289: | $id = intval($id); |
2290: | $procs = $this->get_processlist(); |
2291: | $found = 0; |
2292: | foreach ($procs as $p) { |
2293: | if ($p['id'] == $id) { |
2294: | $found = 1; |
2295: | break; |
2296: | } |
2297: | } |
2298: | if (!$found) { |
2299: | return error("`%d': invalid query id specified", $id); |
2300: | } |
2301: | $q = "KILL $id"; |
2302: | $rs = $db->query($q); |
2303: | |
2304: | return (bool)$rs; |
2305: | } |
2306: | |
2307: | |
2308: | |
2309: | |
2310: | |
2311: | |
2312: | |
2313: | |
2314: | |
2315: | |
2316: | |
2317: | |
2318: | |
2319: | |
2320: | |
2321: | |
2322: | |
2323: | |
2324: | |
2325: | |
2326: | public function get_processlist() |
2327: | { |
2328: | $conns = array(); |
2329: | $db = $this->_connect_root(); |
2330: | $user = $this->databaseAdmin(); |
2331: | $prefix = $this->get_prefix(); |
2332: | $q = 'SELECT id, user, host, db, command, time, state, info FROM ' . |
2333: | "information_schema.processlist WHERE user = '" . |
2334: | $db->real_escape_string($user) . "' OR user LIKE '" . $db->real_escape_string($prefix) . "%'"; |
2335: | $rs = $db->query($q); |
2336: | while (null != ($row = $rs->fetch_object())) { |
2337: | $conns[] = array( |
2338: | 'id' => $row->id, |
2339: | 'user' => $row->user, |
2340: | 'host' => $row->host, |
2341: | 'db' => $row->db, |
2342: | 'command' => $row->command, |
2343: | 'state' => $row->state, |
2344: | 'info' => $row->info |
2345: | ); |
2346: | } |
2347: | |
2348: | return $conns; |
2349: | } |
2350: | |
2351: | |
2352: | |
2353: | |
2354: | |
2355: | |
2356: | public function stats(): array { |
2357: | $conn = \MySQL::initialize(); |
2358: | $vars = [ |
2359: | 'Queries', |
2360: | 'Uptime', |
2361: | 'Connections', |
2362: | 'Questions', |
2363: | 'TABLE_OPEN_CACHE_HITS', |
2364: | 'MAX_USED_CONNECTIONS', |
2365: | 'TABLE_OPEN_CACHE_MISSES', |
2366: | 'OPENED_FILES', |
2367: | 'OPENED_TABLES', |
2368: | 'THREADS_RUNNING', |
2369: | 'THREADS_CREATED' |
2370: | ]; |
2371: | |
2372: | $fields = implode(',', array_map(static function ($var) use ($conn) { |
2373: | return "'$var'"; |
2374: | }, $vars)); |
2375: | $rs = $conn->query("SELECT LOWER(variable_name) AS name, variable_value AS val FROM information_schema.global_status WHERE variable_name IN($fields);"); |
2376: | |
2377: | $items = array_build($rs->fetch_all(\MYSQLI_ASSOC), static function ($k, $v) { |
2378: | return [$v['name'], (int)$v['val']]; |
2379: | }); |
2380: | |
2381: | $items['observed_queries_per_second'] = null; |
2382: | $items['queries_per_second'] = round($items['queries']/$items['uptime'], 4); |
2383: | |
2384: | $key = 'mysql.qpslocf'; |
2385: | $cache = \Cache_Global::spawn(); |
2386: | if (false !== ($locf = $cache->get($key))) { |
2387: | if ($items['uptime'] > $locf[0]) { |
2388: | |
2389: | $items['observed_queries_per_second'] = round(($items['queries'] - $locf[1]) / ($items['uptime'] - $locf[0]), 4); |
2390: | $cache->del($key); |
2391: | } |
2392: | return $items; |
2393: | } else { |
2394: | $cache->set($key, [$items['uptime'], $items['queries']]); |
2395: | } |
2396: | |
2397: | |
2398: | return $items; |
2399: | } |
2400: | |
2401: | public function _cron(Cronus $c) |
2402: | { |
2403: | if (!TELEMETRY_ENABLED) { |
2404: | return; |
2405: | } |
2406: | |
2407: | $collector = new Collector(PostgreSQL::pdo()); |
2408: | $status = $this->stats(); |
2409: | foreach (MysqlMetrics::getAttributeMap() as $attr => $metric) { |
2410: | if (!isset($status[$metric])) { |
2411: | |
2412: | continue; |
2413: | } |
2414: | $val = $status[$metric]; |
2415: | |
2416: | if ($val instanceof Closure) { |
2417: | $val = $val($status); |
2418: | } |
2419: | $collector->add("mysql-{$attr}", null, $val); |
2420: | } |
2421: | |
2422: | } |
2423: | |
2424: | |
2425: | |
2426: | |
2427: | |
2428: | |
2429: | |
2430: | public function schema_column_maxlen($field): ?int |
2431: | { |
2432: | if (!IS_CLI) { |
2433: | return $this->query('mysql_schema_column_maxlen', $field); |
2434: | } |
2435: | if ($field !== 'user' && $field !== 'db') { |
2436: | error("unsupported field `%s' requested", $field); |
2437: | return null; |
2438: | } |
2439: | return \Opcenter\Database\MySQL::fieldLength($field); |
2440: | } |
2441: | } |