Daten-Backup-System unter Linux
Winfried Mueller, www.reintechnisch.de, Start: 02.01.2007, Stand: 08.01.2017
Datensicherung ist eines der wichtigsten Jobs in der Systemadministration. Gut, wenn man dafür ein automatisiertes und fehlersicheres System hat.
Die Form der Datensicherung hängt stark vom Anwendungsfall ab. Es macht einen Unterschied, ob man täglich ein paar Office-Dateien einer kleinen Firma sichern muss oder stündlich Terrabytes von Datenbanken eines großen Unternehmens.
Die hier vorgestellte Lösung ist für die Datensicherung unter Linux für kleine Unternehmen oder für den Privathaushalt gedacht. Die zu sichernden Daten werden im typischen Anwendungsfall täglich vollständig gesichert und passen auf eine CD, DVD oder USB-Stick. Möglich ist auch, auf ein anderes Laufwerk zu sichern, was fest eingebunden ist.
Um die tägliche Menge der zu sichernden Daten zu begrenzen, sollten Dateien, die sich über längere Zeit nicht mehr verändern, in Archiv-Verzeichnisse ausgelagert werden, die dann nicht mehr im täglichen Backupprozess eingebunden sind. Diese Aufteilung in Archivdaten und Bearbeitungsdaten hat sich in der Praxis bewährt. Es ist praktisch und kostengünstig, wenn die gesamten zu sichernden Daten auf eine CD, DVD oder USB-Stick passen.
Im Einsatz ist das hier vorgestellte System seit 2007. Bis 2016 auf einem Ubuntu-Linux-Server (Dapper), der seinen Job die ganzen 9 Jahre bestens erledigt hat. Ab 2016 läuft das Skript auf einem Ubuntu 16.04. Die Portierung sorgte dafür, ein paar Details zu verbessern und vor allem den usb-stick-Transfer einzubauen. Denn die bisherige Praxis, auf DVD zu sichern, wurde ersetzt durch Sicherung auf Sticks. Hauptgrund war vor allem, weil die Kapazität von DVDs nicht mehr reichte.
Das Konzept
Gesichert wird in komprimierte tar-Archive (tgz). Beim Backup gehe ich konservativ zu Werke, benutze lieber seit vielen Jahren gut funktionierende Softwarekomponenten. Greife also auf das Original Tar-Programm zurück.
Es werden sogenannte FileSets definiert, die das zu sichernde Verzeichnis aufnehmen und den Namen des Tar-Archives beinhalten.
Mehrere dieser definierten FileSets werden zu einem BackupSet zusammengefasst. Hier wird definiert, zu welchen Zielort diese Tar-Archive transferiert werden sollen, z.B. auf DVD.
Es handelt sich immer um Komplettsicherungen, das Programm kennt keine Zuwachssicherung. Es reicht zur Wiederherstellung also auch ein Backupmedium, was den Prozess vereinfacht.
Beim Aufruf der Backupsoftware gibt man das BackupSet an, welches bearbeitet werden soll. Damit ist man sehr flexibel - man kann beliebig viele Backupsets definieren, z.B. eine wöchentliche Sicherung auf DVD und eine tägliche Sicherung auf eine andere Festplatte. Oder eine tägliche normale Datensicherung und eine wöchentliche Archiv-Sicherung, in der größere Datenmengen auf eine zweite Festplatte transferiert werden.
Weiterhin gibt es ein rotierendes Backup: In einem speziellen Rotate-Verzeichnis werden Tar-Archive über z.B. 10 Tage rotiert. Damit hat man direkten Zugriff auf den Stand der letzten Tage.
Die Zeitsteuerung des Aufrufes ist nicht Teil des Backup-Systems, sondern geschieht über Cron-Jobs.
Datenschutz ist wichtig, weshalb alle Backuparchive verschlüsselt werden. Zum Einsatz kommt das Werkzeug aespipe, welches in der Standardeinstellung 128 Bit AES verschlüsselt. Die Gefahr ist einfach sehr groß, dass externe Speichermedien in falsche Hände gelangen. Weil wir dort alle Unternehmensdaten gespeichert haben, ist der Schutz besonders wichtig. Das Archiv erhält nach der Verschlüsselung noch die Endung .ap (aespipe).
Beim Brennen wird auf die bewährten Programme mkisofs, cdrecord und growisofs zurückgegriffen.
Über md5sum werden Prüfsummen gebildet, womit später die Konsistenz der Datensicherung geprüft werden kann. So kann man z.B. auch jährlich wichtige Sicherungs-Medien überprüfen. Zur Überprüfung wird auch gleich ein Skript mit auf das Ziel geschrieben.
Um sicher zu gehen, dass der Brenn/Kopiervorgang auch korrekt funktioniert hat, wird nach der Übertragung auf das Zielmedium das jeweilige Archiv auf md5sum-Konsistenz überprüft. Tritt hier ein Fehler auf, wird das im Logfile ersichtlich. Zur Überprüfung wird bei CD/DVD das Medium gemountet. Das klappt natürlich nur, wenn in der fstab der entsprechende Eintrag enthalten ist.
Die Konfiguration geschieht über eine Yaml-Datei. In einem Logfile (/var/log/lbackup.log) werden die Ergebnisse der Datensicherung festgehalten. Das Logfile sollte gelegentlich mit logrotate rotiert werden.
Ein Restore wird derzeit noch manuell durchgeführt, dies unterstützt die Software noch nicht. Da es sich bei tar und aespipe um Standardwerkzeuge handelt, ist das kein großes Problem:
aespipe -d -p3 < elmi_data.tgz.ap 3< /etc/lbackup/elmi-pass >elmi_data.tgz tar -xvzf elmi_data.tgz
Die Konfiguration
Eine typische Yaml-Konfigurationsdatei, abgelegt unter /etc/lbackup/lbackup.cfg, könnte so aussehen:
#lbackup Configuration File # # --- DebugLevel: '7' BackupPath: '/home/pchome/lose1/wm/backup' RestorePath: '/home/pchome/lose1/wm/backup/restore' LogPath: '/var/log' DefaultBackupSet: 'elmi-data-cd' Helper: aespipe: '/usr/bin/aespipe' tar: '/bin/tar' mkisofs: '/usr/bin/mkisofs' cdrecord: '/usr/bin/cdrecord' growisofs: '/usr/bin/growisofs' md5sum: '/usr/bin/md5sum' BackupSets: - Name: 'elmi-data-cd' Type: 'burncd' Dst: '/dev/hdc' Mount: '/media/cdrom0' SpeedR: '16' SpeedRW: '8' Rotate: 'yes' FileSets: - 'elmi_data' - 'elmi_var' - 'elmi_etc' - Name: 'elmi-data-dvd' Type: 'burndvd' Dst: '/dev/hdc' Mount: '/media/cdrom0' SpeedDVD: '2' Rotate: 'yes' FileSets: - 'elmi_data' - 'elmi_var' - 'elmi_etc' - Name: 'elmi-data-file' Type: 'file' Dst: '/home/backup/elmi_data' FileSets: - 'elmi_data' - 'elmi_var' - 'elmi_etc' - Name: 'elmi-data-usbstick' Type: 'usbstick' Dst: '/media/backupstick' Rotate: 'yes' FileSets: - 'elmi_data' - 'elmi_var' - 'elmi_etc' FileSets: - Name: 'elmi_data' TarFile: 'elmi_data.tgz' SrcPath: '/home' ExcludeFile: '/etc/lbackup/elmi-data-exclude.lst' Rotate: '7' PWFile: '/etc/lbackup/elmi-pass' Disable: 'no' - Name: 'elmi_var' TarFile: 'elmi_var.tgz' SrcPath: '/var' ExcludeFile: '/etc/lbackup/elmi-var-exclude.lst' Rotate: '7' PWFile: '/etc/lbackup/elmi-pass' Disable: 'no' - Name: 'elmi_etc' TarFile: 'elmi_etc.tgz' SrcPath: '/etc' ExcludeFile: '' Rotate: '7' PWFile: '/etc/lbackup/elmi-pass' Disable: 'no'
Zuerst werden die Verzeichnisse BackupPath, RestorePath und LogPath definiert. RestorePath wird derzeit noch nicht verwendet. BackupPath ist ein Arbeitsverzeichnis, in dem alle Archive erzeugt werden und wo auch Rotate-Backups abgelegt werden. Hierfür sollte man also genug Plattenplatz für vorsehen. LogPath ist typischerweise /var/log, die Logdatei wäre dann unter /var/log/lbackup.log zu finden.
Der DefaultBackupSet ist der Backupset, der benutzt wird, wenn beim Aufruf nicht explizit ein Backupset ausgewählt wird.
Bei den BackupSets sind 3 Stück definiert: Einmal ein Backupset für CD, einmal einer für DVD und ein weiterer für ein Verzeichnis auf einer Festplatte oder einem Memorystick. Alle 3 Backupsets sind vom Inhalt identisch - sie beinhalten die gleichen FileSets, also die selben Archive. Es sind elmi_data, elmi_var und elmi_etc. Der Name eines jeden BackupSets muss einzigartig sein, damit wir diesen später darüber starten können. Als Type gibt es "burncd", "burndvd" und "file".
Beim Type "burncd" gibt es die zusätzlichen Parameter SpeedR und SpeedRW, womit man die Brenngeschwindigkeit für CDR und CDRW einstellen kann.
Beim Type "burndvd" gibt es lediglich den Parameter SpeedDVD, weil technisch bedingt keine Möglichkeit bestand, den Medientyp zu ermitteln. Hier wird also unabhängig vom Medium immer mit der gleichen Geschwindigkeit gebrannt.
Der Parameter Mount wird benötigt, um nach dem brennen auf CD/DVD diese zu mounten und den Kopiervorgang zu überprüfen.
Der Parameter Rotate gibt an, ob bei diesem Backupset ein Rotatebackup angestoßen werden soll. Bei "yes" werden dann alle Archive der eingebundenen Filesets im Rotateverzeichnis rotiert und das neu erzeugte Archiv hinzugefügt.
Im BackupSet sind unter FileSets alle Filesets aufgelistet, die einbezogen werden sollen. Hier können beliebig viele Filesets gelistet sein, die natürlich definiert sein müssen.
Kommen wir zu den FileSets. Diese haben einen Namen, der ebenfalls eindeutig sein muss. Ebenso sollte unter TarFile ein eindeutiger Tar-Filename eingetragen werden, typischerweise mit Endung ".tgz". Unter SrcPath trägt man dass Quellverzeichnis ein, was gesichert werden soll. Bei jedem FileSet kann nur ein Quellverzeichnis definiert werden. Unter ExcludeFile kann eine existierende Datei eingetragen werden, in der man einzelne Dateien oder Verzeichnisse einträgt, die vom Backup ausgeschlossen werden sollen. Diese Datei wird direkt dem tar-Programm mittels --exclude-from=FILE übergeben. Jede Zeile muss mit einem Punkt beginnen, danach die absolute Verzeichnisangabe, hier ein Beispiel:
./var/spool/postfix/private/defer ./var/cache ./var/run/acpid.sock
Man kann damit sowohl ganze Verzeichnisse ausschließen, inkl. aller Unterverzeichnisse, wie auch einzelne Dateien. Es gibt jedoch keine Jokerzeichen oder reguläre Ausdrücke. Dies liegt an der Designentscheidung, die recht eingeschränkten Möglichkeiten von tar zu benutzen.
Mit dem Parameter Rotate in der Fileset-Konfiguration wird angegeben, wie groß der Rotate-Umfang ist. Würde man z.B. täglich ein Backup mit Rotate machen und den Rotateumfang eines Filesets auf 7 setzen, so würde man von der ganzen Woche Archive haben, z.B. elmi_var.tgz.ap.1, elmi_var.tgz.ap.2, ... elmi_var.tgz.ap.7. Bei jedem rotieren verschwindet das letzte Archiv, alle anderen werden eine Nummer weiter geschoben und vorne dran kommt das neu erzeugte Archiv. Wie man das auch von Logrotate her kennt. Das Rotate-Verzeichnis ist übrigens BackupPath/rotate.
Wird beim entsprechenden FileSet bei Rotate eine 0 eingetragen, wird dieses Archiv nicht rotiert.
Mit Disable='yes' kann man bestimmte Filesets vom Backup ausschließen, was sich aber nur für temporäre Testsituationen empfiehlt. Damit sollte man vorsichtig umgehen, nicht dass man vergisst, ein FileSet wieder zu aktivieren.
Der Parameter PWFile ist ein Verweis auf eine Datei, in dem sich das Backup-Passwort befindet. Da sich das Passwort dort im Klartext befindet, darf es nur für root lesbar sein. Weil das Backup über cron auch mit Root-Rechten gestartet wird, hat es dann Zugriff. Man sollte sich natürlich im Klaren darüber sein, dass jemand, der physikalischen Zugriff auf den Rechner hat, an dieses Passwort herankommen kann.
Wichtig ist bei diesem Passwort-File, dass es nur das Passwort enthält, ohne eine Zeilenschaltung oder angehängte Leerzeichen. Manche Editoren hängen automatisch eine Zeilenschaltung an. Hier sollte man mit hexdump nochmal kontrollieren, ob neben dem Passwort keinerlei Steuerzeichen mehr enthalten sind. Das Kennwort muss auch mindestens 16 Zeichen lang sein, sonst wird es von aespipe nicht verarbeitet.
Auch sollte man nach der Einrichtung mit aespipe probieren, ob man ein Archiv auch entschlüsselt bekommt, wenn man das Passwort manuell bei aespipe eintippt:
aespipe -d < backup-archiv.tgz.ap >backup-archiv.tgz tar -xvzf backup-archiv.tgz
Da aespipe nicht prüfen kann, ob ein Passwort korrekt ist, werden wir es erst erfahren, wenn wir mit tar das Archiv entpacken wollen. Hier zeigt sich, ob es sich um ein gültiges Archiv handelt.
Wenn das alles funktioniert, ist man für den Notfall gerüstet. Dann braucht es keine spezielle Backupsoftware und auch keine Passwort-Datei. Dann reichen Standardwerkzeuge, um ein Restore zu machen.
Für die Entschlüsselung funktioniert übrigens auch mein in Ruby geschriebenes rcrypto-Programm, was man unter RSAESEncryption findet. Damit ist eine Entschlüsselung sogar unter Windows möglich. Es ist immer gut, im Notfall auf unterschiedlichste Plattformen und Alternativen zurückgreifen zu können.
Das bei Verlust des Kennworts die Datensicherung wertlos ist, sollte klar sein. Ein AES-128 gilt als extrem sicher. Siehe hierzu auch Wikipedia: AES
Installation
Die Installation des Programms ist grundsätzlich recht gutmütig. Ich hab darauf geachtet, nur auf Standard-Ruby-Bibliotheken zurückzugreifen. Ganz gelungen ist mir das nicht, es braucht open4, was man sich aber mit "gem install open4" problemlos holen kann. Wie man gem installiert, sollte klar sein und das sollte ja auch auf jedem Ruby-System verfügbar sein. Die openssl-Bibliothek sollte die Distribution als Paket verfügbar haben. In Ubuntu-Dapper ist es "libopenssl-ruby1.8", bei Ubuntu 16.04 ist die Bibliothek schon mit der Installation von Ruby installiert (Paket ruby).
Eigene Bibliotheken habe ich direkt in den Quellcode eingebunden, so dass der Installationsaufwand gering bleibt.
Das Programm wird am besten unter /usr/local/bin/lbackup abgelegt und mit chmod 700 für root ausführbar gemacht. Natürlich müssen auch die Helper installiert sein (tar, aespipe, growisofs, mkisofs, cdrecord, md5sum). All dies sind Standardpakete, die in jeder modernen Distribution enthalten sein sollten. Unter Ubuntu Dapper sind sie es zumindest.
Dann gilt es, die Konfigdatei unter /etc/lbackup/lbackup.cfg abzulegen. In das Verzeichnis /etc/lbackup/ packt man am besten auch noch die Passwort-Dateien und die Exclude-Dateien.
Mit "mkdir /var/lib/lbackup" muss noch ein Verzeichnis für persistente Daten angelegt werden (JobNr).
Wird auf USB-Sticks ein Backup gemacht, muss das System über fstab wissen, wie es diesen Stick mounten kann. Um keine falschen Sticks fürs Backup einzubinden, sollte man sie mit e2label z.B. auf "backup" labeln und dann über die fstab so einbinden:
LABEL=backup /media/backupstick ext4 noauto 0 0
Zusätzlich kann auf jeden Stick eine Datei mit dem Namen "bknr" angelegt werden, wo man eine eindeutige Sticknummer in Zeile 1 hinterlegt. Diese Sticknummer wird später ins Logfile geschrieben. Somit weiß man über das Logfile, welcher konkrete Stick bei einem Backup beschrieben wurde.
Umgedreht schreibt lbackup (ab Version 2071-01-08) eine Datei mit dem Namen "meta" auf den Stick, in dem u.a. eine Job-Nr hinterlegt ist. Diese Jobnummer befindet sich auch im Logfile. Auch hierüber ist eine eindeutige Zuordnung eines Backups zu einem Logeintrag möglich.
Das Verzeichnis, was in der Konfigdatei unter BackupPath und RestorePath angegeben ist, muss existieren. BackupPath ist das Verzeichnis, wo erstmal das komplette Backup erzeugt wird, bevor es zum eigentlichen Ziel transportiert wird.
Ein Aufruf von
lbackup --help
sollte nun schonmal funktionieren.
Benutzung
Im einfachsten Fall ruft man nur lbackup ohne Parameter auf. Dann wird der DefaultBackupset ausgeführt. Am Anfang sollte man -v benutzen, um ausführliche Informationen über den Backupvorgang zu erhalten.
Hier ein Beispiel, welches den Backupset elmi-data-file startet:
lbackup -v elmi-data-file
Sollte alles funktionieren, erhält man als Rückmeldung etwa sowas:
Processing Backupset <elmi-data-file> Processing fileset <elmi_data> Generate archive... Encrypt archive... Generate md5file... Copy archive to backupset... Processing fileset <elmi_var> ... Verify backupsets md5sum... Check Dir: /home/backup/elmi_data Check md5sum elmi_data.tgz.ap.md5sum: Ok Check md5sum elmi_var.tgz.ap.md5sum: Ok ...
Klar, dass man sich, wenn alles funktioniert, Cronjobs einrichtet, wo der Start des Backups mit Benutzer root automatisch geschieht. Dann beschränkt sich der Aufwand für das Backup auf den täglichen Wechsel des Backup-Mediums und auf eine regelmäßige Prüfung des Backup-Logfiles. Die Prüfung des Logfiles kann man natürlich auch an einem Werkzeug delegieren.
Quellcode
Versionshistorie:
- 2017/01/08 - JobNr über Datei meta. Stick-Nr über Datei bknr
- 2016/02/16 - USB-Stick-Support, Ruby 2.3.0 unter Ubuntu 16.04
- 2007/03/20 - diverse Kleinigkeiten
- 2007/02/23 - Fehler bei Check md5sum beseitigt
- 2007/02/06 - Erste veröffentlichte Version
Version 2017-01-08
Getestet unter Ubuntu 16.04.
#!/usr/bin/env ruby # # This file is automatically generated. DO NOT MODIFY! # #!/usr/bin/ruby # encoding: utf-8 # # lbackup - Linux Backup System # Winfried Mueller, www.reintechnisch.de, (c) 2007, GPL # Start: 02.01.2007 # Version = "2017-01-08" # Changelog: # 08.01.2017 - meta File auf Stick (JobNr), bknr File auf Stick, Mehr Infos Logfile # 15.02.2016 - USB-Stick-Support, Ruby 2.3.0 unter Ubuntu 16.04 # 20.03.2007 - cdrecord jetzt mit -dao require "rubygems" require "yaml" require "fileutils" require "digest/md5" require "open4" require "optparse" # ========== begin include wmclasslib/dbg ========== module Dbg VERBOSE = FALSE end class AssertError < StandardError; end class RequireError < StandardError; end class EnsureError < StandardError; end # debug print helper def dp( line ) $stderr.puts "Dbg: #{line}" end def x_assert( dsc="" ) begin raise AssertError, "Assert: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def x_require( dsc="" ) begin raise RequireError, "Require: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def x_ensure( dsc="" ) begin raise EnsureError, "Ensure: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def dbg_warn begin raise rescue backtrace = $!.backtrace backtrace.shift $stderr.print "dbg: Warn => " $stderr.puts backtrace.shift if Dbg::VERBOSE backtrace.each { |l| puts " " + l } end end end def dbg_info begin raise rescue backtrace = $!.backtrace backtrace.shift $stderr.print "dbg: Info => " $stderr.puts backtrace.shift if Dbg::VERBOSE backtrace.each { |l| puts " " + l } end end end # ========== end include wmclasslib/dbg ========== $debuglevel = 7 # Begriffe: # Fileset: # Eine Anzahl von Dateien, die in einem Verzeichnis liegen # und in einem Backuparchiv zusammengefasst werden. # # Backupset: # Zusammengehörige Anzahl von Filesets, die gemeinsam zu # einem Ziel übertragen werden. # # Debug Ausgabe def dbg_out(descr, value="") if $debuglevel == 7 STDERR.puts( "DBG: #{descr}: #{value}" ) end end # Print Warn/Error to STDERR def printr( s ) STDERR.print( s ) end def file_size_human( size ) fsize = size / 1024 fsizeh = "#{fsize} KB" if fsize > 1024 fsize = fsize / 1024 fsizeh = "#{fsize} MB" end fsizeh end class ConsolePrinter def initialize( ) @indent = 0 @enabled = false end def enable() @enabled = true end def disable() @enabled = false end def print( s ) if @enabled Kernel::print " " * @indent puts s end end def indent_plus() @indent += 2 end def indent_minus() if @indent >= 2 @indent -= 2 end end def indent_reset() @indent = 0 end end class Logger def initialize( logfn ) # Logfile muss mit *.log enden x_require unless logfn =~ /\.log$/ @logfn = logfn end # Ausgabe ins Logfile # type: :err, :warn, :info # msg: Message als String oder Array # def log( type, msg ) x_require unless [:err, :warn, :info, :info].include?( type ) x_require unless msg.class == String || msg.class == Array time = Time.new.strftime( "%a %b %d %H:%M:%S %Z %Y" ) case type when :err type_str = "E" when :warn type_str = "W" when :info type_str = "I" end case msg when String msg_a = msg.split( /[\r\n]+/ ) when Array msg_a = msg end x_require unless msg_a.size > 0 entry = sprintf( "%-3s [%s] %s\n", type_str, time, msg_a[0] ) msg_a[1..-1].each do |line| entry += " " + line + "\n" end File.open( @logfn, File::CREAT|File::APPEND|File::WRONLY ) do |f| f.print( entry ) f.close end end end # Externes Programm ausführen # options: # get_stdout - stdout zurückleiten # get_all - stderr+stdout zurückleiten # no_stdout - stdout unterdrücken # no_stderr - stderr unterdrücken # no_out - stderr + stdout unterdrücken ( > /dev/null 2> /dev/null) # verbose - Ausgabe des Befehls # exception - Exception, wenn Exitcode <> 0 def x( cmd, options={} ) c_options = { :get_stdout => 1, :get_all => 1, :no_stdout => 1, :no_stderr => 1, :no_out => 1, :verbose => 1, :exception => 1 } if !(options.keys - c_options.keys).empty? raise "Unknown options in method x()" end stream_std = "" stream_err = "" if options[:verbose] puts cmd end if options[:get_all] Open4::popen4( cmd ) do |pid, stdin, stdout, stderr| stream_std = stdout.readlines stream_err = stderr.readlines end elsif options[:get_stdout] IO::popen( cmd ) do |io| stream_std = io.readlines end elsif options[:no_out] || (options[:no_stdout] && options[:no_stderr]) system( "#{cmd} > /dev/null 2> /dev/null" ) elsif options[:no_stdout] system( "#{cmd} > /dev/null" ) elsif options[:no_stderr] system( "#{cmd} 2> /dev/null" ) else system( cmd ) end exitcode = $?.to_i >> 8 if options[:exception] && exitcode != 0 info = "error in cmd: #{cmd} exitcode: #{exitcode.to_s}" if $debuglevel > 0 info += "\n#{stream_err}" end raise info end return [exitcode, stream_std, stream_err] end class JobNr @@jpath = "/var/lib/lbackup" @@jfile = "/var/lib/lbackup/jobnr" def initialize() raise unless File.exist?( @@jpath ) if !File.exist?( @@jfile ) @job_nr = 1 else @job_nr = File.open( @@jfile ).gets.to_i + 1 end end def get_job_nr @job_nr end def get_job_nr_fmt sprintf( "%6.6d", @job_nr ) end def save f = File.new( @@jfile, "w" ) f.puts( "#{@job_nr}" ) f.close end end # --------------------------------------------------------------------- class App attr_reader :no_act attr_accessor :verbose # Init mit Yaml-Configfile def initialize( config_fn ) x_require unless config_fn.class == String # Config-File einlesen @config = YAML.load_file( config_fn ) chk_config $debuglevel = @config['DebugLevel'].to_i set_xcmd() mkdstdir() @logger = Logger.new( File.join( @config['LogPath'], "lbackup.log") ) @console = ConsolePrinter.new() @no_act = false end def log_and_print( facility, info) @logger.log( facility, info ) @console.print( info ) end # Einfacherer Zugriff auf Helpers (externe Programme) # Erzeugung von @xcmd, Zugriff z.B. über @xcmd['aespipe'] # def set_xcmd() @xcmd = Hash.new @config['Helper'].each do |name, value| @xcmd[name] = value end end # no_act -> nur so tun, als ob, aber keine Aktionen wirklich ausführen # def no_act=(value) if value @no_act = true else @no_act = false end end # Plausibilitätschecks Yaml-Configfile # def chk_config raise "Missing FileSets in Config." unless @config['FileSets'] raise "Missing BackupSets in Config." unless @config['BackupSets'] fset_names = Hash.new each_fileset do |fset| fset_names[fset['Name']] = true end each_backupset do |bset| bset['FileSets'].each do |fsetname| if !fset_names[fsetname] raise "Undefined Fileset `#{fsetname}' in Backupset `#{bset['Name']}" end end end if !@config['BackupPath'] || @config['BackupPath'].class != String raise "Undefined BackupPath" end if !File.directory?( @config['BackupPath'] ) raise "BackupPath not found" end if !@config['RestorePath'] || @config['BackupPath'].class != String raise "Undefined RestorePath" end if !File.directory?( @config['RestorePath'] ) raise "RestorePath not found. Set it to a available directory." end end # Info # def info puts "Backupsets: " each_backupset do |bset| if bset['Name'] == @config['DefaultBackupSet'] default = "(default)" else default = "" end printf( " %-20s %8s %s", bset['Name'], bset['Type'], default ) print "\n" end puts "Enabled Filesets: " each_fileset do |fset| printf( " %-20s %s\n", fset['Name'], fset['TarFile'] ) end return 0 end # Programmablauf-Controller # def run( backupsetname ) begin if !backupsetname #use default Backupset backupsetname = @config['DefaultBackupSet'] if !backupsetname raise "No Backupset defined." end end if !valid_backupset( backupsetname ) raise "Undefined Backupset: `#{backupsetname}Ž" end backupset = get_backupset( backupsetname ) cleanbackupset() log_and_print( :info, "--- Start Processing Backupset <#{backupsetname}>" ) @console.indent_plus() set_filesize = 0 jnr = JobNr.new() File.open( File.join( @config['BackupPath'], "dst", "meta" ), "w") do |f| f.puts( "Job-NR: #{jnr.get_job_nr_fmt()}" ) f.puts( "Host: #{`hostname`}" ) f.puts( "Started: #{Time.new.to_s}" ) f.puts( "Backupset: #{backupsetname}" ) f.puts( "lbackup version: #{Version}" ) end jnr.save() #Jobnummer für nächsten Job speichern log_and_print( :info, "Job-Nr: #{jnr.get_job_nr_fmt()}" ) each_bfileset( backupset ) do |fset| log_and_print( :info, "Processing fileset <#{fset['Name']}>" ) @console.indent_plus() tarfn = File.join( @config['BackupPath'], "dst", fset['TarFile'] ) #dbg_out( "tarfn", tarfn ) @console.print( "Generate archive..." ) mktar( tarfn, fset['SrcPath'], fset['ExcludeFile'] ) size_human = file_size_human( File.size(tarfn) ) @console.print( "File-Size: #{size_human}" ) @console.print( "Encrypt archive..." ) cryptfn = cryptfile( tarfn, fset['PWFile'] ) size_human = file_size_human( File.size(cryptfn) ) log_and_print( :info, "File: #{File.basename(cryptfn)} Size: #{size_human}" ) set_filesize += File.size( cryptfn ) @console.print( "Generate md5file..." ) md5fn = md5file( cryptfn ) @console.print( "Copy archive to backupset..." ) if backupset['Rotate'] =~ /yes/i @console.print( "Add Fileset to rotate-backup archive..." ) rotatefn = addrotate( cryptfn ) #dbg_out( "rotatefn", rotatefn ) @console.print( "Rotate Backups...\n" ) rotate( rotatefn, fset['Rotate'].to_i ) end @console.indent_minus() end size_human = file_size_human( set_filesize ) log_and_print( :info, "Full Set Size: #{size_human}" ) mkcheckfile( ) log_and_print( :info, "Transfer backupset" ) transfer( backupset ) log_and_print( :info, "Verify backupset" ) if checkmd5backup( backupset ) log_and_print( :info, "All Checksums OK" ) else log_and_print( :err, "Checksums FAILED" ) end rescue SystemExit rescue Exception => exc if exc.backtrace[0] =~ /^(.*):([0-9]+)/ msg = exc.message + " file: #{$1} line: #{$2}" else msg = exc.message end @logger.log( :err, msg ) raise end @console.indent_minus() log_and_print( :info, "--- Done." ) return 0 end # Check, ob Backupsetname in Config existiert # def valid_backupset( name ) r = false each_backupset do |bset| if bset['Name'] == name r = true break end end r end # Backupsetname -> Backupset def get_backupset( name ) each_backupset do |bset| if bset['Name'] == name return bset end end raise "Undefined Backupset `#{name}Ž" end # Erzeuge diverse Verzeichnisse, die für die Backups benötigt werden # dst = Zielverzeichnis, wo ein Backupset zusammengstellt wird # rotate = Verzeichnis für die Rotate-File-Backups def mkdstdir() dirs = [ File.join( @config['BackupPath'], "dst" ), File.join( @config['BackupPath'], "rotate" ) ] dirs.each do |dir| if !File.directory?( dir ) FileUtils.mkdir( dir ) end end end # Erzeuge das tar-Archiv # def mktar( tarfn, srcpath, excl_file ) excl = "" if excl_file && excl_file != "" excl = "--exclude-from #{excl_file}" end cmd = "#{@xcmd['tar']} -C / #{excl} -czf #{tarfn} .#{srcpath}" unless @no_act r = x( cmd, :get_all => true ) if r[0] != 0 printr( "Warn: errors in tar-archiv.\n" ) if $debuglevel > 0 printr( r[2] ) end end else puts cmd end end # Rotiere eine Datei rotate-mal # Backups werden im Rotate-Verzeichnis so oft rotiert, wie # im entsprechenden FileSet angegeben. # def rotate( fname, rotate ) x_require unless rotate.class == Fixnum #dbg_out( "---" ) return if !File.file?( fname ) return if rotate == 0 rotate.downto(1) do |r| srcfile = "#{fname}.#{r-1}" srcfile.sub!( /\.0$/, "" ) #Sonderfall fname -> fname.1 #dbg_out( "srcfile: ", srcfile ) if File.file?( srcfile ) FileUtils.mv( srcfile, "#{fname}.#{r}" ) end end end # Verschlüssele Backupdatei, Lösche unverschlüsselte Quell-Datei # Passwort über Passwort-Datei # Security-Risk!: pw_file darf für andere Nutzer nicht lesbar sein # Achtung!: Es darf nur das Passwort enthalten sein ohne Zeilenschaltung # Am besten überprüfen mit hexdump pw_file # def cryptfile( fn, pw_file ) x_require unless File.file?( pw_file ) dst = fn + ".ap" cmd = "#{@xcmd['aespipe']} -p3 < #{fn} >#{dst} 3< #{pw_file}" x( cmd, :exception => true ) x_assert unless File.file?( fn ) x_assert unless fn =~ /dst.*tgz$/ FileUtils.rm( fn ) return dst end # Erzeuge md5file über Backuparchiv. Das erzeugte File heißt genauso, jedoch # mit zusätzlicher Endung .md5sum. Hiermit kann später die Konsistenz der # Backup-Archivfiles auf dem Backupmedium kontrolliert werden. # def md5file( fn ) d = Digest::MD5.new File.open( fn, "rb" ) do |f| while !f.eof? d << f.read(102400) end end md5fn = fn + ".md5sum" File.open( md5fn, "w" ) do |file| file.printf "%s *%s", d.hexdigest, File.basename( fn ) end return md5fn end # Erzeuge ein Bash-Skript und .bat-Skript namens "check" # zur Überprüfung der Archive über # md5file. Dieses wird mit auf den Backup-Datenträger geschrieben, womit # man dann einfach eine Überprüfung der Backups vornehmen kann. # def mkcheckfile() path = File.join( @config['BackupPath'], "dst" ) cf_content_bat = "" cf_content = "#!/bin/bash\n\n" dir_empty = true Dir.foreach( path ) do |f| next if f !~ /md5sum$/ cf_content << "md5sum -c #{f}\n" cf_content_bat << "md5sum -c #{f}\n" dir_empty = false end if !dir_empty cfn = File.join( path, "check" ) File.open( cfn, "w" ) do |f| f.print( cf_content ) end FileUtils.chmod( 0770, cfn) cfn = File.join( path, "check.bat" ) File.open( cfn, "w" ) do |f| f.print( cf_content_bat ) end FileUtils.chmod( 0770, cfn) end end # Backup-Archiv ins Rotate-Verzeichnis übernehmen # def addrotate( fn ) dst = File.join( @config['BackupPath'], "rotate" ) FileUtils.cp( fn, dst ) return File.join( dst, File.basename(fn) ) end # Das Destination-Zwischen-Verzeichnis leeren, alle Dateien dort # löschen. # Achtung!: Wird BackupPath falsch eingestellt, kann man hier auch # schnell mal was falsches löschen. # def cleanbackupset() path = File.join( @config['BackupPath'], "dst" ) Dir.foreach( path ) do |f| dst = File.join( path, f ) next if !File.file?( dst ) #puts dst x_assert unless dst =~ %r!^#{@config['BackupPath']}! x_assert unless dst =~ %r!dst! FileUtils.rm( dst ) end end # Das Destination-Zwischen-Verzeichnis zum eigentlichen Ziel bringen # Entweder auf CD, DVD oder in ein definiertes Zielverzeichnis # def transfer( bset ) case bset['Type'].downcase when 'burncd' transfer_burncd( bset ) when 'burndvd' transfer_burndvd( bset ) when 'file' transfer_file( bset ) when 'usbstick' transfer_usbstick( bset ) else raise "undefined Destination type in Fileset: #{dst['Type']}" end end # Type "file": Transfer Backup zum einem Zielverzeichnis # def transfer_file( bset ) src_path = File.join( @config['BackupPath'], "dst" ) dst_path = bset['Dst'] Dir.foreach( src_path ) do |f| fname = File.join( src_path, f ) next unless File.file?( fname ) FileUtils.cp( fname, dst_path ) end end # Type "usbstick": Transfer Backup auf einen USB-Stick # def transfer_usbstick( bset ) src_path = File.join( @config['BackupPath'], "dst" ) dst_path = bset['Dst'] # Sicherheitshalber erstmal unmounten, falls bereits gemountet war # 06.12.2016 x( "umount #{dst_path}", :exception => false, :no_out => true ) # ...und jetzt mounten x( "mount #{dst_path}", :exception => true, :no_out => true ) begin #bknr aus Stick auslesen, falls Datei vorhanden #bknr ins Log ausgeben bknr_fn = File.join( dst_path, "bknr" ) if File.exist?( bknr_fn ) File.open( bknr_fn, "r" ) do |f| bknr = f.gets.chomp log_and_print( :info, "Stick BKNR: #{bknr}" ) end end Dir.foreach( src_path ) do |f| fname = File.join( src_path, f ) next unless File.file?( fname ) FileUtils.cp( fname, dst_path ) end ensure x( "umount #{dst_path}", :no_out => true ) end end # Type "burncd": Transfer Backup auf eine CD # def transfer_burncd( bset ) path = File.join( @config['BackupPath'], "dst" ) isofn = File.join( @config['BackupPath'], "cd.iso" ) x( "#{@xcmd['mkisofs']} --quiet -o #{isofn} -R -J #{path}", :get_all => true, :exception => true ) burn_speed = "" device = bset['Dst'] case checkcdtype( device ) when :cdrw blank_cd( device ) burn_speed = bset['SpeedRW'] when :cdr burn_speed = bset['SpeedR'] else raise "Can't find a correct CD-Media" end x( "#{@xcmd['cdrecord']} dev=#{device} speed=#{burn_speed} -dao #{isofn}", :get_all => true, :exception => true ) x_assert unless File.file?( isofn ) FileUtils.rm( isofn ) end # Lösche eine CD-RW vor dem Schreiben # def blank_cd( device ) x( "#{@xcmd['cdrecord']} dev=#{device} blank=fast", :no_out => true, :exception => true ) end # Was für eine CD ist eingelegt? # def checkcdtype( device ) r = x( "#{@xcmd['cdrecord']} -atip dev=#{device}", :get_all => true ) ret = :not_available r[1].each do |line| if line =~ /^\s+Is erasable/ ret = :cdrw break end if line =~ /^\s+Is not erasable/ ret = :cdr break end end ret end # Type "burndvd": Transfer Backup auf eine DVD # def transfer_burndvd( bset ) path = File.join( @config['BackupPath'], "dst" ) isofn = File.join( @config['BackupPath'], "cd.iso" ) burn_speed = (bset['SpeedDVD'] || 2) device = bset['Dst'] stderr = x( "#{@xcmd['dvdrwformat']} -blank #{device}", :get_all => true )[2] #puts "--DBG Beginn" #puts stderr.join #puts "--DBG END" if stderr.join =~ /illegal command-line option/ #puts "--DBG: In if stderr Zweig" # bei manchen Rohlingen geht -blank nicht, aber force x( "#{@xcmd['dvdrwformat']} -force #{device}", :get_all => true) end sleep 10 x( "#{@xcmd['growisofs']} -speed=#{burn_speed} -Z #{device} -R -J #{path}", :get_all => true, :exception => true ) sleep 10 end # Überprüfe die Dateien am Zielort: Das Backuparchiv mit der # dazugehörigen .md5sum Datei. Damit wird sichergestellt, dass # das Backuparchiv konstistent gegenüber der .md5sum Datei ist. # Fehler bei der Erzeugung des Backup-Archives werden mit diesem # Test jedoch nicht erkannt. # Hier wird absichtlich md5sum benutzt und nicht Ruby-nativ berechnet, # um Fehler in der md5sum-Berechnung auszuschließen bzw. als Gegencheck # mit einem anderen Code. # def checkmd5backup( bset ) mnt = nil dir = nil result = true begin case bset['Type'] when 'burncd', 'burndvd' mnt = dir = bset['Mount'] sleep 5 x( "mount -r #{mnt}", :exception => true, :no_out => true ) sleep 5 when 'file' dir = bset['Dst'] when 'usbstick' mnt = dir = bset['Dst'] x( "mount #{mnt}", :exception => true, :no_out => true ) sleep 2 end md5files = Array.new each_bfileset(bset) do |fset| md5files << fset['TarFile'] + ".ap.md5sum" end md5files.each do |file| result = x( "cd #{dir}; #{@xcmd['md5sum']} -c #{file}", :get_stdout => true ) if result[1][0].chomp !~ /Ok$/i log_and_print( :err, "Check md5sum #{file}: FAILED" ) result = false else log_and_print( :info, "Check md5sum #{file}: OK" ) end end ensure if mnt x( "umount #{mnt}", :no_out => true ) end end return result end # Durchlaufe alle Filesets, die nicht disabled # def each_fileset() @config['FileSets'].each do |fset| next if fset['Disable'] =~ /yes/i yield fset end end # Durchlaufe alle Filesets eines Backupsets # die nicht disabled def each_bfileset( bset ) bset['FileSets'].each do |fsetname| x_marker = false each_fileset do |fset| next if fset['Disable'] =~ /yes/i if fset['Name'] == fsetname x_marker = true yield fset end end # fsetname des aktuellen Backupsets nicht gefunden x_assert unless x_marker end end # Durchlaufe alle Backupsets # def each_backupset() @config['BackupSets'].each do |bset| yield bset end end def verbose_on() @console.enable() end end # ------------------------------------------------------------------------- # Main # begin cfg_file = "/etc/lbackup/lbackup.cfg" debuglevel = nil verbose = false action = :run opts = OptionParser.new do |o| o.banner = "Usage: lbackup [options] [Backupset]" o.separator( "Data-Backup System with AES-Encryption and writing support for CD/DVD" ) o.separator( "" ) o.on( "-c", "--config CFGFILE", "Use this Config instead default." ) do |cf| cfg_file = cf end o.on( "-d", "--debuglevel LEVEL", "Debuglevel 0..7 (default=configfile).)" ) do |dbg| if dbg !~ /^[0-7]/ raise "Undefined debuglevel: #{dbg}" end debuglevel = dbg.to_i end o.on( "-i", "--info", "Backupset Information." ) do action = :info end o.on( "-v", "--verbose", "Verbose mode." ) do verbose = true end o.on("-h", "--help", "This Help." ) do puts o exit 0 end o.on_tail("--version", "Show version") do puts o.ver puts "Written by Winfried Mueller, www.reintechnisch.de" puts "" puts "Copyright (C) 2007 Winfried Mueller" puts "This is free software; see the source for copying conditions." puts "There is NO warranty; not even for MERCHANTABILITY or" puts "FITNESS FOR A PARTICULAR PURPOSE." puts exit end end opts.parse!( ARGV ) bsetname = nil $app = App.new( cfg_file ) if debuglevel $debuglevel = debuglevel end if verbose $app.verbose_on() end # no_act ist noch nicht vollständig implementiert #$app.no_act = true if action == :run if ARGV[0].class == String bsetname = ARGV[0] #dbg_out( "bsetname: ", bsetname ) end exitcode = $app.run(bsetname) else exitcode = $app.info end exit exitcode rescue SystemExit rescue Exception => exc printr( "E: #{exc.message}\n" ) #STDERR.puts ARGV.options.to_s raise if !$debuglevel || $debuglevel > 0 exit 1 end
Version 2016-05-15
Getestet unter Ubuntu 16.04.
#!/usr/bin/ruby # encoding: utf-8 # # lbackup - Linux Backup System # Winfried Mueller, www.reintechnisch.de, (c) 2007, GPL # Start: 02.01.2007; Stand: 16.05.2016 # Version = "2016-05-16" # Changelog: # 15.02.2016 - USB-Stick-Support, Ruby 2.3.0 unter Ubuntu 16.04 # 20.03.2007 - cdrecord jetzt mit -dao require "rubygems" require "yaml" require "fileutils" require "digest/md5" require "open4" require "optparse" # ========== begin include wmclasslib/dbg ========== module Dbg VERBOSE = FALSE end class AssertError < StandardError; end class RequireError < StandardError; end class EnsureError < StandardError; end # debug print helper def dp( line ) $stderr.puts "Dbg: #{line}" end def x_assert( dsc="" ) begin raise AssertError, "Assert: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def x_require( dsc="" ) begin raise RequireError, "Require: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def x_ensure( dsc="" ) begin raise EnsureError, "Ensure: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def dbg_warn begin raise rescue backtrace = $!.backtrace backtrace.shift $stderr.print "dbg: Warn => " $stderr.puts backtrace.shift if Dbg::VERBOSE backtrace.each { |l| puts " " + l } end end end def dbg_info begin raise rescue backtrace = $!.backtrace backtrace.shift $stderr.print "dbg: Info => " $stderr.puts backtrace.shift if Dbg::VERBOSE backtrace.each { |l| puts " " + l } end end end # ========== end include wmclasslib/dbg ========== $debuglevel = 7 # Begriffe: # Fileset: # Eine Anzahl von Dateien, die in einem Verzeichnis liegen # und in einem Backuparchiv zusammengefasst werden. # # Backupset: # Zusammengehörige Anzahl von Filesets, die gemeinsam zu # einem Ziel übertragen werden. # # Debug Ausgabe def dbg_out(descr, value="") if $debuglevel == 7 STDERR.puts( "DBG: #{descr}: #{value}" ) end end # Print verbose def printv( s ) if $app.verbose print s end end # Print Warn/Error to STDERR def printr( s ) STDERR.print( s ) end class Logger def initialize( logfn ) # Logfile muss mit *.log enden x_require unless logfn =~ /\.log$/ @logfn = logfn end # Ausgabe ins Logfile # type: :err, :warn, :info # msg: Message als String oder Array # def log( type, msg ) x_require unless [:err, :warn, :info, :info].include?( type ) x_require unless msg.class == String || msg.class == Array time = Time.new.strftime( "%a %b %d %H:%M:%S %Z %Y" ) case type when :err type_str = "E" when :warn type_str = "W" when :info type_str = "I" end case msg when String msg_a = msg.split( /[\r\n]+/ ) when Array msg_a = msg end x_require unless msg_a.size > 0 entry = sprintf( "%-3s [%s] %s\n", type_str, time, msg_a[0] ) msg_a[1..-1].each do |line| entry += " " + line + "\n" end File.open( @logfn, File::CREAT|File::APPEND|File::WRONLY ) do |f| f.print( entry ) f.close end end end # Externes Programm ausführen # options: # get_stdout - stdout zurückleiten # get_all - stderr+stdout zurückleiten # no_stdout - stdout unterdrücken # no_stderr - stderr unterdrücken # no_out - stderr + stdout unterdrücken ( > /dev/null 2> /dev/null) # verbose - Ausgabe des Befehls # exception - Exception, wenn Exitcode <> 0 def x( cmd, options={} ) c_options = { :get_stdout => 1, :get_all => 1, :no_stdout => 1, :no_stderr => 1, :no_out => 1, :verbose => 1, :exception => 1 } if !(options.keys - c_options.keys).empty? raise "Unknown options in method x()" end stream_std = "" stream_err = "" if options[:verbose] puts cmd end if options[:get_all] Open4::popen4( cmd ) do |pid, stdin, stdout, stderr| stream_std = stdout.readlines stream_err = stderr.readlines end elsif options[:get_stdout] IO::popen( cmd ) do |io| stream_std = io.readlines end elsif options[:no_out] || (options[:no_stdout] && options[:no_stderr]) system( "#{cmd} > /dev/null 2> /dev/null" ) elsif options[:no_stdout] system( "#{cmd} > /dev/null" ) elsif options[:no_stderr] system( "#{cmd} 2> /dev/null" ) else system( cmd ) end exitcode = $?.to_i >> 8 if options[:exception] && exitcode != 0 info = "error in cmd: #{cmd} exitcode: #{exitcode.to_s}" if $debuglevel > 0 info += "\n#{stream_err}" end raise info end return [exitcode, stream_std, stream_err] end # --------------------------------------------------------------------- class App attr_reader :no_act attr_accessor :verbose # Init mit Yaml-Configfile def initialize( config_fn ) x_require unless config_fn.class == String # Config-File einlesen @config = YAML.load_file( config_fn ) chk_config $debuglevel = @config['DebugLevel'].to_i set_xcmd() mkdstdir() @logger = Logger.new( File.join( @config['LogPath'], "lbackup.log") ) @no_act = false @verbose = false end # Einfacherer Zugriff auf Helpers (externe Programme) # Erzeugung von @xcmd, Zugriff z.B. über @xcmd['aespipe'] # def set_xcmd() @xcmd = Hash.new @config['Helper'].each do |name, value| @xcmd[name] = value end end # no_act -> nur so tun, als ob, aber keine Aktionen wirklich ausführen # def no_act=(value) if value @no_act = true else @no_act = false end end # Plausibilitätschecks Yaml-Configfile # def chk_config raise "Missing FileSets in Config." unless @config['FileSets'] raise "Missing BackupSets in Config." unless @config['BackupSets'] fset_names = Hash.new each_fileset do |fset| fset_names[fset['Name']] = true end each_backupset do |bset| bset['FileSets'].each do |fsetname| if !fset_names[fsetname] raise "Undefined Fileset `#{fsetname}' in Backupset `#{bset['Name']}" end end end if !@config['BackupPath'] || @config['BackupPath'].class != String raise "Undefined BackupPath" end if !File.directory?( @config['BackupPath'] ) raise "BackupPath not found" end if !@config['RestorePath'] || @config['BackupPath'].class != String raise "Undefined RestorePath" end if !File.directory?( @config['RestorePath'] ) raise "RestorePath not found. Set it to a available directory." end end # Info # def info puts "Backupsets: " each_backupset do |bset| if bset['Name'] == @config['DefaultBackupSet'] default = "(default)" else default = "" end printf( " %-20s %8s %s", bset['Name'], bset['Type'], default ) print "\n" end puts "Enabled Filesets: " each_fileset do |fset| printf( " %-20s %s\n", fset['Name'], fset['TarFile'] ) end return 0 end # Programmablauf-Controller # def run( backupsetname ) begin if !backupsetname #use default Backupset backupsetname = @config['DefaultBackupSet'] if !backupsetname raise "No Backupset defined." end end if !valid_backupset( backupsetname ) raise "Undefined Backupset: `#{backupsetname}Ž" end backupset = get_backupset( backupsetname ) cleanbackupset() printv( "Processing Backupset <#{backupsetname}>\n" ) @logger.log( :info, "--- Start Processing Backupset <#{backupsetname}>" ) each_bfileset( backupset ) do |fset| printv( " Processing fileset <#{fset['Name']}>\n" ) @logger.log( :info, "Processing fileset <#{fset['Name']}>" ) tarfn = File.join( @config['BackupPath'], "dst", fset['TarFile'] ) #dbg_out( "tarfn", tarfn ) printv( " Generate archive...\n" ) mktar( tarfn, fset['SrcPath'], fset['ExcludeFile'] ) printv( " Encrypt archive...\n" ) cryptfn = cryptfile( tarfn, fset['PWFile'] ) cryptfsize = File.stat( cryptfn ).size / 1024 @logger.log( :info, "File: #{File.basename(cryptfn)} Size: #{cryptfsize.to_s}K" ) printv( " Generate md5file...\n" ) md5fn = md5file( cryptfn ) printv( " Copy archive to backupset...\n" ) if backupset['Rotate'] =~ /yes/i printv( " Add Fileset to rotate-backup archive...\n" ) rotatefn = addrotate( cryptfn ) #dbg_out( "rotatefn", rotatefn ) printv( " Rotate Backups...\n" ) rotate( rotatefn, fset['Rotate'].to_i ) end end mkcheckfile( ) printv( " Transfer backupset...\n" ) transfer( backupset ) printv( " Verify backupsets md5sum...\n" ) checkmd5backup( backupset ) rescue SystemExit rescue Exception => exc if exc.backtrace[0] =~ /^(.*):([0-9]+)/ msg = exc.message + " file: #{$1} line: #{$2}" else msg = exc.message end @logger.log( :err, msg ) raise end @logger.log( :info, "--- Done." ) return 0 end # Check, ob Backupsetname in Config existiert # def valid_backupset( name ) r = false each_backupset do |bset| if bset['Name'] == name r = true break end end r end # Backupsetname -> Backupset def get_backupset( name ) each_backupset do |bset| if bset['Name'] == name return bset end end raise "Undefined Backupset `#{name}Ž" end # Erzeuge diverse Verzeichnisse, die für die Backups benötigt werden # dst = Zielverzeichnis, wo ein Backupset zusammengstellt wird # rotate = Verzeichnis für die Rotate-File-Backups def mkdstdir() dirs = [ File.join( @config['BackupPath'], "dst" ), File.join( @config['BackupPath'], "rotate" ) ] dirs.each do |dir| if !File.directory?( dir ) FileUtils.mkdir( dir ) end end end # Erzeuge das tar-Archiv # def mktar( tarfn, srcpath, excl_file ) excl = "" if excl_file && excl_file != "" excl = "--exclude-from #{excl_file}" end cmd = "#{@xcmd['tar']} -C / #{excl} -czf #{tarfn} .#{srcpath}" unless @no_act r = x( cmd, :get_all => true ) if r[0] != 0 printr( "Warn: errors in tar-archiv.\n" ) if $debuglevel > 0 printr( r[2] ) end end else puts cmd end end # Rotiere eine Datei rotate-mal # Backups werden im Rotate-Verzeichnis so oft rotiert, wie # im entsprechenden FileSet angegeben. # def rotate( fname, rotate ) x_require unless rotate.class == Fixnum #dbg_out( "---" ) return if !File.file?( fname ) return if rotate == 0 rotate.downto(1) do |r| srcfile = "#{fname}.#{r-1}" srcfile.sub!( /\.0$/, "" ) #Sonderfall fname -> fname.1 #dbg_out( "srcfile: ", srcfile ) if File.file?( srcfile ) FileUtils.mv( srcfile, "#{fname}.#{r}" ) end end end # Verschlüssele Backupdatei, Lösche unverschlüsselte Quell-Datei # Passwort über Passwort-Datei # Security-Risk!: pw_file darf für andere Nutzer nicht lesbar sein # Achtung!: Es darf nur das Passwort enthalten sein ohne Zeilenschaltung # Am besten überprüfen mit hexdump pw_file # def cryptfile( fn, pw_file ) x_require unless File.file?( pw_file ) dst = fn + ".ap" cmd = "#{@xcmd['aespipe']} -p3 < #{fn} >#{dst} 3< #{pw_file}" x( cmd, :exception => true ) x_assert unless File.file?( fn ) x_assert unless fn =~ /dst.*tgz$/ FileUtils.rm( fn ) return dst end # Erzeuge md5file über Backuparchiv. Das erzeugte File heißt genauso, jedoch # mit zusätzlicher Endung .md5sum. Hiermit kann später die Konsistenz der # Backup-Archivfiles auf dem Backupmedium kontrolliert werden. # def md5file( fn ) d = Digest::MD5.new File.open( fn, "rb" ) do |f| while !f.eof? d << f.read(102400) end end md5fn = fn + ".md5sum" File.open( md5fn, "w" ) do |file| file.printf "%s *%s", d.hexdigest, File.basename( fn ) end return md5fn end # Erzeuge ein Bash-Skript und .bat-Skript namens "check" # zur Überprüfung der Archive über # md5file. Dieses wird mit auf den Backup-Datenträger geschrieben, womit # man dann einfach eine Überprüfung der Backups vornehmen kann. # def mkcheckfile() path = File.join( @config['BackupPath'], "dst" ) cf_content_bat = "" cf_content = "#!/bin/bash\n\n" dir_empty = true Dir.foreach( path ) do |f| next if f !~ /md5sum$/ cf_content << "md5sum -c #{f}\n" cf_content_bat << "md5sum -c #{f}\n" dir_empty = false end if !dir_empty cfn = File.join( path, "check" ) File.open( cfn, "w" ) do |f| f.print( cf_content ) end FileUtils.chmod( 0770, cfn) cfn = File.join( path, "check.bat" ) File.open( cfn, "w" ) do |f| f.print( cf_content_bat ) end FileUtils.chmod( 0770, cfn) end end # Backup-Archiv ins Rotate-Verzeichnis übernehmen # def addrotate( fn ) dst = File.join( @config['BackupPath'], "rotate" ) FileUtils.cp( fn, dst ) return File.join( dst, File.basename(fn) ) end # Das Destination-Zwischen-Verzeichnis leeren, alle Dateien dort # löschen. # Achtung!: Wird BackupPath falsch eingestellt, kann man hier auch # schnell mal was falsches löschen. # def cleanbackupset() path = File.join( @config['BackupPath'], "dst" ) Dir.foreach( path ) do |f| dst = File.join( path, f ) next if !File.file?( dst ) #puts dst x_assert unless dst =~ %r!^#{@config['BackupPath']}! x_assert unless dst =~ %r!dst! FileUtils.rm( dst ) end end # Das Destination-Zwischen-Verzeichnis zum eigentlichen Ziel bringen # Entweder auf CD, DVD oder in ein definiertes Zielverzeichnis # def transfer( bset ) case bset['Type'].downcase when 'burncd' transfer_burncd( bset ) when 'burndvd' transfer_burndvd( bset ) when 'file' transfer_file( bset ) when 'usbstick' transfer_usbstick( bset ) else raise "undefined Destination type in Fileset: #{dst['Type']}" end end # Type "file": Transfer Backup zum einem Zielverzeichnis # def transfer_file( bset ) src_path = File.join( @config['BackupPath'], "dst" ) dst_path = bset['Dst'] Dir.foreach( src_path ) do |f| fname = File.join( src_path, f ) next unless File.file?( fname ) FileUtils.cp( fname, dst_path ) end end # Type "usbstick": Transfer Backup auf einen USB-Stick # def transfer_usbstick( bset ) src_path = File.join( @config['BackupPath'], "dst" ) dst_path = bset['Dst'] x( "mount #{dst_path}", :exception => true, :no_out => true ) begin Dir.foreach( src_path ) do |f| fname = File.join( src_path, f ) next unless File.file?( fname ) FileUtils.cp( fname, dst_path ) end ensure x( "umount #{dst_path}", :no_out => true ) end end # Type "burncd": Transfer Backup auf eine CD # def transfer_burncd( bset ) path = File.join( @config['BackupPath'], "dst" ) isofn = File.join( @config['BackupPath'], "cd.iso" ) x( "#{@xcmd['mkisofs']} --quiet -o #{isofn} -R -J #{path}", :get_all => true, :exception => true ) burn_speed = "" device = bset['Dst'] case checkcdtype( device ) when :cdrw blank_cd( device ) burn_speed = bset['SpeedRW'] when :cdr burn_speed = bset['SpeedR'] else raise "Can't find a correct CD-Media" end x( "#{@xcmd['cdrecord']} dev=#{device} speed=#{burn_speed} -dao #{isofn}", :get_all => true, :exception => true ) x_assert unless File.file?( isofn ) FileUtils.rm( isofn ) end # Lösche eine CD-RW vor dem Schreiben # def blank_cd( device ) x( "#{@xcmd['cdrecord']} dev=#{device} blank=fast", :no_out => true, :exception => true ) end # Was für eine CD ist eingelegt? # def checkcdtype( device ) r = x( "#{@xcmd['cdrecord']} -atip dev=#{device}", :get_all => true ) ret = :not_available r[1].each do |line| if line =~ /^\s+Is erasable/ ret = :cdrw break end if line =~ /^\s+Is not erasable/ ret = :cdr break end end ret end # Type "burndvd": Transfer Backup auf eine DVD # def transfer_burndvd( bset ) path = File.join( @config['BackupPath'], "dst" ) isofn = File.join( @config['BackupPath'], "cd.iso" ) burn_speed = (bset['SpeedDVD'] || 2) device = bset['Dst'] stderr = x( "#{@xcmd['dvdrwformat']} -blank #{device}", :get_all => true )[2] #puts "--DBG Beginn" #puts stderr.join #puts "--DBG END" if stderr.join =~ /illegal command-line option/ #puts "--DBG: In if stderr Zweig" # bei manchen Rohlingen geht -blank nicht, aber force x( "#{@xcmd['dvdrwformat']} -force #{device}", :get_all => true) end sleep 10 x( "#{@xcmd['growisofs']} -speed=#{burn_speed} -Z #{device} -R -J #{path}", :get_all => true, :exception => true ) sleep 10 end # Überprüfe die Dateien am Zielort: Das Backuparchiv mit der # dazugehörigen .md5sum Datei. Damit wird sichergestellt, dass # das Backuparchiv konstistent gegenüber der .md5sum Datei ist. # Fehler bei der Erzeugung des Backup-Archives werden mit diesem # Test jedoch nicht erkannt. # Hier wird absichtlich md5sum benutzt und nicht Ruby-nativ berechnet, # um Fehler in der md5sum-Berechnung auszuschließen bzw. als Gegencheck # mit einem anderen Code. # def checkmd5backup( bset ) mnt = nil dir = nil begin case bset['Type'] when 'burncd', 'burndvd' mnt = dir = bset['Mount'] sleep 5 x( "mount -r #{mnt}", :exception => true, :no_out => true ) sleep 5 when 'file' dir = bset['Dst'] when 'usbstick' mnt = dir = bset['Dst'] x( "mount #{mnt}", :exception => true, :no_out => true ) sleep 2 end md5files = Array.new each_bfileset(bset) do |fset| md5files << fset['TarFile'] + ".ap.md5sum" end printv( " Check Dir: #{dir}\n" ) md5files.each do |file| printv( " Check md5sum #{file}: " ) result = x( "cd #{dir}; #{@xcmd['md5sum']} -c #{file}", :get_stdout => true ) if result[1][0].chomp !~ /Ok$/i raise "File: #{file} md5sum error" end printv( "Ok\n" ) end ensure if mnt x( "umount #{mnt}", :no_out => true ) end end end # Durchlaufe alle Filesets, die nicht disabled # def each_fileset() @config['FileSets'].each do |fset| next if fset['Disable'] =~ /yes/i yield fset end end # Durchlaufe alle Filesets eines Backupsets # die nicht disabled def each_bfileset( bset ) bset['FileSets'].each do |fsetname| x_marker = false each_fileset do |fset| next if fset['Disable'] =~ /yes/i if fset['Name'] == fsetname x_marker = true yield fset end end # fsetname des aktuellen Backupsets nicht gefunden x_assert unless x_marker end end # Durchlaufe alle Backupsets # def each_backupset() @config['BackupSets'].each do |bset| yield bset end end end # ------------------------------------------------------------------------- # Main # begin cfg_file = "/etc/lbackup/lbackup.cfg" debuglevel = nil verbose = false action = :run opts = OptionParser.new do |o| o.banner = "Usage: lbackup [options] [Backupset]" o.separator( "Data-Backup System with AES-Encryption and writing support for CD/DVD" ) o.separator( "" ) o.on( "-c", "--config CFGFILE", "Use this Config instead default." ) do |cf| cfg_file = cf end o.on( "-d", "--debuglevel LEVEL", "Debuglevel 0..7 (default=configfile).)" ) do |dbg| if dbg !~ /^[0-7]/ raise "Undefined debuglevel: #{dbg}" end debuglevel = dbg.to_i end o.on( "-i", "--info", "Backupset Information." ) do action = :info end o.on( "-v", "--verbose", "Verbose mode." ) do verbose = true end o.on("-h", "--help", "This Help." ) do puts o exit 0 end o.on_tail("--version", "Show version") do puts o.ver puts "Written by Winfried Mueller, www.reintechnisch.de" puts "" puts "Copyright (C) 2007 Winfried Mueller" puts "This is free software; see the source for copying conditions." puts "There is NO warranty; not even for MERCHANTABILITY or" puts "FITNESS FOR A PARTICULAR PURPOSE." puts exit end end opts.parse!( ARGV ) bsetname = nil $app = App.new( cfg_file ) if debuglevel $debuglevel = debuglevel end if verbose $app.verbose = true end # no_act ist noch nicht vollständig implementiert #$app.no_act = true if action == :run if ARGV[0].class == String bsetname = ARGV[0] #dbg_out( "bsetname: ", bsetname ) end exitcode = $app.run(bsetname) else exitcode = $app.info end exit exitcode rescue SystemExit rescue Exception => exc printr( "E: #{exc.message}\n" ) #STDERR.puts ARGV.options.to_s raise if !$debuglevel || $debuglevel > 0 exit 1 end
Version 2007-03-20
Getestet unter: Ubuntu Dapper, Ruby 1.8.4, aespipe 2.3b, growisofs 5.21, mkisofs 2.01, cdrecord-clone 2.01.01a01
Quellcode von lbackup.rb:
#!/usr/bin/ruby1.8 # # lbackup - Linux Backup System # Winfried Mueller, www.reintechnisch.de, (c) 2007, GPL # Start: 02.01.2007; Stand: 20.03.2007 # Version = "2007/03/20" # Changelog: # 20.03.2007 - cdrecord jetzt mit -dao require "rubygems" require "yaml" require "fileutils" require "digest/md5" require "open4" require "optparse" # ========== begin include wmclasslib/dbg ========== module Dbg VERBOSE = FALSE end class AssertError < StandardError; end class RequireError < StandardError; end class EnsureError < StandardError; end # debug print helper def dp( line ) $stderr.puts "Dbg: #{line}" end def x_assert( dsc="" ) begin raise AssertError, "Assert: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def x_require( dsc="" ) begin raise RequireError, "Require: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def x_ensure( dsc="" ) begin raise EnsureError, "Ensure: #{dsc}" rescue =>exc new_backtrace = exc.backtrace new_backtrace.shift exc.set_backtrace( new_backtrace ) raise end end def dbg_warn begin raise rescue backtrace = $!.backtrace backtrace.shift $stderr.print "dbg: Warn => " $stderr.puts backtrace.shift if Dbg::VERBOSE backtrace.each { |l| puts " " + l } end end end def dbg_info begin raise rescue backtrace = $!.backtrace backtrace.shift $stderr.print "dbg: Info => " $stderr.puts backtrace.shift if Dbg::VERBOSE backtrace.each { |l| puts " " + l } end end end # ========== end include wmclasslib/dbg ========== $debuglevel = 7 # Begriffe: # Fileset: # Eine Anzahl von Dateien, die in einem Verzeichnis liegen # und in einem Backuparchiv zusammengefasst werden. # # Backupset: # Zusammengehörige Anzahl von Filesets, die gemeinsam zu # einem Ziel übertragen werden. # # Debug Ausgabe def dbg_out(descr, value="") if $debuglevel == 7 STDERR.puts( "DBG: #{descr}: #{value}" ) end end # Print verbose def printv( s ) if $app.verbose print s end end # Print Warn/Error to STDERR def printr( s ) STDERR.print( s ) end class Logger def initialize( logfn ) # Logfile muss mit *.log enden x_require unless logfn =~ /\.log$/ @logfn = logfn end # Ausgabe ins Logfile # type: :err, :warn, :info # msg: Message als String oder Array # def log( type, msg ) x_require unless [:err, :warn, :info, :info].include?( type ) x_require unless msg.class == String || msg.class == Array time = Time.new.strftime( "%a %b %d %H:%M:%S %Z %Y" ) case type when :err type_str = "E" when :warn type_str = "W" when :info type_str = "I" end case msg when String msg_a = msg.split( /[\r\n]+/ ) when Array msg_a = msg end x_require unless msg_a.size > 0 entry = sprintf( "%-3s [%s] %s\n", type_str, time, msg_a[0] ) msg_a[1..-1].each do |line| entry += " " + line + "\n" end File.open( @logfn, File::CREAT|File::APPEND|File::WRONLY ) do |f| f.print( entry ) f.close end end end # Externes Programm ausführen # options: # get_stdout - stdout zurückleiten # get_all - stderr+stdout zurückleiten # no_stdout - stdout unterdrücken # no_stderr - stderr unterdrücken # no_out - stderr + stdout unterdrücken ( > /dev/null 2> /dev/null) # verbose - Ausgabe des Befehls # exception - Exception, wenn Exitcode <> 0 def x( cmd, options={} ) c_options = { :get_stdout => 1, :get_all => 1, :no_stdout => 1, :no_stderr => 1, :no_out => 1, :verbose => 1, :exception => 1 } if !(options.keys - c_options.keys).empty? raise "Unknown options in method x()" end stream_std = "" stream_err = "" if options[:verbose] puts cmd end if options[:get_all] Open4::popen4( cmd ) do |pid, stdin, stdout, stderr| stream_std = stdout.readlines stream_err = stderr.readlines end elsif options[:get_stdout] IO::popen( cmd ) do |io| stream_std = io.readlines end elsif options[:no_out] || (options[:no_stdout] && options[:no_stderr]) system( "#{cmd} > /dev/null 2> /dev/null" ) elsif options[:no_stdout] system( "#{cmd} > /dev/null" ) elsif options[:no_stderr] system( "#{cmd} 2> /dev/null" ) else system( cmd ) end exitcode = $?.to_i >> 8 if options[:exception] && exitcode != 0 info = "error in cmd: #{cmd}" if $debuglevel > 0 info += "\n#{stream_err}" end raise info end return [exitcode, stream_std, stream_err] end # --------------------------------------------------------------------- class App attr_reader :no_act attr_accessor :verbose # Init mit Yaml-Configfile def initialize( config_fn ) x_require unless config_fn.class == String # Config-File einlesen @config = YAML.load_file( config_fn ) chk_config $debuglevel = @config['DebugLevel'].to_i set_xcmd() mkdstdir() @logger = Logger.new( File.join( @config['LogPath'], "lbackup.log") ) @no_act = false @verbose = false end # Einfacherer Zugriff auf Helpers (externe Programme) # Erzeugung von @xcmd, Zugriff z.B. über @xcmd['aespipe'] # def set_xcmd() @xcmd = Hash.new @config['Helper'].each do |name, value| @xcmd[name] = value end end # no_act -> nur so tun, als ob, aber keine Aktionen wirklich ausführen # def no_act=(value) if value @no_act = true else @no_act = false end end # Plausibilitätschecks Yaml-Configfile # def chk_config raise "Missing FileSets in Config." unless @config['FileSets'] raise "Missing BackupSets in Config." unless @config['BackupSets'] fset_names = Hash.new each_fileset do |fset| fset_names[fset['Name']] = true end each_backupset do |bset| bset['FileSets'].each do |fsetname| if !fset_names[fsetname] raise "Undefined Fileset `#{fsetname}' in Backupset `#{bset['Name']}" end end end if !@config['BackupPath'] || @config['BackupPath'].class != String raise "Undefined BackupPath" end if !File.directory?( @config['BackupPath'] ) raise "BackupPath not found" end if !@config['RestorePath'] || @config['BackupPath'].class != String raise "Undefined RestorePath" end if !File.directory?( @config['RestorePath'] ) raise "RestorePath not found. Set it to a available directory." end end # Info # def info puts "Backupsets: " each_backupset do |bset| if bset['Name'] == @config['DefaultBackupSet'] default = "(default)" else default = "" end printf( " %-20s %8s %s", bset['Name'], bset['Type'], default ) print "\n" end puts "Enabled Filesets: " each_fileset do |fset| printf( " %-20s %s\n", fset['Name'], fset['TarFile'] ) end return 0 end # Programmablauf-Controller # def run( backupsetname ) begin if !backupsetname #use default Backupset backupsetname = @config['DefaultBackupSet'] if !backupsetname raise "No Backupset defined." end end if !valid_backupset( backupsetname ) raise "Undefined Backupset: `#{backupsetname}´" end backupset = get_backupset( backupsetname ) cleanbackupset() printv( "Processing Backupset <#{backupsetname}>\n" ) @logger.log( :info, "--- Start Processing Backupset <#{backupsetname}>" ) each_bfileset( backupset ) do |fset| printv( " Processing fileset <#{fset['Name']}>\n" ) @logger.log( :info, "Processing fileset <#{fset['Name']}>" ) tarfn = File.join( @config['BackupPath'], "dst", fset['TarFile'] ) #dbg_out( "tarfn", tarfn ) printv( " Generate archive...\n" ) mktar( tarfn, fset['SrcPath'], fset['ExcludeFile'] ) printv( " Encrypt archive...\n" ) cryptfn = cryptfile( tarfn, fset['PWFile'] ) cryptfsize = File.stat( cryptfn ).size / 1024 @logger.log( :info, "File: #{File.basename(cryptfn)} Size: #{cryptfsize.to_s}K" ) printv( " Generate md5file...\n" ) md5fn = md5file( cryptfn ) printv( " Copy archive to backupset...\n" ) if backupset['Rotate'] =~ /yes/i printv( " Add Fileset to rotate-backup archive...\n" ) rotatefn = addrotate( cryptfn ) #dbg_out( "rotatefn", rotatefn ) printv( " Rotate Backups...\n" ) rotate( rotatefn, fset['Rotate'].to_i ) end end mkcheckfile( ) printv( " Transfer backupset...\n" ) transfer( backupset ) printv( " Verify backupsets md5sum...\n" ) checkmd5backup( backupset ) rescue SystemExit rescue Exception => exc if exc.backtrace[0] =~ /^(.*):([0-9]+)/ msg = exc.message + " file: #{$1} line: #{$2}" else msg = exc.message end @logger.log( :err, msg ) raise end @logger.log( :info, "--- Done." ) return 0 end # Check, ob Backupsetname in Config existiert # def valid_backupset( name ) r = false each_backupset do |bset| if bset['Name'] == name r = true break end end r end # Backupsetname -> Backupset def get_backupset( name ) each_backupset do |bset| if bset['Name'] == name return bset end end raise "Undefined Backupset `#{name}´" end # Erzeuge diverse Verzeichnisse, die für die Backups benötigt werden # dst = Zielverzeichnis, wo ein Backupset zusammengstellt wird # rotate = Verzeichnis für die Rotate-File-Backups def mkdstdir() dirs = [ File.join( @config['BackupPath'], "dst" ), File.join( @config['BackupPath'], "rotate" ) ] dirs.each do |dir| if !File.directory?( dir ) FileUtils.mkdir( dir ) end end end # Erzeuge das tar-Archiv # def mktar( tarfn, srcpath, excl_file ) excl = "" if excl_file && excl_file != "" excl = "--exclude-from #{excl_file}" end cmd = "#{@xcmd['tar']} -C / #{excl} -czf #{tarfn} .#{srcpath}" unless @no_act r = x( cmd, :get_all => true ) if r[0] != 0 printr( "Warn: errors in tar-archiv.\n" ) if $debuglevel > 0 printr( r[2] ) end end else puts cmd end end # Rotiere eine Datei rotate-mal # Backups werden im Rotate-Verzeichnis so oft rotiert, wie # im entsprechenden FileSet angegeben. # def rotate( fname, rotate ) x_require unless rotate.class == Fixnum #dbg_out( "---" ) return if !File.file?( fname ) return if rotate == 0 rotate.downto(1) do |r| srcfile = "#{fname}.#{r-1}" srcfile.sub!( /\.0$/, "" ) #Sonderfall fname -> fname.1 #dbg_out( "srcfile: ", srcfile ) if File.file?( srcfile ) FileUtils.mv( srcfile, "#{fname}.#{r}" ) end end end # Verschlüssele Backupdatei, Lösche unverschlüsselte Quell-Datei # Passwort über Passwort-Datei # Security-Risk!: pw_file darf für andere Nutzer nicht lesbar sein # Achtung!: Es darf nur das Passwort enthalten sein ohne Zeilenschaltung # Am besten überprüfen mit hexdump pw_file # def cryptfile( fn, pw_file ) x_require unless File.file?( pw_file ) dst = fn + ".ap" cmd = "#{@xcmd['aespipe']} -p3 < #{fn} >#{dst} 3< #{pw_file}" x( cmd, :exception => true ) x_assert unless File.file?( fn ) x_assert unless fn =~ /dst.*tgz$/ FileUtils.rm( fn ) return dst end # Erzeuge md5file über Backuparchiv. Das erzeugte File heißt genauso, jedoch # mit zusätzlicher Endung .md5sum. Hiermit kann später die Konsistenz der # Backup-Archivfiles auf dem Backupmedium kontrolliert werden. # def md5file( fn ) d = Digest::MD5.new File.open( fn, "rb" ) do |f| while !f.eof? d << f.read(102400) end end md5fn = fn + ".md5sum" File.open( md5fn, "w" ) do |file| file.printf "%s %s", d.hexdigest, File.basename( fn ) end return md5fn end # Erzeuge ein Bash-Skript namens "check" zur Überprüfung der Archive über # md5file. Dieses wird mit auf den Backup-Datenträger geschrieben, womit # man dann einfach eine Überprüfung der Backups vornehmen kann. # def mkcheckfile() path = File.join( @config['BackupPath'], "dst" ) cf_content = "#!/bin/bash\n\n" dir_empty = true Dir.foreach( path ) do |f| next if f !~ /md5sum$/ cf_content << "md5sum -c #{f}\n" dir_empty = false end if !dir_empty cfn = File.join( path, "check" ) File.open( cfn, "w" ) do |f| f.print( cf_content ) end FileUtils.chmod( 0770, cfn) end end # Backup-Archiv ins Rotate-Verzeichnis übernehmen # def addrotate( fn ) dst = File.join( @config['BackupPath'], "rotate" ) FileUtils.cp( fn, dst ) return File.join( dst, File.basename(fn) ) end # Das Destination-Zwischen-Verzeichnis leeren, alle Dateien dort # löschen. # Achtung!: Wird BackupPath falsch eingestellt, kann man hier auch # schnell mal was falsches löschen. # def cleanbackupset() path = File.join( @config['BackupPath'], "dst" ) Dir.foreach( path ) do |f| dst = File.join( path, f ) next if !File.file?( dst ) #puts dst x_assert unless dst =~ %r!^#{@config['BackupPath']}! x_assert unless dst =~ %r!dst! FileUtils.rm( dst ) end end # Das Destination-Zwischen-Verzeichnis zum eigentlichen Ziel bringen # Entweder auf CD, DVD oder in ein definiertes Zielverzeichnis # def transfer( bset ) case bset['Type'].downcase when 'burncd' transfer_burncd( bset ) when 'burndvd' transfer_burndvd( bset ) when 'file' transfer_file( bset ) else raise "undefined Destination type in Fileset: #{dst['Type']}" end end # Type "file": Transfer Backup zum einem Zielverzeichnis # def transfer_file( bset ) src_path = File.join( @config['BackupPath'], "dst" ) dst_path = bset['Dst'] Dir.foreach( src_path ) do |f| fname = File.join( src_path, f ) next unless File.file?( fname ) FileUtils.cp( fname, dst_path ) end end # Type "burncd": Transfer Backup auf eine CD # def transfer_burncd( bset ) path = File.join( @config['BackupPath'], "dst" ) isofn = File.join( @config['BackupPath'], "cd.iso" ) x( "#{@xcmd['mkisofs']} --quiet -o #{isofn} -R -J #{path}", :get_all => true, :exception => true ) burn_speed = "" device = bset['Dst'] case checkcdtype( device ) when :cdrw blank_cd( device ) burn_speed = bset['SpeedRW'] when :cdr burn_speed = bset['SpeedR'] else raise "Can't find a correct CD-Media" end x( "#{@xcmd['cdrecord']} dev=#{device} speed=#{burn_speed} -dao #{isofn}", :get_all => true, :exception => true ) x_assert unless File.file?( isofn ) FileUtils.rm( isofn ) end # Lösche eine CD-RW vor dem Schreiben # def blank_cd( device ) x( "#{@xcmd['cdrecord']} dev=#{device} blank=fast", :no_out => true, :exception => true ) end # Was für eine CD ist eingelegt? # def checkcdtype( device ) r = x( "#{@xcmd['cdrecord']} -atip dev=#{device}", :get_all => true ) ret = :not_available r[1].each do |line| if line =~ /^\s+Is erasable/ ret = :cdrw break end if line =~ /^\s+Is not erasable/ ret = :cdr break end end ret end # Type "burndvd": Transfer Backup auf eine DVD # def transfer_burndvd( bset ) path = File.join( @config['BackupPath'], "dst" ) isofn = File.join( @config['BackupPath'], "cd.iso" ) burn_speed = (bset['SpeedDVD'] || 2) device = bset['Dst'] x( "#{@xcmd['growisofs']} -speed=#{burn_speed} -Z #{device} -R -J #{path}", :get_all => true, :exception => true ) sleep 10 end # Überprüfe die Dateien am Zielort: Das Backuparchiv mit der # dazugehörigen .md5sum Datei. Damit wird sichergestellt, dass # das Backuparchiv konstistent gegenüber der .md5sum Datei ist. # Fehler bei der Erzeugung des Backup-Archives werden mit diesem # Test jedoch nicht erkannt. # Hier wird absichtlich md5sum benutzt und nicht Ruby-nativ berechnet, # um Fehler in der md5sum-Berechnung auszuschließen bzw. als Gegencheck # mit einem anderen Code. # def checkmd5backup( bset ) mnt = nil dir = nil begin case bset['Type'] when 'burncd', 'burndvd' mnt = dir = bset['Mount'] x( "mount #{mnt}", :exception => true, :no_out => true ) sleep 5 when 'file' dir = bset['Dst'] end md5files = Array.new each_bfileset(bset) do |fset| md5files << fset['TarFile'] + ".ap.md5sum" end printv( " Check Dir: #{dir}\n" ) md5files.each do |file| printv( " Check md5sum #{file}: " ) result = x( "cd #{dir}; #{@xcmd['md5sum']} -c #{file}", :get_stdout => true ) if result[1][0].chomp !~ /Ok$/i raise "File: #{file} md5sum error" end printv( "Ok\n" ) end ensure if mnt x( "umount #{mnt}", :no_out => true ) end end end # Durchlaufe alle Filesets, die nicht disabled # def each_fileset() @config['FileSets'].each do |fset| next if fset['Disable'] =~ /yes/i yield fset end end # Durchlaufe alle Filesets eines Backupsets # die nicht disabled def each_bfileset( bset ) bset['FileSets'].each do |fsetname| x_marker = false each_fileset do |fset| next if fset['Disable'] =~ /yes/i if fset['Name'] == fsetname x_marker = true yield fset end end # fsetname des aktuellen Backupsets nicht gefunden x_assert unless x_marker end end # Durchlaufe alle Backupsets # def each_backupset() @config['BackupSets'].each do |bset| yield bset end end end # ------------------------------------------------------------------------- # Main # begin cfg_file = "/etc/lbackup/lbackup.cfg" debuglevel = nil verbose = false action = :run opts = OptionParser.new do |o| o.banner = "Usage: lbackup [options] [Backupset]" o.separator( "Data-Backup System with AES-Encryption and writing support for CD/DVD" ) o.separator( "" ) o.on( "-c", "--config CFGFILE", "Use this Config instead default." ) do |cf| cfg_file = cf end o.on( "-d", "--debuglevel LEVEL", "Debuglevel 0..7 (default=configfile).)" ) do |dbg| if dbg !~ /^[0-7]/ raise "Undefined debuglevel: #{dbg}" end debuglevel = dbg.to_i end o.on( "-i", "--info", "Backupset Information." ) do action = :info end o.on( "-v", "--verbose", "Verbose mode." ) do verbose = true end o.on("-h", "--help", "This Help." ) do puts o exit 0 end o.on_tail("--version", "Show version") do puts o.ver puts "Written by Winfried Mueller, www.reintechnisch.de" puts "" puts "Copyright (C) 2007 Winfried Mueller" puts "This is free software; see the source for copying conditions." puts "There is NO warranty; not even for MERCHANTABILITY or" puts "FITNESS FOR A PARTICULAR PURPOSE." puts exit end end opts.parse!( ARGV ) bsetname = nil $app = App.new( cfg_file ) if debuglevel $debuglevel = debuglevel end if verbose $app.verbose = true end # no_act ist noch nicht vollständig implementiert #$app.no_act = true if action == :run if ARGV[0].class == String bsetname = ARGV[0] #dbg_out( "bsetname: ", bsetname ) end exitcode = $app.run(bsetname) else exitcode = $app.info end exit exitcode rescue SystemExit rescue Exception => exc printr( "E: #{exc.message}\n" ) #STDERR.puts ARGV.options.to_s raise if !$debuglevel || $debuglevel > 0 exit 1 end