1: | <?php |
2: | declare(strict_types=1); |
3: | |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
11: | |
12: | |
13: | |
14: | |
15: | use Module\Support\Sql; |
16: | |
17: | |
18: | |
19: | |
20: | |
21: | |
22: | class Pgsql_Module extends Sql |
23: | { |
24: | const DEPENDENCY_MAP = [ |
25: | 'siteinfo', |
26: | 'users', |
27: | 'diskquota' |
28: | ]; |
29: | |
30: | const IDENTIFIER_MAXLEN = 63; |
31: | |
32: | const PG_TEMP_PASSWORD = '23f!eoj3'; |
33: | const PGSQL_DATADIR = '/var/lib/pgsql'; |
34: | |
35: | |
36: | |
37: | |
38: | const PER_DATABASE_CONNECTION_LIMIT = PGSQL_DATABASE_CONCURRENCY_LIMIT ?: DATABASE_CONCURRENCY_LIMIT; |
39: | |
40: | |
41: | const MASTER_USER = 'root'; |
42: | |
43: | protected const PGSQL_PERMITTED_EXTENSIONS = PGSQL_USER_EXTENSIONS; |
44: | |
45: | |
46: | |
47: | |
48: | |
49: | |
50: | public function __construct() |
51: | { |
52: | parent::__construct(); |
53: | $this->exportedFunctions = array( |
54: | '*' => PRIVILEGE_SITE, |
55: | 'version' => PRIVILEGE_ALL, |
56: | 'get_elevated_password_backend' => PRIVILEGE_ALL | PRIVILEGE_SERVER_EXEC, |
57: | 'prep_tablespace_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
58: | 'vacuum_backend' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
59: | 'get_uptime' => PRIVILEGE_ALL, |
60: | 'get_username' => PRIVILEGE_ALL, |
61: | 'get_password' => PRIVILEGE_ALL, |
62: | 'set_password' => PRIVILEGE_ALL, |
63: | 'enabled' => PRIVILEGE_SITE | PRIVILEGE_USER, |
64: | 'get_prefix' => PRIVILEGE_SITE | PRIVILEGE_USER, |
65: | |
66: | 'export_pipe_real' => PRIVILEGE_SITE | PRIVILEGE_SERVER_EXEC, |
67: | 'resolve_site_from_database' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
68: | |
69: | |
70: | 'get_database_size' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
71: | 'get_table_sizes' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
72: | 'database_exists' => PRIVILEGE_SITE | PRIVILEGE_ADMIN, |
73: | 'site_from_tablespace' => PRIVILEGE_ADMIN |
74: | ); |
75: | } |
76: | |
77: | public function __destruct() |
78: | { |
79: | foreach ($this->_tempUsers as $user) { |
80: | if (!$this->user_exists($user)) { |
81: | continue; |
82: | } |
83: | $this->_delete_temp_user($user); |
84: | } |
85: | } |
86: | |
87: | |
88: | |
89: | |
90: | |
91: | |
92: | |
93: | |
94: | |
95: | public function user_exists($user, $host = 'localhost') |
96: | { |
97: | $db = \PostgreSQL::initialize(); |
98: | $prefix = $this->get_prefix(); |
99: | if (!($this->permission_level & PRIVILEGE_ADMIN) && $user != $this->databaseAdmin() && |
100: | 0 !== strpos($user, $prefix) |
101: | ) { |
102: | $user = $prefix . $user; |
103: | } |
104: | $q = $db->query_params('SELECT 1 FROM pg_authid WHERE rolname = $1', array($db->escape_string($user))); |
105: | |
106: | return !$q || $db->num_rows() > 0; |
107: | } |
108: | |
109: | public function get_prefix() |
110: | { |
111: | return $this->getServiceValue('pgsql', 'dbaseprefix'); |
112: | } |
113: | |
114: | private function _delete_temp_user($user) |
115: | { |
116: | if (!$this->delete_user($user)) { |
117: | return false; |
118: | } |
119: | |
120: | $idx = array_search($user, $this->_tempUsers); |
121: | if ($idx !== false) { |
122: | unset($this->_tempUsers[$idx]); |
123: | } |
124: | |
125: | return true; |
126: | } |
127: | |
128: | |
129: | |
130: | |
131: | |
132: | |
133: | |
134: | |
135: | |
136: | |
137: | |
138: | public function delete_user($user, $cascade = false) |
139: | { |
140: | if ($user == $this->databaseAdmin() && !Util_Account_Hooks::is_mode('delete')) { |
141: | return error('Cannot remove main user'); |
142: | } |
143: | if (!$this->user_exists($user)) { |
144: | return error("db user `$user' not found"); |
145: | } |
146: | |
147: | $prefix = $this->get_prefix(); |
148: | if ($user !== $this->databaseAdmin() && strncmp($user, $prefix, strlen($prefix))) { |
149: | $user = $prefix . $user; |
150: | } |
151: | $tblspace = $this->get_tablespace(); |
152: | $pghandler = \PostgreSQL::initialize(); |
153: | Opcenter\Database\PostgreSQL::terminateUser($user); |
154: | if (function_exists('pg_escape_literal')) { |
155: | $usersafe = pg_escape_identifier($pghandler->getHandler(), $user); |
156: | } else { |
157: | $usersafe = '"' . pg_escape_string($pghandler->getHandler(), $user) . '"'; |
158: | } |
159: | $pghandler->query('REVOKE ALL ON TABLESPACE ' . $tblspace . ' FROM ' . $usersafe . ($cascade ? ' CASCADE' : '')); |
160: | $pghandler->query('DROP ROLE ' . $usersafe); |
161: | |
162: | if ($pghandler->error) { |
163: | return new PostgreSQLError('Invalid query, ' . $pghandler->error); |
164: | } |
165: | |
166: | return true; |
167: | |
168: | } |
169: | |
170: | |
171: | |
172: | |
173: | |
174: | |
175: | |
176: | |
177: | public function get_tablespace(): ?string |
178: | { |
179: | return $this->getServiceValue( |
180: | 'pgsql', |
181: | 'tablespace', |
182: | \Opcenter\Database\PostgreSQL::getTablespaceFromUser($this->databaseAdmin()) |
183: | ); |
184: | } |
185: | |
186: | |
187: | |
188: | |
189: | |
190: | |
191: | |
192: | public function site_from_tablespace(string $tblspace): ?string |
193: | { |
194: | $db = \PostgreSQL::pdo(); |
195: | $query = "SELECT rolname FROM pg_authid JOIN pg_tablespace ON (pg_authid.oid = pg_tablespace.spcowner) WHERE spcname = " . $db->quote($tblspace); |
196: | $rs = $db->query($query); |
197: | if (!$rs) { |
198: | return null; |
199: | } |
200: | $user = $rs->fetchObject()->rolname; |
201: | if ($siteid = \Auth::get_site_id_from_admin($user)) { |
202: | return 'site' . $siteid; |
203: | } |
204: | |
205: | $map = \Opcenter\Map::load('pgsql.usermap'); |
206: | |
207: | return $map->fetch($user) ?: null; |
208: | } |
209: | |
210: | public function set_username($user) |
211: | { |
212: | if (!IS_CLI) { |
213: | return $this->query('pgsql_set_username', $user); |
214: | } |
215: | |
216: | return $this->_set_pg_param('username', $user); |
217: | |
218: | } |
219: | |
220: | private function _set_pg_param($param, $val) |
221: | { |
222: | $pwd = $this->user_getpwnam(); |
223: | $file = $this->domain_fs_path() . $pwd['home'] . '/.pgpass'; |
224: | |
225: | if (!file_exists($file)) { |
226: | \Opcenter\Filesystem::touch($file, $this->user_id, $this->group_id, 0600); |
227: | } |
228: | |
229: | return \Opcenter\Database\PostgreSQL::setUserConfigurationField($file, $param, $val); |
230: | |
231: | } |
232: | |
233: | public function get_password($user = null) |
234: | { |
235: | if (DEMO_ADMIN_LOCK && ($this->permission_level & PRIVILEGE_ADMIN) && posix_getuid()) { |
236: | return error("Demo may not alter ~/.pgpass"); |
237: | } |
238: | |
239: | if (!IS_CLI) { |
240: | return $this->query('pgsql_get_password', $user); |
241: | } |
242: | if (!$user) { |
243: | $user = $this->databaseAdmin(); |
244: | } |
245: | $pwd = $this->user_getpwnam($user); |
246: | if (!$pwd) { |
247: | return error('unknown system user `%s\'', $user); |
248: | } |
249: | $file = $this->domain_fs_path() . $pwd['home'] . '/.pgpass'; |
250: | if (!file_exists($file)) { |
251: | return false; |
252: | } |
253: | |
254: | return \Opcenter\Database\PostgreSQL::getUserConfiguration($file)['password']; |
255: | } |
256: | |
257: | public function get_elevated_password_backend() |
258: | { |
259: | return Opcenter\Database\MySQL::rootPassword(); |
260: | } |
261: | |
262: | |
263: | |
264: | |
265: | |
266: | |
267: | |
268: | public function change_prefix($prefix) |
269: | { |
270: | return error('use sql_change_prefix'); |
271: | } |
272: | |
273: | public function get_sql_prefix() |
274: | { |
275: | deprecated('use pgsql_get_prefix'); |
276: | |
277: | return $this->get_prefix(); |
278: | } |
279: | |
280: | |
281: | |
282: | |
283: | |
284: | |
285: | |
286: | |
287: | |
288: | public function service_enabled() |
289: | { |
290: | deprecated('use enabled()'); |
291: | |
292: | return $this->enabled(); |
293: | } |
294: | |
295: | |
296: | |
297: | |
298: | |
299: | |
300: | |
301: | |
302: | public function enabled() |
303: | { |
304: | return parent::svc_enabled('pgsql'); |
305: | } |
306: | |
307: | |
308: | |
309: | |
310: | |
311: | |
312: | |
313: | public function create_database($db) |
314: | { |
315: | if (!$this->enabled()) { |
316: | return error("%(service)s is disabled", ['service' => 'PostgreSQL']); |
317: | } |
318: | |
319: | if (!preg_match(\Regex::SQL_DATABASE, $db)) { |
320: | return error("invalid database name `%s'", $db); |
321: | } |
322: | if ($this->database_exists($db)) { |
323: | return error("database `$db' exists"); |
324: | } |
325: | $prefix = $this->get_prefix(); |
326: | |
327: | |
328: | if (0 !== strpos($db, $prefix)) { |
329: | $db = $prefix . $db; |
330: | } |
331: | |
332: | if (null !== ($limit = $this->getConfig('pgsql', 'dbasenum', null)) && $limit >= 0) { |
333: | $count = \count($this->list_databases()); |
334: | if ($count >= $limit) { |
335: | return error("Database limit `%d' reached - cannot create additional databases", $limit); |
336: | } |
337: | } |
338: | |
339: | if (!$this->prep_tablespace()) { |
340: | return false; |
341: | } |
342: | |
343: | $dbaseadmin = $this->databaseAdmin(); |
344: | $pghandler = \PostgreSQL::initialize(); |
345: | $pghandler->query('CREATE DATABASE ' . pg_escape_identifier($pghandler->getHandler(), $db) . ' WITH OWNER = ' . |
346: | pg_escape_identifier($pghandler->getHandler(), $dbaseadmin) . ' TABLESPACE = ' . |
347: | pg_escape_identifier($pghandler->getHandler(), $this->get_tablespace()) . ' CONNECTION LIMIT = ' . |
348: | static::PER_DATABASE_CONNECTION_LIMIT); |
349: | if ($pghandler->error) { |
350: | return error('error while creating database: %s', $pghandler->error); |
351: | } |
352: | |
353: | $version = $this->version(); |
354: | |
355: | if ($version >= 150000 && $version < 160000) { |
356: | $this->set_owner($db, $dbaseadmin); |
357: | } |
358: | |
359: | return info("created database `%s'", $db); |
360: | } |
361: | |
362: | |
363: | |
364: | |
365: | |
366: | |
367: | |
368: | public function database_exists($db) |
369: | { |
370: | if (!($this->permission_level & PRIVILEGE_ADMIN)) { |
371: | $prefix = $this->get_prefix(); |
372: | if (0 !== strpos($db, $prefix)) { |
373: | $db = $prefix . $db; |
374: | } |
375: | } |
376: | $pgdb = \PostgreSQL::initialize(); |
377: | $q = $pgdb->query_params('SELECT 1 FROM pg_database WHERE datname = $1', array($pgdb->escape_string($db))); |
378: | |
379: | return !$q || $pgdb->num_rows() > 0; |
380: | } |
381: | |
382: | |
383: | |
384: | |
385: | |
386: | |
387: | |
388: | public function prep_tablespace() |
389: | { |
390: | |
391: | $user = $this->databaseAdmin(); |
392: | if (\Opcenter\Database\PostgreSQL::getTablespaceFromUser($user)) { |
393: | return true; |
394: | } |
395: | $path = $this->domain_fs_path() . self::PGSQL_DATADIR; |
396: | if (!file_exists($path)) { |
397: | $this->query('pgsql_prep_tablespace_backend', $path); |
398: | } |
399: | |
400: | return \Opcenter\Database\PostgreSQL::initializeTablespace($this->site, $path, $user); |
401: | } |
402: | |
403: | |
404: | |
405: | |
406: | |
407: | |
408: | |
409: | |
410: | public function add_extension(string $db, string $extension): bool |
411: | { |
412: | if (!IS_CLI) { |
413: | return $this->query('pgsql_add_extension', $db, $extension); |
414: | } |
415: | |
416: | $extensions = $this->_getPermittedExtensions(); |
417: | if (!in_array($extension, $extensions, true)) { |
418: | return error("extension `%s' unrecognized or disallowed usage", $extension); |
419: | } |
420: | |
421: | $prefix = $this->get_prefix(); |
422: | if (0 !== strpos($db, $prefix)) { |
423: | $db = $prefix . $db; |
424: | } |
425: | |
426: | $dbs = $this->list_databases(); |
427: | if (!in_array($db, $dbs, true)) { |
428: | return error("database `%s' unknown", $db); |
429: | } |
430: | |
431: | $cmd = 'CREATE EXTENSION IF NOT EXISTS ' . $extension . ' WITH SCHEMA public'; |
432: | $proc = Util_Process_Safe::exec('psql -c %s %s', $cmd, $db); |
433: | if (!$proc['success']) { |
434: | return error('extension creation failed - %s', $proc['stderr']); |
435: | } |
436: | |
437: | return $proc['success']; |
438: | } |
439: | |
440: | |
441: | |
442: | |
443: | |
444: | |
445: | protected function _getPermittedExtensions() |
446: | { |
447: | return static::PGSQL_PERMITTED_EXTENSIONS; |
448: | } |
449: | |
450: | |
451: | |
452: | |
453: | |
454: | |
455: | |
456: | public function list_databases(): array |
457: | { |
458: | |
459: | $prefix = $this->get_prefix(); |
460: | if (!$prefix) { |
461: | |
462: | if (!$this->enabled()) { |
463: | return []; |
464: | } |
465: | if ($this->enabled()) { |
466: | report('Prefixless site - bug'); |
467: | } |
468: | |
469: | return []; |
470: | } |
471: | $pgdb = \PostgreSQL::initialize(); |
472: | $pgdb->query("SELECT datname FROM pg_database WHERE datname LIKE '" |
473: | . str_replace(array('-', '_'), array('', '\_'), $prefix) . "%' OR datdba = " |
474: | . "(SELECT oid FROM pg_roles WHERE rolname = '" . $this->databaseAdmin() . "')"); |
475: | $dbs = array(); |
476: | while ($row = $pgdb->fetch_object()) { |
477: | $dbs[] = $row->datname; |
478: | } |
479: | |
480: | return $dbs; |
481: | } |
482: | |
483: | |
484: | |
485: | |
486: | |
487: | |
488: | |
489: | |
490: | public function set_owner(string $db, string $owner): bool |
491: | { |
492: | $pgdb = \PostgreSQL::initialize(); |
493: | |
494: | $db = $pgdb->escape_string($this->canonicalize($db)); |
495: | $users = $this->list_users(); |
496: | |
497: | if (!isset($users[$owner])) { |
498: | $tmp = $this->canonicalize($owner); |
499: | if (!isset($users[$tmp])) { |
500: | return error("Unknown pgsql user `%s'", $owner); |
501: | } |
502: | $owner = $tmp; |
503: | } |
504: | $dbs = $this->list_databases(); |
505: | if (!in_array($db, $dbs, true)) { |
506: | return error("Unknown database `%s'", $db); |
507: | } |
508: | |
509: | $q = 'ALTER DATABASE ' . pg_escape_identifier($pgdb->getHandler(), $db) . ' OWNER TO ' . pg_escape_identifier($pgdb->getHandler(), $owner); |
510: | if (false === $pgdb->query($q)) { |
511: | return error("Failed to change owner to `%s'", $owner); |
512: | } |
513: | |
514: | if ($this->version() >= 150000) { |
515: | |
516: | $pgdb = \PostgreSQL::stub(); |
517: | $pgdb->connect(null, null, null, $db); |
518: | $pgdb->query( |
519: | "GRANT ALL ON DATABASE " . pg_escape_identifier($pgdb->getHandler(), $db) . " TO " . |
520: | pg_escape_identifier($pgdb->getHandler(), $owner) |
521: | ); |
522: | $q = 'GRANT USAGE, CREATE ON SCHEMA public TO ' . pg_escape_identifier($pgdb->getHandler(), $owner); |
523: | $pgdb->query($q); |
524: | } |
525: | |
526: | return true; |
527: | } |
528: | |
529: | |
530: | |
531: | |
532: | |
533: | |
534: | |
535: | private function canonicalize(string $name): string |
536: | { |
537: | $prefix = $this->get_prefix(); |
538: | if ($prefix && 0 !== strpos($name, $prefix)) { |
539: | $name = $prefix . $name; |
540: | } |
541: | |
542: | return $name; |
543: | } |
544: | |
545: | |
546: | |
547: | |
548: | |
549: | |
550: | |
551: | public function list_users(): array |
552: | { |
553: | |
554: | if (!$prefix = $this->get_prefix()) { |
555: | return []; |
556: | } |
557: | |
558: | $pgdb = \PostgreSQL::initialize(); |
559: | $q = $pgdb->query("SELECT rolname, rolpassword, rolconnlimit FROM pg_authid WHERE rolname = '" |
560: | . $this->databaseAdmin() . "' OR rolname LIKE '" . str_replace(array('-', '_'), array('', '\_'), |
561: | $prefix) . "%' ORDER BY rolname"); |
562: | $users = array(); |
563: | while ($row = $pgdb->fetch_object()) { |
564: | $users[$row->rolname] = array( |
565: | 'max_connections' => (int)$row->rolconnlimit, |
566: | 'password' => $row->rolpassword |
567: | ); |
568: | } |
569: | |
570: | return $users; |
571: | } |
572: | |
573: | |
574: | |
575: | |
576: | |
577: | |
578: | |
579: | |
580: | |
581: | |
582: | public function set_privileges(string $user, string $host, string $db, array $privileges): bool |
583: | { |
584: | deprecated_func('Use change_owner() to change database owner'); |
585: | return $this->change_owner($db, $user); |
586: | } |
587: | |
588: | |
589: | |
590: | |
591: | |
592: | |
593: | |
594: | |
595: | |
596: | public function change_owner(string $db, string $newowner): bool |
597: | { |
598: | $prefix = $this->get_prefix(); |
599: | |
600: | if (!$this->user_exists($newowner)) { |
601: | if (0 !== strpos($newowner, $prefix)) { |
602: | return $this->change_owner($db, $prefix . $newowner); |
603: | } |
604: | return error("User `%s' does not exist", $newowner); |
605: | } |
606: | |
607: | if (!$this->database_exists($db)) { |
608: | if (0 !== strpos($db, $prefix)) { |
609: | return $this->change_owner($prefix . $db, $newowner); |
610: | } |
611: | return error("Database `%s' does not exist", $db); |
612: | } |
613: | |
614: | $pgdb = \PostgreSQL::initialize(); |
615: | |
616: | $vendor = \Opcenter\Database\PostgreSQL::vendor(); |
617: | $res = \PostgreSQL::initialize()->query($vendor->changeDatabaseOwner($db, $newowner)); |
618: | |
619: | |
620: | return $res && $res->affected_rows() > 0; |
621: | } |
622: | |
623: | |
624: | |
625: | |
626: | |
627: | |
628: | |
629: | public function get_owner(string $database): ?string |
630: | { |
631: | if (!$this->database_exists($database)) { |
632: | $prefix = $this->get_prefix(); |
633: | if (0 !== strpos($database, $prefix)) { |
634: | return $this->get_owner($prefix . $database); |
635: | } |
636: | } |
637: | if (!($tblspace = $this->get_tablespace())) { |
638: | report('Failed tablespace inquiry for %s on %s', $database, $this->site); |
639: | return null; |
640: | } |
641: | $pgdb = \PostgreSQL::initialize(); |
642: | $vendor = \Opcenter\Database\PostgreSQL::vendor(); |
643: | |
644: | $res = \PostgreSQL::initialize()->query($vendor->ownerFromDatabase($database, $tblspace)); |
645: | if (!$res) { |
646: | return null; |
647: | } |
648: | |
649: | return array_get($res->fetch_assoc(), 'owner', null); |
650: | } |
651: | |
652: | |
653: | |
654: | |
655: | |
656: | |
657: | |
658: | |
659: | public function prep_tablespace_backend($location) |
660: | { |
661: | if (!is_dir($location)) { |
662: | mkdir($location) || fatal("failed to create pgsql data directory `%s'", $location); |
663: | } |
664: | chown($location, 'postgres'); |
665: | chgrp($location, (int)\Session::get('group_id', posix_getgrnam('postgres'))); |
666: | chmod($location, 02750); |
667: | } |
668: | |
669: | |
670: | |
671: | |
672: | |
673: | |
674: | |
675: | |
676: | |
677: | |
678: | public function add_user_permissions($user, $db, array $opts) |
679: | { |
680: | return error('Function not implemented in PostgreSQL'); |
681: | } |
682: | |
683: | public function delete_user_permissions($user, $db) |
684: | { |
685: | return error('Function not implemented in PostgreSQL'); |
686: | } |
687: | |
688: | |
689: | |
690: | |
691: | |
692: | |
693: | |
694: | public function get_user_permissions($user, $db) |
695: | { |
696: | return error('Function not implemented in PostgreSQL'); |
697: | } |
698: | |
699: | |
700: | |
701: | |
702: | |
703: | |
704: | |
705: | |
706: | public function delete_database($db) |
707: | { |
708: | $pgdb = \PostgreSQL::initialize(); |
709: | $prefix = $this->get_prefix(); |
710: | if (!$prefix) { |
711: | report('Prefixless site - bug'); |
712: | |
713: | return false; |
714: | } |
715: | if (0 !== strpos($db, $prefix)) { |
716: | $db = $prefix . $db; |
717: | } |
718: | $db = $pgdb->escape_string($db); |
719: | if (!in_array($db, $this->list_databases())) { |
720: | return error("Unknown database `%s'", $db); |
721: | } |
722: | $resp = \Opcenter\Database\PostgreSQL::dropDatabase($db); |
723: | |
724: | if (in_array($db, $this->list_backups(), true)) { |
725: | $this->delete_backup($db); |
726: | } |
727: | |
728: | if (!$resp) { |
729: | return error('Error while dropping database, ' . $pgdb->error); |
730: | } |
731: | |
732: | return true; |
733: | } |
734: | |
735: | |
736: | |
737: | |
738: | |
739: | |
740: | |
741: | public function delete_backup($db) |
742: | { |
743: | return parent::delete_backup_real('pgsql', $db); |
744: | } |
745: | |
746: | |
747: | |
748: | |
749: | |
750: | |
751: | |
752: | |
753: | |
754: | |
755: | |
756: | |
757: | |
758: | |
759: | |
760: | public function edit_user($user, $password, $maxconn = null) |
761: | { |
762: | $prefix = str_replace('-', '', $this->get_prefix()); |
763: | if ($user != $this->databaseAdmin() && |
764: | strncmp($user, $prefix, strlen($prefix)) |
765: | ) { |
766: | $user = $prefix . $user; |
767: | } |
768: | if (is_int($maxconn) && ($maxconn < 1)) { |
769: | $maxconn = self::DEFAULT_CONCURRENCY_LIMIT; |
770: | } |
771: | if (!$password && !$maxconn) { |
772: | return warn("no action taken for `$user'"); |
773: | } |
774: | if ($password && strlen($password) < self::MIN_PASSWORD_LENGTH) { |
775: | return error('pgsql password must be at least %d characters long', self::MIN_PASSWORD_LENGTH); |
776: | } |
777: | $pgdb = \PostgreSQL::pdo(); |
778: | $params = [ |
779: | ':name' => $user, |
780: | ':password' => $password, |
781: | ':connlimit' => $maxconn |
782: | ]; |
783: | |
784: | if (!$password && is_int($maxconn)) { |
785: | $query = 'UPDATE pg_authid SET rolconnlimit = :connlimit WHERE rolname = :name'; |
786: | unset($params[':password']); |
787: | } else if ($password && is_int($maxconn)) { |
788: | $query = 'UPDATE pg_authid SET rolpassword = :password, rolconnlimit = :connlimit WHERE rolname = :name'; |
789: | } else if ($password && !is_int($maxconn)) { |
790: | $query = \Opcenter\Database\PostgreSQL::vendor()->setPasswordCrypted($user, $password); |
791: | $params = []; |
792: | } |
793: | |
794: | $stmt = $pgdb->prepare($query); |
795: | |
796: | if (!$stmt->execute($params)) { |
797: | return error("Failed to edit user `%s': %s", |
798: | $user, |
799: | array_get($stmt->errorInfo(), 2, 'UNKNOWN') |
800: | ); |
801: | } |
802: | |
803: | |
804: | if ($password && (str_starts_with($password, 'md5') && strlen($password) !== 35 || !str_starts_with($password, 'md5') && !str_starts_with($password, 'SCRAM-SHA-256$'))) { |
805: | $query = \Opcenter\Database\PostgreSQL::vendor()->setPassword($user, $password); |
806: | |
807: | if ($user === $this->get_username()) { |
808: | defer($_, fn() => $this->set_password($password)); |
809: | } |
810: | |
811: | return (bool)(\PostgreSQL::initialize()->query($query) ?: |
812: | error("failed to update postgresql password for user `%s'", $user)); |
813: | |
814: | } |
815: | |
816: | return true; |
817: | } |
818: | |
819: | public function get_username() |
820: | { |
821: | if (!IS_CLI) { |
822: | return $this->query('pgsql_get_username'); |
823: | } |
824: | |
825: | $user = ($this->permission_level & PRIVILEGE_SITE) ? $this->databaseAdmin() : $this->username; |
826: | $pwd = $this->user_getpwnam($user); |
827: | if (!$pwd) { |
828: | return error('unknown system user `%s\'', $user); |
829: | } |
830: | $file = $this->domain_fs_path() . $pwd['home'] . '/.pgpass'; |
831: | if (!file_exists($file)) { |
832: | return $this->username; |
833: | } |
834: | $contents = file_get_contents($file); |
835: | if (!preg_match(Regex::SQL_PGPASS, $contents, $matches)) { |
836: | return $user; |
837: | } |
838: | |
839: | if (!$matches['username'] || $matches['username'] === '*') { |
840: | return $this->username; |
841: | } |
842: | |
843: | return $matches['username']; |
844: | } |
845: | |
846: | public function set_password($password) |
847: | { |
848: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
849: | return error("Demo may not alter ~/.pgpass"); |
850: | } |
851: | |
852: | if (!IS_CLI) { |
853: | return $this->query('pgsql_set_password', $password); |
854: | } |
855: | |
856: | return $this->_set_pg_param('password', $password); |
857: | |
858: | } |
859: | |
860: | |
861: | |
862: | |
863: | |
864: | |
865: | |
866: | public function vacuum($db) |
867: | { |
868: | $pgdb = \PostgreSQL::initialize(); |
869: | $db = $pgdb->escape_string($db); |
870: | $prefix = $this->get_prefix(); |
871: | |
872: | |
873: | if (0 !== strpos($db, $prefix)) { |
874: | $db = $prefix . $db; |
875: | } |
876: | $q = 'SELECT 1 FROM pg_database WHERE datname = $1 ' . |
877: | "AND datdba = (SELECT oid FROM pg_roles WHERE rolname = '" . $this->databaseAdmin() . "')"; |
878: | $pgdb->query_params($q, array($db)); |
879: | if ($pgdb->num_rows() < 1) { |
880: | return error("Database `$db' not owned by main user"); |
881: | } |
882: | |
883: | return $this->query('pgsql_vacuum_backend', $db); |
884: | } |
885: | |
886: | public function vacuum_backend($db) |
887: | { |
888: | $status = Util_Process::exec(['vacuumdb', '-zfq', '-d', $db]); |
889: | |
890: | return $status['success'] ?: error('Vacuum failed: %s', coalesce($status['stderr'], $status['stdout'])); |
891: | } |
892: | |
893: | public function truncate_database($db) |
894: | { |
895: | |
896: | return $this->_empty_truncate_wrapper($db, 'truncate'); |
897: | } |
898: | |
899: | private function _empty_truncate_wrapper($db, $mode) |
900: | { |
901: | if ($mode != 'truncate' && $mode != 'empty') { |
902: | return error("unknown mode `%s'", $mode); |
903: | } |
904: | if ($mode == 'empty') { |
905: | |
906: | $mode = 'drop'; |
907: | } |
908: | |
909: | $prefix = $this->get_prefix(); |
910: | if (strncmp($db, $prefix, strlen($prefix))) { |
911: | $db = $prefix . $db; |
912: | } |
913: | |
914: | if (!$this->database_exists($db)) { |
915: | return error("unknown database, `%s'", $db); |
916: | } |
917: | |
918: | $user = $this->_create_temp_user($db); |
919: | if (!$user) { |
920: | return error("failed to %s db `%s'", $mode, $db); |
921: | } |
922: | $dsn = 'host=localhost dbname=' . $db . ' user=' . $user . ' password=' . self::PG_TEMP_PASSWORD; |
923: | $sqldb = pg_connect($dsn); |
924: | if (!$sqldb) { |
925: | $this->_delete_temp_user($user); |
926: | |
927: | return error("failed to %s db `%s', db connection failed", $mode, $db); |
928: | } |
929: | |
930: | $q = 'SELECT n.nspname as "schema", ' . |
931: | 'c.relname as "name", ' . |
932: | 'r.rolname as "owner"' . |
933: | 'FROM pg_catalog.pg_class c ' . |
934: | 'JOIN pg_catalog.pg_roles r ON r.oid = c.relowner ' . |
935: | 'LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace ' . |
936: | "WHERE c.relkind IN ('r','') " . |
937: | "AND n.nspname <> 'pg_catalog' " . |
938: | "AND n.nspname !~ '^pg_toast' " . |
939: | 'AND pg_catalog.pg_table_is_visible(c.oid) ' . |
940: | 'ORDER BY 1,2;'; |
941: | $rs = \pg_query($sqldb, $q); |
942: | $pgver = $this->version(); |
943: | |
944: | $identity = ($mode !== 'drop' && $pgver >= 80400) ? 'RESTART IDENTITY' : ''; |
945: | while (false !== ($res = pg_fetch_object($rs))) { |
946: | if (function_exists('pg_escape_identifier')) { |
947: | $tablesafe = \pg_escape_identifier($sqldb, $res->name); |
948: | } else { |
949: | $tablesafe = '"' . \pg_escape_string($sqldb, $res->name) . '"'; |
950: | } |
951: | $q = strtoupper($mode) . ' TABLE ' . $tablesafe . ' ' . $identity . ' CASCADE'; |
952: | if (!($res = \pg_query($sqldb, $q))) { |
953: | warn("failed to %s table `%s': %s", $mode, $res->name, pg_errormessage($sqldb)); |
954: | } |
955: | } |
956: | $this->_delete_temp_user($user); |
957: | |
958: | return true; |
959: | } |
960: | |
961: | |
962: | |
963: | private function _create_temp_user($db) |
964: | { |
965: | |
966: | $prefix = $this->get_prefix(); |
967: | $maxlen = self::IDENTIFIER_MAXLEN - strlen($prefix); |
968: | if ($maxlen < 1) { |
969: | warn('temp pgsql user exceeds field length'); |
970: | return false; |
971: | } |
972: | |
973: | $user = $prefix . \Opcenter\Auth\Password::generate(min(6, $maxlen), 'a-f0-9'); |
974: | |
975: | if ($this->user_exists($user)) { |
976: | return error('cannot create temp pgsql user'); |
977: | } |
978: | |
979: | if (!$this->add_user($user, self::PG_TEMP_PASSWORD, 1)) { |
980: | return error('unable to create role on pgsql database %s', $db); |
981: | } |
982: | |
983: | $sqldb = \PostgreSQL::stub()->connect(null, null, null, $db); |
984: | |
985: | $sqldb->query('GRANT "' . $this->databaseAdmin() . '" TO "' . $user . '"'); |
986: | |
987: | $this->_register_temp_user($user); |
988: | |
989: | return $user; |
990: | |
991: | } |
992: | |
993: | |
994: | |
995: | |
996: | public function add_user(string $user, string $password, int $maxconn = self::DEFAULT_CONCURRENCY_LIMIT): bool |
997: | { |
998: | if (!$this->enabled()) { |
999: | return error("%(service)s is disabled", ['service' => 'PostgreSQL']); |
1000: | } |
1001: | if (!$user) { |
1002: | return error('no username specified'); |
1003: | } |
1004: | $prefix = str_replace('-', '', $this->get_prefix()); |
1005: | if ($user !== $this->databaseAdmin() && |
1006: | 0 !== strpos($user, $prefix)) { |
1007: | $user = $prefix . $user; |
1008: | } |
1009: | |
1010: | if ($this->user_exists($user)) { |
1011: | return error("pg user `$user' exists"); |
1012: | } |
1013: | |
1014: | if ($maxconn < 0) { |
1015: | $maxconn = self::PER_DATABASE_CONNECTION_LIMIT; |
1016: | } |
1017: | if (strlen($password) < self::MIN_PASSWORD_LENGTH) { |
1018: | return error('Password must be at least %d characters', self::MIN_PASSWORD_LENGTH); |
1019: | } else if ($maxconn < 0) { |
1020: | return error('Max connections, queries, and updates must be greater than -1'); |
1021: | } |
1022: | if (!\Opcenter\Database\PostgreSQL::createUser($user, $password)) { |
1023: | return false; |
1024: | } |
1025: | \Opcenter\Database\PostgreSQL::setRole($user, $this->databaseAdmin()); |
1026: | $vendor = \Opcenter\Database\PostgreSQL::vendor(); |
1027: | |
1028: | return (bool)\PostgreSQL::initialize()->query($vendor->setMaxConnections($user, $maxconn)); |
1029: | } |
1030: | |
1031: | public function version(bool $pretty = false): int|string |
1032: | { |
1033: | $version = \Opcenter\Database\PostgreSQL::version(); |
1034: | if (!$pretty) { |
1035: | return $version; |
1036: | } |
1037: | $pgver = array(); |
1038: | foreach (array('patch', 'minor', 'major') as $v) { |
1039: | $pgver[$v] = (int)$version % 100; |
1040: | $version /= 100; |
1041: | } |
1042: | |
1043: | return $pgver['major'] . '.' . $pgver['minor'] . '.' . |
1044: | $pgver['patch']; |
1045: | } |
1046: | |
1047: | |
1048: | |
1049: | |
1050: | |
1051: | |
1052: | |
1053: | public function empty($db) |
1054: | { |
1055: | if (!$this->database_exists($db)) { |
1056: | return true; |
1057: | } |
1058: | |
1059: | return \count(\Opcenter\Database\PostgreSQL::getTablesFromDatabase($db)) === 0; |
1060: | } |
1061: | |
1062: | public function empty_database($db) |
1063: | { |
1064: | return $this->_empty_truncate_wrapper($db, 'empty'); |
1065: | } |
1066: | |
1067: | |
1068: | |
1069: | |
1070: | public function import($db, $file) |
1071: | { |
1072: | if (!IS_CLI) { |
1073: | return $this->query('pgsql_import', $db, $file); |
1074: | } |
1075: | |
1076: | $prefix = $this->get_prefix(); |
1077: | |
1078: | if (strncmp($db, $prefix, strlen($prefix))) { |
1079: | $db = $prefix . $db; |
1080: | } |
1081: | |
1082: | $dbs = $this->list_databases(); |
1083: | if (false === array_search($db, $dbs, true)) { |
1084: | return error("database `%s' does not exist", $db); |
1085: | } |
1086: | $unlink = null; |
1087: | if (false === ($realfile = $this->_preImport($file, $unlink))) { |
1088: | return false; |
1089: | } |
1090: | $user = $this->_create_temp_user($db); |
1091: | if (!$user) { |
1092: | return error('import failed - cannot create temp user'); |
1093: | } |
1094: | $proc = new Util_Process_Safe(); |
1095: | $proc->setEnvironment('PGPASSWORD', self::PG_TEMP_PASSWORD); |
1096: | $cmd = 'psql --set=ON_ERROR_STOP=on -q -h 127.0.0.1 -f %(file)s -U %(user)s %(db)s'; |
1097: | if (\basename($realfile) === 'toc.dat' && file_exists(\dirname($realfile) . '/restore.sql')) { |
1098: | |
1099: | $realfile = \dirname($realfile); |
1100: | $cmd = 'pg_restore -h 127.0.0.1 -x -O -U %(user)s -d %(db)s %(file)s'; |
1101: | } |
1102: | $args = array( |
1103: | 'password' => self::PG_TEMP_PASSWORD, |
1104: | 'file' => $realfile, |
1105: | 'user' => $user, |
1106: | 'db' => $db |
1107: | ); |
1108: | $proc->setPriority(19); |
1109: | $status = $proc->run($cmd, $args); |
1110: | $this->_delete_temp_user($user); |
1111: | $this->_postImport($unlink); |
1112: | |
1113: | if (!$status['success']) { |
1114: | return error('import failed: %s', $status['error']); |
1115: | } |
1116: | |
1117: | return $status['success']; |
1118: | } |
1119: | |
1120: | public function export($db, $file = null) |
1121: | { |
1122: | if (!IS_CLI) { |
1123: | return $this->query('pgsql_export', $db, $file); |
1124: | } |
1125: | if (is_null($file)) { |
1126: | $file = $db . '.sql'; |
1127: | } |
1128: | |
1129: | if ($file[0] !== '/' && $file[0] !== '.' && $file[0] !== '~') { |
1130: | $path = $this->domain_fs_path() . '/tmp/' . $file; |
1131: | } else { |
1132: | $path = $this->file_make_path($file); |
1133: | } |
1134: | if (!$path) { |
1135: | return error("invalid file `%s'", $file); |
1136: | } |
1137: | |
1138: | if (file_exists($path) && |
1139: | (filesize($path) > 0 || realpath($path) !== $path || fileowner($path) < USER_MIN_UID)) |
1140: | { |
1141: | return error('%s: file exists, cannot overwrite', $file); |
1142: | } |
1143: | |
1144: | $file = $this->file_unmake_path($path); |
1145: | $pdir = dirname($file); |
1146: | if (!$this->file_exists($pdir) && !$this->file_create_directory($pdir, 0755, true)) { |
1147: | return error("failed to create parent directory, `%s'", $pdir); |
1148: | } |
1149: | |
1150: | if (!in_array($db, $this->list_databases())) { |
1151: | return error("invalid database `%s'", $db); |
1152: | } |
1153: | |
1154: | $user = $this->_create_temp_user($db); |
1155: | if (!$user) { |
1156: | return error('pgsql export failed - unable to create user'); |
1157: | } |
1158: | |
1159: | $fsizelimit = Util_Ulimit::get('fsize'); |
1160: | if ($this->get_database_size($db) > $fsizelimit / self::DB_BIN2TXT_MULT) { |
1161: | |
1162: | Util_Ulimit::set('fsize', 'unlimited'); |
1163: | } else { |
1164: | $fsizelimit = null; |
1165: | } |
1166: | |
1167: | $cmd = 'cd %s && umask 077 && env PGPASSWORD=%s pg_dump -h 127.0.0.1 --if-exists -c -U %s -x --file=%s %s'; |
1168: | |
1169: | $proc = new Util_Process_Safe(); |
1170: | |
1171: | $proc->setSgid($this->group_id); |
1172: | $proc->setSuid($this->user_id); |
1173: | $proc->setPriority(19); |
1174: | $status = $proc->run($cmd, |
1175: | sys_get_temp_dir(), |
1176: | self::PG_TEMP_PASSWORD, |
1177: | $user, |
1178: | $path, |
1179: | $db |
1180: | ); |
1181: | |
1182: | if ($user != self::MASTER_USER) { |
1183: | $this->_delete_temp_user($user); |
1184: | } |
1185: | if (!is_null($fsizelimit)) { |
1186: | Util_Ulimit::set('fsize', $fsizelimit); |
1187: | } |
1188: | |
1189: | if (!file_exists($path) || !$status['success']) { |
1190: | return error('export failed: %s', $status['stderr']); |
1191: | } |
1192: | |
1193: | return $this->file_unmake_path($path); |
1194: | } |
1195: | |
1196: | |
1197: | |
1198: | |
1199: | |
1200: | |
1201: | |
1202: | |
1203: | |
1204: | public function get_database_size($db) |
1205: | { |
1206: | $pgdb = \PostgreSQL::initialize(); |
1207: | $size = $pgdb->query('SELECT pg_database_size(' . pg_escape_literal($pgdb->getHandler(), $db) . ') as size')->fetch_object(); |
1208: | |
1209: | return (int)$size->size; |
1210: | } |
1211: | |
1212: | public function get_table_sizes(string $db) |
1213: | { |
1214: | if (!$this->database_exists($db)) { |
1215: | return error("Unknown database `%s'", $db); |
1216: | } |
1217: | $db = $this->canonicalize($db); |
1218: | $conn = \PostgreSQL::stub(); |
1219: | $conn->connect(mDatabase: $db); |
1220: | $relations = []; |
1221: | $query = \Opcenter\Database\PostgreSQL::vendor()->tableSize($db); |
1222: | $res = $conn->query($query); |
1223: | while($r = $res->fetch_object()) { |
1224: | $relations[] = [ |
1225: | 'schema' => $r->schema_name, |
1226: | 'table' => null !== $r->type, |
1227: | 'size' => (int)$r->table_size, |
1228: | 'name' => $r->relname |
1229: | ]; |
1230: | } |
1231: | |
1232: | return $relations; |
1233: | } |
1234: | |
1235: | |
1236: | |
1237: | |
1238: | |
1239: | |
1240: | |
1241: | |
1242: | public function clone(string $from, string $to): bool |
1243: | { |
1244: | if ($this->database_exists($to) && !$this->empty($to)) { |
1245: | return error("Database `%s' already exists", $to); |
1246: | } |
1247: | if (!$this->database_exists($from)) { |
1248: | return error("Database `%s' does not exist", $from); |
1249: | } |
1250: | if (!$this->database_exists($to) && !$this->create_database($to)) { |
1251: | return false; |
1252: | } |
1253: | |
1254: | $pipe = $this->export_pipe($from); |
1255: | defer($_, static function () use ($pipe) { |
1256: | if (file_exists($pipe)) { |
1257: | unlink($pipe); |
1258: | } |
1259: | }); |
1260: | |
1261: | return $this->import($to, $this->file_unmake_path($pipe)); |
1262: | } |
1263: | |
1264: | |
1265: | |
1266: | |
1267: | |
1268: | |
1269: | |
1270: | public function export_pipe($db) |
1271: | { |
1272: | $dbs = $this->list_databases(); |
1273: | if (!\in_array($db, $dbs, true)) { |
1274: | $originalDb = $db; |
1275: | $db = $this->get_prefix() . $db; |
1276: | if (!\in_array($db, $dbs, true)) { |
1277: | return error('Invalid database %s', $originalDb); |
1278: | } |
1279: | } |
1280: | |
1281: | $user = $this->_create_temp_user($db); |
1282: | |
1283: | return $this->query('pgsql_export_pipe_real', $db, $user); |
1284: | } |
1285: | |
1286: | |
1287: | |
1288: | |
1289: | |
1290: | |
1291: | |
1292: | public function resolve_site_from_database(string $db): ?string |
1293: | { |
1294: | $db = strtok($db, '/'); |
1295: | $prefix = strtok($db, '_') . '_'; |
1296: | |
1297: | if ($this->site) { |
1298: | return $this->database_exists($db) ? $this->site : null; |
1299: | } |
1300: | |
1301: | if (!preg_match(Regex::SQL_DATABASE, $db)) { |
1302: | error("Invalid database name"); |
1303: | |
1304: | return null; |
1305: | } |
1306: | |
1307: | if (1 || $db === $prefix) { |
1308: | $path = Opcenter\Database\PostgreSQL::tablespaceLocationFromDatabase($db); |
1309: | |
1310: | if (!$path || !str_starts_with($path, FILESYSTEM_VIRTBASE)) { |
1311: | return null; |
1312: | } |
1313: | $site = strtok(substr($path, strlen(FILESYSTEM_VIRTBASE)), '/'); |
1314: | |
1315: | } else { |
1316: | $site = Opcenter\Map::read(\Opcenter\Database\PostgreSQL::PREFIX_MAP)[$prefix]; |
1317: | } |
1318: | |
1319: | |
1320: | return $site ?: null; |
1321: | } |
1322: | |
1323: | |
1324: | |
1325: | |
1326: | |
1327: | |
1328: | |
1329: | |
1330: | |
1331: | |
1332: | |
1333: | public function export_pipe_real($db, $user) |
1334: | { |
1335: | if (!IS_CLI) { |
1336: | return $this->query('pgsql_export_pipe_real', $db, $user); |
1337: | } |
1338: | |
1339: | |
1340: | $cmd = '/usr/bin/pg_dump -h 127.0.0.1 -U %s -x --file=%s %s'; |
1341: | |
1342: | |
1343: | $fifo = tempnam($this->domain_fs_path('/tmp'), 'id-' . $this->site); |
1344: | unlink($fifo); |
1345: | if (!posix_mkfifo($fifo, 0600)) { |
1346: | return error('failed to ready pipe for export'); |
1347: | } |
1348: | chown($fifo, File_Module::UPLOAD_UID); |
1349: | $proc = new Util_Process_Fork(); |
1350: | $proc->setUser(APNSCP_SYSTEM_USER); |
1351: | |
1352: | |
1353: | $proc->setPriority(19); |
1354: | $proc->setEnvironment('PGPASSWORD', self::PG_TEMP_PASSWORD); |
1355: | $status = $proc->run($cmd, |
1356: | $user, |
1357: | $fifo, |
1358: | $db |
1359: | ); |
1360: | |
1361: | if (!$status['success'] || !file_exists($fifo)) { |
1362: | return error('export failed: %s', $status['stderr']); |
1363: | } |
1364: | register_shutdown_function(static function () use ($fifo) { |
1365: | if (file_exists($fifo)) { |
1366: | unlink($fifo); |
1367: | } |
1368: | |
1369: | }); |
1370: | |
1371: | return $fifo; |
1372: | } |
1373: | |
1374: | |
1375: | |
1376: | |
1377: | |
1378: | |
1379: | public function get_uptime(): int |
1380: | { |
1381: | $q = $this->psql->query('SELECT pg_postmaster_start_time() as st')->fetch_object(); |
1382: | |
1383: | return $q->st; |
1384: | } |
1385: | |
1386: | |
1387: | public function add_backup( |
1388: | string $db, |
1389: | string $extension = DATABASE_BACKUP_EXTENSION, |
1390: | int $span = DATABASE_BACKUP_SPAN, |
1391: | int $preserve = DATABASE_BACKUP_PRESERVE, |
1392: | $email = '' |
1393: | ) |
1394: | { |
1395: | return parent::add_backup_real('pgsql', $db, $extension, $span, $preserve, $email); |
1396: | } |
1397: | |
1398: | public function edit_backup( |
1399: | string $db, |
1400: | string $extension = DATABASE_BACKUP_EXTENSION, |
1401: | int $span = DATABASE_BACKUP_SPAN, |
1402: | int $preserve = DATABASE_BACKUP_PRESERVE, |
1403: | $email = '' |
1404: | ) |
1405: | { |
1406: | return parent::edit_backup_real('pgsql', $db, $extension, $span, $preserve, $email); |
1407: | } |
1408: | |
1409: | public function list_backups() |
1410: | { |
1411: | return parent::list_backups_real('pgsql'); |
1412: | } |
1413: | |
1414: | |
1415: | |
1416: | |
1417: | |
1418: | |
1419: | |
1420: | |
1421: | |
1422: | |
1423: | |
1424: | |
1425: | |
1426: | public function get_backup_config($db) |
1427: | { |
1428: | return parent::get_backup_config_real('pgsql', $db); |
1429: | } |
1430: | } |