1 #! /usr/bin/perl  | 
         | 
     2 use strict;  | 
         | 
     3 use warnings;  | 
         | 
     4   | 
         | 
     5 use IO::File;  | 
         | 
     6 use File::Basename;  | 
         | 
     7 use Net::FTP;  | 
         | 
     8 use Perl6::Slurp;  | 
         | 
     9 use Getopt::Long;  | 
         | 
    10 use Sys::Hostname;  | 
         | 
    11 use Pod::Usage;  | 
         | 
    12 use POSIX qw(strftime);;  | 
         | 
    13 use English qw(-no_match_vars);  | 
         | 
    14 use 5.10.0;  | 
         | 
    15 use if $ENV{DEBUG} => qw(Smart::Comments); | 
         | 
    16   | 
         | 
    17 $ENV{LC_ALL} = "C"; | 
         | 
    18   | 
         | 
    19 my $ME = basename $0;  | 
         | 
    20   | 
         | 
    21 my @CONFIGS = ("/etc/$ME", "$ENV{HOME}/.$ME", "$ME.conf"); | 
         | 
    22   | 
         | 
    23 my $NODE = hostname;  | 
         | 
    24 my $NOW = time();  | 
         | 
    25   | 
         | 
    26 my $opt_level = 0;  | 
         | 
    27 my $opt_today = strftime("%F", localtime $NOW); | 
         | 
    28 my @opt_debug = ();  | 
         | 
    29 my $opt_verbose = 0;  | 
         | 
    30 my $opt_dry = 0;  | 
         | 
    31 my $opt_force = 0;  | 
         | 
    32   | 
         | 
    33 sub get_configs(@);  | 
         | 
    34 sub get_candidates();  | 
         | 
    35 sub verbose(@);  | 
         | 
    36   | 
         | 
    37 our @AT_EXIT;  | 
         | 
    38 END { $_->() foreach @AT_EXIT }; | 
         | 
    39 $SIG{INT} = sub { warn "Got signal INT\n"; exit 1 }; | 
         | 
    40   | 
         | 
    41 my %CONFIG = (  | 
         | 
    42     FTP_DIR => "backup/daily/$NODE",  | 
         | 
    43     FTP_PASSIVE => 1,  | 
         | 
    44     FULL_CYCLE => 7,	    # not used  | 
         | 
    45 );  | 
         | 
    46   | 
         | 
    47 MAIN: { | 
         | 
    48     GetOptions(  | 
         | 
    49 	"l|level=i" => \$opt_level,  | 
         | 
    50 	"d|debug:s" => sub { push @opt_debug, split /,/, $_[1] }, | 
         | 
    51 	"h|help" => sub { pod2usage(-exit => 0, -verbose => 1) }, | 
         | 
    52 	"m|man" => sub { pod2usage(-exit => 0, -verbose => 3) }, | 
         | 
    53 	"v|verbose" => \$opt_verbose,  | 
         | 
    54 	"dry" => \$opt_dry,  | 
         | 
    55 	"f|force" => \$opt_force,  | 
         | 
    56     ) or pod2usage;  | 
         | 
    57   | 
         | 
    58     my %cf = (%CONFIG, get_configs(@CONFIGS));  | 
         | 
    59     my @dev = get_candidates();  | 
         | 
    60     ### current candiates: @dev  | 
         | 
    61   | 
         | 
    62     my $ftp = new FTP($cf{FTP_HOST},  | 
         | 
    63 	Passive => $cf{FTP_PASSIVE},  | 
         | 
    64 	Debug => @opt_debug ~~ /^ftp$/) or die $@;  | 
         | 
    65     $ftp->login or die $ftp->message;  | 
         | 
    66     $ftp->try(binary => ());  | 
         | 
    67     $ftp->try(mkpath => $cf{FTP_DIR});     | 
         | 
    68     $ftp->try(cwd => $cf{FTP_DIR}); | 
         | 
    69   | 
         | 
    70     given ($opt_level) { | 
         | 
    71 	when(0) { | 
         | 
    72 	    $ftp->try(mkpath => $opt_today);  | 
         | 
    73 	    $ftp->try(cwd => $opt_today);  | 
         | 
    74 	}  | 
         | 
    75 	default { | 
         | 
    76 	    # find the last full backup directory  | 
         | 
    77 	    my $last_full = (reverse sort grep /^\d{4}-\d{2}-\d{2}$/, $ftp->ls)[0]; | 
         | 
    78 	    die "no last full backup found in @{[$ftp->pwd]}\n" | 
         | 
    79 		if not $last_full;  | 
         | 
    80 	    $ftp->try(cwd => $last_full);  | 
         | 
    81 	}  | 
         | 
    82     }  | 
         | 
    83   | 
         | 
    84     # now sitting inside the directory for the last full backup  | 
         | 
    85     verbose "Now in @{[$ftp->pwd]}.\n"; | 
         | 
    86   | 
         | 
    87     # and now we can start doing something with our filesystems  | 
         | 
    88     foreach my $dev (@dev) { | 
         | 
    89   | 
         | 
    90 	my $file = basename($dev->{dev}) . "." | 
         | 
    91 	    . strftime("%F_%R", localtime $NOW) | 
         | 
    92 	    . ".$opt_level.ssl";  | 
         | 
    93 	my $label = "$NODE:" . basename($dev->{rdev}); | 
         | 
    94 	verbose "Working on $dev->{dev} as $dev->{rdev}, stored as $file\n"; | 
         | 
    95 	next if $opt_dry;  | 
         | 
    96   | 
         | 
    97 	## complain if there is already a full backup in this  | 
         | 
    98 	## sequence  | 
         | 
    99 	##die "level 0 dir should be empty\n" if @{$ftp->try(ls => "*.0.*")}; | 
         | 
   100   | 
         | 
   101 	# For LVM do a snapshot, for regular partitions  | 
         | 
   102 	# do nothing. But anyway the device to dump is named in $dev->{dump} | 
         | 
   103 	if ($dev->{lvm}) { | 
         | 
   104 	    # we can do a snapshot  | 
         | 
   105 	    # FIXME: calculate the size  | 
         | 
   106 	    my $snap = "$dev->{lvm}{path}-0"; | 
         | 
   107   | 
         | 
   108 	    verbose "Creating snapshot $snap\n";  | 
         | 
   109 	    system($_ = "lvcreate -s -L 1G -n $snap $dev->{lvm}{path} >/dev/null"); | 
         | 
   110 	    die "failed system command: $_\n" if $?;  | 
         | 
   111   | 
         | 
   112 	    $dev->{cleanup} = sub { system "lvdisplay $snap &>/dev/null" | 
         | 
   113 				      . " && lvremove -f $snap >/dev/null" };  | 
         | 
   114 	    push @AT_EXIT, $dev->{cleanup}; | 
         | 
   115   | 
         | 
   116 	    (my $device) = (grep /lv name/i, `lvdisplay $snap`)[0] =~ /(\S+)\s*$/;  | 
         | 
   117   | 
         | 
   118 	    system($_ = "fsck -f -C0 -y $device");  | 
         | 
   119 	    warn "fsck on $device (using: $_) failed\n" if $?;  | 
         | 
   120   | 
         | 
   121 	    ($dev->{dump}) = $device; | 
         | 
   122   | 
         | 
   123 	}  | 
         | 
   124 	else { | 
         | 
   125 	    $dev->{dump} = $dev->{rdev} | 
         | 
   126 	}  | 
         | 
   127   | 
         | 
   128 	### $dev  | 
         | 
   129   | 
         | 
   130 	$ENV{key} = $cf{KEY}; | 
         | 
   131 	my $dumper = open(my $dump, "-|") or do { | 
         | 
   132 	    my $head = <<__;  | 
         | 
   133 #! /bin/bash  | 
         | 
   134 if test "\$1" = "--info"; then  | 
         | 
   135     cat <<___  | 
         | 
   136 NODE       : $NODE  | 
         | 
   137 DATE       : $NOW @{[localtime $NOW]} | 
         | 
   138 LEVEL      : $opt_level  | 
         | 
   139 DEVICE     : $dev->{dev} | 
         | 
   140 REAL_DEVICE: $dev->{rdev} | 
         | 
   141 MOUNTPOINT : $dev->{mountpoint} | 
         | 
   142 FSTYPE     : $dev->{fstype} | 
         | 
   143 ___  | 
         | 
   144     exit 0  | 
         | 
   145 fi  | 
         | 
   146 tail -c XXXXX \$0 | openssl enc -d -blowfish "\$@"  | 
         | 
   147 exit  | 
         | 
   148   | 
         | 
   149 __  | 
         | 
   150 	    # adjust the placeholder  | 
         | 
   151 	    $head =~ s/XXXXX/sprintf "% 5s", "+" . (length($head) +1)/e;  | 
         | 
   152 	    print $head;  | 
         | 
   153 	    exec "dump -$opt_level -L $label -f- -u -z6 $dev->{dump}" | 
         | 
   154 	    . "| openssl enc -pass env:key -salt -blowfish";  | 
         | 
   155 	    die "Can't exec dumper\n";  | 
         | 
   156 	};  | 
         | 
   157   | 
         | 
   158 	$ftp->try(put => $dump, $file);  | 
         | 
   159 	$dev->{cleanup}->() if $dev->{cleanup}; | 
         | 
   160 	verbose "Done.\n";  | 
         | 
   161     }  | 
         | 
   162   | 
         | 
   163 }  | 
         | 
   164   | 
         | 
   165 sub verbose(@) { | 
         | 
   166     return if not $opt_verbose;   | 
         | 
   167     print @_;  | 
         | 
   168 }  | 
         | 
   169   | 
         | 
   170 sub get_candidates() { | 
         | 
   171 # return the list of backup candidates  | 
         | 
   172   | 
         | 
   173     my @dev;  | 
         | 
   174   | 
         | 
   175     # later we need the major of the device mapper  | 
         | 
   176     my $dev_mapper = 0;  | 
         | 
   177     $_ = (grep /device.mapper/, slurp("/proc/devices"))[0] | 
         | 
   178 	and $dev_mapper = (split)[0];  | 
         | 
   179   | 
         | 
   180     foreach (slurp("/etc/fstab")) { | 
         | 
   181 	my ($dev, $mp, $fstype, $options, $dump, $check)  | 
         | 
   182 	    = split;  | 
         | 
   183 	next if not $dump;  | 
         | 
   184   | 
         | 
   185 	# $dev does not have to contain the real device  | 
         | 
   186 	my $rdev = $dev;  | 
         | 
   187 	if ($dev ~~ /^(LABEL|UUID)=/) { | 
         | 
   188 	    chomp($rdev = `blkid -c /dev/null -o device -t '$dev'`);  | 
         | 
   189 	}  | 
         | 
   190 	$rdev = readlink $rdev while -l $rdev;  | 
         | 
   191   | 
         | 
   192 	# if it's LVM we gather more information (to support snapshots)  | 
         | 
   193 	# FIXME: could have used `lvdisplay -c'  | 
         | 
   194 	my $lvm;  | 
         | 
   195 	if ((stat $rdev)[6] >> 8 == $dev_mapper) { | 
         | 
   196 	    @{$lvm}{qw/vg lv/} = map { s/--/-/g; $_ } basename($rdev) =~ /(.+[^-])-([^-].+)/; | 
         | 
   197 	    $lvm->{path} = "$lvm->{vg}/$lvm->{lv}"; | 
         | 
   198 	}  | 
         | 
   199   | 
         | 
   200 	push @dev, { | 
         | 
   201 	    dev => $dev,  | 
         | 
   202 	    rdev => $rdev,  | 
         | 
   203 	    mountpoint => $mp,  | 
         | 
   204 	    fstype => $fstype,  | 
         | 
   205 	    lvm => $lvm,  | 
         | 
   206 	};  | 
         | 
   207     }  | 
         | 
   208   | 
         | 
   209     return @dev;  | 
         | 
   210 }  | 
         | 
   211   | 
         | 
   212 sub get_configs(@) { | 
         | 
   213     local $_;  | 
         | 
   214     my %r = ();  | 
         | 
   215     foreach (grep {-f} map { (-d) ? glob("$_/*") : $_ } @_) { | 
         | 
   216   | 
         | 
   217 	# check permission and ownership  | 
         | 
   218 	{ | 
         | 
   219 	    my $p = (stat)[2] & 07777;  | 
         | 
   220 	    my $u = (stat _)[4];  | 
         | 
   221 	    die "$ME: $_ has wrong permissions: found @{[sprintf '%04o', $p]}, need 0600\n" | 
         | 
   222 		if $p != 0600;  | 
         | 
   223 	    die "$ME: owner of $_ ($u) is not the EUID ($EUID) of this process\n"  | 
         | 
   224 		if (stat _)[4] != $EUID;  | 
         | 
   225   | 
         | 
   226 	    # FIXME: should check the containing directories too!  | 
         | 
   227 	};  | 
         | 
   228   | 
         | 
   229 	my $f = new IO::File $_ or die "Can't open $_: $!\n";  | 
         | 
   230 	my %h = map { split /\s*=\s*/, $_, 2 } grep {!/^\s*#/ and /=/} <$f>; | 
         | 
   231 	map { chomp } values %h; | 
         | 
   232 	%r = (%r, %h);  | 
         | 
   233     }  | 
         | 
   234     return %r;  | 
         | 
   235 }  | 
         | 
   236   | 
         | 
   237 { package FTP;  | 
         | 
   238   use strict;  | 
         | 
   239   use warnings;  | 
         | 
   240   use base qw(Net::FTP);  | 
         | 
   241   | 
         | 
   242   sub new { | 
         | 
   243     my $class = shift;  | 
         | 
   244     return bless Net::FTP->new(@_) => $class;  | 
         | 
   245   }  | 
         | 
   246   | 
         | 
   247   sub try { | 
         | 
   248     my $self = shift;  | 
         | 
   249     my $func = shift;  | 
         | 
   250     $self->$func(@_)  | 
         | 
   251 	or die "FTP $func failed: " . $self->message . "\n";  | 
         | 
   252   }  | 
         | 
   253   | 
         | 
   254   sub mkpath { | 
         | 
   255     my $self = shift;  | 
         | 
   256     my $current = $self->pwd();  | 
         | 
   257     foreach (split /\/+/, $_[0]) { | 
         | 
   258 	next if $self->cwd($_);  | 
         | 
   259 	return undef if not $self->message ~~ /no such .*dir/i;  | 
         | 
   260 	return undef if not $self->SUPER::mkdir($_);  | 
         | 
   261 	return undef if not $self->cwd($_);  | 
         | 
   262     }  | 
         | 
   263     $self->cwd($current);  | 
         | 
   264   }  | 
         | 
   265 }  | 
         | 
   266   | 
         | 
   267 __END__  | 
         | 
   268   | 
         | 
   269 =head1 NAME  | 
         | 
   270   | 
         | 
   271 py2b - backup tool  | 
         | 
   272   | 
         | 
   273 =head1 SYNOPSIS  | 
         | 
   274   | 
         | 
   275     py2b [--level <level>] [options]  | 
         | 
   276   | 
         | 
   277 =head1 OPTIONS  | 
         | 
   278   | 
         | 
   279 =over  | 
         | 
   280   | 
         | 
   281 =item B<-d>|B<--debug> [I<item>]  | 
         | 
   282   | 
         | 
   283 Enables debugging for the specified items (comma separated).  | 
         | 
   284 If no item is specified, just some debugging is done.  | 
         | 
   285   | 
         | 
   286 Valid items are B<ftp> and currently nothing else.  | 
         | 
   287   | 
         | 
   288 Even more debugging is shown using the DEBUG=1 environment setting.  | 
         | 
   289   | 
         | 
   290 =item B<-f>|B<--force>  | 
         | 
   291   | 
         | 
   292 Use more power (e.g. overwrite a previous level backup and remove all  | 
         | 
   293 invalidated other backups). (default: 0)  | 
         | 
   294   | 
         | 
   295 =item B<-l>|B<--level> I<level>  | 
         | 
   296   | 
         | 
   297 The backup level. Level other than "0" needs a previous  | 
         | 
   298 level 0 (full) backup. (default: 0)  | 
         | 
   299   | 
         | 
   300 =item B<-v>|B<--verbose>  | 
         | 
   301   | 
         | 
   302 Be verbose. (default: no)  | 
         | 
   303   | 
         | 
   304 =back  | 
         | 
   305   | 
         | 
   306 =head1 FILES  | 
         | 
   307   | 
         | 
   308 The config files are searched in the following places:  | 
         | 
   309   | 
         | 
   310     /etc/py2b  | 
         | 
   311     ~/.py2b  | 
         | 
   312     ./py2b.conf  | 
         | 
   313   | 
         | 
   314 If the location is a directory, all (not hidden) files in this directory are  | 
         | 
   315 considered to be config, if the location a file itself, this is considered to  | 
         | 
   316 be a config file. The config files have to be mode 0600 and they have to be   | 
         | 
   317 owned by the EUID running the process.  | 
         | 
   318   | 
         | 
   319 =cut  | 
         | 
   320   | 
         | 
   321 # vim:sts=4 sw=4 aw ai sm:  | 
         |