#!/usr/bin/perl -w # # mythconverg_backup.pl # # Creates a backup of the MythTV database. # # For details, see: # mythconverg_backup.pl --help # Includes use Getopt::Long; use File::Temp qw/ tempfile /; # Script info $NAME = 'MythTV Database Backup Script'; $VERSION = '1.0.14'; # Some variables we'll use here our ($username, $homedir, $mythconfdir, $database_information_file); our ($mysqldump, $compress, $rotate, $rotateglob, $backup_xmltvids); our ($usage, $debug, $show_version, $show_version_script, $dbh); our ($d_db_name, $d_mysqldump, $d_compress, $d_rotate, $d_rotateglob); # This script does not accept a database password on the command-line. # Any packager who enables the functionality should modify the --help output. # our ($db_password); our ($db_hostname, $db_port, $db_username, $db_name, $db_schema_version); our ($backup_directory, $backup_filename); our ($verbose_level_always, $verbose_level_debug, $verbose_level_error); our %mysql_conf = ('db_host' => '', 'db_port' => -1, 'db_user' => '', 'db_pass' => '', 'db_name' => '', 'db_schemaver' => '' ); our %backup_conf = ('directory' => '', 'filename' => '' ); # Variables used to untaint data our $is_env_tainted = 1; our $old_env_path = $ENV{"PATH"}; our @d_allowed_paths = ("/bin", "/usr/bin", "/usr/local/bin", "/sbin", "/usr/sbin", "/usr/local/sbin" ); our @allowed_paths; # Debug levels $verbose_level_always = 0; $verbose_level_debug = 1; $verbose_level_error = 255; # Defaults $d_db_name = 'mythconverg'; $d_mysqldump = 'mysqldump'; $d_compress = 'gzip'; $d_rotate = 5; $d_rotateglob = $d_db_name.'-????-??????????????.sql*'; # Provide default values for GetOptions $mysqldump = $d_mysqldump; $compress = $d_compress; $rotate = $d_rotate; $rotateglob = $d_rotateglob; $debug = 0; # Load the cli options GetOptions('hostname|DBHostName=s' => \$db_hostname, 'port|DBPort=i' => \$db_port, 'username|DBUserName=s' => \$db_username, # This script does not accept a database password on the command-line. # 'password|DBPassword=s' => \$db_password, 'name|DBName=s' => \$db_name, 'schemaver|DBSchemaVer=s' => \$db_schema_version, 'directory|DBBackupDirectory=s' => \$backup_directory, 'filename|DBBackupFilename=s' => \$backup_filename, 'mysqldump=s' => \$mysqldump, 'compress=s' => \$compress, 'rotate=i' => \$rotate, 'rotateglob|glob=s' => \$rotateglob, 'backup_xmltvids|backup-xmltvids|'. 'xmltvids' => \$backup_xmltvids, 'usage|help|h+' => \$usage, 'version' => \$show_version, 'script_version|script-version|v' => \$show_version_script, 'verbose|debug|d+' => \$debug ); # Print version information sub print_version_information { my $script_name = substr $0, rindex($0, '/') + 1; print "$NAME\n$script_name\nversion: $VERSION\n"; } if ($show_version_script) { print "$NAME,$VERSION,,\n"; exit; } elsif ($show_version) { print_version_information; exit; } # Print usage if ($usage) { print_version_information; print <<EOF; Usage: $0 [options|database_information_file] Creates a backup of the MythTV database. QUICK START: Create a file ~/.mythtv/backuprc with a single line, "DBBackupDirectory=/home/mythtv" (no quotes), and run this script to create a database backup. Use the --verbose argument to see what is happening. # echo "DBBackupDirectory=/home/mythtv" > ~/.mythtv/backuprc # $0 --verbose Make sure you keep the backuprc file for next time. Once you have successfully created a backup, the script may be run without the --verbose argument. To backup xmltvids: Ensure you have a ~/.mythtv/backuprc file, as described above, and execute this script with the --backup_xmltvids argument. # $0 --backup_xmltvids EOF if ($usage > 1) { print <<EOF; DETAILED DESCRIPTION: This script can be called by MythTV for creating automatic database backups. In this mode, it is always exected with a single command-line argument specifying the name of a "database information file" (see DATABASE INFORMATION FILE, below), which contains sufficient information about the database and the backup to allow the script to create a backup without needing any additional configuration files. In this mode, all other MythTV configuration files (including config.xml, mysql.txt) are ignored, but the backup resource file (see RESOURCE FILE, below) and the MySQL option files (i.e. /etc/my.cnf or ~/.my.cnf) will be honored. The script can also be called interactively (i.e. "manually") by the user to create a database backup on demand. Required information may be passed into the script using command-line arguments or with a database information file. If a database information file is specified, all command-line arguments will be ignored. If no database information file is specified, the script will attempt to determine the appropriate configuration by using the MythTV configuration file(s) (preferring config.xml, but falling back to mysql.txt if no config.xml exists). Once the MythTV configuration file has been parsed, the backup resource file (see RESOURCE FILE, below) will be parsed, then command-line arguments will be applied (thus overriding any values determined from the configuration files). The only information required by the script is the directory in which the backup should be created. Therefore, when using a database information file, the DBBackupDirectory should be specified, or if running manually, the --directory command-line argument should be specified. The DBBackupDirectory may be specified in a backup resource file (see RESOURCE FILE, below). Doing so is especially useful for manual backups. If the specified directory is not writable, the script will terminate. Likewise, if a file whose name matches the name to be used for the backup file already exists, the script will terminate. If the database name is not specified, the script will attempt to use the MythTV default database name, $d_db_name. Note that the same is not true for the database username and database password. These must be explicitly specified. The password must be specified in a database information file, a backup resource file, or a MySQL options file. The username may be specified the same way or may be specified using a command-line argument if not using a database information file. While this script may be called while MythTV is running, there is a possibility of creating a backup with data integrity errors (i.e. if MythTV updates data in multiple tables between the time the script backs up the first and subsequent tables). Also, depending on your system configuration, performing a backup (which may result in locking a table while it is being backed up) while recording may cause corruption of the recording or inability to properly write recording data (such as the recording seek table) to the database. Therefore, if configuring this script to run in a cron job, try to ensure it runs at a time when recordings are least likely to occur. Alternatively, by choosing to run the script in a system start/shutdown script (i.e. an init script), you may call the script before starting mythbackend or after stopping mythbackend. Note, however, that checking whether to perform the backup is the responsibility of the init script (not this script)--i.e. in a system with multiple frontends/backends, the init script should ensure the backup is created only on the master backend. DATABASE INFORMATION FILE The database information file contains information about the database and the backup. The information within the file is specified as name=value pairs using the same names as used by the MythTV config.xml and mysql.txt configuration files. The following variables are recognized: DBHostName - The hostname (or IP address) which should be used to find the MySQL server. DBPort - The TCP/IP port number to use for the connection. This may have a value of 0, i.e. if the hostname is localhost or if the server is using the default MySQL port or the port specified in a MySQL options file. DBUserName - The database username to use when connecting to the server. DBPassword - The password to use when connecting to the server. DBName - The name of the database that contains the MythTV data. DBSchemaVer - The MythTV schema version of the database. This value will be used to create the backup filename, but only if the filename has not been specified using DBBackupFilename or the --filename argument. DBBackupDirectory - The directory in which the backup file should be created. This directory may have been specially configured by the user as the "DB Backups" storage group. It is recommended that this directory be used--especially in "common-use" scripts such as those provided by distributions. DBBackupFilename - The name of the file in which the backup should be created. Additional extensions may be added by this script as required (i.e. adding an appropriate suffix, such as ".gz", to the file if it is compressed). If the filename recommended by mythbackend is used, it will be displayed in the GUI messages provided for the user. If the recommended filename is not used, the user will not be told where to find the backup file. If no value is provided, a filename using the default filename format will be chosen. mysqldump - The path (including filename) of the mysqldump executable. compress - The command (including path, if necessary) to use to compress the backup. Using gzip is significantly less resource intensive on an SQL backup file than using bzip2, at the cost of a slightly (about 33%) larger compressed filesize, a difference which should be irrelevant at the filesizes involved (especially when compared to the size of recording files). If you decide to use another compression algorithm, please ensure you test it appropriately to verify it does not negatively affect operation of your system. If no value is specified for compress or if the value '$d_compress' is specified, the script will first attempt to use the IO::Compress::Gzip module to compress the backup file, but, if not available, will run the command specified. Therefore, if IO::Compress::Gzip is installed and functional, specifying a value for compress is unnecessary. If neither approach works, the backup file will be left uncompressed. rotate - The number of backups to keep when rotating. To disable rotation, specify -1. Backup rotation is performed by identifying all files in DBBackupDirectory whose names match the glob specified by rotateglob. It is critical that the chosen backup filenames can be sorted properly using an alphabetical sort. If using the default filename format--which contains the DBSchemaVer--and you downgrade MythTV and restore a backup from an older DBSchemaVer, make sure you move the backups from the newer DBSchemaVer out of the DBBackupDirectory or they may cause your new backups to be deleted. rotateglob - The sh-like glob used to identify files within DBBackupDirectory to be considered for rotation. Be very careful with the value--especially if using a DBBackupDirectory that contains any files other than backups. RESOURCE FILE The backup resource file specifies values using the same format as described for the database information file, above, but is intended as a "permanent," user-created configuration file. The database information file is intended as a "single-use" configuration file, often created automatically (i.e. by a program, such as mythbackend, or a script). The backup resource file should be placed at "~/.mythtv/backuprc" and given appropriate permissions. To be usable by the script, it must be readable. However, it should be protected as required--i.e. if the DBPassword is specified, it should be made readable only by the owner. When specifying a database information file, the resource file is parsed before the database information file to prevent the resource file from overriding the information in the database information file. When no database information file is specified, the resource file is parsed after the MythTV configuration files, but before the command-line arguments to allow the resource file to override values in the configuration files and to allow command-line arguments to override resource file defaults. options: --hostname [database hostname] The hostname (or IP address) which should be used to find the MySQL server. See DBHostName, above. --port [database port] The TCP/IP port number to use for connection to the MySQL server. See DBPort, above. --username [database username] The MySQL username to use for connection to the MySQL server. See DBUserName, above. --name [database name] The name of the database containing the MythTV data. See DBName, above. Default: $d_db_name --schemaver [MythTV database schema version] The MythTV schema version. See DBSchemaVer, above. --directory [directory] The directory in which the backup file should be stored. See DBBackupDirectory, above. --filename [database backup filename] The name to use for the database backup file. If not provided, a filename using a default format will be chosen. See DBBackupFilename, above. --mysqldump [path] The path (including filename) of the mysqldump executable. See mysqldump in the DATABASE INFORMATION FILE description, above. Default: $d_mysqldump --compress [path] The command (including path, if necessary) to use to compress the backup. See compress in the DATABASE INFORMATION FILE description, above. Default: $d_compress --rotate [number] The number of backups to keep when rotating. To disable rotation, specify -1. See rotate in the DATABASE INFORMATION FILE description, above. Default: $d_rotate --rotateglob [glob] The sh-like glob used to identify files within DBBackupDirectory to be considered for rotation. See rotateglob in the DATABASE INFORMATION FILE description, above. Default: $d_rotateglob --backup_xmltvids Rather than creating a backup of the entire database, create a backup of xmltvids. This is useful when doing a full channel scan. The resulting backup is a series of SQL UPDATE statements that can be executed to set the xmltvid for channels whose callsign is the same before and after the scan. Note that the backup file will contain comments with additional channel information, which you can use to identify channels in case the callsign changes. --help Show this help text. --version Show version information. --verbose Show what is happening. --script_version | -v Show script version information. This is primarily useful for scripts or programs needing to parse the version information. EOF } else { print "For detailed help:\n\n# $0 --help --help\n\n"; } exit; } sub verbose { my $level = shift; my $error = 0; if ($level == $verbose_level_error) { $error = 1; } else { return unless ($debug >= $level); } print { $error ? STDERR : STDOUT } join("\n", @_), "\n"; } sub print_configuration { verbose($verbose_level_debug, '', 'Database Information:', " DBHostName: $mysql_conf{'db_host'}", " DBPort: $mysql_conf{'db_port'}", " DBUserName: $mysql_conf{'db_user'}", ' DBPassword: ' . ( $mysql_conf{'db_pass'} ? 'XXX' : '' ), # "$mysql_conf{'db_pass'}", " DBName: $mysql_conf{'db_name'}", " DBSchemaVer: $mysql_conf{'db_schemaver'}", " DBBackupDirectory: $backup_conf{'directory'}", " DBBackupFilename: $backup_conf{'filename'}"); verbose($verbose_level_debug, '', 'Executables:', " mysqldump: $mysqldump", " compress: $compress"); } sub configure_environment { verbose($verbose_level_debug, '', 'Configuring environment:'); # Get the user's login and home directory, so we can look for config files ($username, $homedir) = (getpwuid $>)[0,7]; $username = $ENV{'USER'} if ($ENV{'USER'}); $homedir = $ENV{'HOME'} if ($ENV{'HOME'}); if ($username && !$homedir) { $homedir = "/home/$username"; if (!-e $homedir && -e "/Users/$username") { $homedir = "/Users/$username"; } } verbose($verbose_level_debug, " - username: $username", " - HOME: $homedir"); # Find the config directory $mythconfdir = $ENV{'MYTHCONFDIR'} ? $ENV{'MYTHCONFDIR'} : "$homedir/.mythtv" ; verbose($verbose_level_debug, " - MYTHCONFDIR: $mythconfdir"); } # Though much of the configuration file parsing could be done by the MythTV # Perl bindings, using them to retrieve database information is not appropriate # for a backup script. The Perl bindings require the backend to be running and # use UPnP for autodiscovery. Also, parsing the files "locally" allows # supporting even the old MythTV database configuration file, mysql.txt. sub parse_database_information { my $file = shift; verbose($verbose_level_debug, " - checking: $file"); return 0 unless ($file && -e $file); verbose($verbose_level_debug, " parsing: $file"); open(CONF, $file) or die("\nERROR: Unable to read $file: $!". ', stopped'); while (my $line = <CONF>) { # Cleanup next if ($line =~ m/^\s*#/); $line =~ s/^str //; chomp($line); $line =~ s/^\s+//; $line =~ s/\s+$//; # Split off the var=val pairs my ($var, $val) = split(/ *[\=\: ] */, $line, 2); # Also look for <var>val</var> from config.xml if ($line =~ m/<(\w+)>(.+)<\/(\w+)>$/ && $1 eq $3) { $var = $1; $val = $2; } next unless ($var && $var =~ m/\w/); if (($var eq 'Host') || ($var eq 'DBHostName')) { $mysql_conf{'db_host'} = $val; } elsif (($var eq 'Port') || ($var eq 'DBPort')) { $mysql_conf{'db_port'} = $val; } elsif (($var eq 'UserName') || ($var eq 'DBUserName')) { $mysql_conf{'db_user'} = $val; } elsif (($var eq 'Password') || ($var eq 'DBPassword')) { $mysql_conf{'db_pass'} = $val; $mysql_conf{'db_pass'} =~ s/&/&/sg; $mysql_conf{'db_pass'} =~ s/>/>/sg; $mysql_conf{'db_pass'} =~ s/</</sg; } elsif (($var eq 'DatabaseName') || ($var eq 'DBName')) { $mysql_conf{'db_name'} = $val; } elsif ($var eq 'DBSchemaVer') { $mysql_conf{'db_schemaver'} = $val; } elsif ($var eq 'DBBackupDirectory') { $backup_conf{'directory'} = $val; } elsif ($var eq 'DBBackupFilename') { $backup_conf{'filename'} = $val; } elsif ($var eq 'mysqldump') { $mysqldump = $val; } elsif ($var eq 'compress') { $compress = $val; } elsif ($var eq 'rotate') { $rotate = $val; } elsif ($var eq 'rotateglob') { $rotateglob = $val; } } close CONF; return 1; } sub read_mysql_txt { # Read the "legacy" mysql.txt file in use by MythTV. It could be in a # couple places, so try the usual suspects in the same order that mythtv # does in libs/libmyth/mythcontext.cpp my $found = 0; my $result = 0; my @mysql = ('/usr/local/share/mythtv/mysql.txt', '/usr/share/mythtv/mysql.txt', '/usr/local/etc/mythtv/mysql.txt', '/etc/mythtv/mysql.txt', $homedir ? "$homedir/.mythtv/mysql.txt" : '', 'mysql.txt', $mythconfdir ? "$mythconfdir/mysql.txt" : '', ); foreach my $file (@mysql) { $found = parse_database_information($file); $result = $result + $found; } return $result; } sub read_resource_file { parse_database_information("$mythconfdir/backuprc"); } sub apply_arguments { verbose($verbose_level_debug, '', 'Applying command-line arguments.'); if ($db_hostname) { $mysql_conf{'db_host'} = $db_hostname; } if ($db_port) { $mysql_conf{'db_port'} = $db_port; } if ($db_username) { $mysql_conf{'db_user'} = $db_username; } # This script does not accept a database password on the command-line. # if ($db_password) # { # $mysql_conf{'db_pass'} = $db_password; # } if ($db_name) { $mysql_conf{'db_name'} = $db_name; } if ($db_schema_version) { $mysql_conf{'db_schemaver'} = $db_schema_version; } if ($backup_directory) { $backup_conf{'directory'} = $backup_directory; } if ($backup_filename) { $backup_conf{'filename'} = $backup_filename; } } sub read_config { my $result = 0; # If specified, use only the database information file if ($database_information_file) { verbose($verbose_level_debug, '', 'Database Information File specified. Ignoring all'. ' command-line arguments'); verbose($verbose_level_debug, '', 'Database Information File: '. $database_information_file); unless (-T "$database_information_file") { verbose($verbose_level_always, '', 'The argument you supplied for the database'. ' information file is invalid.', 'If you were trying to specify a backup filename,'. ' please use the --directory ', 'and --filename arguments.'); die("\nERROR: Invalid database information file, stopped"); } # When using a database information file, parse the resource file first # so it cannot override database information file settings read_resource_file; $result = parse_database_information($database_information_file); return $result; } # No database information file, so try the MythTV configuration files. verbose($verbose_level_debug, '', 'Parsing configuration files:'); # Prefer the config.xml file my $file = $mythconfdir ? "$mythconfdir/config.xml" : ''; $result = parse_database_information($file); if (!$result) { # Use the "legacy" mysql.txt file as a fallback $result = read_mysql_txt; } # Read the resource file next to override the config file information, but # to allow command-line arguments to override resource file "defaults" read_resource_file; # Apply the command-line arguments to override the information provided # by the config file(s). apply_arguments; return $result; } sub check_database_libs { # Try to load the DBI library if available (but don't require it) BEGIN { our $has_dbi = 1; eval 'use DBI;'; if ($@) { $has_dbi = 0; } } verbose($verbose_level_debug, '', 'DBI is not installed.') if (!$has_dbi); # Try to load the DBD::mysql library if available (but don't # require it) BEGIN { our $has_dbd = 1; eval 'use DBD::mysql;'; if ($@) { $has_dbd = 0; } } verbose($verbose_level_debug, '', 'DBD::mysql is not installed.') if (!$has_dbd); return ($has_dbi + $has_dbd); } sub check_database { if (!defined($dbh)) { my $have_database_libs = check_database_libs; return 0 if ($have_database_libs < 2); my $temp_host = $mysql_conf{'db_host'}; if ($temp_host =~ /:/) { if ($temp_host =~ /^(?!\[).*(?!\])$/) { $temp_host = "[$temp_host]"; } } $dbh = DBI->connect("dbi:mysql:". "host=$temp_host:". "port=$mysql_conf{'db_port'}:". "database=$mysql_conf{'db_name'}", "$mysql_conf{'db_user'}", "$mysql_conf{'db_pass'}", { PrintError => 1 }); } return 1; } sub create_backup_filename { # Create a default backup filename $backup_conf{'filename'} = $mysql_conf{'db_name'}; if (!$backup_conf{'filename'}) { $backup_conf{'filename'} = $d_db_name; } if ((!$mysql_conf{'db_schemaver'}) && ($mysql_conf{'db_host'}) && ($mysql_conf{'db_name'}) && ($mysql_conf{'db_user'}) && ($mysql_conf{'db_pass'})) { # If DBI is available, query the DB for the schema version if (check_database) { verbose($verbose_level_debug, '', 'No DBSchemaVer specified, querying database.'); my $query = 'SELECT data FROM settings WHERE value = ?'; if (defined($dbh)) { my $sth = $dbh->prepare($query); if ($sth->execute('DBSchemaVer')) { while (my @data = $sth->fetchrow_array) { $mysql_conf{'db_schemaver'} = $data[0]; verbose($verbose_level_debug, "Found DBSchemaVer:". " $mysql_conf{'db_schemaver'}."); } } else { verbose($verbose_level_debug, "Unable to retrieve DBSchemaVer from". " database. Filename will not contain ", "DBSchemaVer."); } } } else { verbose($verbose_level_debug, '', 'No DBSchemaVer specified.', 'DBI and/or DBD:mysql is not available. Unable'. ' to query database to determine ', 'DBSchemaVer. DBSchemaVer will not be included'. ' in backup filename.', 'Please ensure DBI and DBD::mysql are'. ' installed.'); } } if ($mysql_conf{'db_schemaver'}) { $backup_conf{'filename'} .= '-'.$mysql_conf{'db_schemaver'}; } # Format the time using localtime data so we don't have to bring in # another dependency. my @timeData = localtime(time); $backup_conf{'filename'} .= sprintf('-%04d%02d%02d%02d%02d%02d.sql', ($timeData[5] + 1900), ($timeData[4] + 1), $timeData[3], $timeData[2], $timeData[1], $timeData[0]); } sub check_backup_directory { my $result = 0; if ($backup_conf{'directory'}) { $result = 1; } elsif (check_database) # If DBI is available, query the DB for the backup directory { verbose($verbose_level_debug, '', 'No DBBackupDirectory specified, querying database.'); my $query = 'SELECT dirname, hostname FROM storagegroup '. ' WHERE groupname = ?'; if (defined($dbh)) { my $directory; my $sth = $dbh->prepare($query); if ($sth->execute('DB Backups')) { # We don't know the hostname associated with this host, and # since it's not worth parsing the mysql.txt/config.xml # LocalHostName (unique identifier), with fallback to the # system hostname, and handling issues along the way, just look # for any available DB Backups directory and, if none are # usable, look for a Default group directory while (my @data = $sth->fetchrow_array) { $directory = $data[0]; if (-d $directory && -w $directory) { $backup_conf{'directory'} = $directory; verbose($verbose_level_debug, "Found DB Backups directory:". " $backup_conf{'directory'}."); $result = 1; $sth->finish; last; } } } if ($result == 0 && $sth->execute('Default')) { while (my @data = $sth->fetchrow_array) { $directory = $data[0]; if (-d $directory && -w $directory) { $backup_conf{'directory'} = $directory; verbose($verbose_level_debug, "Found Default directory:". " $backup_conf{'directory'}."); $result = 1; $sth->finish; last; } } } } if ($result == 0) { verbose($verbose_level_debug, "Unable to retrieve DBBackupDirectory from". " database."); } } return $result; } sub check_config { verbose($verbose_level_debug, '', 'Checking configuration.'); # Check directory/filename if (!check_backup_directory) { print_configuration; die("\nERROR: DBBackupDirectory not specified, stopped"); } if ((!-d $backup_conf{'directory'}) || (!-w $backup_conf{'directory'})) { print_configuration; verbose($verbose_level_error, '', 'ERROR: DBBackupDirectory is not a directory or is '. 'not writable. Please specify', ' a directory in your database information file'. ' using DBBackupDirectory.', ' If not using a database information file,'. ' please specify the ', ' --directory command-line option.'); die("\nInvalid backup directory, stopped"); } if (!$backup_conf{'filename'}) { if ($backup_xmltvids) { my $file = 'mythtv_xmltvid_backup'; # Format the time using localtime data so we don't have to bring in # another dependency. my @timeData = localtime(time); $file .= sprintf('-%04d%02d%02d%02d%02d%02d.sql', ($timeData[5] + 1900), ($timeData[4] + 1), $timeData[3], $timeData[2], $timeData[1], $timeData[0]); $backup_conf{'filename'} = $file; } else { create_backup_filename; } } if ( -e "$backup_conf{'directory'}/$backup_conf{'filename'}") { verbose($verbose_level_error, '', 'ERROR: The specified file already exists.'); die("\nInvalid backup filename, stopped"); } if (!$mysql_conf{'db_name'}) { verbose($verbose_level_debug, '', "WARNING: DBName not specified. Using $d_db_name"); $mysql_conf{'db_name'} = $d_db_name; } # Though the script will attempt a backup even if no other database # information is provided (i.e. using "defaults" from the MySQL options # file, warning the user that some "normally-necessary" information is not # provided may be nice. return if (!$debug); if (!$mysql_conf{'db_host'}) { verbose($verbose_level_debug, '', 'WARNING: DBHostName not specified.', ' Assuming it is specified in the MySQL'. ' options file.'); } if (!$mysql_conf{'db_user'}) { verbose($verbose_level_debug, '', 'WARNING: DBUserName not specified.', ' Assuming it is specified in the MySQL'. ' options file.'); } if (!$mysql_conf{'db_pass'}) { verbose($verbose_level_debug, '', 'WARNING: DBPassword not specified.', ' Assuming it is specified in the MySQL'. ' options file.'); } } sub create_defaults_extra_file { return '' if (!$mysql_conf{'db_pass'}); verbose($verbose_level_debug, '', "Attempting to use supplied password for $mysqldump.", 'Any [client] or [mysqldump] password specified in the MySQL'. ' options file will', 'take precedence.'); # Let tempfile handle unlinking on exit so we don't have to verify that the # file with $filename is the file we created my ($fh, $filename) = tempfile(UNLINK => 1); # Quote the password if it contains # or whitespace or quotes. # Quoting of values in MySQL options files is only supported on MySQL # 4.0.16 and above, so only quote if required. my $quote = ''; my $safe_password = $mysql_conf{'db_pass'}; if ($safe_password =~ /[#'"\s]/) { $quote = "'"; $safe_password =~ s/'/\\'/g; } print $fh "[client]\npassword=${quote}${safe_password}${quote}\n". "[mysqldump]\npassword=${quote}${safe_password}${quote}\n"; return $filename; } sub do_xmltvid_backup { my $exit = 1; if (check_database) { my ($chanid, $channum, $callsign, $name, $xmltvid); my $query = " SELECT chanid, channum, callsign, name, xmltvid". " FROM channel ". "ORDER BY CAST(channum AS SIGNED),". " CAST(SUBSTRING(channum". " FROM (1 +". " LOCATE('_', channum) +". " LOCATE('-', channum) +". " LOCATE('#', channum) +". " LOCATE('.', channum)))". " AS SIGNED)"; my $sth = $dbh->prepare($query); verbose($verbose_level_debug, '', 'Querying database for xmltvid information.'); my $file = "$backup_conf{'directory'}/$backup_conf{'filename'}"; open BACKUP, '>', $file or die("\nERROR: Unable to open". " $file: $!, stopped"); for ($section = 0; $section < 2; $section++) { if ($sth->execute) { while (my @data = $sth->fetchrow_array) { $chanid = $data[0]; $channum = $data[1]; $callsign = $data[2]; $name = $data[3]; $xmltvid = $data[4]; verbose($verbose_level_debug, "Found channel: $chanid, $channum, $callsign,". " $name, $xmltvid.") if ($section == 0); if ($xmltvid && $callsign) { if ($section == 0) { print BACKUP "-- Start Channel Data\n". "-- ID: '$chanid'\n". "-- Number: '$channum'\n". "-- Callsign: '$callsign'\n". "-- Name: '$name'\n". "-- XMLTVID: '$xmltvid'\n". "-- End Channel Data\n"; print BACKUP "UPDATE channel". " SET xmltvid = '$xmltvid'". " WHERE callsign = '$callsign'". ";\n"; } else { print BACKUP "UPDATE channel". " SET xmltvid = '$xmltvid'". " WHERE channum = '$channum'". " AND name = '$name';\n"; } } } if ($section == 0) { verbose($verbose_level_debug, '', 'Successfully backed up xmltvid'. ' information.'. '', '', 'Creating alternate format backup.'); print BACKUP "\n\n/* Alternate format */\n". "/*\n"; } else { print BACKUP "*/\n"; verbose($verbose_level_debug, 'Successfully created alternate format'. ' backup.'); } $exit = 0; } else { verbose($verbose_level_error, '', 'ERROR: Unable to retrieve xmltvid information'. ' from database.'); die("\nError retrieving xmltvid information, stopped"); } } close BACKUP; } else { verbose($verbose_level_error, '', 'ERROR: Unable to backup xmltvids without Perl'. ' database libraries.', ' Please ensure the Perl DBI and DBD::mysql'. ' modules are installed.'); die("\nPerl database libraries missing, stopped"); } return $exit; } # This subroutine performs limited checking of a command and untaints the # command (and the environment) if the command seems to use an absolute path # containing no . or .. references or if it's a simple command name referencing # an executable in a "normal" directory for binaries. It should only be called # after careful consideration of the effects of doing so and of whether it # makes sense to override taint-mode runtime checking of the value. sub untaint_command { my $command = shift; my $allow_untaint = 0; # Only allow directories from @d_allowed_paths that exist in the PATH unless (@allowed_paths) { foreach my $path (split(/:/, $old_env_path)) { if (grep(/^$path$/, @d_allowed_paths)) { push(@allowed_paths, $path); } } verbose($verbose_level_debug + 2, '', 'Allowing paths:', @allowed_paths, 'From PATH: '.$old_env_path); } verbose($verbose_level_debug + 2, '', 'Verifying command: '.$command); if ($command =~ /^\//) { verbose($verbose_level_debug + 2, ' - Command starts with /.'); if (! ($command =~ /\/\.+\//)) { verbose($verbose_level_debug + 2, ' - Command does not contain dir refs.'); if (-e "$command" && -f "$command" && -x "$command") { # Seems to be a valid executable specified with a path starting # with / and having no current/previous directory references verbose($verbose_level_debug + 2, 'Unmodified command meets untaint requirements.', $command); $allow_untaint = 1; } } } else { foreach my $path (@allowed_paths) { if (-e "$path/$command" && -f "$path/$command" && -x "$path/$command") { # Seems to be a valid executable in a "normal" directory for # binaries $command = "$path/$command"; verbose($verbose_level_debug + 2, 'Command seems to be a simple command in a'. ' normal directory for binaries: '.$command); $allow_untaint = 1; } } } if ($allow_untaint) { if ($command =~ /^(.*)$/) { verbose($verbose_level_debug + 1, 'Untainting command: '.$command); $command = $1; $ENV{'PATH'} = ''; $is_env_tainted = 0; } } return $command; } # This subroutine performs limited checking of file or directory paths and # untaints the path if it seems to be an absolute path to a normal file or # directory and contains no . or .. references. It should only be called after # careful consideration of the effects of doing so and of whether it makes # sense to override taint-mode runtime checking of the value. sub untaint_path { my $path = shift; verbose($verbose_level_debug + 2, '', 'Verifying path: '.$path); if ($path =~ /^\//) { verbose($verbose_level_debug + 2, ' - Path starts with /.'); if (! ($path =~ /\/\.+\//)) { verbose($verbose_level_debug + 2, ' - Path contains no dir refs.'); if (-e "$path" && (-f "$path" || -d "$path")) { # Seems to be a file or directory path starting with / and # having no current/previous directory references if ($path =~ /^(.*)$/) { verbose($verbose_level_debug + 1, 'Untainting path: '.$path); $path = $1; } } } } return $path; } # This subroutine does absolutely no data checking. It blindly accepts a # possibly-tainted value and "untaints" it. It should only be called after # careful consideration of the effects of doing so and of whether it makes # sense to override taint-mode runtime checking of the value. sub untaint_data { my $value = shift; if ($value =~ /^(.*)$/) { verbose($verbose_level_debug + 1, 'Untainting data: '.$value); $value = $1; } return $value; } sub reset_environment { if (!$is_env_tainted) { $is_env_tainted = 1; $ENV{'PATH'} = $old_env_path; } } sub do_backup { my $defaults_extra_file = create_defaults_extra_file; my $host_arg = ''; my $port_arg = ''; my $user_arg = ''; if ($defaults_extra_file) { $defaults_arg = " --defaults-extra-file='$defaults_extra_file'"; } else { $defaults_arg = ''; } # For users running in environments where taint mode is activated (i.e. # running mythtv-setup or mythbackend as root), executing a command line # built with tainted data will fail. Therefore, try to untaint data if it # meets certain basic requirements. my $safe_mysqldump = $mysqldump; $safe_mysqldump = untaint_command($safe_mysqldump); $safe_mysqldump =~ s/'/'\\''/sg; $mysql_conf{'db_name'} = untaint_data($mysql_conf{'db_name'}); $mysql_conf{'db_host'} = untaint_data($mysql_conf{'db_host'}); $mysql_conf{'db_port'} = untaint_data($mysql_conf{'db_port'}); $mysql_conf{'db_user'} = untaint_data($mysql_conf{'db_user'}); $backup_conf{'directory'} = untaint_path($backup_conf{'directory'}); # Can't use untaint_path because the filename is not a full path and the # file doesn't yet exist, anyway $backup_conf{'filename'} =~ s/'/'\\''/g; $backup_conf{'filename'} = untaint_data($backup_conf{'filename'}); my $output_file = "$backup_conf{'directory'}/$backup_conf{'filename'}"; $output_file =~ s/'/'\\''/sg; # Create the args for host, port, and user, shell-escaping values, as # necessary. my $safe_db_name = $mysql_conf{'db_name'}; $safe_db_name =~ s/'/'\\''/g; my $safe_string; if ($mysql_conf{'db_host'}) { $safe_string = $mysql_conf{'db_host'}; $safe_string =~ s/'/'\\''/g; $host_arg = " --host='$safe_string'"; } if ($mysql_conf{'db_port'} > 0) { $safe_string = $mysql_conf{'db_port'}; $safe_string =~ s/'/'\\''/g; $port_arg = " --port='$safe_string'"; } if ($mysql_conf{'db_user'}) { $safe_string = $mysql_conf{'db_user'}; $safe_string =~ s/'/'\\''/g; $user_arg = " --user='$safe_string'"; } # Use redirects to capture stderr (for debug) and send stdout (the backup) # to a file my $command = "'${safe_mysqldump}'${defaults_arg}${host_arg}". "${port_arg}${user_arg} --add-drop-table --add-locks ". "--allow-keywords --complete-insert --extended-insert ". "--lock-tables --no-create-db --quick --add-drop-table ". "'$safe_db_name' 2>&1 1>'$output_file'"; verbose($verbose_level_debug, '', 'Executing command:', $command); my $result = `$command`; my $exit = $? >> 8; verbose($verbose_level_debug, '', "$mysqldump exited with status: $exit"); verbose($verbose_level_debug, "$mysqldump output:", $result) if ($exit); reset_environment; return $exit; } sub compress_backup { if (!-e "$backup_conf{'directory'}/$backup_conf{'filename'}") { verbose($verbose_level_debug, '', 'Unable to find backup file to compress'); return 1; } my $result = 0; verbose($verbose_level_debug, '', 'Attempting to compress backup file.'); if ($d_compress eq $compress) { # Try to load the IO::Compress::Gzip library if available (but don't # require it) BEGIN { our $has_compress_gzip = 1; # Though this does nothing, it prevents an invalid "only used # once" warning that occurs for users without IO::Compress # installed. undef $GzipError; eval 'use IO::Compress::Gzip qw(gzip $GzipError);'; if ($@) { $has_compress_gzip = 0; } } if (!$has_compress_gzip) { verbose($verbose_level_debug, " - IO::Compress::Gzip is not installed."); } else { if (-e "$backup_conf{'directory'}/$backup_conf{'filename'}.gz") { verbose($verbose_level_debug, '', 'A file whose name is the backup filename'. ' with the \'.gz\' extension already', 'exists. Leaving backup uncompressed.'); return 1; } verbose($verbose_level_debug, " - Compressing backup file with IO::Compress::Gzip."); $result = gzip( "$backup_conf{'directory'}/$backup_conf{'filename'}" => "$backup_conf{'directory'}/$backup_conf{'filename'}.gz"); if ((defined($result)) && (-e "$backup_conf{'directory'}/". "$backup_conf{'filename'}.gz")) { # For users running in environments where taint mode is # activated (i.e. running mythtv-setup or mythbackend as # root), unlinking a file whose path is built with tainted data # will fail. Therefore, try to untaint the path if it meets # certain basic requirements. my $uncompressed_file = $backup_conf{'directory'}."/". $backup_conf{'filename'}; $uncompressed_file = untaint_path($uncompressed_file); $uncompressed_file =~ s/'/'\\''/sg; verbose($verbose_level_debug + 2, "Unlinking uncompressed file: $uncompressed_file"); unlink "$uncompressed_file"; $backup_conf{'filename'} = "$backup_conf{'filename'}.gz"; verbose($verbose_level_debug, '', 'Successfully compressed backup to file:', "$backup_conf{'directory'}/". "$backup_conf{'filename'}"); return 0; } verbose($verbose_level_debug, " Error: $GzipError"); } } # Try to compress the file with the compress binary. verbose($verbose_level_debug, " - Compressing backup file with $compress."); my $backup_path = "$backup_conf{'directory'}/$backup_conf{'filename'}"; # For users running in environments where taint mode is activated (i.e. # running mythtv-setup or mythbackend as root), executing a command line # built with tainted data will fail. Therefore, try to untaint data if it # meets certain basic requirements. $compress = untaint_command($compress); $compress =~ s/'/'\\''/sg; $backup_path = untaint_path($backup_path); $backup_path =~ s/'/'\\''/sg; my $command = "'$compress' '$backup_path' 2>&1"; verbose($verbose_level_debug, '', 'Executing command:', $command); my $output = `$command`; my $exit = $? >> 8; verbose($verbose_level_debug, '', "$compress exited with status: $exit"); if ($exit) { verbose($verbose_level_debug, "$compress output:", $output); } else { $backup_conf{'filename'} = "$backup_conf{'filename'}.gz"; } reset_environment; return $exit; } sub rotate_backups { if (($rotate < 1) || (!defined($rotateglob)) || (!$rotateglob)) { verbose($verbose_level_debug, '', 'Backup file rotation disabled.'); return 0; } verbose($verbose_level_debug, '', 'Rotating backups.'); verbose($verbose_level_debug, '', 'Searching for files matching pattern:', "$backup_conf{'directory'}/$rotateglob"); my @files = <$backup_conf{'directory'}/$rotateglob>; my @sorted_files = sort { lc($a) cmp lc($b) } @files; my $num_files = @sorted_files; verbose($verbose_level_debug, " - Found $num_files matching files."); $num_files = $num_files - $rotate; $num_files = 0 if ($num_files < 0); verbose($verbose_level_debug, '', "Deleting $num_files and keeping (up to) $rotate backup". ' files.'); my $index = 0; foreach my $file (@sorted_files) { if ($index++ < $num_files) { if ($file eq "$backup_conf{'directory'}/$backup_conf{'filename'}") { # This is the just-created backup. Warn the user that older # backups with newer schema versions may cause rotation to # fail. verbose($verbose_level_debug, '', 'WARNING: You seem to have reverted to an'. ' older database schema version.', 'You should move all backups from newer schema'. ' versions to another directory or', 'delete them to prevent your new backups from'. ' being deleted on rotation.', ''); verbose($verbose_level_debug, " - Keeping backup file: $file"); } else { verbose($verbose_level_debug, " - Deleting old backup file: $file"); # For users running in environments where taint mode is # activated (i.e. running mythtv-setup or mythbackend as # root), unlinking a file whose path is built with tainted data # will fail. Therefore, try to untaint the path if it meets # certain basic requirements. $file = untaint_path($file); $file =~ s/'/'\\''/sg; unlink "$file"; } } else { verbose($verbose_level_debug, " - Keeping backup file: $file"); } } return 1; } ############################################################################## # Main functionality ############################################################################## # The first argument after option parsing, if it exists, should be a database # information file. $database_information_file = shift; configure_environment; read_config; check_config; print_configuration; my $status = 1; if ($backup_xmltvids) { $status = do_xmltvid_backup; } else { $status = do_backup; if (!$status) { compress_backup; rotate_backups; } } $dbh->disconnect if (defined($dbh)); exit $status;