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