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