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\Support\Sql; |
18: | use Opcenter\Database\MySQL\DefinerRemap; |
19: | use Opcenter\Net\IpCommon; |
20: | |
21: | |
22: | |
23: | |
24: | |
25: | |
26: | class Mysql_Module extends Sql |
27: | { |
28: | const DEPENDENCY_MAP = [ |
29: | 'siteinfo', |
30: | 'diskquota' |
31: | ]; |
32: | const MYSQL_DATADIR = '/var/lib/mysql'; |
33: | |
34: | const NEW_API_VERSION = 50720; |
35: | |
36: | const DEFAULT_CONCURRENCY_LIMIT = 10; |
37: | |
38: | const MAX_CONCURRENCY_LIMIT = MYSQL_CONCURRENCY_LIMIT ?: DATABASE_CONCURRENCY_LIMIT; |
39: | |
40: | |
41: | const EXPORT_CMD = '/usr/bin/mysqldump --add-drop-table --add-drop-trigger --triggers --events -q -R'; |
42: | |
43: | protected $exportedFunctions = array( |
44: | '*' => PRIVILEGE_SITE, |
45: | 'version' => PRIVILEGE_ALL, |
46: | 'get_elevated_password_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC, |
47: | 'create_database_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
48: | 'delete_database_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
49: | 'get_uptime' => PRIVILEGE_ALL, |
50: | 'assert_permissions' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
51: | 'set_option' => PRIVILEGE_ALL, |
52: | 'get_option' => PRIVILEGE_ALL, |
53: | 'export_pipe_real' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
54: | 'enabled' => PRIVILEGE_SITE | PRIVILEGE_USER, |
55: | 'repair_database' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
56: | 'get_prefix' => PRIVILEGE_SITE | PRIVILEGE_USER, |
57: | |
58: | |
59: | 'get_database_size' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
60: | 'database_exists' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
61: | '_export_old' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
62: | 'resolve_site_from_database' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
63: | 'recover_innodb_from_disk' => PRIVILEGE_ADMIN, |
64: | 'stats' => PRIVILEGE_ALL |
65: | ); |
66: | |
67: | public function __destruct() |
68: | { |
69: | foreach ($this->_tempUsers as $user) { |
70: | if (!$this->user_exists($user)) { |
71: | continue; |
72: | } |
73: | $this->_delete_temp_user($user); |
74: | } |
75: | } |
76: | |
77: | public function user_exists($user, $host = 'localhost') |
78: | { |
79: | if (!$user) { |
80: | return false; |
81: | } |
82: | $conn = $this->_connect_root(); |
83: | $prefix = $this->get_prefix(); |
84: | if (!($this->permission_level & PRIVILEGE_ADMIN) && $user !== $this->getServiceValue('mysql', 'dbaseadmin') && |
85: | 0 !== strpos($user, $prefix) |
86: | ) { |
87: | $user = $prefix . $user; |
88: | } |
89: | |
90: | $q = $conn->query("SELECT user FROM user WHERE user = '" . |
91: | $conn->escape_string($user) . "' AND host = '" . $conn->escape_string($host) . "'"); |
92: | |
93: | return !$q || $q->num_rows > 0; |
94: | } |
95: | |
96: | public function get_prefix() |
97: | { |
98: | return $this->getServiceValue('mysql', 'dbaseprefix'); |
99: | } |
100: | |
101: | |
102: | |
103: | |
104: | |
105: | |
106: | |
107: | |
108: | private function _delete_temp_user($user) |
109: | { |
110: | if (!Opcenter\Database\MySQL::deleteUser($user, 'localhost')) { |
111: | return false; |
112: | } |
113: | |
114: | |
115: | $idx = array_search($user, $this->_tempUsers, true); |
116: | if ($idx !== false) { |
117: | unset($this->_tempUsers[$idx]); |
118: | } |
119: | |
120: | return true; |
121: | } |
122: | |
123: | |
124: | |
125: | |
126: | |
127: | |
128: | |
129: | |
130: | public function delete_user($user, $host) |
131: | { |
132: | if ($user === $this->username && !Util_Account_Hooks::is_mode('delete')) { |
133: | return error('Cannot remove main user'); |
134: | } else if (!$this->user_exists($user, $host)) { |
135: | return error("user `%s' on `%s' does not exist", $user, $host); |
136: | } |
137: | $prefix = $this->get_prefix(); |
138: | if ($user !== $this->getConfig('mysql', 'dbaseadmin') && strpos($user, $prefix) !== 0) { |
139: | $user = $prefix . $user; |
140: | } |
141: | if (\Opcenter\Database\MySQL::version() >= static::NEW_API_VERSION) { |
142: | return Opcenter\Database\MySQL::deleteUser($user, $host); |
143: | } |
144: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
145: | $conn->select_db('mysql'); |
146: | $stmt = $conn->prepare('DELETE FROM user WHERE user = ? AND host = ?'); |
147: | $stmt->bind_param('ss', $user, $host); |
148: | $stmt->execute(); |
149: | if ($stmt->error) { |
150: | return new MySQLError('Invalid query, ' . $stmt->error); |
151: | } |
152: | |
153: | $stmt2 = $conn->prepare('DELETE FROM db WHERE user = ? AND host = ?'); |
154: | $stmt2->bind_param('ss', $user, $host); |
155: | $stmt2->execute(); |
156: | if (!$stmt2->error) { |
157: | $conn->query('FLUSH PRIVILEGES'); |
158: | } else { |
159: | return new MySQLError('Invalid query, ' . $stmt2->error); |
160: | } |
161: | |
162: | return ($stmt->affected_rows > 0); |
163: | |
164: | } |
165: | |
166: | |
167: | |
168: | |
169: | |
170: | |
171: | |
172: | public function store_password($sqlpasswd) |
173: | { |
174: | return $this->set_option('password', $sqlpasswd, 'client'); |
175: | } |
176: | |
177: | |
178: | |
179: | |
180: | |
181: | |
182: | |
183: | |
184: | |
185: | |
186: | |
187: | |
188: | public function set_option(string $option, string $value = null, string $group = 'client') |
189: | { |
190: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
191: | return error("Demo may not alter ~/.my.cnf"); |
192: | } |
193: | |
194: | if (!IS_CLI) { |
195: | return $this->query('mysql_set_option', $option, $value, $group); |
196: | } |
197: | $home = $this->user_get_user_home(); |
198: | $path = $this->domain_fs_path() . "${home}/.my.cnf"; |
199: | if (is_link($path) && ( |
200: | false === ($link = readlink($path)) || |
201: | 0 !== strpos($this->domain_fs_path(), realpath($link))) |
202: | ) |
203: | { |
204: | unlink($path); |
205: | } |
206: | if (!file_exists($path)) { |
207: | \Opcenter\Filesystem::touch($path, $this->user_id, $this->group_id, 0600); |
208: | } |
209: | |
210: | return \Opcenter\Database\MySQL::setUserConfigurationField($path, $option, $value, $group); |
211: | } |
212: | |
213: | |
214: | |
215: | |
216: | |
217: | |
218: | public function get_password() |
219: | { |
220: | return $this->get_option('password'); |
221: | } |
222: | |
223: | |
224: | |
225: | |
226: | |
227: | |
228: | |
229: | |
230: | public function get_option($option, $group = 'client') |
231: | { |
232: | if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) { |
233: | return error("Demo may not alter ~/.my.cnf"); |
234: | } |
235: | |
236: | if (!IS_CLI) { |
237: | return $this->query('mysql_get_option', $option, $group); |
238: | } |
239: | $home = $this->user_get_user_home(); |
240: | $paths = [ |
241: | $this->domain_fs_path() . "${home}/.my.cnf", |
242: | '/etc/my.cnf' |
243: | ]; |
244: | foreach ($paths as $path) { |
245: | if (is_link($path) && 0 !== strpos($this->domain_fs_path(), realpath(readlink($path)))) { |
246: | |
247: | continue; |
248: | } |
249: | if (null !== ($val = array_get(\Opcenter\Database\MySQL::getUserConfiguration($path), |
250: | "${group}.${option}", null))) { |
251: | return $val; |
252: | } |
253: | } |
254: | |
255: | return null; |
256: | } |
257: | |
258: | public function get_elevated_password_backend() |
259: | { |
260: | if (!IS_CLI) { |
261: | fatal('needs execution from backend'); |
262: | } |
263: | |
264: | return Opcenter\Database\MySQL::rootPassword(); |
265: | } |
266: | |
267: | |
268: | |
269: | |
270: | |
271: | |
272: | |
273: | |
274: | |
275: | |
276: | public function import($db, $file) |
277: | { |
278: | if (!IS_CLI) { |
279: | return $this->query('mysql_import', $db, $file); |
280: | } |
281: | |
282: | $prefix = $this->get_prefix(); |
283: | |
284: | if (strncmp($db, $prefix, strlen($prefix))) { |
285: | $db = $prefix . $db; |
286: | } |
287: | |
288: | $dbs = $this->list_databases(); |
289: | if (false === array_search($db, $dbs, true)) { |
290: | return error("database `%s' does not exist", $db); |
291: | } |
292: | $unlink = null; |
293: | if (false === ($realfile = $this->_preImport($file, $unlink))) { |
294: | return false; |
295: | } |
296: | |
297: | $tempUser = $this->_create_temp_user($db); |
298: | if (!$tempUser) { |
299: | $this->_postImport($unlink); |
300: | return error('unable to import database'); |
301: | } |
302: | |
303: | |
304: | |
305: | |
306: | |
307: | |
308: | |
309: | |
310: | |
311: | |
312: | |
313: | $bulk = []; |
314: | |
315: | if (filetype($realfile) === 'file') { |
316: | $ret = Util_Process_Safe::exec('sed -i %s %s', [ |
317: | '{ |
318: | /DEFINER=[^* ]*\(\*\| \)/w /dev/stdout |
319: | s/DEFINER=[^* ]*\(\*\| \)/\1/g |
320: | }', |
321: | $realfile |
322: | ]); |
323: | |
324: | if (!$ret['success']) { |
325: | return error("Aborting import from `%s'. Failed to perform DEFINER pass: %s", $file, $ret['error']); |
326: | } |
327: | |
328: | |
329: | if (preg_match_all(Regex::SQL_MYSQL_DEFINER, $ret['stdout'], $matches, PREG_SET_ORDER)) { |
330: | |
331: | $bulkChangeCredentials = []; |
332: | $prefix = $this->get_prefix(); |
333: | $users = []; |
334: | foreach ($this->list_users() as $user => $hosts) { |
335: | if (0 === strpos($user, $prefix)) { |
336: | $user = substr($user, \strlen($prefix)); |
337: | } |
338: | $users[$user] = []; |
339: | foreach (array_keys($hosts) as $host) { |
340: | $users[$user][$host] = 1; |
341: | } |
342: | } |
343: | |
344: | foreach ($matches as $gm) { |
345: | |
346: | if ($gm['type'] === 'FUNCTION') { |
347: | $gm['type'] = 'PROCEDURE'; |
348: | } |
349: | |
350: | if (false !== ($pos = strpos($gm['user'], '_'))) { |
351: | $gm['user'] = substr($gm['user'], ++$pos); |
352: | } |
353: | |
354: | if (!isset($users[$gm['user']])) { |
355: | warn( |
356: | 'User %s not found in user table. Converting %s grant for %s to %s', |
357: | $gm['user'], $gm['type'], $gm['name'], $this->getConfig('mysql', 'dbaseadmin') |
358: | ); |
359: | $gm['user'] = $this->getConfig('mysql', 'dbaseadmin'); |
360: | $gm['host'] = 'localhost'; |
361: | } else if (!isset($users[$gm['user']][$gm['host']])) { |
362: | warn( |
363: | 'Host %s not found in user table. Converting %s grant for %s to localhost', |
364: | $gm['host'], $gm['type'], $gm['user'] |
365: | ); |
366: | $gm['host'] = 'localhost'; |
367: | } |
368: | |
369: | if (empty($bulkChangeCredentials)) { |
370: | $bulkChangeCredentials = [ |
371: | $gm['user'], |
372: | $gm['host'] |
373: | ]; |
374: | } else if ($bulkChangeCredentials !== [$gm['user'], $gm['host']]) { |
375: | warn( |
376: | 'Import only supports 1 DEFINER adjustment. Converting %s (type: %s) from `%s`@`%s` to `%s`@`%s`', |
377: | $gm['name'], $gm['type'], $gm['user'], $gm['host'], $bulkChangeCredentials[0], |
378: | $bulkChangeCredentials[1] |
379: | ); |
380: | } |
381: | if (empty($bulk[$gm['type']])) { |
382: | $bulk[$gm['type']] = []; |
383: | } |
384: | $bulk[$gm['type']][] = $gm['name']; |
385: | } |
386: | |
387: | if (isset($bulkChangeCredentials[0]) && $bulkChangeCredentials[0] !== $this->getConfig('mysql', |
388: | 'dbaseadmin')) { |
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->username . "'"); |
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->username . "' 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->getConfig('mysql', 'dbaseadmin'); |
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->getConfig('mysql', 'dbaseadmin') && 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->username) . "'@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->username |
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->getConfig('mysql', 'dbaseadmin'); |
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->getServiceValue('mysql', 'dbaseadmin') && |
1186: | strncmp($user, $prefix, strlen($prefix)) |
1187: | ) { |
1188: | $user = $prefix . $user; |
1189: | } |
1190: | if ($user != $this->username && !preg_match('/^' . $prefix . '/', $user)) { |
1191: | return error("invalid user `%s'", $user); |
1192: | } |
1193: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
1194: | $conn->select_db('mysql'); |
1195: | |
1196: | if ($db === '%') { |
1197: | if ($user === $this->getConfig('mysql', 'dbaseadmin')) { |
1198: | return warn('User %s already has permission to all databases - ignoring', $this->getConfig('mysql', 'dbaseadmin')); |
1199: | } |
1200: | |
1201: | $db = $prefix . '%'; |
1202: | } else if (0 === strpos($db, $prefix)) { |
1203: | $rs = $conn->query("SELECT 1 FROM db WHERE user = '" . $this->username . "' AND (db = '" . $db . "' OR db = '" . str_replace('_', '\\_', $db) . "')"); |
1204: | if ($rs->num_rows < 1) { |
1205: | return error("No grants found in database on `%s' for user `%s'", $db, $this->username); |
1206: | } |
1207: | } |
1208: | |
1209: | $valid_opts = array( |
1210: | 'select' => false, |
1211: | 'insert' => false, |
1212: | 'update' => false, |
1213: | 'delete' => false, |
1214: | 'create' => false, |
1215: | 'drop' => false, |
1216: | 'grant' => false, |
1217: | 'references' => false, |
1218: | 'index' => false, |
1219: | 'alter' => false, |
1220: | 'create_tmp_table' => false, |
1221: | 'show_view' => false, |
1222: | 'create_view' => false, |
1223: | 'create_routine' => false, |
1224: | 'alter_routine' => false, |
1225: | 'lock_tables' => false, |
1226: | 'execute' => false, |
1227: | 'event' => false, |
1228: | 'trigger' => false |
1229: | ); |
1230: | |
1231: | if (count($privileges) <= 2 && (isset($privileges['read']) || isset($privileges['write']))) { |
1232: | |
1233: | $tmp = array(); |
1234: | if (!empty($privileges['read'])) { |
1235: | $tmp['select'] = $tmp['show_view'] = $tmp['execute'] = true; |
1236: | } |
1237: | |
1238: | if (!empty($privileges['write'])) { |
1239: | $write = array_diff(array_keys($valid_opts), array('select', 'show_view', 'execute')); |
1240: | $tmp2 = array_fill_keys($write, true); |
1241: | $tmp = array_merge($tmp, $tmp2); |
1242: | } |
1243: | $privileges = $tmp; |
1244: | } |
1245: | |
1246: | $opts_copy = $valid_opts; |
1247: | foreach ($valid_opts as $opt => $enabled) { |
1248: | if (isset($privileges[($opt)]) && $privileges[$opt]) { |
1249: | $valid_opts[$opt] = $opt . '_priv'; |
1250: | } else { |
1251: | unset($valid_opts[$opt]); |
1252: | } |
1253: | } |
1254: | |
1255: | $revoke_opts = array_diff_key($opts_copy, $valid_opts); |
1256: | foreach (array_keys($opts_copy) as $name) { |
1257: | $opts[] = (isset($valid_opts[$name])) ? 'Y' : 'N'; |
1258: | } |
1259: | |
1260: | array_walk($opts_copy, static function (&$key, $val) { |
1261: | $key = $val . '_priv'; |
1262: | }); |
1263: | if ($db !== $prefix . '%') { |
1264: | $rs = $conn->query("SELECT db FROM db WHERE user = '" . $user . "' AND host = '" . $host . "' AND db = '" . str_replace('_', '\\_', $prefix) . '%' . "'"); |
1265: | if ($rs->num_rows > 0) { |
1266: | 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); |
1267: | } |
1268: | } |
1269: | |
1270: | $rs = $conn->query("SELECT 1 FROM db where user = '" . $user . "' AND host = '" . $host . "' AND db = '" . str_replace('_', |
1271: | '\\_', $db) . "'"); |
1272: | if ($db === $prefix . '%' || $rs->num_rows > 0) { |
1273: | $db = str_replace('_', '\\_', $db); |
1274: | } |
1275: | |
1276: | $conn->query('REPLACE INTO db (' . implode(', ', |
1277: | $opts_copy) . ", `host`, `db`, `user`) VALUES ('" . implode("', '", $opts) . |
1278: | "', '" . $host . "', '" . $db . "', '" . $user . "');"); |
1279: | $ar = $conn->affected_rows; |
1280: | |
1281: | if ($conn->error) { |
1282: | return error('Error when applying grants, ' . $conn->error); |
1283: | } |
1284: | |
1285: | $conn->query('FLUSH PRIVILEGES;'); |
1286: | |
1287: | return $ar > 0; |
1288: | } |
1289: | |
1290: | |
1291: | |
1292: | |
1293: | |
1294: | |
1295: | public function delete_user_permissions($user, $host, $db) |
1296: | { |
1297: | deprecated_func('use revoke_from_mysql_db()'); |
1298: | |
1299: | return $this->revoke_privileges($user, $host, $db); |
1300: | } |
1301: | |
1302: | |
1303: | |
1304: | |
1305: | |
1306: | |
1307: | |
1308: | |
1309: | |
1310: | public function revoke_privileges($user, $host, $db) |
1311: | { |
1312: | $prefix = $this->get_prefix(); |
1313: | if ($user != $this->getServiceValue('mysql', 'dbaseadmin') && |
1314: | strncmp($user, $prefix, strlen($prefix)) |
1315: | ) { |
1316: | $user = $prefix . $user; |
1317: | } |
1318: | if ($user != $this->username && !preg_match('/^' . $prefix . '/', $user)) { |
1319: | return error("invalid user `$user'"); |
1320: | } |
1321: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
1322: | $conn->select_db('mysql'); |
1323: | $stmt = $conn->prepare('DELETE FROM db WHERE user = ? AND host = ? AND (db = ? OR db = ?)'); |
1324: | $canonical = str_replace('_', '\_', $db); |
1325: | $stmt->bind_param('ssss', $user, $host, $db, $canonical); |
1326: | $res = $stmt->execute(); |
1327: | if (!$res) { |
1328: | return error('Query error while dropping permissions, ' . $stmt->error); |
1329: | } |
1330: | |
1331: | $conn->query('FLUSH PRIVILEGES;'); |
1332: | |
1333: | return $stmt->affected_rows > 0; |
1334: | } |
1335: | |
1336: | |
1337: | |
1338: | public function get_user_permissions($user, $host, $db) |
1339: | { |
1340: | deprecated_func('use get_privileges()'); |
1341: | |
1342: | return $this->get_privileges($user, $host, $db); |
1343: | } |
1344: | |
1345: | |
1346: | |
1347: | |
1348: | |
1349: | |
1350: | |
1351: | |
1352: | |
1353: | |
1354: | |
1355: | public function get_privileges($user, $host, $db) |
1356: | { |
1357: | $prefix = $this->get_prefix(); |
1358: | if ($user != $this->getServiceValue('mysql', 'dbaseadmin') && |
1359: | strncmp($user, $prefix, strlen($prefix)) |
1360: | ) { |
1361: | $user = $prefix . $user; |
1362: | } |
1363: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
1364: | $conn->select_db('mysql'); |
1365: | |
1366: | $stmt = $conn->prepare('SELECT Select_priv, Insert_priv, Update_priv, DELETE_PRIV, |
1367: | CREATE_PRIV, DROP_PRIV, GRANT_PRIV, REFERENCES_PRIV, |
1368: | INDEX_PRIV, ALTER_PRIV, CREATE_TMP_TABLE_PRIV, |
1369: | LOCK_TABLES_PRIV, CREATE_VIEW_PRIV, SHOW_VIEW_PRIV, |
1370: | CREATE_ROUTINE_PRIV, ALTER_ROUTINE_PRIV, EXECUTE_PRIV, |
1371: | EVENT_PRIV, TRIGGER_PRIV FROM db WHERE user = ? AND db IN (?,?,?) AND host = ?'); |
1372: | $dbSafe = str_replace('_', '\\_', $db); |
1373: | $wcSafe = str_replace('_', '\\_', $prefix . '%'); |
1374: | $stmt->bind_param('sssss', $user, $db, $dbSafe, $wcSafe, $host); |
1375: | $stmt->execute(); |
1376: | $select = $insert = $update = $delete = $create = $drop = $domain = |
1377: | $grant = $references = $index = $alter = $create_tmp_table = |
1378: | $lock_tables = $create_view = $show_view = $create_routine = |
1379: | $alter_routine = $execute = $event = $trigger = null; |
1380: | $stmt->bind_result($select, $insert, $update, $delete, $create, $drop, $grant, |
1381: | $references, $index, $alter, $create_tmp_table, $lock_tables, |
1382: | $create_view, $show_view, $create_routine, $alter_routine, |
1383: | $execute, $event, $trigger); |
1384: | |
1385: | if ($stmt->fetch()) { |
1386: | $priv = array( |
1387: | 'select' => $select, |
1388: | 'insert' => $insert, |
1389: | 'update' => $update, |
1390: | 'delete' => $delete, |
1391: | 'create' => $create, |
1392: | 'drop' => $drop, |
1393: | 'grant' => $grant, |
1394: | 'references' => $references, |
1395: | 'index' => $index, |
1396: | 'alter' => $alter, |
1397: | 'create_tmp_table' => $create_tmp_table, |
1398: | 'lock_tables' => $lock_tables, |
1399: | 'create_view' => $create_view, |
1400: | 'show_view' => $show_view, |
1401: | 'create_routine' => $create_routine, |
1402: | 'alter_routine' => $alter_routine, |
1403: | 'execute' => $execute, |
1404: | 'trigger' => $trigger, |
1405: | 'event' => $event |
1406: | ); |
1407: | array_walk($priv, static function (&$key, $val) { |
1408: | $key = $key == 'Y'; |
1409: | }); |
1410: | $stmt->close(); |
1411: | } else { |
1412: | $priv = array( |
1413: | 'select' => false, |
1414: | 'insert' => false, |
1415: | 'update' => false, |
1416: | 'delete' => false, |
1417: | 'create' => false, |
1418: | 'drop' => false, |
1419: | 'grant' => false, |
1420: | 'references' => false, |
1421: | 'index' => false, |
1422: | 'alter' => false, |
1423: | 'create_tmp_table' => false, |
1424: | 'lock_tables' => false, |
1425: | 'create_view' => false, |
1426: | 'show_view' => false, |
1427: | 'create_routine' => false, |
1428: | 'alter_routine' => false, |
1429: | 'execute' => false, |
1430: | ); |
1431: | } |
1432: | return $priv; |
1433: | } |
1434: | |
1435: | |
1436: | |
1437: | |
1438: | |
1439: | |
1440: | |
1441: | |
1442: | |
1443: | |
1444: | |
1445: | |
1446: | public function version($pretty = false) |
1447: | { |
1448: | $version = \Opcenter\Database\MySQL::version(); |
1449: | if (!$pretty) { |
1450: | return $version; |
1451: | } |
1452: | |
1453: | $mysqlver = array(); |
1454: | foreach (array('patch', 'minor', 'major') as $v) { |
1455: | $mysqlver[$v] = (int)$version % 100; |
1456: | $version /= 100; |
1457: | } |
1458: | |
1459: | return $mysqlver['major'] . '.' . $mysqlver['minor'] . '.' . |
1460: | $mysqlver['patch']; |
1461: | |
1462: | } |
1463: | |
1464: | |
1465: | |
1466: | |
1467: | |
1468: | |
1469: | |
1470: | public function delete_database($db) |
1471: | { |
1472: | $db = str_replace('\\\\', '\\', $db); |
1473: | $prefix = $this->get_prefix(); |
1474: | if (!$prefix) { |
1475: | report('Prefixless site - bug'); |
1476: | |
1477: | return false; |
1478: | } |
1479: | $prefixwc = str_replace('_', '\_', $prefix) . '%'; |
1480: | $conn = new mysqli('localhost', self::MASTER_USER, $this->_get_elevated_password()); |
1481: | $conn->select_db('mysql'); |
1482: | $stmt = $conn->prepare('SELECT user FROM db WHERE (user = ? OR db LIKE ?) AND (db = ? OR db = ?)'); |
1483: | $canonical = str_replace('_', '\_', $db); |
1484: | $stmt->bind_param('ssss', $this->username, $prefixwc, $db, $canonical); |
1485: | $stmt->execute(); |
1486: | $stmt->store_result(); |
1487: | if ($stmt->num_rows < 1) { |
1488: | $stmt->free_result(); |
1489: | |
1490: | |
1491: | if (strncmp($db, $prefix, strlen($prefix))) { |
1492: | $db = $prefix . $db; |
1493: | |
1494: | return $this->delete_database($db); |
1495: | } |
1496: | if (!in_array($db, $this->list_databases(), true)) { |
1497: | |
1498: | return error("Unknown database `%s'", $db); |
1499: | } |
1500: | } |
1501: | $stmt->free_result(); |
1502: | $this->query('mysql_assert_permissions'); |
1503: | \Opcenter\Database\MySQL::dropDatabaseGrants($db); |
1504: | |
1505: | |
1506: | |
1507: | \Opcenter\Database\MySQL::flush(); |
1508: | \Opcenter\Database\MySQL::dropDatabase($db); |
1509: | |
1510: | $this->query('mysql_delete_database_backend', $db); |
1511: | |
1512: | if (in_array($db, $this->list_backups(), true)) { |
1513: | $this->delete_backup($db); |
1514: | } |
1515: | |
1516: | |
1517: | if ($conn->error) { |
1518: | return error("error while removing database `$db' - " . $conn->error); |
1519: | } |
1520: | |
1521: | return true; |
1522: | } |
1523: | |
1524: | |
1525: | |
1526: | |
1527: | |
1528: | |
1529: | |
1530: | public function delete_backup($db) |
1531: | { |
1532: | return parent::delete_backup_real('mysql', $db); |
1533: | } |
1534: | |
1535: | |
1536: | |
1537: | |
1538: | public function assert_permissions() |
1539: | { |
1540: | if (!file_exists($this->domain_fs_path() . self::MYSQL_DATADIR)) { |
1541: | return false; |
1542: | } |
1543: | |
1544: | chown($this->domain_fs_path() . self::MYSQL_DATADIR, 'mysql'); |
1545: | chgrp($this->domain_fs_path() . self::MYSQL_DATADIR, $this->group_id); |
1546: | |
1547: | return true; |
1548: | } |
1549: | |
1550: | public function delete_database_backend($db) |
1551: | { |
1552: | $db = \Opcenter\Database\MySQL::canonicalize($db); |
1553: | if (is_link(self::MYSQL_DATADIR . '/' . $db)) { |
1554: | @unlink(self::MYSQL_DATADIR . '/' . $db); |
1555: | } |
1556: | |
1557: | return true; |
1558: | |
1559: | } |
1560: | |
1561: | |
1562: | |
1563: | |
1564: | |
1565: | |
1566: | |
1567: | |
1568: | |
1569: | |
1570: | |
1571: | |
1572: | |
1573: | |
1574: | |
1575: | |
1576: | |
1577: | |
1578: | |
1579: | |
1580: | |
1581: | public function edit_user(string $user, string $host, array $opts): bool |
1582: | { |
1583: | $prefix = $this->get_prefix(); |
1584: | if ($user !== $this->getServiceValue('mysql', 'dbaseadmin') && 0 !== strpos($user, $prefix)) { |
1585: | $user = $prefix . $user; |
1586: | } |
1587: | if (!is_array($opts)) { |
1588: | return error('Options must be an array'); |
1589: | } |
1590: | if (isset($opts['ssl_type'])) { |
1591: | $opts['ssl_type'] = strtoupper($opts['ssl_type']); |
1592: | if ($opts['ssl_type'] != '' && $opts['ssl_type'] != 'ANY' && |
1593: | $opts['ssl_type'] != 'SPECIFIED' && $opts['ssl_type'] != 'X509' |
1594: | ) { |
1595: | return error('Invalid cipher type'); |
1596: | } |
1597: | } |
1598: | |
1599: | if (isset($opts['host']) && $opts['host'] != 'localhost') { |
1600: | |
1601: | if (!IpCommon::valid($opts['host']) && !preg_match(Regex::SQL_MYSQL_IP_WILDCARD, $opts['host'])) { |
1602: | return error("rejected host `%s': only numeric IP addresses are permitted, not hostnames", |
1603: | $opts['host']); |
1604: | } |
1605: | } |
1606: | |
1607: | $defaults = array( |
1608: | 'host' => $host, |
1609: | 'password' => null, |
1610: | 'max_user_connections' => self::DEFAULT_CONCURRENCY_LIMIT, |
1611: | 'max_updates' => 0, |
1612: | 'max_questions' => 0, |
1613: | 'use_ssl' => false, |
1614: | 'ssl_type' => '', |
1615: | 'ssl_cipher' => '', |
1616: | 'x509_subject' => '', |
1617: | 'x509_issuer' => '' |
1618: | ); |
1619: | |
1620: | |
1621: | |
1622: | $mergeopts = $opts; |
1623: | foreach ($defaults as $def_nam => $def_val) { |
1624: | if (!isset($mergeopts[$def_nam])) { |
1625: | $mergeopts[$def_nam] = $def_val; |
1626: | } |
1627: | } |
1628: | |
1629: | if (!$mergeopts['use_ssl']) { |
1630: | $mergeopts['x509_subject'] = $mergeopts['x509_issuer'] = $mergeopts['ssl_cipher'] = $mergeopts['ssl_type'] = ''; |
1631: | } else if (!isset($mergeopts['ssl_type'])) { |
1632: | $mergeopts['ssl_type'] = 'ANY'; |
1633: | } |
1634: | |
1635: | if ($mergeopts['max_user_connections'] < 1) { |
1636: | $mergeopts['max_user_connections'] = self::DEFAULT_CONCURRENCY_LIMIT; |
1637: | } |
1638: | |
1639: | if ($mergeopts['max_questions'] < 0 || $mergeopts['max_updates'] < 0) { |
1640: | return error('Max queries and updates must be greater than 0'); |
1641: | } |
1642: | if (isset($opts['max_user_connections']) && $opts['max_user_connections'] > static::MAX_CONCURRENCY_LIMIT) { |
1643: | return error('Max connection limit %d. Must file a ticket justifying need. ' . |
1644: | 'Check index placements first.', static::MAX_CONCURRENCY_LIMIT); |
1645: | } |
1646: | if (!is_null($mergeopts['password']) && strlen($mergeopts['password']) < self::MIN_PASSWORD_LENGTH) { |
1647: | return error('password must be at least %d characters long', self::MIN_PASSWORD_LENGTH); |
1648: | } |
1649: | |
1650: | if (!empty($mergeopts['use_ssl']) && $user === $this->getServiceValue('mysql', |
1651: | 'dbaseadmin') && $host === 'localhost') { |
1652: | warn("Enabling SSL for localhost will break phpMyAdmin access on this account"); |
1653: | } |
1654: | |
1655: | $conn = $this->_connect_root(); |
1656: | |
1657: | $stmt = $conn->prepare('SELECT user FROM user WHERE user = ? AND host = ?'); |
1658: | $stmt->bind_param('ss', $user, $host); |
1659: | $stmt->execute(); |
1660: | $stmt->store_result(); |
1661: | if ($stmt->num_rows < 1) { |
1662: | $stmt->free_result(); |
1663: | return error('invalid user@host specified: %s@%s', $user, $host); |
1664: | } |
1665: | $stmt->free_result(); |
1666: | |
1667: | if ($host === 'localhost' && |
1668: | $user === $this->getServiceValue('mysql','dbaseadmin') && |
1669: | ($mergeopts['host'] ?: $host) !== $host) |
1670: | { |
1671: | return error("Cannot modify the hostname attribute for %(user)s on %(host)s", ['user' => $user, 'host' => $host]); |
1672: | } |
1673: | |
1674: | if (Opcenter\Database\MySQL::version() >= self::NEW_API_VERSION) { |
1675: | $params = [ |
1676: | $user, |
1677: | $host, |
1678: | [ |
1679: | 'ssl' => $mergeopts['use_ssl'] ?: false, |
1680: | 'subject' => $mergeopts['x509_subject'] ?: null, |
1681: | 'issuer' => $mergeopts['x509_issuer'] ?: null, |
1682: | 'ssl_type' => $mergeopts['ssl_type'] ?: null, |
1683: | 'password' => $mergeopts['password'] ?: null, |
1684: | 'connections' => (int)($mergeopts['max_user_connections'] ?: 0), |
1685: | 'updates' => (int)($mergeopts['max_updates'] ?: 0), |
1686: | 'query' => (int)($mergeopts['max_questions'] ?: 0), |
1687: | 'host' => $mergeopts['host'] ?: null |
1688: | ] |
1689: | ]; |
1690: | if (!Opcenter\Database\MySQL::alterUser(...$params)) { |
1691: | return false; |
1692: | } |
1693: | } else { |
1694: | $stmt = $conn->prepare('UPDATE user |
1695: | SET |
1696: | host = ?, |
1697: | ssl_type = ?, |
1698: | ssl_cipher = ?, |
1699: | x509_issuer = ? , |
1700: | x509_subject = ?, |
1701: | max_questions = ?, |
1702: | max_updates = ?, |
1703: | max_user_connections = ? |
1704: | WHERE |
1705: | user = ? |
1706: | AND |
1707: | host = ?'); |
1708: | |
1709: | $stmt->bind_param('sssssiiiss', |
1710: | $mergeopts['host'], |
1711: | $mergeopts['ssl_type'], |
1712: | $mergeopts['ssl_cipher'], |
1713: | $mergeopts['x509_issuer'], |
1714: | $mergeopts['x509_subject'], |
1715: | $mergeopts['max_questions'], |
1716: | $mergeopts['max_updates'], |
1717: | $mergeopts['max_user_connections'], |
1718: | $user, |
1719: | $host |
1720: | ); |
1721: | $stmt->execute(); |
1722: | if ($stmt->error) { |
1723: | return new MySQLError('Invalid query, ' . $stmt->error); |
1724: | } |
1725: | |
1726: | if ($mergeopts['host'] !== $defaults['host']) { |
1727: | $stmt = $conn->prepare('UPDATE db SET host = ? WHERE user = ? AND host = ?'); |
1728: | $stmt->bind_param('sss', $mergeopts['host'], $user, $defaults['host']); |
1729: | $stmt->execute(); |
1730: | if ($stmt->error) { |
1731: | return error('error while updating DB grants, %s', $stmt->error); |
1732: | } |
1733: | } |
1734: | |
1735: | if ($mergeopts['password']) { |
1736: | $pwclause = 'password(?)'; |
1737: | $password = $mergeopts['password']; |
1738: | |
1739: | if ($password[0] == '*' && strlen($password) == 41 |
1740: | && ctype_xdigit(substr($password, 1)) || |
1741: | |
1742: | strlen($password) == 16 && ctype_xdigit($password) && version_compare(platform_version(), '6', |
1743: | '<') |
1744: | ) { |
1745: | $pwclause = '?'; |
1746: | } |
1747: | $stmt2 = $conn->prepare('UPDATE user SET password = ' . $pwclause . ' WHERE user = ? AND host = ?;'); |
1748: | |
1749: | $stmt2->bind_param('sss', $password, $user, $mergeopts['host']); |
1750: | $stmt2->execute(); |
1751: | if ($stmt2->error) { |
1752: | return new MySQLError('Query error while updating password, ' . $stmt2->error); |
1753: | } |
1754: | } |
1755: | } |
1756: | |
1757: | $conn->query('FLUSH PRIVILEGES'); |
1758: | |
1759: | if ($user === $this->username && $mergeopts['password']) { |
1760: | $this->set_option('user', $this->username, 'client'); |
1761: | $this->set_option('password', |
1762: | str_replace(array('"'), array('\"'), $mergeopts['password']), |
1763: | 'client' |
1764: | ); |
1765: | } |
1766: | |
1767: | return true; |
1768: | } |
1769: | |
1770: | |
1771: | |
1772: | |
1773: | |
1774: | |
1775: | |
1776: | |
1777: | |
1778: | |
1779: | public function service_enabled() |
1780: | { |
1781: | deprecated('use enabled()'); |
1782: | |
1783: | return $this->enabled(); |
1784: | } |
1785: | |
1786: | |
1787: | |
1788: | |
1789: | |
1790: | |
1791: | |
1792: | |
1793: | public function enabled() |
1794: | { |
1795: | return parent::svc_enabled('mysql'); |
1796: | } |
1797: | |
1798: | public function truncate_database($db) |
1799: | { |
1800: | |
1801: | return $this->_mysql_empty_truncate_wrapper($db, 'truncate'); |
1802: | } |
1803: | |
1804: | private function _mysql_empty_truncate_wrapper($db, $mode) |
1805: | { |
1806: | if ($mode != 'truncate' && $mode != 'empty') { |
1807: | return error("unknown mode `%s'", $mode); |
1808: | } |
1809: | if ($mode == 'empty') { |
1810: | |
1811: | $mode = 'drop'; |
1812: | } |
1813: | |
1814: | $prefix = $this->getServiceValue('mysql', 'dbaseprefix'); |
1815: | if (0 !== strpos($db, $prefix)) { |
1816: | $db = $prefix . $db; |
1817: | } |
1818: | |
1819: | if (!$this->database_exists($db)) { |
1820: | return error("unknown database, `%s'", $db); |
1821: | } |
1822: | |
1823: | $user = $this->_create_temp_user($db); |
1824: | if (!$user) { |
1825: | return error("failed to %s db `%s'", $mode, $db); |
1826: | } |
1827: | $conn = new mysqli('localhost', $user); |
1828: | $conn->set_charset('utf8mb4'); |
1829: | if (!$conn->select_db($db)) { |
1830: | return error("unable to establish db connection for user `%s' on db `%s'", $user, $db); |
1831: | } |
1832: | |
1833: | $conn->query('SET FOREIGN_KEY_CHECKS=0'); |
1834: | |
1835: | $q = "SELECT CONCAT('" . strtoupper($mode) . " TABLE ','`', table_schema,'`','.','`',TABLE_NAME,'`', ';') |
1836: | FROM INFORMATION_SCHEMA.TABLES where table_schema in ('" . $conn->escape_string($db) . "');"; |
1837: | $res = $conn->query($q); |
1838: | while (null !== ($rs = $res->fetch_row())) { |
1839: | if (!$conn->query($rs[0])) { |
1840: | warn("failed to %s table `%s'", $mode, $rs[0]); |
1841: | } |
1842: | } |
1843: | |
1844: | $conn->query('SET @@FOREIGN_KEY_CHECKS=1;'); |
1845: | if (!$res) { |
1846: | return error("%s failed on database `%s': `%s'", $mode, $db, $conn->error); |
1847: | } |
1848: | $this->_delete_temp_user($user); |
1849: | |
1850: | return true; |
1851: | } |
1852: | |
1853: | |
1854: | |
1855: | |
1856: | |
1857: | |
1858: | |
1859: | public function empty($db) { |
1860: | if (!$this->database_exists($db)) { |
1861: | return true; |
1862: | } |
1863: | |
1864: | return \count(\Opcenter\Database\MySQL::getTablesFromDatabase($db)) === 0; |
1865: | } |
1866: | |
1867: | public function empty_database($db) |
1868: | { |
1869: | return $this->_mysql_empty_truncate_wrapper($db, 'empty'); |
1870: | } |
1871: | |
1872: | |
1873: | |
1874: | |
1875: | |
1876: | |
1877: | |
1878: | |
1879: | public function export($db, $file = null) |
1880: | { |
1881: | if (!IS_CLI) { |
1882: | return $this->query('mysql_export', $db, $file); |
1883: | } |
1884: | |
1885: | if (is_null($file)) { |
1886: | $file = $db . '.sql'; |
1887: | } |
1888: | if (!in_array($db, $this->list_databases(), true)) { |
1889: | return error('Invalid database ' . $db); |
1890: | } |
1891: | if ($file[0] !== '/' && $file[0] !== '.' && $file[0] !== '~') { |
1892: | $file = '/tmp/' . $file; |
1893: | } |
1894: | $pdir = dirname($file); |
1895: | if (!$this->file_exists($pdir) && !$this->file_create_directory($pdir, 0755, true)) { |
1896: | return error("failed to create parent directory, `%s'", $pdir); |
1897: | } |
1898: | $path = $this->file_make_path($file); |
1899: | if (!$path) { |
1900: | return error("invalid file `%s'", $file); |
1901: | } |
1902: | |
1903: | if (file_exists($path) && |
1904: | (filesize($path) > 0 || realpath($path) !== $path || fileowner($path) < USER_MIN_UID)) |
1905: | { |
1906: | return error('%s: file exists, cannot overwrite', $file); |
1907: | } |
1908: | $user = $this->_create_temp_user($db); |
1909: | $cmd = 'umask 077 && ' . self::EXPORT_CMD . ' -u %s %s > %s'; |
1910: | if (!$user) { |
1911: | $user = self::MASTER_USER; |
1912: | $rootpw = escapeshellarg($this->_get_elevated_password()); |
1913: | $cmd = str_replace('-u %s', '-u %s -p' . $rootpw, $cmd); |
1914: | } |
1915: | |
1916: | $fsizelimit = Util_Ulimit::get('fsize'); |
1917: | if ($this->get_database_size($db) > $fsizelimit / self::DB_BIN2TXT_MULT) { |
1918: | |
1919: | Util_Ulimit::set('fsize', 'unlimited'); |
1920: | } else { |
1921: | |
1922: | $fsizelimit = null; |
1923: | } |
1924: | $proc = new Util_Process_Safe(); |
1925: | |
1926: | $proc->setSgid($this->group_id); |
1927: | $proc->setSuid($this->user_id); |
1928: | $proc->setPriority(19); |
1929: | |
1930: | $status = $proc->run($cmd, |
1931: | $user, |
1932: | $db, |
1933: | $path |
1934: | ); |
1935: | |
1936: | if ($user != self::MASTER_USER) { |
1937: | $this->_delete_temp_user($user); |
1938: | } |
1939: | |
1940: | if (!is_null($fsizelimit)) { |
1941: | Util_Ulimit::set('fsize', $fsizelimit); |
1942: | } |
1943: | |
1944: | if (!$status['success'] || !file_exists($path)) { |
1945: | return error('export failed: %s', $status['stderr']); |
1946: | } |
1947: | |
1948: | if (!$status['success']) { |
1949: | return error('export failed: %s', $status['stderr']); |
1950: | } |
1951: | |
1952: | return $this->file_unmake_path($path); |
1953: | } |
1954: | |
1955: | |
1956: | |
1957: | |
1958: | |
1959: | |
1960: | |
1961: | |
1962: | public function clone(string $from, string $to): bool |
1963: | { |
1964: | if ($this->database_exists($to) && !$this->empty($to)) { |
1965: | return error("Database `%s' already exists", $to); |
1966: | } |
1967: | if (!$this->database_exists($from)) { |
1968: | return error("Database `%s' does not exist", $from); |
1969: | } |
1970: | if (!$this->database_exists($to) && !$this->create_database($to)) { |
1971: | return false; |
1972: | } |
1973: | $pipe = $this->export_pipe($from); |
1974: | defer($_, static function () use ($pipe) { |
1975: | if (file_exists($pipe)) { |
1976: | unlink($pipe); |
1977: | } |
1978: | }); |
1979: | return $this->import($to, $this->file_unmake_path($pipe)); |
1980: | } |
1981: | |
1982: | |
1983: | |
1984: | |
1985: | |
1986: | |
1987: | |
1988: | |
1989: | |
1990: | public function get_database_size($db) |
1991: | { |
1992: | if (!IS_CLI) { |
1993: | $resp = $this->query('mysql_get_database_size', $db); |
1994: | |
1995: | return (int)$resp; |
1996: | } |
1997: | |
1998: | |
1999: | $dir = self::MYSQL_DATADIR . '/' . \Opcenter\Database\MySQL::canonicalize($db); |
2000: | |
2001: | |
2002: | if (($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) && !is_link($dir)) { |
2003: | $this->_move_db($db); |
2004: | } |
2005: | if (!file_exists($dir) || (is_link($dir) && !readlink($dir))) { |
2006: | warn($db . ': database does not exist'); |
2007: | |
2008: | return 0; |
2009: | } |
2010: | |
2011: | $space = 0; |
2012: | $dh = opendir($dir); |
2013: | if (!$dh) { |
2014: | error("failed to open database directory `%s'", $dir); |
2015: | |
2016: | return 0; |
2017: | } |
2018: | while (($file = readdir($dh)) !== false) { |
2019: | if ($file == '.' || $file == '..') { |
2020: | continue; |
2021: | } |
2022: | $space += filesize($dir . '/' . $file); |
2023: | } |
2024: | closedir($dh); |
2025: | |
2026: | return $space; |
2027: | } |
2028: | |
2029: | |
2030: | |
2031: | private function _move_db($db) |
2032: | { |
2033: | |
2034: | |
2035: | |
2036: | |
2037: | |
2038: | $prefix = $this->getServiceValue('mysql', 'dbaseadmin'); |
2039: | if (strncmp($db, $prefix, strlen($prefix))) { |
2040: | return true; |
2041: | } |
2042: | $dbfs = \Opcenter\Database\MySQL::canonicalize($db); |
2043: | $src = self::MYSQL_DATADIR . '/' . $dbfs; |
2044: | $dest = $this->domain_fs_path() . self::MYSQL_DATADIR . '/' . $dbfs; |
2045: | if (is_dir($dest)) { |
2046: | report('dest db exists - %s', $dest); |
2047: | |
2048: | return false; |
2049: | } |
2050: | \Opcenter\Filesystem::mkdir($dest, 'mysql', $this->group_id, 02750); |
2051: | |
2052: | |
2053: | |
2054: | |
2055: | |
2056: | |
2057: | |
2058: | |
2059: | |
2060: | $dh = opendir($src); |
2061: | if (!$dh) { |
2062: | return error("could not relocate database `%s'", $db); |
2063: | } |
2064: | |
2065: | while (false !== ($file = readdir($dh))) { |
2066: | if ($file == '.' || $file == '..') { |
2067: | continue; |
2068: | } |
2069: | rename($src . '/' . $file, $dest . '/' . $file); |
2070: | chown($dest . '/' . $file, 'mysql'); |
2071: | chgrp($dest . '/' . $file, $this->group_id); |
2072: | } |
2073: | |
2074: | rmdir($src); |
2075: | symlink($dest, $src); |
2076: | warn("database `%s' relocated under account filesystem root", $db); |
2077: | $db = $this->_connect_root(); |
2078: | $db->query('FLUSH TABLES'); |
2079: | |
2080: | return true; |
2081: | } |
2082: | |
2083: | |
2084: | |
2085: | |
2086: | |
2087: | |
2088: | |
2089: | |
2090: | |
2091: | |
2092: | public function export_pipe($db) |
2093: | { |
2094: | if (version_compare(platform_version(), '4.5', '<=')) { |
2095: | return error('platform version too old to support download feature'); |
2096: | } |
2097: | |
2098: | $dbs = $this->list_databases(); |
2099: | if (!\in_array($db, $dbs, true)) { |
2100: | $originalDb = $db; |
2101: | $db = $this->get_prefix() . $db; |
2102: | if (!\in_array($db, $dbs, true)) { |
2103: | return error('Invalid database %s', $originalDb); |
2104: | } |
2105: | } |
2106: | |
2107: | $user = $this->_create_temp_user($db); |
2108: | |
2109: | return $this->query('mysql_export_pipe_real', $db, $user); |
2110: | } |
2111: | |
2112: | |
2113: | |
2114: | |
2115: | |
2116: | |
2117: | |
2118: | |
2119: | |
2120: | |
2121: | |
2122: | |
2123: | |
2124: | |
2125: | |
2126: | public function export_pipe_real($db, $user) |
2127: | { |
2128: | if (!IS_CLI) { |
2129: | return $this->query('mysql_export_pipe_real', $db, $user); |
2130: | } |
2131: | |
2132: | |
2133: | |
2134: | |
2135: | $cmd = self::EXPORT_CMD . ' -u %s %s -r%s'; |
2136: | if (!$user) { |
2137: | $user = self::MASTER_USER; |
2138: | $rootpw = escapeshellarg($this->_get_elevated_password()); |
2139: | $cmd = str_replace('-u %s', '-u %s --password=' . $rootpw, $cmd); |
2140: | } |
2141: | |
2142: | |
2143: | $fifo = tempnam($this->domain_fs_path('/tmp'), 'id-' . $this->site); |
2144: | unlink($fifo); |
2145: | if (!posix_mkfifo($fifo, 0600)) { |
2146: | return error('failed to ready pipe for export'); |
2147: | } |
2148: | chown($fifo, File_Module::UPLOAD_UID); |
2149: | $proc = new Util_Process_Fork(); |
2150: | |
2151: | |
2152: | $proc->setPriority(19); |
2153: | $proc->setUser(APNSCP_SYSTEM_USER); |
2154: | |
2155: | $status = $proc->run($cmd, |
2156: | $user, |
2157: | $db, |
2158: | $fifo |
2159: | ); |
2160: | |
2161: | if (!$status['success'] || !file_exists($fifo)) { |
2162: | return error('export failed: %s', $status['stderr']); |
2163: | } |
2164: | register_shutdown_function(static function () use ($fifo) { |
2165: | if (file_exists($fifo)) { |
2166: | unlink($fifo); |
2167: | } |
2168: | |
2169: | }); |
2170: | |
2171: | return $fifo; |
2172: | } |
2173: | |
2174: | |
2175: | |
2176: | |
2177: | |
2178: | |
2179: | |
2180: | |
2181: | public function get_uptime(): int |
2182: | { |
2183: | $db = MySQL::initialize(); |
2184: | |
2185: | return $db->query("SHOW status LIKE 'uptime'")->fetch_object()->value; |
2186: | |
2187: | } |
2188: | |
2189: | |
2190: | |
2191: | public function add_backup( |
2192: | string $db, |
2193: | string $extension = DATABASE_BACKUP_EXTENSION, |
2194: | int $span = DATABASE_BACKUP_SPAN, |
2195: | int $preserve = DATABASE_BACKUP_PRESERVE, |
2196: | $email = '' |
2197: | ) |
2198: | { |
2199: | return parent::add_backup_real('mysql', $db, $extension, $span, $preserve, $email); |
2200: | } |
2201: | |
2202: | public function edit_backup( |
2203: | string $db, |
2204: | string $extension = DATABASE_BACKUP_EXTENSION, |
2205: | int $span = DATABASE_BACKUP_SPAN, |
2206: | int $preserve = DATABASE_BACKUP_PRESERVE, |
2207: | $email = '' |
2208: | ) |
2209: | { |
2210: | return $this->edit_backup_real('mysql', $db, $extension, $span, $preserve, $email); |
2211: | } |
2212: | |
2213: | public function list_backups() |
2214: | { |
2215: | return parent::list_backups_real('mysql'); |
2216: | } |
2217: | |
2218: | |
2219: | |
2220: | |
2221: | |
2222: | |
2223: | |
2224: | |
2225: | |
2226: | |
2227: | |
2228: | |
2229: | |
2230: | public function get_backup_config($db) |
2231: | { |
2232: | return parent::get_backup_config_real('mysql', $db); |
2233: | } |
2234: | |
2235: | public function repair_database($db) |
2236: | { |
2237: | if (!IS_CLI) { |
2238: | return $this->query('mysql_repair_database', $db); |
2239: | } |
2240: | |
2241: | if (!$this->database_exists($db)) { |
2242: | return error("unknown database `%s'", $db); |
2243: | } |
2244: | |
2245: | $sqlroot = $this->domain_fs_path() . self::MYSQL_DATADIR; |
2246: | if (!file_exists($sqlroot . '/' . $db)) { |
2247: | |
2248: | $prefix = $this->get_prefix(); |
2249: | $db = $prefix . $db; |
2250: | } |
2251: | |
2252: | $files = glob($sqlroot . '/' . $db . '/*'); |
2253: | if (count($files) < 2) { |
2254: | return true; |
2255: | } |
2256: | |
2257: | |
2258: | $quota = $this->site_get_account_quota(); |
2259: | $conn = $this->_connect_root(); |
2260: | $q = 'SELECT MAX(Data_length) AS max FROM ' . |
2261: | "information_schema.tables WHERE table_schema = '" . |
2262: | $conn->real_escape_string($db) . "'"; |
2263: | $rs = $conn->query($q); |
2264: | $row = $rs->fetch_object(); |
2265: | $tblsz = $row->max / 1024 * 1.25; |
2266: | |
2267: | $qfree = $quota['qhard'] - $quota['qused']; |
2268: | $cmd = 'env HOME=/root mysqlcheck --auto-repair %s'; |
2269: | if ($tblsz > $qfree) { |
2270: | warn('not enough storage to safely use mysqlcheck (need %d KB have %d KB free): reverting to direct IO', |
2271: | $tblsz, $qfree |
2272: | ); |
2273: | $cmd = 'myisamchk -r -c ' . $sqlroot . '/%s/*.MYI'; |
2274: | } |
2275: | $fsizelimit = Util_Ulimit::get('fsize'); |
2276: | Util_Ulimit::set('fsize', 'unlimited'); |
2277: | $ret = Util_Process_Safe::exec($cmd, array($db), ['mute_stderr' => true]); |
2278: | Util_Ulimit::set('fsize', $fsizelimit); |
2279: | if (!$ret['success'] && false === strpos($ret['stderr'], "doesn't exist")) { |
2280: | return error("`%s' repair failed:\n%s", $db, $ret['stderr']); |
2281: | } |
2282: | |
2283: | return info("`%s' repair succeeded:\n%s", $db, $ret['output']); |
2284: | } |
2285: | |
2286: | |
2287: | |
2288: | |
2289: | |
2290: | |
2291: | |
2292: | |
2293: | public function kill($id) |
2294: | { |
2295: | $db = $this->_connect_root(); |
2296: | $id = intval($id); |
2297: | $procs = $this->get_processlist(); |
2298: | $found = 0; |
2299: | foreach ($procs as $p) { |
2300: | if ($p['id'] == $id) { |
2301: | $found = 1; |
2302: | break; |
2303: | } |
2304: | } |
2305: | if (!$found) { |
2306: | return error("`%d': invalid query id specified", $id); |
2307: | } |
2308: | $q = "KILL $id"; |
2309: | $rs = $db->query($q); |
2310: | |
2311: | return (bool)$rs; |
2312: | } |
2313: | |
2314: | |
2315: | |
2316: | |
2317: | |
2318: | |
2319: | |
2320: | |
2321: | |
2322: | |
2323: | |
2324: | |
2325: | |
2326: | |
2327: | |
2328: | |
2329: | |
2330: | |
2331: | |
2332: | |
2333: | public function get_processlist() |
2334: | { |
2335: | $conns = array(); |
2336: | $db = $this->_connect_root(); |
2337: | $user = $this->username; |
2338: | $prefix = $this->get_prefix(); |
2339: | $q = 'SELECT id, user, host, db, command, time, state, info FROM ' . |
2340: | "information_schema.processlist WHERE user = '" . |
2341: | $db->real_escape_string($user) . "' OR user LIKE '" . $db->real_escape_string($prefix) . "%'"; |
2342: | $rs = $db->query($q); |
2343: | while (null != ($row = $rs->fetch_object())) { |
2344: | $conns[] = array( |
2345: | 'id' => $row->id, |
2346: | 'user' => $row->user, |
2347: | 'host' => $row->host, |
2348: | 'db' => $row->db, |
2349: | 'command' => $row->command, |
2350: | 'state' => $row->state, |
2351: | 'info' => $row->info |
2352: | ); |
2353: | } |
2354: | |
2355: | return $conns; |
2356: | } |
2357: | |
2358: | |
2359: | |
2360: | |
2361: | |
2362: | |
2363: | public function stats(): array { |
2364: | $conn = \MySQL::initialize(); |
2365: | $vars = [ |
2366: | 'Queries', |
2367: | 'Uptime', |
2368: | 'Connections', |
2369: | 'Questions', |
2370: | 'TABLE_OPEN_CACHE_HITS', |
2371: | 'MAX_USED_CONNECTIONS', |
2372: | 'TABLE_OPEN_CACHE_MISSES', |
2373: | 'OPENED_FILES', |
2374: | 'OPENED_TABLES', |
2375: | 'THREADS_RUNNING', |
2376: | 'THREADS_CREATED' |
2377: | ]; |
2378: | |
2379: | $fields = implode(',', array_map(static function ($var) use ($conn) { |
2380: | return "'$var'"; |
2381: | }, $vars)); |
2382: | $rs = $conn->query("SELECT LOWER(variable_name) AS name, variable_value AS val FROM information_schema.global_status WHERE variable_name IN($fields);"); |
2383: | |
2384: | $items = array_build($rs->fetch_all(\MYSQLI_ASSOC), static function ($k, $v) { |
2385: | return [$v['name'], (int)$v['val']]; |
2386: | }); |
2387: | |
2388: | $items['observed_queries_per_second'] = null; |
2389: | $items['queries_per_second'] = round($items['queries']/$items['uptime'], 4); |
2390: | |
2391: | $key = 'mysql.qpslocf'; |
2392: | $cache = \Cache_Global::spawn(); |
2393: | if (false !== ($locf = $cache->get($key))) { |
2394: | if ($items['uptime'] > $locf[0]) { |
2395: | |
2396: | $items['observed_queries_per_second'] = round(($items['queries'] - $locf[1]) / ($items['uptime'] - $locf[0]), 4); |
2397: | $cache->del($key); |
2398: | } |
2399: | return $items; |
2400: | } else { |
2401: | $cache->set($key, [$items['uptime'], $items['queries']]); |
2402: | } |
2403: | |
2404: | |
2405: | return $items; |
2406: | } |
2407: | |
2408: | public function _cron(Cronus $c) |
2409: | { |
2410: | if (!TELEMETRY_ENABLED) { |
2411: | return; |
2412: | } |
2413: | |
2414: | $collector = new Collector(PostgreSQL::pdo()); |
2415: | $status = $this->stats(); |
2416: | foreach (MysqlMetrics::getAttributeMap() as $attr => $metric) { |
2417: | if (!isset($status[$metric])) { |
2418: | |
2419: | continue; |
2420: | } |
2421: | $val = $status[$metric]; |
2422: | |
2423: | if ($val instanceof Closure) { |
2424: | $val = $val($status); |
2425: | } |
2426: | $collector->add("mysql-${attr}", null, $val); |
2427: | } |
2428: | |
2429: | } |
2430: | |
2431: | |
2432: | |
2433: | |
2434: | |
2435: | |
2436: | |
2437: | public function schema_column_maxlen($field): ?int |
2438: | { |
2439: | if (!IS_CLI) { |
2440: | return $this->query('mysql_schema_column_maxlen', $field); |
2441: | } |
2442: | if ($field !== 'user' && $field !== 'db') { |
2443: | error("unsupported field `%s' requested", $field); |
2444: | return null; |
2445: | } |
2446: | return \Opcenter\Database\MySQL::fieldLength($field); |
2447: | } |
2448: | } |