chiark / gitweb /
Makefile.am: Stupid workaround for new Automake pettiness.
[distorted-backup] / bkpadmin.in
1 #! /bin/sh
2 ###
3 ### Manage the backup archive structure
4 ###
5 ### (c) 2011 Mark Wooding
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of the distorted.org.uk backup suite.
11 ###
12 ### distorted-backup is free software; you can redistribute it and/or modify
13 ### it under the terms of the GNU General Public License as published by
14 ### the Free Software Foundation; either version 2 of the License, or
15 ### (at your option) any later version.
16 ###
17 ### distorted-backup is distributed in the hope that it will be useful,
18 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
19 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 ### GNU General Public License for more details.
21 ###
22 ### You should have received a copy of the GNU General Public License along
23 ### with distorted-backup; if not, write to the Free Software Foundation,
24 ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
25
26 set -e
27
28 ## Configuration and testing.
29 : ${BKP=/mnt/bkp} ${META=/mnt/bkpmeta}
30 : ${KEYS=/etc/keys}
31
32 case $(id -u) in 0) ;; *) exec userv root bkpadmin "$@" ;; esac
33
34 ###--------------------------------------------------------------------------
35 ### Common utilities.
36
37 quis=${0##*/}
38 version="@VERSION@"
39
40 moan () {
41   ## Print a complaint to standard error.
42
43   echo >&2 "$quis: $*"
44 }
45
46 die () {
47   ## Print a complaint and exit.
48
49   moan "$*"
50   exit 1
51 }
52
53 cleanups=""
54 addcleanup () {
55   cmd=$1
56   ## Add a cleanup command CMD to the list.
57
58   case "$cleanups" in
59     ?*)
60       ;;
61     *)
62       trap 'rc=$?; for c in $cleanups; do $c; done; exit $rc' \
63         EXIT INT TERM
64       ;;
65   esac
66   cleanups=${cleanups+$cleanups }$cmd
67 }
68
69 rmtmp () { case ${tmpdir+t} in t) rm -rf "$tmpdir" ;; esac }
70 addcleanup rmtmp
71 mktmp () {
72   ## Make a temporary directory and output its name.
73
74   case "${tmpdir+t}" in
75     t)
76       ;;
77     *)
78       i=0
79       while :; do
80         r=$(openssl rand -base64 12)
81         tmpdir=${TMPDIR-/tmp}/$quis.$$.$r
82         if mkdir -m700 "$tmpdir" >/dev/null 2>&1; then break; fi
83         case $i in ???) die "failed to create temporary directory" ;; esac
84         i=$(( $i + 1 ))
85       done
86       ;;
87   esac
88   echo "$tmpdir"
89 }
90
91 ###--------------------------------------------------------------------------
92 ### Command dispatch.
93
94 case "${USERV_USER+t}" in t) uservp=t ;; *) uservp=nil ;; esac
95
96 USAGE="COMMAND [ARGUMENT ...]"
97 cmdname=""
98 cmdargs=$USAGE
99 cmds=""
100 _defcmd () {
101   name=$1; shift; args=$*
102   ## Define a command unconditionally.
103
104   cmds="${cmds:+$cmds
105 }$name $args"
106 }
107
108 defcmd () {
109   ## Define a command for privileged users only.
110
111   case $uservp in nil) _defcmd "$@" ;; esac
112 }
113
114 defucmd () {
115   ## Define a command usable via userv.
116
117   _defcmd "$@"
118 }
119
120 usage () {
121   ## Write a usage message for the current command.
122
123   echo "usage: $quis${cmdname:+ $cmdname}${cmdargs:+ $cmdargs}"
124 }
125
126 usage_err () {
127   ## Fail with a usage error.
128
129   usage >&2
130   exit 1
131 }
132
133 lookupcmd () {
134   cmd=$1
135   ## Try to loop up the command CMD.
136
137   while read cmdname cmdargs; do
138     case $cmdname in "$cmd") return ;; esac
139   done <<EOF
140 $cmds
141 EOF
142   die "unknown command \`$cmd'"
143 }
144
145 defucmd help
146 cmd_help () {
147   case $# in 0) ;; *) usage_err ;; esac
148
149   cat <<EOF
150 $quis, version $version
151
152 usage: $quis $USAGE
153
154 Commands provided:
155 EOF
156   while read cmd args; do
157     echo "      $cmd${args:+ $args}"
158   done <<EOF
159 $cmds
160 EOF
161 }
162
163 ###--------------------------------------------------------------------------
164 ### Utility functions.
165
166 sign () {
167   file=$1
168   ## Sign the named FILE, producing a signature FILE.sig.
169
170   seccure-sign -F$KEYS/priv/backup-auth -cp256 -s"$file.sig" <"$file"
171 }
172
173 checkhost () {
174   ## Check that a host is defined.
175
176   case "${host+t}" in
177     t) ;; *) die "no host defined (use \`-H')" ;;
178   esac
179 }
180
181 checkthing () {
182   thing=$1 good=$2 what=$3 string=$4
183   ## Check that STRING is a valid THING -- i.e., it only consists of GOOD
184   ## characters.
185
186   case "$string" in
187     *[!$good]*)
188       die "bad $thing \`$string' given for $what"
189       ;;
190   esac
191 }
192
193 checknum () {
194   what=$1 string=$2
195   ## Check that STRING is at least plausibly numeric.
196
197   checkthing number "0-9" "$what" "$string"
198 }
199
200 checkpath () {
201   what=$1 string=$2
202   ## Check that STRING is a plausible pathname.
203
204   case "$string" in
205     .* | */.* | *[!-a-zA-Z0-9.,_#!%^+=@/:]*)
206       die "bad pathname \`$string' given for $what"
207       ;;
208   esac
209 }
210
211 checkword () {
212   what=$1 thing=$2
213   ## Check that THING doesn't need shell quoting, and doesn't interfere with
214   ## other common delimiter characters.  (Colons aren't allowed because they
215   ## mess up /etc/passwd; slashes aren't allowed because they're directory
216   ## separators.  Leading dots aren't allowed either.  Hashes seem OK.)
217
218   checkthing word "-a-zA-Z0-9.,_#!%^+=@" "$what" "$string"
219 }
220
221 domkdir () {
222   dir=$1 owner=$2 mode=$3
223   ## Make a directory and set permissions on it.
224
225   mkdir -m755 "$dir"
226   chown $owner "$dir"
227   chmod $mode "$dir"
228 }
229
230 ###--------------------------------------------------------------------------
231 ### Volume and volume group maintenance.
232
233 currenttag () {
234   ## Output the tag of the mounted backup volume group.
235
236   dev=$(mntdev $BKP)
237   case "$dev" in
238     /dev/mapper/cbkp-*) echo "${dev#*-}"; return ;;
239     *) die "failed to parse tag from device name \`$dev'" ;;
240   esac
241 }
242
243 guesstag () {
244   ## Guess and print the tag of the available backup volume group.  If there
245   ## is not exactly one volume group available, print an error and fail.
246
247   LVM_SUPPRESS_FD_WARNINGS=t vgs @backup --noheadings -o name,attr | {
248     match=""
249     while read name attr; do
250       case "$name" in bkp-*) ;; *) continue ;; esac
251       case "$attr" in ??x*) continue ;; esac
252       match="$match${match:+ }${name#bkp-}"
253     done
254     case "x$match" in
255       x) die "no backup volume groups available" ;;
256       x*\ *) die "multiple backup volume groups available: $match" ;;
257     esac
258     echo "$match"
259   }
260 }
261
262 mntdev () {
263   dir=$1
264   ## Output a device name for the filesystem mounted on DIR.
265
266   dev=$(mountpoint -d "$dir")
267   devname=$(udevadm info --query=name --path="/dev/block/$dev")
268   case "$devname" in
269     dm-*)
270       devname=mapper/$(dmsetup info -c --noheadings -oname "/dev/$devname")
271       ;;
272   esac
273   echo "/dev/$devname"
274 }
275
276 mntmeta () {
277   tag=$1
278   ## Mount the metadata volume of the backup volume group named TAG.
279
280   if ! mountpoint -q $META; then
281     mount "/dev/bkp-$tag/meta" $META
282   fi
283 }
284
285 cryptkey () {
286   ## Decrypt and output the key for the encrypted volume.  This assumes that
287   ## the metadata volume is already mounted on /mnt/bkpmeta.
288
289   seccure-decrypt -q -m128 -cp256 -F$KEYS/priv/backup-disk <$META/cur/blob
290 }
291
292 decrypt () {
293   tag=$1
294   ## Decrypt but don't mount the encrypted volume of the backup volume group
295   ## named TAG.
296
297   mntmeta "$tag"
298   if [ ! -b "/dev/mapper/cbkp-$tag" ]; then
299     cryptkey | cryptsetup luksOpen --key-file=- \
300       "/dev/bkp-$tag/crypt" "cbkp-$tag"
301   fi
302 }
303
304 mntcrypt () {
305   tag=$1
306   ## Mount the encrypted subvolume of the backup volume group named TAG.  The
307   ## metadata volume will be mounted if necessary.
308
309   decrypt "$tag"
310   if ! mountpoint -q $BKP; then
311     mount "/dev/mapper/cbkp-$tag" $BKP
312   fi
313 }
314
315 umnt () {
316   ## Unmounts a backup volume group: both the encrypted and metadata volumes
317   ## are unmounted.
318
319   if mountpoint -q $BKP; then
320     tag=$(currenttag) cryptclosep=t
321   else
322     cryptclosep=nil
323   fi
324   for i in bkp bkpmeta; do
325     if mountpoint -q /mnt/$i; then umount /mnt/$i; fi
326   done
327   case $cryptclosep in
328     t)
329       if [ -b "/dev/mapper/cbkp-$tag" ]; then
330         cryptsetup luksClose "cbkp-$tag"
331       fi
332   esac
333 }
334
335 defcmd initvol TAG DEVICE
336 cmd_initvol () {
337   case $# in 2) ;; *) usage_err ;; esac
338   tag=$1 dev=$2
339
340   vgcreate --addtag @backup "bkp-$tag" "$dev"
341
342   lvcreate -L4M -nmeta "bkp-$tag"
343   mkfs -text2 -Lmeta "/dev/bkp-$tag/meta"
344   mntmeta "$tag"
345
346   mkdir -m755 $META/new
347   dd if=/dev/random bs=1 count=512 |
348   seccure-encrypt -m128 $(cat $KEYS/pub/backup-disk.pub) >$META/new/blob
349   mv $META/new $META/cur
350
351   lvcreate -l100%FREE -ncrypt "bkp-$tag"
352   cryptkey | cryptsetup luksFormat \
353     --cipher=twofish-xts-benbi:sha256 --hash=sha256 \
354     "/dev/bkp-$tag/crypt" -
355   decrypt "$tag"
356   mkfs -text2 -Lbackup -i1048576 "/dev/mapper/cbkp-$tag"
357   mntcrypt "$tag"
358 }
359
360 defucmd mount "[TAG]"
361 cmd_mount () {
362   case $# in
363     0) tag=$(guesstag) check=nil ;;
364     1) tag=$1 check=t ;;
365     *) usage_err ;;
366   esac
367
368   if mountpoint -q $BKP; then
369     curtag=$(currenttag)
370     case "$check,$curtag" in "t,$tag") ;; t*) exit 1 ;; esac
371   else
372     mntcrypt "$tag"
373   fi
374 }
375
376 defcmd umount
377 cmd_umount () {
378   case $# in 0) ;; *) usage_err ;; esac
379   mntp=nil
380   for i in bkp bkpmeta; do
381     if mountpoint -q /mnt/$i; then mntp=t; fi
382   done
383   case $mntp in
384     nil) die "backup volume not mounted" ;;
385   esac
386   umnt
387 }
388
389 ###--------------------------------------------------------------------------
390 ### Archive maintenance.
391
392 checkdir () {
393   key=$1 dir=$2
394   ## Check a directory which has `hashes' and `hashes.sig' files.
395
396   if ! seccure-verify -q -i"$dir/hashes" -- \
397     $(cat "$KEYS/$key") $(cat "$dir/hashes.sig")
398   then
399     die "failed to verify signature for \`$dir'"
400   fi
401
402   cd "$dir"
403   sha256sum --quiet -c hashes
404
405   tmpdir=$(mktmp)
406   find . -type f -print | sed 's:^\./::' | sort >"$tmpdir/present"
407   { echo hashes
408     echo hashes.sig
409     sed 's/^[a-f0-9]*[* ] //' hashes
410   } | sort >"$tmpdir/checked"
411   cd "$tmpdir"
412   diff -u checked present
413 }
414
415 fixperms () {
416   dir=$1 owner=$2 fmode=$3 dmode=$4
417   ## Fix the directory tree DIR so that everything is owned by OWNER (a
418   ## USER:GROUP pair) and has modes FMODE for files and DMODE for
419   ## directories.
420
421   ## Change all of the ownerships.  This will prevent anyone else from
422   ## changing the permissions on the files.  This assumes that chown(1) is
423   ## secure in recursive mode; I've checked that GNU chown seems correct.
424   chown -R $owner "$dir"
425
426   ## Paranoia: check that we correctly changed all of the files.
427   u=${owner%:*} g=${owner#*:}
428   (cd "$dir"; find . ! \( -user $u -group $g \) -ls) |
429   if read line; then
430     moan "failed to fix permssions on \`$dir'"
431     { echo $line; cat; } | sed 's/^/    /'
432     exit 1
433   fi
434
435   ## Now get to work on the file and directory permissions.
436   find "$dir" -type d -print0 | xargs -0r chmod $dmode
437   find "$dir" ! -type d -print0 | xargs -0r chmod $fmode
438 }
439
440 commitdir () {
441   dir=$1 target=$2
442   ## Commit an `prepare' directory DIR, moving its `incoming' files to
443   ## TARGET.  This will choose the correct name for the directory, but
444   ## assumes that it's already correctly laid out.  We assume that the
445   ## permissions on this directory are safe (e.g., they've already been fixed
446   ## using `fixperms').  On successful exit, DIR won't exist any more.  The
447   ## shell variable `label' is set to the resulting archive name.
448
449   ## If there's no `incoming' directory, then there's nothing to do.  Just
450   ## zap the directory and move on.
451   if [ ! -d "$dir/incoming" ]; then
452     rm -rf "$dir"
453     return
454   fi
455
456   ## Find the datestamp and level numbers to use for this directory.  These
457   ## are created before the `incoming' directory, so they ought to exist.
458   read level date time tz <"$dir/meta"
459
460   ## Find a suitable sequence number for the target.  This is rather ugly;
461   ## sorry.
462   seq=1
463   while :; do
464     anyp=nil
465     for i in "$target"/"$date#$seq".*; do
466       if [ -e "$i" ]; then anyp=t; break; fi
467     done
468     case $anyp in nil) break ;; esac
469     seq=$(( $seq + 1 ))
470   done
471
472   ## Move the directory.
473   label="$date#$seq.$level"
474   mv "$dir/incoming" "$target/$label"
475   rm -rf "$dir"
476
477   ## Update the catalogue.  Replace an existing dump at the same level.
478   ## Assume that dates are monotonically increasing: add the new entry at the
479   ## end.
480   { found=nil
481     while read lab l d t; do
482       if [ $l -ne $level ]; then echo $label $l $d $t; fi
483     done <"$target"/CATALOGUE
484     echo $level $date $time $tz
485   } >"$target"/CATALOGUE.new
486   mv "$target"/CATALOGUE.new "$target"/CATALOGUE
487 }
488
489 defcmd initmeta
490 cmd_initmeta () {
491   case $# in 0) ;; *) usage_err ;; esac
492
493   ## Make a `new' directory and start recording our files.
494   cd $META
495   rm -rf new
496   mkdir -m755 new
497   f=""
498
499   ## Copy the blob from the existing metadata.
500   cp cur/blob new/
501   f="$f blob"
502
503   ## Archive the key recovery information.
504   cd $KEYS
505   tar cfz $META/new/keys.tgz pub/ recov/
506   f="$f keys.tgz"
507
508   ## Copy user and group information.
509   cd $META/new
510   for i in passwd group; do
511     grep -E '^(root|backup|bkp-[[:alnum:]]+):' /etc/$i >$i
512   done
513   f="$f passwd group"
514
515   ## Build the hashes file, and sign it.
516   chown root:root $f
517   chmod 644 $f
518   sha256sum $f >hashes
519   sign hashes
520
521   ## Replace the old metadata.
522   cd $META
523   mv cur old
524   mv new cur
525   rm -rf old
526 }
527
528 defcmd chkmeta
529 cmd_chkmeta () {
530   case $# in 0) ;; *) usage_err ;; esac
531
532   checkdir pub/backup-auth.pub $META/cur
533 }
534
535 today () {
536   ## Report the current date, as ISO8601.  Allow an override.
537
538   case "${forceday+t}" in t) echo "$forceday" ;; *) date +%Y-%m-%d ;; esac
539 }
540
541 defucmd prep ASSET LEVEL \[DATE TIME TZ]
542 cmd_prep () {
543   case $# in
544     2) set -- "$@" $(today) $(date +%H:%M:%S) $(date +%z) ;;
545     5) ;;
546     *) usage_err ;;
547   esac
548   asset=$1 level=$2 date=$3 time=$4 tz=$5
549   checkhost
550   checkword asset "$asset"
551   checknum level "$level"
552   checkthing date -0-9 date "$date"
553   checkthing time :0-9 time "$time"
554   checkthing timezone -+0-9 tz "$tz"
555
556   ## Make the host and asset directories if necessary.
557   cd $BKP
558   for i in $host $asset; do
559     if [ ! -d $i ]; then domkdir $i root:root 755; fi
560     cd $i
561   done
562   if [ ! -d failed ]; then domkdir failed root:root 755; fi
563   for i in . failed; do
564     if [ ! -f $i/CATALOGUE ]; then
565       touch $i/CATALOGUE
566       chown root:root $i/CATALOGUE
567       chmod 644 $i/CATALOGUE
568     fi
569   done
570
571   ## If an existing dump is in progress then archive it as a failure.
572   if [ -d prepare ]; then
573     if [ -d prepare/incoming ]; then
574       fixperms prepare/incoming root:root 640 755
575     fi
576     commitdir prepare failed/
577   fi
578
579   ## Make a new preparation directory.
580   domkdir prepare root:bkp-$host 755
581   echo $level $date $time $tz >prepare/meta
582   domkdir prepare/incoming bkp-$host:bkp-$host 2775
583
584   ## Print the directory name.
585   echo $BKP/$host/$asset/prepare/incoming
586 }
587
588 defucmd abort ASSET
589 cmd_abort () {
590   case $# in 1) ;; *) usage_err ;; esac
591   asset=$1
592   checkhost
593   checkword asset "$asset"
594
595   ## Check that there's something to abort.
596   cd $BKP
597   if [ ! -d $host/$asset/prepare ]; then
598     die "no dump in progress for $host/$asset"
599   fi
600
601   ## Just throw it away.
602   rm -rf $host/$asset/prepare
603 }
604
605 defucmd fail ASSET
606 cmd_fail () {
607   case $# in 1) ;; *) usage_err ;; esac
608   asset=$1
609   checkhost
610   checkword asset "$asset"
611
612   ## Check that there's something to fail.
613   cd $BKP
614   if [ ! -d $host/$asset/prepare ]; then
615     die "no dump in progress for $host/$asset"
616   fi
617
618   ## Archive the failure.  This shouldn't be used to determine dump levels or
619   ## we'll have gaps when things get sorted out.
620   cd $host/$asset
621   if [ -d prepare/incoming ]; then
622     fixperms prepare/incoming root:root 640 755
623   fi
624   commitdir prepare failed/
625 }
626
627 julian () {
628   date=$1
629   ## Convert an ISO8601 DATE to a Julian Day Number.
630
631   ## Extract the components of the date and trim leading zeros (which will
632   ## cause things to be interpreted as octal and fail).
633   year=${date%%-*} rest=${date#*-}; month=${rest%%-*} day=${rest#*-}
634   year=${year#0} month=${month#0} day=${day#0}
635
636   ## The actual calculation: convert a (proleptic) Gregorian calendar date
637   ## into a Julian day number.  This is taken from Wikipedia's page
638   ## http://en.wikipedia.org/wiki/Julian_day#Calculation but the commentary
639   ## is mine.  The epoch is 4713BC-01-01 (proleptic) Julian, or 4714BC-11-24
640   ## proleptic Gregorian.
641
642   ## If the MONTH is January or February then set a = 1, otherwise set a = 0.
643   a=$(( (14 - $month)/12 ))
644
645   ## Compute a year offset relative to 4799BC-03-01.  This puts the leap day
646   ## as the very last day in a year, which is very convenient.  The offset
647   ## here is sufficient to make all y values positive (within the range of
648   ## the JDN calendar), and is a multiple of 400, which is the Gregorian
649   ## cycle length.
650   y=$(( $year + 4800 - $a ))
651
652   ## Compute the offset month number in that year.  These months count from
653   ## zero, not one.
654   m=$(( $month + 12*$a - 3 ))
655
656   ## Now for the main event.  The (153 m + 2)/5 term is a surprising but
657   ## correct trick for obtaining the number of days in the first m months of
658   ## the (shifted) year).  The magic offset 32045 is what you get when you
659   ## plug the proper JDN epoch (year = -4713, month = 11, day = 24) into the
660   ## above machinery.
661   jdn=$(( $day + (153*$m + 2)/5 + 365*$y + $y/4 - $y/100 + $y/400 - 32045 ))
662
663   echo $jdn
664 }
665
666 dumplevel () {
667   fulldate=$1 lastdate=$2
668   ## Return the dump level, given that the most recent full dump occurred on
669   ## FULLDATE and the most revent dump of any kind occurred on LASTDATE.
670
671   ## Actually, we're much more interested in the day difference between these
672   ## two times.
673   fulljdn=$(julian $fulldate)
674   lastjdn=$(julian $lastdate)
675   now=$(today); nowjdn=$(julian $now)
676   lastday=$(( $lastjdn - $fulljdn ))
677   today=$(( $nowjdn - $fulljdn ))
678
679   ## If the difference is greater than 512 then we know we should do a full
680   ## dump.  (This provides an upper bound for the search below.  It should
681   ## never happen in practice, of course.)
682   if [ $(( $today - $lastday )) -ge 512 ]; then echo 0; return; fi
683
684   ## Now we work out the correct dump level.  This will assume that the
685   ## previous dump had a sensible level.  If dumps are omitted, then we will
686   ## choose a lower (more comprehensive) dump level than the schedule calls
687   ## for; such an overestimation will mean that we will probably end up
688   ## dumping too much again.  This is the right error to make.
689   ##
690   ## We use a Towers of Hanoi schedule.  If we're doing dumps every day, then
691   ## on day n since the last full dump, we work out the dump level as
692   ## follows: write n = 2^s t where t is odd (i.e., s is the number of
693   ## trailing zero bits in the binary representation of n); then the dump
694   ## level on day n is 9 - s.  This is enough for 512 days without a full
695   ## dump, and it fails gracefully anyway.
696   ##
697   ## Now we have to deal with the problem of skipping dumps.  Suppose the
698   ## last dump was on day m = 2^u v, and it's now day n = 2^s t.  We ought to
699   ## take the lowest dump level of any intervening day, i.e., the dump level
700   ## is 9 - a for the largest a such that there exists b with m < l = 2^a b
701   ## <= n.  We claim that such an l is unique.  Suppose, to the contrary,
702   ## that m < 2^a b < 2^a b' <= n, with both b and b' odd.  Then m < 2^{a+1}
703   ## (b + 1)/2 <= n, contradicting maximality of a.
704   ##
705   ## How does this help?  Observe that n = 2^s t = 2^a b + o, for some o <
706   ## 2^a: if o >= 2^a then 2^a (b + 1) <= n contradicting uniqueness of l.
707   ## Similarly, m = 2^u v = 2^a b - r, for some r <= 2^a (otherwise m <
708   ## 2^a (b - 1), again contradicting uniqueness).  Therefore, m and n are
709   ## identical from bit a + 1 onwards, and differ at bit a.  In other words,
710   ## a is the position of the most significant set bit in m XOR n.
711   diff=$(( lastday ^ today ))
712
713   ## We know that the bit position must be less than 16.
714   t=16 n=0
715   while [ $diff -gt 1 ]; do
716     xx=$(( $diff >> $t ))
717     if [ $xx -gt 0 ]; then
718       diff=$xx n=$(( $n + $t ))
719     fi
720     t=$(( $t >> 1 ))
721   done
722
723   echo $(( 9 - $n ))
724 }
725
726 defucmd level ASSET
727 cmd_level () {
728   case $# in 1) ;; *) usage_err ;; esac
729   asset=$1
730   checkhost
731   checkword asset "$asset"
732
733   ## Set the correct directory.  If it doesn't exist then we obviously need a
734   ## level-0 dump.
735   cd $BKP
736   full="0 1970-01-01 00:00:00 +0000"
737   if [ ! -d $host/$asset ]; then echo $full; return; fi
738   cd $host/$asset
739
740   ## We need the time of the most recent dump of any kind, and the most
741   ## recent level-zero dump.
742   fulldate=none lastdate=none
743   while read label level date time tz; do
744     if [ $level -eq 0 ]; then fulldate=$date; fi
745     lastdate=$date
746   done <CATALOGUE
747   case $fulldate in none) echo $full; return ;; esac
748   level=$(dumplevel $fulldate $lastdate)
749
750   ## Determine the time of the most recent dump of the same or more inclusive
751   ## level.
752   date=none
753   while read lab l d t; do
754     if [ $l -le $level ]; then date=$d time=$t; fi
755   done <CATALOGUE
756   echo $level $date $time $tz
757 }
758
759 defucmd hash ASSET FILE HASH
760 cmd_hash () {
761   case $# in 3) ;; *) usage_err ;; esac
762   asset=$1 file=$2 hash=$3
763   checkword asset "$asset"
764   checkpath file "$file"
765   checkword hash "$hash"
766
767   cd $BKP/$host/$asset/prepare
768
769   if [ -f hashes ]; then
770     while read h f; do
771       case "$f" in "$file") die "file \`$file' already hashed" ;; esac
772     done <hashes
773     cp hashes hashes.new
774   fi
775   echo "$hash  $file" >>hashes.new
776   mv hashes.new hashes
777 }
778
779 defucmd commit ASSET
780 cmd_commit () {
781   case $# in 1) ;; *) usage_err ;; esac
782   asset=$1
783   checkhost
784   checkword asset "$asset"
785
786   cd $BKP/$host/$asset/prepare
787   fixperms incoming root:bkp-$host 640 755
788   findargs=""
789
790   if [ -f hashes ]; then
791     while read hash name; do
792       if [ ! -f "incoming/$name" ]; then
793         die "precomputed hash for nonexistent or non-file \`$name'"
794       fi
795       findargs="$findargs ! -path incoming/$name"
796     done <hashes
797     cp hashes hashes.calc
798   fi
799
800   find incoming -type f $findargs -print0 | \
801     xargs -0r sha256sum | \
802     sed 's:  incoming/:  :' \
803     >>hashes.calc
804   sort -k2 hashes.calc >incoming/hashes
805   sign incoming/hashes
806   chmod 640 incoming/hashes incoming/hashes.sig
807   chown root:bkp-$host incoming/hashes incoming/hashes.sig
808
809   cd ..
810   commitdir prepare .
811   echo "$label"
812 }
813
814 defucmd check ASSET LABEL
815 cmd_check () {
816   case $# in 2) ;; *) usage_err ;; esac
817   asset=$1 label=$2
818   checkhost
819   checkword asset "$asset"
820   checkword label "$label"
821
822   checkdir pub/backup-auth.pub $BKP/$host/$asset/$label
823 }
824
825 defucmd catalogue ASSET
826 cmd_catalogue () {
827   case $# in 1) ;; *) usage_err ;; esac
828   asset=$1
829   checkhost
830   checkword asset "$asset"
831
832   cat $BKP/$host/$asset/CATALOGUE
833 }
834
835 defucmd outdated ASSET
836 cmd_outdated () {
837   case $# in 1) ;; *) usage_err ;; esac
838   asset=$1
839   checkhost
840   checkword asset "$asset"
841
842   cd $BKP/$host/$asset
843   for i in [0-9]*#*.*; do
844     if [ -d "$i" ]; then echo "$i"; fi
845   done |
846   sort -rn |
847   { best=10
848     while read tag; do
849       date=${tag%%#*} level=${tag##*.}
850       if [ $level -le $best ]
851       then best=$level
852       else echo "$tag"
853       fi
854     done
855   }
856 }
857
858 ###--------------------------------------------------------------------------
859 ### Main program.
860
861 defcmd test CMD '[ARGS ...]'
862 cmd_test () { "$@"; }
863
864 case $uservp in
865   t)
866     host=${USERV_USER#bkp-}
867     opts="h"
868     ;;
869   nil)
870     unset host
871     opts="hH:D:"
872     ;;
873 esac
874
875 while getopts "$opts" opt; do
876   case "$opt" in
877     h) cmd_help; exit ;;
878     H) host=$OPTARG ;;
879     D) forceday=$OPTARG ;;
880     *) usage_err ;;
881   esac
882 done
883 shift $(( $OPTIND - 1 ))
884
885 case $# in 0) usage_err ;; esac
886 lookupcmd "$1"; shift
887 cmd_$cmdname "$@"
888
889 ###----- That's all, folks --------------------------------------------------