&2; exit 1; } is_gzip() { [ "$(head -c 2 "$backup" | od -An -tx1 | tr -d ' \n')" = 1f8b ]; } stream() { if is_gzip; then gunzip -c "$backup"; else cat "$backup"; fi; } header() { stream | head -c "$1" | od -An -tx1 | tr -d ' \n'; } is_tar() { [ "$(stream | head -c 262 | tail -c 5)" = ustar ]; } is_text() { [ "$(stream | head -c 65536 | tr -d '\000' | wc -c | tr -d ' ')" = "$(stream | head -c 65536 | wc -c | tr -d ' ')" ]; } extract_tar() { work=$(mktemp -d) && trap 'rm -rf "$work"' EXIT && stream | tar -xf - -C "$work" || fail 'The tar backup cannot be extracted.'; } use_single_tar_member() { [ "$(find "$work" -type f | wc -l | tr -d ' ')" = 1 ] && backup=$(find "$work" -type f); } if is_gzip; then gunzip -t "$backup" || fail 'The gzip backup is corrupt or incomplete. Nothing was changed.' fi SH; /** * Streams an all-databases MySQL or MariaDB dump without its system databases. The mysql * database holds the users and their passwords (user and global_priv), so restoring it * would give the target the source passwords after its next restart. Everything from a * switch to a system database (`-- Current Database:` or USE) up to the next switch to * another database is dropped, as are the CREATE DATABASE statements of system databases. */ private const MYSQL_SYSTEM_DATABASE_FILTER = <<<'AWK' function database(line) { if (index(line, "`") > 0) { sub(/^[^`]*`/, "", line); sub(/`.*$/, "", line); return tolower(line) } gsub(/\/\*[^*]*\*\//, " ", line) line = tolower(line) sub(/^[ \t]*(use|create[ \t]+(database|schema))[ \t]+/, "", line) sub(/^if[ \t]+not[ \t]+exists[ \t]+/, "", line) sub(/[ \t;].*$/, "", line) return line } function is_system(name) { return name == "mysql" || name == "sys" || name == "performance_schema" || name == "information_schema" } /^-- Current Database: `/ { skip = is_system(database($0)) } tolower($0) ~ /^[ \t]*use[ \t]/ { skip = is_system(database($0)) } tolower($0) ~ /^[ \t]*create[ \t]+(database|schema)[ \t]/ { if (is_system(database($0))) { next } skip = 0 } /^\/\*!40103 SET TIME_ZONE=@OLD_TIME_ZONE/ { skip = 0 } !skip { print } AWK; /** * Prints a PostgreSQL SQL dump without the table data of its COPY ... FROM stdin blocks. */ private const POSTGRES_COPY_DATA_FILTER = 'data { if ($0 == "\\\\.") data = 0; next } { print } /^COPY .* FROM stdin;$/ { data = 1 }'; /** * @param bool $keepOwners PostgreSQL archives: restore object owners and privileges. Off by default, * because roles from another host (for example RDS) usually do not exist here. * @param string|null $sqliteDatabase SQLite: the database file to restore into, validated by the resource. * @param bool $restoreMysqlUsers MySQL and MariaDB all-databases backups: also restore the system databases * (users, passwords and privileges). Off by default, because the source * passwords would replace the ones Coolify stores for this database. */ public function buildRestoreCommand(object $resource, string $path, bool $dumpAll, bool $replaceExisting = false, bool $keepOwners = false, ?string $sqliteDatabase = null, bool $restoreMysqlUsers = false): string { $script = match ($this->databaseType($resource)) { 'postgresql' => $this->postgresqlPrelude().($dumpAll ? $this->postgresqlDumpAll() : $this->postgresqlSingle($replaceExisting, $keepOwners)), 'mysql' => $this->mysql('mysql', 'MYSQL', $dumpAll, $restoreMysqlUsers), 'mariadb' => $this->mysql('mariadb', 'MARIADB', $dumpAll, $restoreMysqlUsers), 'mongodb' => $this->mongodb($replaceExisting), 'sqlite' => $this->sqlite($resource->databaseFilePath($sqliteDatabase)), default => throw new InvalidArgumentException('Database import is not supported for this database type.'), }; return 'backup='.escapeshellarg($path)."\n".self::PRELUDE.$script; } /** * Prepares a backup inside the Coolify helper image. bz2, xz and single-file zip * backups are decompressed because database images do not ship those tools; * plain and gzip backups are moved unchanged. Exits non-zero on any failure. */ public function buildNormalizeScript(string $source, string $target): string { $source = escapeshellarg($source); $target = escapeshellarg($target); return << "\$out" || { echo 'The bz2 backup cannot be decompressed.' >&2; exit 1; } ;; fd377a585a00) unxz -c "\$f" > "\$out" || { echo 'The xz backup cannot be decompressed.' >&2; exit 1; } ;; 504b0304*) d=\$(mktemp -d) && unzip -q "\$f" -d "\$d" || { echo 'The zip backup cannot be extracted.' >&2; exit 1; } [ "\$(find "\$d" -type f | wc -l | tr -d ' ')" = 1 ] || { echo 'A zip backup must contain exactly one file.' >&2; exit 1; } mv "\$(find "\$d" -type f)" "\$out" ;; *) mv "\$f" "\$out" ;; esac SH; } public function buildPostgresRestoreScanScript(object $resource, string $path): ?string { if ($this->databaseType($resource) !== 'postgresql') { return null; } $escapedPath = escapeshellarg($path); // Token separator PostgreSQL treats as whitespace: real whitespace, a /* ... */ // block comment (also nested or holding asterisks), or a -- comment through the // line end. The joined scan maps line ends to '~' (after it deletes every real // '~'), so a -- comment ends at the next '~'. Comment forms also match inside // string literals, which can only block more input, never less. $sep = '([[:space:]]|~|/\\*.*\\*/|--[^~]*~)'; // A TO/FROM PROGRAM token pair anywhere (so it is also found inside DO blocks, // EXECUTE strings and function bodies), a server-side large-object import or // export, or ALTER SYSTEM. The pair is matched without the former (^|;) anchor // and [^;]* limit, which a ';' inside a string literal or dynamic SQL bypassed. $sqlPattern = "(from|to){$sep}+program|lo_(import|export){$sep}*\\(|alter{$sep}+system{$sep}+(set|reset)"; // psql meta-commands that can run a shell command, anywhere on a line (a meta-command // may follow SQL without a ';'). An odd backslash run starts a meta-command; COPY data // writes a literal backslash as "\\", so its rows do not match. Blocked: \!, output // piped by \o/\out/\g/\gx/\w/\write, \copy ... program, \setenv and the editor commands, // the include commands (they run a file that \o or \copy wrote), and backquotes in the // arguments of a command. \n, \b, \v and \N are COPY escapes that psql does not know, // and psql does not expand backquotes for unknown commands. $psqlPattern = '(^|[^\\\\])(\\\\\\\\)*\\\\(!|(o|out|gx?|w|write)[[:space:]][^\\\\]*\||copy[[:space:]].*program|(setenv|e|edit|ef|ev|i|ir|include|include_relative)([[:space:]]|$)|([^nbv[:space:]\\\\]|[nbv][^[:space:]\\\\])[^[:space:]\\\\]*[[:space:]][^\\\\]*`)'; $escapedSqlPattern = escapeshellarg($sqlPattern); $escapedPsqlPattern = escapeshellarg($psqlPattern); $contents = "{ gunzip -cf {$escapedPath} 2>/dev/null || cat {$escapedPath}; }"; // Comments are no longer stripped: deleting the rest of a line on "--" also cut // inside string literals, which let "SELECT '--'; COPY ... PROGRAM" slip through. // A commented-out PROGRAM statement is now blocked too, which is the safer default. // Table data of COPY ... FROM stdin blocks is not scanned: it is data, not SQL, and ordinary // text such as "how to program" matched the patterns. pg_dump writes each block as the line // "COPY ... FROM stdin;", the rows, and the line "\.". When awk is missing or fails, the // scan receives a line that matches, so the restore is blocked. $withoutCopyData = static fn (string $source): string => "{ {$source} | LC_ALL=C awk ".escapeshellarg(self::POSTGRES_COPY_DATA_FILTER)." || echo '\\! from program'; }"; $scan = static fn (string $source): string => $withoutCopyData($source)." | LC_ALL=C grep -Eiq {$escapedPsqlPattern} || ".$withoutCopyData($source)." | tr -d '~' | tr '\\n\\r' '~~' | LC_ALL=C grep -Eiq {$escapedSqlPattern}"; $customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null'); $sqlScan = $scan($contents); $blockedProgram = 'echo \'Blocked PostgreSQL restore: the backup contains a COPY ... PROGRAM, psql shell command, lo_import/lo_export, or ALTER SYSTEM pattern. If you trust this dump, restore it from the database container terminal.\'; exit 1'; $blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1'; return <</dev/null 2>&1 || { echo 'Blocked PostgreSQL restore: awk is required to scan the backup.'; exit 1; } header=\$({$contents} | head -c 5) tar_magic=\$({$contents} | head -c 262 | tail -c 5) if [ "\$header" = 'PGDMP' ] || [ "\$tar_magic" = 'ustar' ]; then inspect=\$(mktemp) trap 'rm -f "\$inspect"' EXIT if ! {$contents} > "\$inspect"; then {$blockedInspect} fi if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then {$blockedInspect} fi if {$customScan}; then {$blockedProgram} fi elif {$sqlScan}; then {$blockedProgram} fi SH; } public function buildPostgresSafetyCommand(object $resource, string $container, string $path): ?string { $script = $this->buildPostgresRestoreScanScript($resource, $path); if ($script === null) { return null; } return 'docker exec '.$container.' sh -c '.escapeshellarg($script); } public function supports(object $resource): bool { return in_array($this->databaseType($resource), ['postgresql', 'mysql', 'mariadb', 'mongodb', 'sqlite'], true); } public function databaseType(object $resource): string { $class = $resource->getMorphClass(); $type = ($resource instanceof ServiceDatabase || str_contains(strtolower($class), 'service')) ? strtolower($resource->databaseType()) : strtolower($class); return match (true) { str_contains($type, 'postgres') => 'postgresql', str_contains($type, 'mariadb') => 'mariadb', str_contains($type, 'mysql') => 'mysql', str_contains($type, 'mongo') => 'mongodb', str_contains($type, 'sqlite') => 'sqlite', default => 'unsupported', }; } /** * Sets the PostgreSQL user once (see PostgresUser) and defines the checks that run after the * backup format is accepted and before the first change. When no user variable is set, * "postgres" is a guess, so a failed connection must stop the restore with a clear error. */ private function postgresqlPrelude(): string { return 'pg_user='.PostgresUser::shellExpression()."\n".<<<'SH' check_connection() { psql -U "$pg_user" -d template1 -Atc 'SELECT 1' >/dev/null 2>&1 || fail "Cannot connect to PostgreSQL as user \"$pg_user\". Set POSTGRES_USER on the database container to its superuser. Nothing was changed." } check_superuser() { [ "$(psql -U "$pg_user" -d template1 -Atc 'SELECT rolsuper FROM pg_roles WHERE rolname = current_user' 2>/dev/null)" = t ] || fail "Restoring all databases needs a PostgreSQL superuser, and \"$pg_user\" is not one or cannot connect. Set POSTGRES_USER on the database container to its superuser. Nothing was changed." } SH; } /** * pg_dump custom and tar archives are restored with pg_restore, SQL dumps with psql. * pg_restore cannot read gzip files, so both clients receive the backup on stdin. * SQL cannot replace single objects, so replacing recreates the target database. * Archives skip owners and privileges unless they are kept, because a single missing * role would roll back the whole single-transaction restore. */ private function postgresqlSingle(bool $replaceExisting, bool $keepOwners): string { // Every restore runs in one transaction, so a failure part-way rolls back and leaves the // current data as it was, also when --clean dropped objects first. $owners = $keepOwners ? '' : ' --no-owner --no-acl'; $clean = $replaceExisting ? ' --clean --if-exists' : ''; $suffix = bin2hex(random_bytes(16)); $sqlRestore = $replaceExisting ? "\n new=coolify_restore_new_{$suffix}\n old=coolify_restore_old_{$suffix}\n".<<<'SH' check_connection echo 'SQL backups cannot replace single objects. The backup is restored into a new database first; the current database is replaced only when that restore succeeds.' if ! existing=$(printf '%s\n' "SELECT 1 FROM pg_database WHERE datname IN (:'new', :'old');" | psql -v ON_ERROR_STOP=1 -At -v new="$new" -v old="$old" -U "$pg_user" -d template1); then fail 'The temporary database names could not be checked. Nothing was changed.' fi [ -z "$existing" ] || fail 'A temporary database name is already in use. Nothing was changed.' createdb -U "$pg_user" "$new" || exit 1 if ! stream | psql -v ON_ERROR_STOP=1 --single-transaction -U "$pg_user" -d "$new"; then PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$new" fail 'The SQL restore failed. The current database was not changed.' fi if ! printf '%s\n' "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = :'db' AND pid <> pg_backend_pid();" 'ALTER DATABASE :"db" RENAME TO :"old";' 'ALTER DATABASE :"new" RENAME TO :"db";' | psql -v ON_ERROR_STOP=1 -v db="$db" -v old="$old" -v new="$new" -U "$pg_user" -d template1 >/dev/null; then # Undo a half-done swap: the current database keeps its name and data. printf '%s\n' 'ALTER DATABASE :"old" RENAME TO :"db";' | psql -v db="$db" -v old="$old" -U "$pg_user" -d template1 >/dev/null 2>&1 PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$new" fail 'The current database is in use and could not be replaced. Nothing was changed.' fi PGOPTIONS='-c client_min_messages=warning' dropdb --maintenance-db=template1 -U "$pg_user" --if-exists "$old" || exit 1 SH : <<<'SH' check_connection stream | psql -v ON_ERROR_STOP=1 --single-transaction -U "$pg_user" -d "$db" SH; return <</dev/null 2>&1 || fail 'pg_restore cannot read this backup archive. Nothing was changed.' elif is_text; then kind=sql else fail 'Unsupported PostgreSQL backup format. Use a pg_dump archive (custom or tar format) or an SQL file. Nothing was changed.' fi check_superuser psql -U "$pg_user" -d template1 -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" || exit 1 psql -U "$pg_user" -d template1 -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U "$pg_user" --maintenance-db=template1 --if-exists {} || exit 1 createdb -U "$pg_user" "$db" || exit 1 [ "$db" = postgres ] || createdb -U "$pg_user" postgres || exit 1 if [ "$kind" = archive ]; then stream | pg_restore -U "$pg_user" -d "$db" else stream | psql -U "$pg_user" -d "$db" fi SH; } /** * MySQL and MariaDB restore SQL dumps. A tar backup must wrap exactly one dump. * The all-databases mode checks the backup before it drops any database and skips * the system databases unless their users and privileges are restored as well. */ private function mysql(string $binary, string $prefix, bool $dumpAll, bool $restoreMysqlUsers = false): string { $preflight = <<<'SH' if is_tar; then extract_tar use_single_tar_member || fail 'A tar backup must contain exactly one SQL dump. Nothing was changed.' fi is_text || fail 'Unsupported backup format. Use an SQL dump. Nothing was changed.' SH; if (! $dumpAll) { return $preflight.<</dev/null || true; done {$root} -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \\`',schema_name,'\\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | {$root} || exit 1 {$root} -e "CREATE DATABASE IF NOT EXISTS \\`{$database}\\`;" || exit 1 {$source} | {$root} {$database} SH; } /** * MongoDB restores mongodump archives (plain or gzip) and dump directories packed * as tar. Replacing existing data drops each restored collection first. */ private function mongodb(bool $replaceExisting): string { $drop = $replaceExisting ? ' --drop' : ''; return << "\$backup.db" || fail 'The backup cannot be read. Nothing was changed.' [ "\$(head -c 15 "\$backup.db")" = 'SQLite format 3' ] || { rm -f "\$backup.db"; fail 'The backup is not a SQLite database. Nothing was changed.'; } sqlite3 -bail {$file} '.timeout 10000' ".restore \$backup.db"; status=\$?; rm -f "\$backup.db"; exit \$status SH; } }