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