Blob


1 #!/bin/sh
2 # ISC License
3 #
4 # Copyright 2025 xs <xs@inda.re>
5 #
6 # Permission to use, copy, modify, and/or distribute this software for any
7 # purpose with or without fee is hereby granted, provided that the above
8 # copyright notice and this permission notice appear in all copies.
9 #
10 # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
11 # REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
12 # FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
13 # INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
14 # LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
15 # OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
16 # PERFORMANCE OF THIS SOFTWARE.
18 # shellcheck disable=SC2046,SC2031
20 # Global configuration
21 # ====================
23 VERSION=0.1.3
24 : "${HOSTNAME:=$(uname -n)}"
26 # shellcheck disable=SC2031
27 _init_env() {
28 _debug _INIT_ENV
30 # Here is the default configuration
31 : "${R7_DEBUG:=no}"
32 : "${R7_DRYRUN:=no}"
33 : "${R7_GROUP_ALLPREFIX:=no}"
34 : "${R7_GROUP_SUMMARY:=no}"
35 : "${R7_GROUP_EXITONERROR:=no}"
36 : "${R7_PARALLEL:=no}"
37 : "${R7_PREFIX:=install state doc}"
38 : "${R7_SHOWOUTPUT:=no}"
39 : "${R7_WORKDIR:=$PWD}"
40 : "${R7_SITE_CONFIG:=$R7_WORKDIR/site-config}"
41 : "${R7_OUTPUTDIR:=$R7_WORKDIR/_output}"
42 : "${SSH_CONFIG_DIR:=$R7_WORKDIR/.ssh}"
43 : "${SSH_CONFIG_FILE:=$R7_WORKDIR/.ssh/config}"
44 : "${SSH_CONTROL_DIR:=$SSH_CONFIG_DIR/control}"
45 : "${SSH_IDENTITY_FILE:=$R7_WORKDIR/.ssh/id_ed25519_r7}"
46 : "${SSH_KNOWN_HOSTS:=$R7_WORKDIR/.ssh/known_hosts}"
47 : "${SSH_CONNECT_TIMEOUT:=3}"
49 _debug ENVIRONMENT
51 # Validate environment
52 if test "$R7_DEBUG" = yes; then
53 set | grep -e '^R7_' -e '^SSH' 1>&2
54 fi
56 for param in "$R7_DEBUG" "$R7_DRYRUN" "$R7_PARALLEL"; do
57 case $param in
58 yes | no) : ;;
59 *) _fail "Invalid parameter value: '$param'" ;;
60 esac
61 done
63 R7_WORKDIR_tmp=$(_resolv_dir "$R7_WORKDIR")
64 test -d "$R7_WORKDIR_tmp" || _error "Working directory not found: '$R7_WORKDIR'"
65 R7_WORKDIR=$R7_WORKDIR_tmp
66 R7_RUNDIR=$R7_WORKDIR/_run
67 R7_OUTPUTDIR=$R7_WORKDIR/_output
69 R7_SITE_CONFIG_tmp=$(_resolv_file "$R7_SITE_CONFIG")
70 test -f "$R7_SITE_CONFIG_tmp" || _error "Site configuration not found: '$R7_SITE_CONFIG'"
71 R7_SITE_CONFIG=$R7_SITE_CONFIG_tmp
73 SSH_CONFIG_FILE_tmp=$(_resolv_file "$SSH_CONFIG_FILE")
74 test -f "$SSH_CONFIG_FILE_tmp" || _error "Unable to read SSH configuration: '$SSH_CONFIG_FILE'"
75 SSH_CONFIG_FILE=$SSH_CONFIG_FILE_tmp
77 SSH_IDENTITY_FILE_tmp=$(_resolv_file "$SSH_IDENTITY_FILE")
78 test -f "$SSH_IDENTITY_FILE_tmp" || _error "Unable to read SSH identity file: '$SSH_IDENTITY_FILE'"
79 SSH_IDENTITY_FILE=$SSH_IDENTITY_FILE_tmp
81 R7_ENV_READY=yes
82 unset R7_WORKDIR_tmp R7_SITE_CONFIG_tmp SSH_CONFIG_FILE_tmp SSH_IDENTITY_FILE_tmp
83 }
85 # Interface
86 # =========
88 _fail() {
89 printf "FAILURE: %s\n" "$*" >&2
90 exit 99
91 }
93 _error() {
94 printf "ERROR: %s\n" "$*" >&2
95 }
97 _debug() {
98 test "$R7_DEBUG" = yes && printf %s\\n "${*:-$(cat -)}" >&2
99 }
101 info() {
102 if test "$R7_ENV_READY" = yes; then
103 cd "$R7_WORKDIR" || return 2
104 _trace_id_last=$(trace_id_last)
105 if test -n "$_trace_id_last"; then
106 printf -- "- (%s) - in %s the %s\n" \
107 "$_trace_id_last" "$(basename "$R7_WORKDIR")" "$(_epoch "$_trace_id_last")"
108 nodes_active | sed 's/^/ - (active) /'
109 echo
110 fi
111 output_dump
112 summary
113 fi
114 return 0
117 alias help=usage
118 usage() {
119 cat <<eof
120 version: $VERSION
121 usage: $0 [options] [command [arguments...]]
122 usage options:
123 -h Show usage and exit
124 -d Debug
125 -i SSH identity file
126 -F SSH client configuration file
127 -w Work directory where to find ./nodes and ./groups
128 -r Run directory, default to ./_run
129 -o Output directory, default to ./_output
130 -c Site configuration file, default to ./site-config
131 -n Dry run, prevent any host modification
132 -e Exit the group execution on the error function
133 -p Run each host in parallel per group call
134 -O Show output interactively
135 -P Prefix
136 -a Select all available prefixes for a group command
137 -s Output-dump and summary are applied after a group command
139 commands:
140 run-config Run the site configuration
141 group Deploy specified group to hosts
142 output-dump Dump the run directory to the output directory
143 output Consult the output
144 summary Summary of errors and changes
145 digraph-host Produce digraph host
146 digraph-ssh Produce SSH IDs digraph
147 import Import TARGET host in current nodes directory
148 info Default command showing active nodes and summary
149 init Initialize a new work directory
151 site-config format:
152 prefix [prefix...] # Define the prefix to be executed in order
153 group groupname ['.*'|hostname...] # Deploy specified group to hosts
155 eof
158 # shellcheck disable=SC2086
159 _main() {
160 test -n "$ZSH_VERSION" && {
161 echo >&2 "ZSH is unsupported. Exiting."
162 return 3
164 while getopts 'P:F:r:o:i:w:c:npdseaOh' arg; do
165 case $arg in
166 h)
167 usage
168 return 0
169 ;;
170 d)
171 R7_DEBUG=yes
172 _debug "R7 DEBUG $VERSION"
173 ;;
174 i) SSH_IDENTITY_FILE=$OPTARG ;;
175 F) SSH_CONFIG_FILE=$OPTARG ;;
176 w) R7_WORKDIR=$OPTARG ;;
177 r) R7_RUNDIR=$OPTARG ;;
178 o) R7_OUTPUTDIR=$OPTARG ;;
179 s) R7_GROUP_SUMMARY=yes ;;
180 a) R7_GROUP_ALLPREFIX=yes ;;
181 e) R7_GROUP_EXITONERROR=yes ;;
182 c) R7_SITE_CONFIG=$OPTARG ;;
183 n)
184 R7_DRYRUN=yes
185 _debug "MODE DRYRUN"
186 ;;
187 p) R7_PARALLEL=yes ;;
188 P)
189 R7_PREFIX=$OPTARG
190 prefix $R7_PREFIX
191 ;;
192 O) R7_SHOWOUTPUT=yes ;;
193 *)
194 usage
195 echo 1>&2 "Unknown option '$arg'"
196 exit 2
197 ;;
198 esac
199 done
200 shift $((OPTIND - 1))
202 case $1 in
203 init | workdir-init)
204 shift
205 _workdir_init "$@"
206 exit $?
207 ;;
208 esac
210 _init_env
212 case $1 in
213 rc | run-config) run_config ;;
214 group)
215 shift
216 trace_id=$(_trace_id_new)
217 if test "$R7_GROUP_ALLPREFIX" = yes; then
218 prefix $(group_prefixes "$1")
219 else
220 prefix $R7_PREFIX
221 fi
222 group "$@"
223 if test "$R7_GROUP_SUMMARY" = yes; then
224 output_dump
225 summary
226 fi
227 ;;
228 info) info ;;
229 import)
230 shift
231 import "$@"
232 ;;
233 out | output)
234 shift
235 output "$@"
236 ;;
237 outdump | output-dump) output_dump ;;
238 summary) summary ;;
239 digraph-host)
240 prefix $R7_PREFIX
241 digraph_host
242 ;;
243 digraph-ssh)
244 digraph_ssh_id_hosts
245 ;;
246 html) html "$@" ;;
247 '') info ;;
248 *)
249 case $0 in
250 -) exit 33 ;;
251 *r7)
252 echo 1>&2 "Unknown command '$1'"
253 usage
254 exit 2
255 ;;
256 *) prefix $R7_PREFIX ;;
257 esac
258 ;;
259 esac
262 _resolv_dir() {
263 if test -d "$1"; then
264 cd "$1" || return 2
265 printf %s "$PWD"
266 fi
269 _resolv_file() {
270 if test -f "$1"; then
271 cd "${1%/*}" || return 2
272 printf %s "$PWD/${1##*/}"
273 fi
276 # shellcheck disable=SC2030
277 _workdir_init() (
278 _debug _WORKDIR_INIT "$PWD" "$R7_WORKDIR"
279 if test -n "$R7_WORKDIR"; then
280 mkdir -p "$R7_WORKDIR" || _fail "Unable to create directory: '$R7_WORKDIR'"
281 cd "$R7_WORKDIR" || _fail "Unable to cd to directory: '$R7_WORKDIR'"
282 R7_WORKDIR=$PWD
283 fi
284 R7_WORKDIR=$PWD
285 mkdir -p groups
286 mkdir -p nodes
287 mkdir -p .ssh/control
288 : "${SSH_CONFIG_FILE:=$R7_WORKDIR/.ssh/config}"
289 : "${SSH_IDENTITY_FILE:=$R7_WORKDIR/.ssh/id_ed25519_r7}"
290 R7_SITE_CONFIG=./site-config
291 if ! test -f "$R7_SITE_CONFIG"; then
292 cat >"$R7_SITE_CONFIG" <<-eof
293 # Example r7 site configuration
294 group common '.*'
295 eof
296 fi
297 if ! test -f "$SSH_CONFIG_FILE"; then
298 cat >"$SSH_CONFIG_FILE" <<eof
299 # Example SSH client configuration
300 Host *
301 AddKeysToAgent yes
302 BatchMode yes
303 ConnectTimeout 3
304 ConnectionAttempts 1
305 ControlMaster auto
306 #ControlPath $R7_WORKDIR/.ssh/control/%C
307 ControlPath $R7_WORKDIR/.ssh/control/%r@%h:%p
308 ControlPersist 2m
309 IdentityFile $SSH_IDENTITY_FILE
310 StrictHostKeyChecking yes
311 User root
312 UserKnownHostsFile $R7_WORKDIR/.ssh/known_hosts
314 Host alpha
315 Hostname 10.10.0.1
317 Host beta
318 Hostname 10.10.0.2
320 Host gamma
321 Hostname 10.10.0.3
322 eof
323 fi
324 touch "${SSH_IDENTITY_FILE}" "${SSH_IDENTITY_FILE}.pub"
325 touch .ssh/known_hosts
326 echo Created:
327 find "$PWD" -exec printf -- "\t- %s\n" {} +
330 # SSH functions
331 # =============
333 ssh() (
334 _debug "SSH $*"
335 /usr/bin/ssh "-oConnectTimeout=$SSH_CONNECT_TIMEOUT" -F "$SSH_CONFIG_FILE" "$@"
338 alias ssh-debug=ssh_debug
339 ssh_debug() {
340 _debug "SSH $*"
341 ssh -vvv "$@" :
344 alias ssh-known-hosts=ssh_known_hosts
345 ssh_known_hosts() {
346 /usr/bin/ssh-keygen -lv -f "${SSH_KNOWN_HOSTS:-$R7_WORKDIR/.ssh/known_hosts}"
349 alias ssh-authorized-keys-reset=ssh_authorized_keys_reset
350 alias ssh-authreset=ssh_authorized_keys_reset
351 ssh_authorized_keys_reset() {
352 ssh -o BatchMode=no "$@" "
353 mkdir -p ~/.ssh
354 chmod 700 ~/.ssh
355 touch ~/.ssh/authorized_keys
356 chmod 600 ~/.ssh/authorized_keys
357 echo '$(cat "$SSH_IDENTITY_FILE.pub")' >~/.ssh/authorized_keys
361 alias ssh-accept-new=ssh_accept_new
362 ssh_accept_new() {
363 ssh -o StrictHostKeyChecking=accept-new "$@" :
366 alias ssh-control-master-clean=ssh_control_master_clean
367 ssh_control_master_clean() {
368 find "$SSH_CONTROL_DIR" -type f -exec rm {} \;
371 alias ssh-users=ssh_users
372 ssh_users() {
373 cd "$R7_WORKDIR/nodes" || _fail "Unable to cd to '$R7_WORKDIR/nodes'."
374 find ./* -type f -name "authorized_keys_*" |
375 sed 's;^./;;' |
376 sort | while read -r keyfile; do
377 echo "- $keyfile:"
378 grep -v "^#" "$R7_WORKDIR/nodes/$keyfile"
379 done
382 alias ssh-ids=ssh_ids
383 ssh_ids() {
384 ssh_users | grep -v ^- | sort -u
387 alias ssh-authorized-keys=ssh_authorized_keys
388 alias ssh-auth=ssh_authorized_keys
389 ssh_authorized_keys() {
390 ssh_users | grep -e ^- | sed -e 's/^- /nodes\//' -e 's/:$//'
393 alias ssh-ids-hosts=ssh_ids_hosts
394 # shellcheck disable=SC2046
395 ssh_ids_hosts() {
396 ssh_ids | awk '{ print $3 }' |
397 while read -r id; do
398 grep -e "$id" $(ssh_authorized_keys)
399 done | while IFS="$IFS/:" read -r _ hostname _ keyfile _ _ comment; do
400 printf '"%s" -> "%s" -> "%s"\n' "${comment#* }" "${keyfile#authorized_keys_}" "$hostname"
401 done | sort
404 unlock() {
405 pkill ssh-agent
406 eval "$(ssh-agent)"
407 ssh-add "$SSH_IDENTITY_FILE"
410 copy() (
411 test "$R7_DRYRUN" = yes && return 0
412 _debug "COPY $1 $2 BEGIN"
413 node=${2%%:*}
414 dir=${2##*:}
415 if r=$(ssh "$node" command -v openrsync); then
416 if openrsync -V 2>/dev/null; then
417 _debug "COPY $1 $2 USE OPENRSYNC"
418 openrsync -e "/usr/bin/ssh -oConnectTimeout=$SSH_CONNECT_TIMEOUT -F $SSH_CONFIG_FILE" \
419 --rsync-path="$r" \
420 --delete \
421 -a "$1" "$2" && return 0
422 fi
423 fi
424 if r=$(ssh "$node" command -v rsync); then
425 if rsync -V 1>/dev/null; then
426 _debug "COPY $1 $2 USE RSYNC"
427 rsync -e "/usr/bin/ssh -oConnectTimeout=$SSH_CONNECT_TIMEOUT -F $SSH_CONFIG_FILE" \
428 --rsync-path="$r" \
429 --delete \
430 -aq "$1" "$2" && return 0
431 fi
432 fi
433 _debug "COPY $1 $2 USE SCP"
434 dir=${dir:-/tmp/r7}
435 ssh "$node" rm -fr "$dir/$1" 2>/dev/null
436 /usr/bin/scp "-oConnectTimeout=$SSH_CONNECT_TIMEOUT" -qF "$SSH_CONFIG_FILE" -r "$1" "$2"
439 # Deployment functions
440 # ====================
442 _trace_id_new() { date +%s; }
444 nodes() (
445 cd "$R7_WORKDIR" || return 2
446 test -d ./nodes || return 0
447 find nodes -type d -maxdepth 1 | tail -n +2 | awk -F/ '{ print $2 }'
450 alias nodes-active=nodes_active
451 alias nodesact=nodes_active
452 nodes_active() (
453 test "$R7_DRYRUN" = yes && {
454 nodes
455 return 0
457 if test -s "$R7_WORKDIR/.active_nodes"; then
458 cat "$R7_WORKDIR/.active_nodes"
459 else
460 touch "$R7_WORKDIR/.active_nodes"
461 for node in $(nodes); do
462 _debug "ACTIVE $node"
463 if ssh "$node" :; then
464 printf "%s\\n" "$node" >>"$R7_WORKDIR/.active_nodes"
465 fi
466 done
467 cat "$R7_WORKDIR/.active_nodes"
468 fi
471 alias run-config=run_config
472 alias rc=run_config
473 # shellcheck disable=SC1090,SC2031
474 run_config() {
475 trace_id=$(_trace_id_new)
476 # shellcheck disable=SC1091
477 _debug LOADING "$R7_SITE_CONFIG"
478 sh -n "$R7_SITE_CONFIG" && . "$R7_SITE_CONFIG"
481 # Inherits $target from group()
482 # shellcheck disable=SC2031
483 _deploy() (
484 _debug "DEPLOY $new_name $target"
485 cd "$R7_WORKDIR" || exit 2
486 stem="groups/$new_name"
487 nodedir="nodes/$target"
488 test "$R7_DRYRUN" = yes || {
489 if test -d "$stem.dir"; then
490 ssh "$target" 'test -d /tmp/r7 || { umask 027; mkdir -p /tmp/r7; }'
491 copy "$stem.dir" "$target:/tmp/r7" || return 3
492 fi
493 if test -d "$nodedir"; then
494 test -f /tmp/r7/"$trace_id.$target" || {
495 ssh "$target" 'test -d /tmp/r7 || { umask 027; mkdir -p /tmp/r7; }'
496 copy "$nodedir" "$target:/tmp/r7" || return 3
498 fi
500 if test -f "$stem"; then
501 if _payload "$stem" | sh -n; then
502 _debug SYNTAX OK "$stem"
503 test "$R7_DRYRUN" = yes || {
504 _payload "$stem" | ssh 2>&1 "$target" /bin/sh - |
505 _collect "$R7_RUNDIR/$trace_id.$target.$new_name"
507 else
508 _debug SYNTAX KO "$stem"
509 echo 1>&2 "syntax error in '$stem', skipping..."
510 return 3
511 fi
512 fi
515 members() (
516 set -f -- $(printf -- "-e %s " "$@")
517 nodes_active | grep "$@"
520 # shellcheck disable=SC2030
521 group() (
522 test $# -eq 0 && return 0
523 cd "$R7_WORKDIR" || return 2
524 umask 027
525 test "$R7_DRYRUN" = yes || {
526 mkdir -p "$R7_RUNDIR" || return 2
527 cd "$R7_RUNDIR" || return 2
529 _debug "CREATED $R7_RUNDIR"
530 new_name=$1
531 _debug "PROCESSING GROUP $new_name"
532 shift
533 # shellcheck disable=SC2046
534 set -fm
535 for target in $(members "$@"); do
536 case $R7_PARALLEL in
537 yes)
538 _deploy &
539 ;;
540 *)
541 _deploy
542 ;;
543 esac
544 done
545 wait
548 group_prefixes() (
549 grep 2>/dev/null -e "^.*()" "$R7_WORKDIR/groups/$1" | cut -d\( -f1 | cut -d_ -f1 | uniq
552 alias group-functions=group_functions
553 alias groupfuncs=group_functions
554 group_functions() (
555 if test "$R7_GROUP_ALLPREFIX" = yes; then
556 grep 2>/dev/null -e "^.*()" "$R7_WORKDIR/groups/$1" | cut -d\( -f1
557 return 0
558 fi
559 for pre in $prefix; do
560 grep 2>/dev/null -e "^${pre}_.*()" "$R7_WORKDIR/groups/$1" | cut -d\( -f1
561 done
564 prefix() {
565 prefix="$*"
566 R7_PREFIX=$prefix
567 export prefix R7_PREFIX
570 _call() (
571 cd "$R7_WORKDIR" || return 2
572 grep -e "^$2_.*()" "$1" |
573 cut -d\( -f1 | while read -r function; do
574 _debug "CALL $1 $function"
575 echo trace "${1#*/}" "$function" START
576 echo "_ret=; $function || { _ret=\$?; : ;}"
577 echo trace "${1#*/}" "$function" END "\${_ret:-0}"
578 test "$R7_DRYRUN" = yes && {
579 echo 1>&2 "- (dryrun) $target:$new_name::$function"
581 done
584 _payload() (
585 echo "trace_id=$trace_id"
586 echo "groupname=$new_name"
587 echo "exitonerror=$R7_GROUP_EXITONERROR"
588 cat <<-'eof'
589 trace() {
590 echo ":: $trace_id $(nodename) $*" 1>&2
592 error() {
593 _ret=$?
594 trace ERROR "$*" 1>&2
595 test "$exitonerror" = yes && {
596 case $_ret in (0) _ret=1 ;; esac
597 trace EXIT $_ret
598 exit $_ret
601 nodename() (
602 hostname=${hostname:-$(uname -n)}
603 echo "${hostname%%.*}"
605 node() (
606 atom=/tmp/r7/$(nodename)/$1
607 test -f "$atom" && return 0
608 test -d "$atom" && return 0
609 return 31
611 nodedir() {
612 echo "/tmp/r7/$(nodename)"
614 groupdir() (
615 echo "/tmp/r7/$groupname.dir"
617 _presource() {
618 test $# -gt 0 || {
619 error missing source argument
620 return 2
622 source "$(test -f "$1" && printf %s "$PWD/${1##"$PWD"}")"
624 nodesource() {
625 node "$1" && cd "$(nodedir)" && _presource "$1"
627 groupsource() {
628 cd "$(groupdir)" && _presource "$1"
630 source() {
631 if sh -n "$1"; then
632 trace SOURCE "$1"
633 . "$1"
634 else
635 trace SOURCE FAILED "$1"
636 return 2
637 fi
639 install() ( cd /tmp/r7 && r7_install "$@" ; )
640 groupinstall() ( cd "$(groupdir)" && r7_install "$@" ; )
641 nodeinstall() ( cd "$(nodedir)" && r7_install "$@" ; )
642 _getperm() {
643 test -f "$1" && {
644 ls -l "$1" | grep -v total | awk '{ print $1 " " $3 " " $4 }'
647 _setperm() {
648 before=$(_getperm "$1")
649 if test -n "$owner"; then
650 chown "$owner" "$1" || {
651 error chown "$1"
652 return 2
654 fi
655 if test -n "$mode"; then
656 chmod "$mode" "$1" || {
657 error chmod "$1"
658 return 2
660 fi
661 after=$(_getperm "$1")
662 if test "$before" != "$after"; then
663 trace PERMISSIONS CHANGE "$before" TO "$after" ON "$1"
664 fi
666 _install() {
667 test -e "$2" && {
668 trace DIFF
669 diff -U2 "$2" "$1"
671 cp "$1" "$2" ||
672 { error copying "$source" to "$2"; return 2 ; }
673 _setperm "$2"
674 return 0
676 r7_install() (
677 while getopts "o:m:" arg; do
678 case $arg in
679 o) owner=$OPTARG ;;
680 m) mode=$OPTARG ;;
681 *) : ;;
682 esac
683 done
684 shift $((OPTIND - 1))
685 source=$1
686 target=$2
687 source_dirname=${1%/*}
688 if ! test -e "$source"; then
689 error "$source" not found
690 return 2
691 fi
692 case $target in
693 */) test -d "$target" || {
694 error "$target" directory not found
695 return 2
696 } ;;
697 esac
698 if test -e "$target"; then
699 cmp -s "$source" "$target"
700 ret=$?
701 destination_is=file
702 fi
703 if test -d "$target"; then
704 new_filename="$target/${source##$source_dirname/}"
705 cmp -s "$source" "$new_filename"
706 ret=$?
707 destination_is=directory
708 fi
709 if test -e "$source"; then
710 if test -d "$target"; then
711 destination_is=directory
712 else
713 destination_is=file
714 fi
715 fi
716 case "$ret" in
717 0) return 30 ;;
718 *)
719 case $destination_is in
720 directory)
721 new_filename="$target/${source##$source_dirname/}"
722 _install "$source" "$new_filename"
723 ;;
724 file)
725 _install "$source" "$target"
726 ;;
727 esac
728 ;;
729 esac
731 installdir() { r7_installdir "$@" ; }
732 groupinstalldir() ( cd "$(groupdir)" && r7_installdir "$@" ; )
733 nodeinstalldir() ( cd "$(nodedir)" && r7_installdir "$@" ; )
734 r7_installdir() (
735 while getopts "o:m:O:M:" arg; do
736 case $arg in
737 o) fileowner=$OPTARG ;;
738 m) filenode=$OPTARG ;;
739 O) dirowner=$OPTARG ;;
740 M) dirmode=$OPTARG ;;
741 *) : ;;
742 esac
743 done
744 shift $((OPTIND - 1))
745 source=$1
746 target=$2
747 source_basename=${source##*/}
748 source_dirname=$PWD/${source%%/*}
749 target_basename=${target##*/}
750 test -d "$source" || {
751 error "$source" directory not found
752 return 2
754 for dir in $(find "$source" -type d); do
755 newdir=${dir#./}
757 cd "$target" || return 2
758 mkdir -p "$newdir" || { error unable to create "$newdir"; return 2 ; }
759 owner=$dirowner mode=$dirmode _setperm "$1"
761 done
762 for file in $(find "$source" -type f); do
763 newfile=${file#./}
764 r7_install -o "$fileowner" -m "$filemode" "$file" "$target/$newfile"
765 done
767 set -e
768 cd /tmp/r7 && umask 027
769 set +e
770 nodename=$(nodename)
771 nodedir=$(nodedir)
772 groupdir=$(groupdir)
773 trace CONNECTED $(date +%s)
774 eof
775 cat "$1"
776 _debug "PREFIX $R7_PREFIX"
777 for run in $R7_PREFIX; do
778 _call "$1" "$run"
779 done
780 cat <<-'eof'
781 trace DISCONNECT $(date +%s)
782 eof
785 # Interactive output function
786 # ===========================
788 # shellcheck disable=SC2086,SC2030
789 _collect() (
790 tee -a "$1" | while read -r line; do
791 set -- $line
792 case $line in
793 ::*)
794 # Traces from payload
795 trace_id=$2
796 host=$3
797 group=$4
798 function=$5
799 status=$6
800 endcode=$7
801 # Traces from _call
802 case $status in
803 START)
804 save_group=$group save_function=$function
805 in_function=1
806 if test "$R7_SHOWOUTPUT" = yes; then
807 echo "- (output) $host:$group::$function"
808 fi
809 continue
810 ;;
811 END)
812 in_function=0
813 case $endcode in
814 0 | 30 | 31) echo "- (ok) $host:$group::$function" ;;
815 *) echo "! (error $endcode) $host:$group::$function" ;;
816 esac
817 ;;
818 esac
819 # Traces from functions
820 case $2 in
821 "$trace_id")
822 case $4 in
823 SOURCE) echo "+ (source) $host:$save_group::$save_function" ;;
824 DIFF) echo "+ (diff) $host:$save_group::$save_function" ;;
825 PERMISSIONS) echo "+ (permissions) $host:$save_group::$save_function" ;;
826 ERROR) echo "! (error) $host:$save_group::$save_function" ;;
827 EXIT) echo "! (exit) $host:$save_group::$save_function" ;;
828 esac
829 ;;
830 esac
831 ;;
832 esac
834 if test "$R7_SHOWOUTPUT" = yes; then
835 test "$in_function" -eq 1 &&
836 printf "%s\\n" "$line"
837 fi
838 done
841 # Text output functions
842 # =====================
844 alias output-dump=output_dump
845 alias outdump=output_dump
846 output_dump() (
847 set -e -- $(output_latest)
848 test $# -eq 0 && return 0
849 cd "$R7_WORKDIR" || _fail "Unable to cd to workdir: $R7_WORKDIR"
850 test "$R7_DRYRUN" = yes || {
851 mkdir -p "$R7_OUTPUTDIR"
852 awk -voutput_dir="$R7_OUTPUTDIR" '
853 BEGIN {
854 trace_id=""
855 hostname=""
856 funcname=""
857 groupname=""
858 in_function_block=0
860 /^:: [0-9].*/ {
861 if ($4 == "CONNECTED") {
862 trace_id=$2
863 hostname=$3
864 timestamp_connect=$5
865 system("mkdir -p " output_dir "/" hostname)
868 $2 == trace_id && $6 == "START" {
869 groupname=$4
870 funcname=$5
871 in_function_block=1
872 output_node = output_dir "/" hostname "/" groupname "/" funcname
873 output_status = output_node "/status"
874 output_output = output_node "/output"
875 system("rm -fr " output_node)
876 system("mkdir -p " output_node)
877 next
879 $2 == trace_id && $4 == "EXIT" {
880 in_function_block=0
881 system("echo " $5 " >" output_status)
883 $2 == trace_id && $6 == "END" {
884 in_function_block=0
885 system("echo " $7 " >" output_status)
887 in_function_block == 1 {
888 print > output_output
890 $2 == trace_id && $4 == "DISCONNECT" {
891 timestamp_disconnect=$5
892 difference=timestamp_disconnect - timestamp_connect
893 output_duration = output_dir "/" hostname "/" groupname "/duration"
894 system("echo " difference " >" output_duration)
896 ' "$@"
900 _epoch() (
901 case $(uname) in
902 OpenBSD | FreeBSD | NetBSD | Darwin)
903 date -r "$1"
904 ;;
905 Linux)
906 date -d "@$1"
907 ;;
908 *)
909 perl -e "print scalar localtime($1)"
910 ;;
911 esac
915 alias output-latest=output_latest
916 alias outlatest=output_latest
917 output_latest() (
918 set -e
919 latest=$(
920 # shellcheck disable=SC2012
921 ls 2>/dev/null -lt1 "$R7_RUNDIR/" | while read -r file; do
922 echo "${file%%.*}"
923 break
924 done
926 test -z "$latest" && return 0
927 latest_date=$(_epoch "$latest")
928 if test -n "$latest_date"; then
929 find "$R7_RUNDIR" -type f -name "$latest.*"
930 fi
933 _output_files() (
934 cd "$R7_OUTPUTDIR" 2>/dev/null || return 2
935 find ./* -type f -name 'output'
938 _status_files() (
939 cd "$R7_OUTPUTDIR" 2>/dev/null || return 2
940 find ./* -type f -name 'status'
943 _duration_files() (
944 cd "$R7_OUTPUTDIR" 2>/dev/null || return 2
945 find ./* -type f -name 'duration'
948 _column() {
949 column -t 2>/dev/null ||
950 cat -
953 alias output-status=output_status
954 alias outstat=output_status
955 output_status() (
956 cd "$R7_OUTPUTDIR" || return 2
957 echo HOST GROUP FUNCTION STATUS
958 _status_files | {
959 while read -r status; do
960 echo "$status" | while IFS=/ read -r _ host groupname funcname _; do
961 echo "$host" "$groupname" "$funcname" "$(cat "$status")"
962 done
963 done
964 } | _column
967 alias output-duration=output_duration
968 alias outdur=output_duration
969 output_duration() (
970 cd "$R7_OUTPUTDIR" || return 2
971 echo HOST GROUP DURATION
972 _duration_files | {
973 while read -r duration; do
974 echo "$duration" | while IFS=/ read -r _ host groupname _; do
975 echo "$host" "$groupname" "$(cat "$duration")"
976 done
977 done
978 } | _column
981 alias out=output
982 # shellcheck disable=SC2254
983 output() (
984 cd "$R7_OUTPUTDIR" || return 2
985 _output_files | while read -r output; do
986 echo "$output" | while IFS=/ read -r _ host groupname funcname _; do
987 case $host in ${1:-*})
988 case $groupname in ${2:-*})
989 case $funcname in ${3:-*})
990 echo "- $host" "$groupname" "$funcname"
991 echo
992 sed 's/^/ /' "$R7_OUTPUTDIR/$output"
993 echo
994 ;;
995 esac
996 ;;
997 esac
998 ;;
999 esac
1000 done
1001 done
1004 alias trace-id-last=trace_id_last
1005 alias lastid=trace_id_last
1006 trace_id_last() {
1007 output_latest | while read -r output; do
1008 output=${output##*/}
1009 output=${output%%.*}
1010 echo "$output"
1011 break
1012 done
1015 # _output/tools0/r7test/r7test_diff/output::: 1748408055 tools0 DIFF /tmp/difftest r7test.dir/difftest
1016 alias output-diffs=output_diffs
1017 alias outdiffs=output_diffs
1018 # shellcheck disable=SC2048,SC2086
1019 output_diffs() (
1020 trace_id=$(trace_id_last)
1021 cd "$R7_OUTPUTDIR" || return 0
1022 grep -r "^:: ${trace_id:-} .* DIFF" | uniq | while read -r line; do
1023 set -- $line
1024 output_file=${1%%:::}
1025 OIFS=$IFS
1026 IFS=/
1027 set -- $output_file
1028 host=$1 groupname=$2 funcname=$3
1029 IFS=$OIFS
1030 echo "- (diff) -" "in" "$host" "$groupname" "$funcname" "($trace_id)"
1031 echo
1032 sed 's/^/ /' "$R7_OUTPUTDIR/$host/$groupname/$funcname/output" | grep -v "^ :: $trace_id $host"
1033 echo
1034 done || return 0
1035 return 0
1038 # :: 1748415578 tools0 PERMISSIONS CHANGE -rw-r-xr-x root wheel -> -rw-r--r-- root wheel on /tmp/difftest
1039 alias output-permissions=output_permissions
1040 alias outperm=output_permissions
1041 # shellcheck disable=SC2048,SC2086
1042 output_permissions() (
1043 trace_id=$(trace_id_last)
1044 cd "$R7_OUTPUTDIR" || return 0
1045 grep -r "^:: ${trace_id:-} .* PERMISSIONS" | while read -r line; do
1046 set -- $line
1047 output_file=${1%%:::}
1048 OIFS=$IFS
1049 IFS=/
1050 set -- $output_file
1051 host=$1 groupname=$2 funcname=$3
1052 IFS=$OIFS
1053 echo "- (permissions) -" "in" "$host" "$groupname" "$funcname" "($trace_id)"
1054 echo
1055 grep -e "^:: ${trace_id:-} .* PERMISSIONS CHANGE" "$R7_OUTPUTDIR/$host/$groupname/$funcname/output" |
1056 while read -r _ _ _ _ _ mode owner group _ mode2 owner2 group2 _ filename; do
1057 echo " - $filename from $mode $owner:$group to $mode2 $owner2:$group2"
1058 done
1059 echo
1060 done
1061 return 0
1064 alias output-errors=output_errors
1065 alias outerr=output_errors
1066 output_errors() (
1067 trace_id=$(trace_id_last)
1068 output_status | while read -r host groupname funcname status; do
1069 case $status in
1070 STATUS | 0 | 30 | 31) continue ;;
1071 esac
1072 echo "- (error $status) -" "in" "$host" "$groupname" "$funcname" "($trace_id)"
1073 if test -f "$R7_OUTPUTDIR/$host/$groupname/$funcname/output"; then
1074 echo
1075 sed 's/^/ /' "$R7_OUTPUTDIR/$host/$groupname/$funcname/output"
1076 echo
1078 done
1079 return 0
1082 summary() (
1083 test -d "$R7_OUTPUTDIR" || return 0
1084 summary_out=$(
1085 output_errors
1086 output_diffs
1087 output_permissions
1089 test -n "$summary_out" &&
1090 printf "%s\n" "$summary_out"
1091 return 0
1094 # Digraph functions
1095 # =================
1097 # Used in the digraph generation because inside a heredoc
1098 # context, memebers don't work as expected (OKSH).
1099 # shellcheck disable=SC2048,SC2086
1100 _members2() (
1101 case $1 in
1102 "'.*'")
1103 nodes | grep -e '.*'
1104 return 0
1106 esac
1107 set -f -- $(printf -- "-e %s " "$@")
1108 nodes | grep $*
1111 alias digraph-host=digraph_host
1112 # shellcheck disable=SC2086
1113 digraph_host() (
1114 _debug "DIGRAPH HOST"
1115 cat <<-eof
1116 digraph host {
1117 layout="dot"
1118 splines="true"
1119 graph [overlap="false", rankdir=LR, mode=KK]
1121 set -f
1122 for node in $(nodes); do echo "\"$node\" [peripheries=2 color=blue]"; done
1123 grep 2>/dev/null "^group" "$R7_SITE_CONFIG" | while read -r _ groupname nodes; do
1124 echo "\"$groupname\" [color=orange]"
1125 for node in $(_members2 $nodes); do
1126 echo "\"$node\" -> \"$groupname\""
1127 done
1128 for funct in $(group_functions "$groupname"); do
1129 echo "\"$groupname::$funct\" [color=red]"
1130 echo "\"$groupname\" -> \"$groupname::$funct\""
1131 done
1132 done
1135 eof
1138 alias digraph-ssh-id-hosts=digraph_ssh_id_hosts
1139 digraph_ssh_id_hosts() {
1140 cat <<-eof
1141 digraph ssh_id_hosts {
1142 layout="dot"
1143 splines="true"
1144 graph [overlap="false"]
1145 $(ssh_ids_hosts)
1147 eof
1150 # Html
1151 # ====
1152 # TODO: WIP
1154 md() {
1155 pandoc -t html -f markdown+smart "$@"
1158 _su() {
1159 summary | while read -r line; do
1160 set -- $line
1161 case $1 in
1163 printf %s\\n "$line"
1165 *) continue ;;
1166 esac
1167 echo
1168 echo '```'
1169 cat "$R7_OUTPUTDIR/$6/$7/$8/output"
1170 echo '```'
1171 done
1174 html() {
1175 _debug HTML
1176 cat <<-eof
1177 <!DOCTYPE html>
1178 <html lang="en">
1179 <head>
1180 <meta charset="UTF-8">
1181 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1182 <title>inda.re report</title>
1183 <style type="text/css" media="screen">
1184 html { margin: 4%; }
1185 body {
1186 margin: 0 auto;
1187 max-width: 40em;
1188 padding-left: 50px;
1189 padding-right: 50px;
1190 padding-top: 50px;
1191 padding-bottom: 50px;
1192 hyphens: auto;
1193 overflow-wrap: break-word;
1194 text-rendering: optimizeLegibility;
1195 font-kerning: normal;
1197 p {
1198 text-align: justify;
1199 text-justify: inter-word;
1201 @media screen and (min-width: 1000px) {
1202 html {
1203 max-width:1300px;
1204 margin: auto;
1207 @media (prefers-color-scheme: dark) {
1208 :root {
1209 --red: #E05434;
1210 --green: #20B700;
1211 --blue: #A7C1E5;
1212 --buildhead: #474749;
1214 :link {
1215 color: #BAD7FF;
1217 :visited {
1218 color: #F6BAFF;
1220 body {
1221 background-color: #1E1F21;
1222 color: #EEEFF1;
1225 </style>
1226 </head>
1227 <body>
1228 <h1>${PWD##*/}</h1>
1229 <nav>
1230 <ul>
1231 <li><a href="#Documentation">Documentation</a></li>
1232 <li><a href="#Summary">Summary</a></li>
1233 </ul>
1234 </nav>
1235 <main>
1236 <section id="Documentation">
1237 $(md README.md)
1238 </section>
1239 <section id="Summary">
1240 $(_su | md)
1241 </section>
1242 </main>
1243 <footer>
1244 <p>r7</p>
1245 </footer>
1246 </body>
1247 </html>
1248 eof
1249 return 0
1252 # Importers
1253 # =========
1254 # TODO: WIP
1256 import() {
1257 cd "$R7_WORKDIR" || _fail "Unable to cd to '$R7_WORKDIR'"
1258 target=$1
1259 etc_files_to_import='
1260 acme-client.conf
1261 dhcpd.conf
1262 dhcpcd.conf
1263 doas.conf
1264 fstab
1265 hosts
1266 httpd.conf
1267 installurl
1268 login.conf
1269 mygate
1270 myname
1271 ntpd.conf
1272 pf.conf
1273 sysctl.conf
1274 unwind.conf
1275 resolv.conf
1276 gotd.conf
1277 gotwebd.conf
1279 etc_mail_files_to_import='
1280 mail/aliases
1281 mail/smtpd.conf
1282 mail/spamd.conf
1284 etc_ssh_files_to_import='
1285 ssh/sshd_config
1286 ssh/ssh_config
1288 var_unbound_files='
1289 db/named.root
1290 etc/unbound.conf
1292 var_nsd_files='
1293 etc/nsd.conf
1295 var_postgresql_files='
1296 data/pg_hba.conf
1297 data/postgresql.conf
1299 if test -n "$target"; then
1300 echo >&2 "-> Importing target host: $target"
1301 case $(ssh "$target" uname) in OpenBSD) : ;; *)
1302 _error "Platform unsupported or host unavailable."
1303 return 3
1305 esac
1306 _OpenBSD_import_ssh_authorized_keys
1307 _OpenBSD_import_etc_if_exists
1308 _OpenBSD_import_etc_ssh_if_exists
1309 _OpenBSD_import_etc_mail_if_exists
1310 _OpenBSD_import_unbound_if_enabled
1311 _OpenBSD_import_nsd_if_enabled
1312 _OpenBSD_import_postgresql_if_installed
1313 echo >&2 "-> done: $target"
1314 else
1315 echo >&2 "usage: $0 target-host"
1319 _OpenBSD_import_ssh_authorized_keys() {
1320 other_users=$(
1321 ssh "$target" getent passwd | awk -F: '$3 >= 1000 { print $1 }' |
1322 grep -v nobody
1325 for user in root $other_users; do
1326 # shellcheck disable=SC2029
1327 home=$(ssh "$target" getent passwd "$user" | awk -F: '{ print $6 }')
1328 authorized_keys="$home/.ssh/authorized_keys"
1329 ssh "$target" test -f "$authorized_keys" || continue
1330 mkdir -p "nodes/$target/ssh/"
1331 echo >&2 "--> Importing $authorized_keys"
1332 #_import_copy "root@$target:$authorized_keys" "nodes/$target/ssh/authorized_keys_$user"
1333 scp "root@$target:$authorized_keys" "nodes/$target/ssh/authorized_keys_$user"
1334 done
1337 _OpenBSD_import_etc_if_exists() {
1338 mkdir -p nodes/"$target"
1339 interfaces=$(ssh "$target" 'cd /etc && ls -1 hostname.*')
1340 echo >&2 "--> Importing /etc"
1341 for file in $etc_files_to_import $interfaces; do
1342 ssh "$target" test -f "'/etc/$file'" &&
1343 _import_copy "$target:/etc/$file" "nodes/$target/"
1344 done
1347 _OpenBSD_import_etc_ssh_if_exists() {
1348 mkdir -p nodes/"$target"
1349 echo >&2 "--> Importing /etc/ssh"
1350 for file in $etc_ssh_files_to_import; do
1351 ssh "$target" test -f "'/etc/$file'" &&
1352 _import_copy "$target:/etc/$file" "nodes/$target/ssh"
1353 done
1356 _OpenBSD_import_etc_mail_if_exists() {
1357 mkdir -p nodes/"$target"
1358 echo >&2 "--> Importing /etc/ssh"
1359 for file in $etc_mail_files_to_import; do
1360 ssh "$target" test -f "'/etc/$file'" &&
1361 _import_copy "$target:/etc/$file" "nodes/$target/mail"
1362 done
1365 _OpenBSD_import_unbound_if_enabled() {
1366 ssh "$target" rcctl check unbound >/dev/null && {
1367 echo >&2 "--> Importing /var/unbound"
1368 for file in $var_unbound_files; do
1369 prefix=${file%%/*}
1370 mkdir -p "nodes/$target/unbound/$prefix"
1371 _import_copy "$target:/var/unbound/$file" "nodes/$target/unbound/$prefix/"
1372 done
1373 return 0
1377 _OpenBSD_import_nsd_if_enabled() {
1378 ssh "$target" rcctl check nsd >/dev/null && {
1379 echo >&2 "--> Import /var/nsd"
1380 for file in $var_nsd_files; do
1381 prefix=${file%%/*}
1382 mkdir -p "nodes/$target/nsd/$prefix"
1383 _import_copy "$target:/var/nsd/$file" "nodes/$target/nsd/$prefix/"
1384 done
1385 return 0
1389 _OpenBSD_import_postgresql_if_installed() {
1390 ssh "$target" postgres -V 2>/dev/null | grep -q PostgreSQL && {
1391 echo >&2 "--> Importing /var/postgresql"
1392 for file in $var_postgresql_files; do
1393 prefix=${file%%/*}
1394 mkdir -p "nodes/$target/postgresql/$prefix"
1395 _import_copy "$target:/var/postgresql/$file" "nodes/$target/postgresql/$prefix/"
1396 done
1397 echo >&2 "-- Importing /etc/login.conf.d/postgresql"
1398 mkdir -p "nodes/$target/login.conf.d"
1399 _import_copy "$target:/etc/login.conf.d/postgresql" "nodes/$target/login.conf.d/"
1400 return 0
1404 _import_copy() {
1405 _debug "IMPORT $1 $2 USE OPENRSYNC"
1406 openrsync -e "/usr/bin/ssh -oConnectTimeout=$SSH_CONNECT_TIMEOUT -F $SSH_CONFIG_FILE" \
1407 --rsync-path="/usr/bin/openrsync" \
1408 -a "$1" "$2" && return 0
1411 # Main
1412 # ====
1414 _main "${@:-}"