Commit | Line | Data |
---|---|---|
99248ed2 MW |
1 | #! /bin/sh |
2 | ### | |
3 | ### Manage the backup archive structure | |
4 | ### | |
5 | ### (c) 2011 Mark Wooding | |
6 | ### | |
7 | ||
8 | ###----- Licensing notice --------------------------------------------------- | |
9 | ### | |
13678d88 MW |
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 | |
99248ed2 MW |
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 | ### | |
13678d88 | 17 | ### distorted-backup is distributed in the hope that it will be useful, |
99248ed2 MW |
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 | ### | |
13678d88 MW |
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, | |
99248ed2 MW |
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 -------------------------------------------------- |