OwlCyberSecurity - MANAGER
Edit File: comet_native_mysql_restore
#!/usr/local/cpanel/3rdparty/bin/perl # Copyright 2026 WebPros International, LLC # All rights reserved. # copyright@cpanel.net http://cpanel.net # This code is subject to the cPanel license. Unauthorized copying is prohibited. package scripts::comet_native_mysql_restore; use cPstrict; use Getopt::Long (); use Cpanel::Logger (); my $logger; # A backtick-quoted identifier (capture = the raw name, `` still doubled). # Declared up here: file-scope lexicals below the `exit run(...)` line are # never initialized when the script is executed rather than loaded. my $RX_IDENT = qr/`((?:[^`]|``)+)`/; my $RX_IDENT_NC = qr/`(?:[^`]|``)+`/; # A mysqldump-family SET pragma that assigns a quoted literal to sql_mode: # (prefix up to the opening quote)(the mode list)(closing quote + comment end). my $RX_SQL_MODE_SET = qr{\A((?:\s*--[^\n]*\n)*\s*/\*M?!\d{4,6}\s+SET\s[^']*?\bsql_mode\s*=\s*')([^']*)('\s*\*/\s*)\z}is; sub _logger { return $logger ||= Cpanel::Logger->new(); } =encoding utf-8 =head1 NAME comet_native_mysql_restore - Import a Comet native MySQL snapshot stream (CPANEL-54620) =head1 SYNOPSIS comet_native_mysql_restore --newuser <cpanel_user> --db <name> [--db <name> ...] =head1 DESCRIPTION Restore-side import helper for the Native Account-Data Model (C<databases_native_mysql> Protected Items). The Comet C<backup-tool> agent executes this script once per restored C<< <db>.sql >> stream (a C<RESTORETYPE_PROCESS_PERFILE> restore) and pipes the mysqldump/mariadb-dump SQL text to its stdin. The script filters the stream and imports it over cPanel's managed MySQL admin connection — the same trust model as the legacy C<restorepkg --from-stdin> database restore. MySQL credentials never appear in the Comet restore request or on the command line. Filtering, per the FACT-0019 spike findings (raw replay of DEFINER clauses requires ALL PRIVILEGES precisely because dumps can carry cross-user DEFINERs): =over 4 =item * Every C<DEFINER=`x`@`y`> clause on a DDL line is REWRITTEN to C<DEFINER=`<newuser>`@`localhost`> — the restore target owns every object it restores. A bare strip would instead make the importing account the definer of C<SQL SECURITY DEFINER> objects, which is an escalation hazard. =item * C<USE> and C<CREATE DATABASE> statements are validated against the authorized C<--db> list (which mirrors the fail-closed restore authorization in C<ProtectedItemRestore>). A stream whose C<USE>/C<CREATE DATABASE> names any other database aborts the import before the offending statement is executed. =item * Values the dump carries for B<generated> (C<VIRTUAL>/C<STORED>/ C<PERSISTENT>) columns are replaced with C<DEFAULT> (CPANEL-57349). mariadb-dump writes the computed value of every generated column into its C<INSERT>s; MariaDB ignores it under the dump's non-strict C<SQL_MODE>, but MySQL 8 rejects any value other than C<DEFAULT> (error 3105), which failed the whole database import. C<DEFAULT> is the one value both engines accept, and the server recomputes the column either way. See L</neutralize_generated_column_values>. =item * A C<SET sql_mode> the target server rejects is re-run without the modes it does not know, e.g. MariaDB's C<NO_AUTO_CREATE_USER> on MySQL 8 (CPANEL-57398). See L</execute_statement>. =back B<Trust model.> This helper imports vault content over cPanel's managed MySQL admin connection, exactly as the legacy C<restorepkg --from-stdin> database restore does; that admin-replay-of-vault-content is the security bar, and this path holds parity with it, no more and no less. The C<USE>/C<CREATE DATABASE> allowlist and the DEFINER rewrite are B<defense-in-depth>, B<not> a sandbox: a deliberately tampered stream could still reach other databases through a cross-database-qualified identifier (C<UPDATE `other`.`t` ...>), just as a tampered legacy DB tarball could. What the native model changes is blast radius — one snapshot now holds every tenant's databases — so the fail-closed per-database authorization in C<ProtectedItemRestore> (which decides B<which> streams are even fetched) is the real cross-tenant boundary, and this filter backs it up. A dedicated least-privilege restore user (spike floor P1-R6) is the planned hardening (waypoint 1b). The admin handle is obtained with C<mysql_multi_statements> B<disabled>, so a crafted single line that packs a second statement after an allowed leading C<USE> (C<USE `ok`; DROP ...>) is rejected by the server rather than executed — the one-statement-per-C<do()> assumption below then holds. Statement splitting is C<DELIMITER>-aware, mirroring the parsing Comet's own engine applies to these dumps (routines/triggers arrive inside DELIMITER blocks). Only mysqldump-family streams are supported: data lines never embed raw newlines (mysqldump escapes them), so line-anchored DDL detection is sound. Exits non-zero on any validation or import failure so the agent records the restore as failed. =cut exit run(@ARGV) unless caller; sub run (@argv) { my ( $newuser, @dbs ); Getopt::Long::GetOptionsFromArray( \@argv, 'newuser=s' => \$newuser, 'db=s@' => \@dbs, ) or return _usage(); return _usage() unless defined $newuser && @dbs; # Fail-closed identity checks. The username becomes a quoted identifier in # rewritten DEFINER clauses; the database names bound the import scope. if ( !_is_valid_target_user($newuser) ) { _logger()->error("comet_native_mysql_restore: refusing import: invalid or unknown target user"); return 1; } my %allowed_db; for my $db (@dbs) { if ( !_is_valid_backed_up_db_name($db) ) { _logger()->error("comet_native_mysql_restore: refusing import: database name fails validation"); return 1; } $allowed_db{$db} = 1; } my $dbh = _admin_dbh(); # $statements counts every executed statement, including mysqldump's own # SET-pragma header/footer lines; $meaningful_applied only goes true once # something that actually restores content (not just a session-setting # pragma) runs. A stream that is empty, comment-only, or truncated right # after the header (so nothing is left mid-statement to trip the # truncation die below) leaves both false -- that is "nothing was # actually applied", not a successful restore. my $meaningful_applied = 0; my %exec_state = ( db => undef, dropped_modes => {} ); my $statements = eval { import_sql_stream( \*STDIN, sub ($stmt) { $meaningful_applied ||= !_is_header_pragma_statement($stmt); execute_statement( $dbh, $stmt, \%exec_state ); return; }, $newuser, \%allowed_db, ); }; if ( my $err = $@ ) { # Never echo statement text (it is customer data); the error from DBI # already carries the server-side context. my $where = defined $exec_state{db} ? " (database '$exec_state{db}')" : ''; _logger()->error("comet_native_mysql_restore: import failed for user '$newuser'$where: $err"); return 1; } if ( !$statements || !$meaningful_applied ) { _logger()->error("comet_native_mysql_restore: import failed for user '$newuser': the database dump contained no data (it may be empty or truncated)"); return 1; } _logger()->info( sprintf( "comet_native_mysql_restore: imported %d statement(s) for user '%s' (databases: %s)", $statements, $newuser, join( ',', sort keys %allowed_db ) ) ); return 0; } sub _usage { print STDERR "usage: comet_native_mysql_restore --newuser <cpanel_user> --db <name> [--db <name> ...]\n"; return 1; } =head1 FUNCTIONS =head2 import_sql_stream( $in_fh, $exec_cb, $newuser, \%allowed_db ) Reads a mysqldump-family SQL stream from C<$in_fh>, applies the DEFINER rewrite and database-scope enforcement line-wise, splits it into statements (honoring C<DELIMITER> changes), and calls C<$exec_cb> with each complete statement (after L</neutralize_generated_column_values>). Returns the number of statements executed. Dies on scope violations or when C<$exec_cb> dies. =cut sub import_sql_stream ( $in_fh, $exec_cb, $newuser, $allowed_db ) { my $delim = ';'; my @pending; my $statements = 0; my %generated_state = ( db => '', tables => {}, warned => {} ); # generated-column rewrite while ( my $line = readline $in_fh ) { $line =~ s/\r?\n\z//; # Client-side DELIMITER directive: switch and swallow (never sent to # the server), exactly like the mysql client and Comet's own parser. if ( $line =~ m/\ADELIMITER[ \t]+(\S+)/ ) { $delim = $1; next; } assert_line_in_database_scope( $line, $allowed_db ); $line = rewrite_definers_in_line( $line, $newuser ); push @pending, $line; if ( _ends_with( $line, $delim ) ) { my $stmt = join( "\n", @pending ); $stmt = substr( $stmt, 0, length($stmt) - length($delim) ); @pending = (); # Comment-only / whitespace-only chunks need not hit the server. if ( $stmt =~ m/\S/ && $stmt !~ m/\A(?:\s*--[^\n]*\n?)+\z/ ) { neutralize_generated_column_values( \$stmt, \%generated_state ); $exec_cb->($stmt); $statements++; } } } # Trailing content with no terminating delimiter is comment/whitespace # residue in well-formed dumps; anything else is a truncated stream. my $residue = join( "\n", @pending ); if ( $residue =~ m/\S/ && $residue !~ m/\A(?:\s*(?:--[^\n]*)?\n?)+\z/ ) { die "SQL stream ended mid-statement (truncated restore stream?)\n"; } return $statements; } =head2 execute_statement( $dbh, $stmt, \%state ) Runs one statement on C<$dbh>. C<\%state> is per-stream state, initialised as C<< { db => undef, dropped_modes => {} } >>; C<db> tracks the last C<USE> so a failure can name the database. B<sql_mode values the target does not know (CPANEL-57398).> mysqldump-family dumps wrap every trigger, routine and event in C<< /*!50003 SET sql_mode = '<source modes>' */ >>. MariaDB's default sql_mode includes C<NO_AUTO_CREATE_USER>, which MySQL 8 removed, so the server rejects that C<SET> (error 1231) and the whole database import used to fail. When such a C<SET sql_mode> statement fails, each listed mode is tried on its own and the statement is re-run with only the modes this server accepts. The target server is the authority: no engine or version list is kept here. A same-engine import never fails the C<SET>, so it runs exactly as before. The dropped modes are logged once each. If no mode was rejected (the failure has another cause), the original error is raised as before; if the filtered C<SET> still fails, its error is raised. A failed MariaDB-only C<SEQUENCE> statement gets a message that names it, because the target engine cannot hold that object at all. =cut sub execute_statement ( $dbh, $stmt, $state ) { return _note_use( $stmt, $state ) if eval { $dbh->do($stmt); 1 }; my $err = $@; if ( my ( $head, $modes, $tail ) = $stmt =~ $RX_SQL_MODE_SET ) { my ( @kept, @dropped ); for my $mode ( grep { length } split /\s*,\s*/, $modes ) { if ( _server_accepts_sql_mode( $dbh, $mode ) ) { push @kept, $mode } else { push @dropped, $mode } } die $err unless @dropped; # not an unknown-mode failure $dbh->do( $head . join( ',', @kept ) . $tail ); for my $mode ( grep { !$state->{dropped_modes}{$_}++ } @dropped ) { _logger()->warn("comet_native_mysql_restore: this database server does not support the sql_mode value '$mode' used in the dump; triggers, routines, and events that used it are created without it"); } return; } if ( $stmt =~ m/^\s*(?:CREATE|DROP)\s+(?:OR\s+REPLACE\s+)?SEQUENCE\b/mi ) { die "the dump contains a SEQUENCE object, which exists only on MariaDB and cannot be created on this database server: $err"; } die $err; } sub _note_use ( $stmt, $state ) { $state->{db} = _unquote_ident($1) if $stmt =~ m/\A(?:\s*--[^\n]*\n)*\s*USE\s+$RX_IDENT/i; return; } # A mode name is a bare word (never quoted text), so it is safe to inline. # The probe changes the session sql_mode; the caller's filtered SET that # follows replaces it. sub _server_accepts_sql_mode ( $dbh, $mode ) { return 0 unless $mode =~ m/\A\w+\z/; return eval { $dbh->do("SET SESSION sql_mode = '$mode'"); 1 } ? 1 : 0; } =head2 rewrite_definers_in_line( $line, $newuser ) On DDL-context lines only, rewrites every C<DEFINER=user@host> to C<DEFINER=`$newuser`@`localhost`>. Data lines (C<INSERT ...>) are returned untouched, so string contents can never be corrupted. Because a missed DEFINER on a C<SQL SECURITY DEFINER> object is a privilege-escalation hole (not merely a cosmetic miss), the DDL-context gate is deliberately broad: any line beginning with C<CREATE>/C<ALTER> (case-insensitive) or a MySQL C</*!NNNNN> or MariaDB C</*M!NNNNN> versioned conditional comment. mysqldump and mariadb-dump only ever emit a DEFINER clause inside one of these forms; INSERT/data lines never begin this way, so a C<DEFINER=> substring embedded in string data is never rewritten. =cut sub rewrite_definers_in_line ( $line, $newuser ) { return $line unless $line =~ m{\A\s*(?:/\*M?!\d{4,6}|CREATE\b|ALTER\b)}i; $line =~ s{ DEFINER \s* = \s* (?: `[^`]*` | '[^']*' | "[^"]*" | [\w.-]+ ) # user part \@ (?: `[^`]*` | '[^']*' | "[^"]*" | [\w.%-]+ ) # host part }{DEFINER=`$newuser`\@`localhost`}xgi; return $line; } =head2 neutralize_generated_column_values( \$stmt, \%state ) Rewrites C<$stmt> in place so that every value an C<INSERT>/C<REPLACE> supplies for a generated column becomes C<DEFAULT> (CPANEL-57349). C<\%state> is per-stream state, initialised as C<< { db => '', tables => {}, warned => {} } >>. C<USE> sets the current database. A C<CREATE TABLE> records that table's column layout under database + table (only when the table has a generated column), and is itself left unchanged. A later C<INSERT INTO `t` VALUES> or C<INSERT INTO `t` (`a`, ...) VALUES> for a recorded table has the values at the generated positions replaced. The other values are kept byte for byte. Fail-safe: when the statement's shape is not understood (a tuple with the wrong number of values, an unterminated string, a trailing clause, an unexpected column list) the statement is left unchanged, which is exactly the behaviour before this rewrite, and a warning names the table (once per table, never any row data). =cut sub neutralize_generated_column_values ( $sref, $state ) { # mysqldump folds the preceding `-- ...` comment banner into the statement. $$sref =~ m/\A(?:\s*--[^\n]*\n)*\s*/gc; my $keyword_at = pos($$sref) // 0; $$sref =~ m/\G(CREATE|USE|INSERT|REPLACE)\b/gci or return; my $keyword = uc $1; if ( $keyword eq 'USE' ) { $state->{db} = _unquote_ident($1) if $$sref =~ m/\G\s+$RX_IDENT/gc; return; } if ( $keyword eq 'CREATE' ) { return unless $$sref =~ m/\G\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?$RX_IDENT\s*\(/gci; my $key = "$state->{db}\0" . _unquote_ident($1); my $layout = _generated_column_layout( substr( $$sref, $keyword_at ) ); if ($layout) { $state->{tables}{$key} = $layout } else { delete $state->{tables}{$key} } return; } # INSERT / REPLACE: nothing to do (and nothing copied) unless this stream # has a table with a generated column. return unless %{ $state->{tables} }; return unless $$sref =~ m/\G(?:\s+(?:IGNORE|LOW_PRIORITY|DELAYED|HIGH_PRIORITY))*\s+INTO\s+$RX_IDENT/gci; my $table = _unquote_ident($1); my $layout = $state->{tables}{"$state->{db}\0$table"} or return; my @columns; if ( $$sref =~ m/\G\s*\(\s*((?:$RX_IDENT_NC\s*,\s*)*$RX_IDENT_NC)\s*\)\s*VALUES\s*/gc ) { @columns = map { _unquote_ident($_) } $1 =~ m/$RX_IDENT/g; } elsif ( $$sref =~ m/\G\s*VALUES\s*/gci ) { @columns = @{ $layout->{positional} }; } else { return _warn_unchanged( $state, $table ); } my %replace_idx = map { $_ => 1 } grep { $layout->{generated}{ $columns[$_] } } 0 .. $#columns; return unless %replace_idx; # e.g. a MySQL-source dump, which already omits them my $spans = _generated_value_spans( $sref, pos($$sref), scalar(@columns), \%replace_idx ) or return _warn_unchanged( $state, $table ); # One forward pass: splicing each span in place would re-shift the tail of # a ~1 MB extended INSERT once per row (quadratic). my ( $out, $at ) = ( '', 0 ); for my $span (@$spans) { $out .= substr( $$sref, $at, $span->[0] - $at ) . 'DEFAULT'; $at = $span->[0] + $span->[1]; } $$sref = $out . substr( $$sref, $at ); return; } sub _warn_unchanged ( $state, $table ) { return if $state->{warned}{"$state->{db}\0$table"}++; _logger()->warn("comet_native_mysql_restore: could not locate the generated-column values in an INSERT for table '$table'; importing it unchanged"); return; } # Column layout of a mysqldump-family CREATE TABLE (one column definition per # line, each starting with its backtick-quoted name). Returns undef unless the # table has at least one generated column. `positional` is the column order a # column-list-less INSERT fills (INVISIBLE columns are skipped by the server). sub _generated_column_layout ($create) { my ( @positional, %generated ); for my $line ( split /\n/, $create ) { next unless $line =~ m/\A\s*$RX_IDENT\s+(.*)\z/; my ( $name, $definition ) = ( _unquote_ident($1), $2 ); # Judge the definition with string literals (DEFAULT/COMMENT text) and # quoted identifiers (e.g. a column named `invisible`) removed. $definition =~ s/'(?:[^'\\]|\\.|'')*'/''/g; $definition =~ s/$RX_IDENT_NC/``/g; # A generated expression is always parenthesised; this deliberately # excludes system-versioning `GENERATED ALWAYS AS ROW START/END`. $generated{$name} = 1 if $definition =~ m/\bGENERATED\s+ALWAYS\s+AS\s*\(/i || $definition =~ m/\bAS\s*\(.*\)\s*(?:VIRTUAL|STORED|PERSISTENT)\b/i; push @positional, $name unless $definition =~ m/\bINVISIBLE\b/i; } return undef unless %generated; # callers test truthiness return { positional => \@positional, generated => \%generated }; } # Scans the VALUES tuples of $$sref from offset $start. Returns [ [offset, length], ... ] # for every value whose 0-based column index is in \%replace_idx, or undef when # the text is not a plain list of $ncols-wide tuples. A value is any run of # quoted strings and bare tokens (numbers, NULL, 0x.., _binary '..', X'..'); # the scan is an iterative tokenizer so long strings never hit regex limits. sub _generated_value_spans ( $sref, $start, $ncols, $replace_idx ) { pos($$sref) = $start; my @spans; while (1) { $$sref =~ m/\G\s*\(/gc or return undef; my $col = 0; while (1) { $$sref =~ m/\G\s*/gc; my ( $value_start, $value_end ) = ( pos($$sref), undef ); # Fast path for the common single-token value (number, NULL, 0x.., # or a string with no escapes); anything else takes the loop below. my $single_token = $$sref =~ m/\G(?:[^,()'\s]++|'[^'\\]*+')(?=\s*[,)])/gc; $value_end = pos($$sref) if $single_token; while ( !$single_token ) { if ( $$sref =~ m/\G'/gc ) { while (1) { next if $$sref =~ m/\G[^'\\]+/gc; next if $$sref =~ m/\G\\./gcs; next if $$sref =~ m/\G''/gc; last if $$sref =~ m/\G'/gc; return undef; # unterminated string } $value_end = pos($$sref); } elsif ( $$sref =~ m/\G[^,()'\s]+/gc ) { $value_end = pos($$sref); } elsif ( $$sref =~ m/\G\s+(?=[^,()\s])/gc ) { # whitespace inside a value (e.g. `_binary 'x'`) } else { last; } } return undef unless defined $value_end; # empty value push @spans, [ $value_start, $value_end - $value_start ] if $replace_idx->{$col}; $col++; $$sref =~ m/\G\s*/gc; next if $$sref =~ m/\G,/gc; last if $$sref =~ m/\G\)/gc; return undef; # nested paren / junk } return undef unless $col == $ncols; next if $$sref =~ m/\G\s*,/gc; last if $$sref =~ m/\G\s*\z/gc; return undef; # trailing clause } return \@spans; } sub _unquote_ident ($quoted) { ( my $name = $quoted ) =~ s/``/`/g; return $name; } =head2 assert_line_in_database_scope( $line, \%allowed_db ) Dies (fail-closed) when a line is a C<USE> or C<CREATE DATABASE> statement naming a database outside the authorized set. mysqldump's C<--databases> streams contain exactly one of each, naming the dumped database. =cut sub assert_line_in_database_scope ( $line, $allowed_db ) { my $named; # mysqldump/mariadb-dump always backtick-quote the database identifier in # USE and CREATE DATABASE (never a bare, unquoted name), and a backtick # can never appear inside a legal cPanel database name (see # Cpanel::Validate::DB::Name), so matching everything between a pair of # backticks is both sufficient and correct for the full range of legal # names (hyphens, dots, spaces, etc. included) -- not just [A-Za-z0-9_]. if ( $line =~ m/\AUSE\s+`([^`]+)`\s*;?/i ) { $named = $1; } elsif ( $line =~ m{\ACREATE\s+DATABASE\s+(?:/\*!\d+\s+IF\s+NOT\s+EXISTS\s*\*/\s*|IF\s+NOT\s+EXISTS\s+)?`([^`]+)`}i ) { $named = $1; } return unless defined $named; return if $allowed_db->{$named}; die "SQL stream references database '$named', which is outside the authorized restore scope (fail-closed)\n"; } =head2 _is_header_pragma_statement( $stmt ) True when C<$stmt> is nothing but one of mysqldump/mariadb-dump's own session-setting header/footer lines -- a bare C<SET> wrapped entirely in a single MySQL/MariaDB versioned conditional comment, e.g. C<< /*!40101 SET NAMES utf8mb4 */ >> or C<< /*!40103 SET TIME_ZONE=... */ >>. Every mysqldump-family stream opens and closes with a run of these; none of them restores any database content, so a dump that executed only these (CPANEL-56040: an empty, comment-only, or header-truncated stream) must not be reported as a successful restore even though each C<SET> itself succeeded. =cut sub _is_header_pragma_statement ($stmt) { # The stream's very first pragma is often glued, in the same statement, # to the whole-line `-- ...` comment banner mysqldump prints ahead of # it (those lines never end in the statement delimiter, so the splitter # in import_sql_stream folds them into the next statement). Strip that # leading comment banner before checking for the pragma shape. return $stmt =~ m{\A(?:\s*--[^\n]*\n)*\s*/\*M?!\d{4,6}\s+SET\b.*\*/\s*\z}is ? 1 : 0; } sub _ends_with ( $line, $delim ) { return length($line) >= length($delim) && substr( $line, -length($delim) ) eq $delim; } # The target must be an existing cPanel account; its name is embedded in # quoted DEFINER identifiers. Validation + existence check, both fail-closed. sub _is_valid_target_user ($newuser) { return 0 unless defined $newuser && $newuser =~ m/\A[a-z0-9][a-z0-9_.-]{0,31}\z/; require Cpanel::Config::HasCpUserFile; return Cpanel::Config::HasCpUserFile::has_cpuser_file($newuser) ? 1 : 0; } # The backup side (Whostmgr::CometBackup::NativeMysql::compute_mysql_include_list) # captures every database Cpanel::DB::Map::Reader reports the account owns -- # i.e. every name that was ever legal enough for cPanel to create, which is # whatever Cpanel::Validate::DB::Name::verify_mysql_database_name_format # accepts (hyphens, dots, spaces, etc. -- far broader than [A-Za-z0-9_]). # Reusing that same core format check (rather than a stricter homegrown # regex) is what keeps every backed-up database restorable; reserved-name # checking is deliberately skipped here since we are restoring, not minting, # a database. sub _is_valid_backed_up_db_name ($db) { return 0 unless defined $db && length $db; require Cpanel::Validate::DB::Name; return eval { Cpanel::Validate::DB::Name::verify_mysql_database_name_format($db); 1 } ? 1 : 0; } # Managed cPanel MySQL admin connection — the same connector the plugin uses # to provision the backup user and re-apply grants. Isolated for mocking. # # mysql_multi_statements is DISABLED for the import handle: the default cPanel # admin handle enables it, which would let a crafted line pack a second # statement after an allowed leading USE ("USE `ok`; DROP ...") past the # per-line scope check. With it off, the server rejects any such line, so each # do() below executes exactly one statement. sub _admin_dbh { require Cpanel::MysqlUtils::Connect; return Cpanel::MysqlUtils::Connect::get_dbi_handle( extra_args => { mysql_multi_statements => 0 } ); } 1;