From d8a2f732b300cdbb892e0878fe87dbb7a0ef6d03 Mon Sep 17 00:00:00 2001 From: Patrick Spek Date: Fri, 15 Apr 2022 16:32:43 +0200 Subject: Initial commit --- lib/logging.bash | 22 +++ lib/main.bash | 163 ++++++++++++++++++ lib/subcommands/bootstrap.bash | 72 ++++++++ lib/subcommands/ssh.bash | 39 +++++ lib/subcommands/sysinfo.bash | 11 ++ lib/util.bash | 365 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 672 insertions(+) create mode 100644 lib/logging.bash create mode 100644 lib/main.bash create mode 100644 lib/subcommands/bootstrap.bash create mode 100644 lib/subcommands/ssh.bash create mode 100644 lib/subcommands/sysinfo.bash create mode 100644 lib/util.bash (limited to 'lib') diff --git a/lib/logging.bash b/lib/logging.bash new file mode 100644 index 0000000..05b95c6 --- /dev/null +++ b/lib/logging.bash @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# The base function to output logging information. This should *not* be used +# directly, but the helper functions can be used safely. +log() { + local system=$1 ; shift + + printf "\e[32m[%s]\e[m \e[33m%s[\e[m%s\e[0;33m]\e[m: %s\e[m\n" \ + "$(date +%FT%T)" \ + "$system" \ + "$$" \ + "$*" \ + >&2 +} + +debug() { [[ -n $BASHTARD_DEBUG ]] && log "$1" "$(printf "\e[0;37m%s" "${@:2}")" ; } +info() { log "$1" "$(printf "\e[m%s" "${@:2}")" ; } +notice() { log "$1" "$(printf "\e[0;34m%s" "${@:2}")" ; } +warn() { log "$1" "$(printf "\e[1;39m%s" "${@:2}")" ; } +crit() { log "$1" "$(printf "\e[0;33m%s" "${@:2}")" ; } +alert() { log "$1" "$(printf "\e[0;31m%s" "${@:2}")" ; } +emerg() { log "$1" "$(printf "\e[1;31m%s" "${@:2}")" ; } diff --git a/lib/main.bash b/lib/main.bash new file mode 100644 index 0000000..8075b59 --- /dev/null +++ b/lib/main.bash @@ -0,0 +1,163 @@ +#!/usr/bin/env bash + +# shellcheck source=lib/util.bash +source "$(dirname "${BASH_SOURCE[0]}")/util.bash" + +# shellcheck source=lib/logging.bash +source "$(dirname "${BASH_SOURCE[0]}")/logging.bash" + +main() { + debug "$BASHTARD_NAME/main" "Running from $BASHTARD_BASEDIR" + debug "$BASHTARD_NAME/main" "Configuration dir is at $BASHTARD_ETCDIR" + debug "$BASHTARD_NAME/main" "> $0 $*" + + [[ -z $1 ]] && usage && exit 2 + + local subcommand="$1" + shift + + debug "$BASHTARD_NAME/main" "Handling subcommand '$subcommand'" + + subcommand_src="$BASHTARD_BASEDIR/lib/subcommands/$subcommand.bash" + + debug "$BASHTARD_NAME/main" "Checking $subcommand_src" + + if [[ ! -f $subcommand_src ]] + then + debug "$BASHTARD_NAME/main" "No script found to handle action, showing usage" + usage + exit 2 + fi + + # Declare some global variables + declare -A BASHTARD_PLATFORM + + # Figure out system details + debug "$BASHTARD_NAME/main" "Discovering system information" + discover_system + + # Export BASHTARD_ variables + export BASHTARD_PLATFORM + export BASHTARD_SYSTEM="${subcommand%%/*}" + + # Source the file defining the subcommand. + debug "$BASHTARD_NAME/main" "Sourcing $subcommand_src" + source "$subcommand_src" + + # Maintain our own tempdir + export TMPDIR="$BASEDIR/tmp/$RANDOM" + mkdir -p -- "$TMPDIR" + debug "$BASHTARD_NAME/main" "\$TMPDIR set to $TMPDIR" + + # Actually perform the subcommand + debug "$BASHTARD_NAME/main" "Running subcommand" + subcommand "$@" + local subcommand_exit=$? + + # Clean up if necessary + if [[ -z $BASHTARD_MESSY ]] + then + debug "$BASHTARD_NAME/main" "Cleaning up tempfiles at $TMPDIR" + rm -rf -- "$TMPDIR" + fi + + ## Use the subcommand's exit code + exit $subcommand_exit +} + +usage() { + cat < + $BASHTARD_NAME bootstrap + $BASHTARD_NAME del + $BASHTARD_NAME ssh + $BASHTARD_NAME sync + $BASHTARD_NAME sysinfo + +Perform maintenance on your infra. + +Commands: + add Add a configuration playbook to this machine. + bootstrap Bootstrap the $BASHTARD_NAME configuration system. + del Remove a configuration playbook from this machine. + ssh Run a given command on all known hosts. + sync Pull latest changes through git, and synchronize all added + playbooks. + sysinfo Show gathered information about this system. + +Playbooks: +EOF +} + +# Discover information about the system. If any bugs are reported and you want +# more information about the system the user is running on, additional checks +# can be added here, and the user will simply have to include the output of the +# sysinfo command in their message to you. +discover_system() { + BASHTARD_PLATFORM["os"]="$(discover_system_os)" + BASHTARD_PLATFORM["arch"]="$(discover_system_arch)" + BASHTARD_PLATFORM["version"]="$(discover_system_version)" + BASHTARD_PLATFORM["term"]="$TERM" + BASHTARD_PLATFORM["fqdn"]="$(hostname -f)" + + # When on a Linux-using OS, check for the specific distribution in use. + if [[ ${BASHTARD_PLATFORM[os]} == *"linux"* ]] + then + BASHTARD_PLATFORM["distro"]="$(discover_system_distro)" + fi + + BASHTARD_PLATFORM[key]="$(discover_system_key)" +} + +discover_system_arch() { + uname -m +} + +discover_system_distro() { + if [[ -f /etc/os-release ]] + then + ( + source /etc/os-release + printf "%s" "$NAME" \ + | awk '{print tolower($0)}' \ + | sed 's@[/+ ]@_@g' + ) + return + fi + + crit "No /etc/os-release found. Are you sure you're on a sane GNU+Linux distribution?" + + if command -v pacman > /dev/null + then + warn "$BASHTARD_NAME/main" "Found pacman, assuming Archlinux as distro." + printf "%s" "archlinux" + return + fi +} + +discover_system_version() { + printf "%s" "$(uname -r | awk '{print tolower($0)}')" +} + +discover_system_key() { + key+="${BASHTARD_PLATFORM[os]}" + + if [[ ${BASHTARD_PLATFORM[distro]} ]] + then + key+="-${BASHTARD_PLATFORM[distro]}" + fi + + printf "%s" "$key" +} + +discover_system_os() { + if command -v uname > /dev/null + then + printf "%s" "$(uname -s | awk '{print tolower($0)}' | sed 's@[/+ ]@_@g')" + return + fi +} + +main "$@" diff --git a/lib/subcommands/bootstrap.bash b/lib/subcommands/bootstrap.bash new file mode 100644 index 0000000..b2e540b --- /dev/null +++ b/lib/subcommands/bootstrap.bash @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +subcommand() +{ + remote="$1" ; shift + + if [[ -z "$remote" ]] + then + info "bootstrap" "No remote given, bootstrapping from scratch" + bootstrap_local + return + fi + + bootstrap_remote +} + +bootstrap_local() +{ + local dirs=( + "$BASHTARD_ETCDIR" + "$BASHTARD_ETCDIR/hosts.d" + "$BASHTARD_ETCDIR/os.d" + "$BASHTARD_ETCDIR/playbooks.d" + "$BASHTARD_ETCDIR/playbook-registry.d/" + ) + + local files=( + "$BASHTARD_ETCDIR/defaults" + "$BASHTARD_ETCDIR/playbooks" + "$BASHTARD_ETCDIR/playbook-registry.d/${BASHTARD_PLATFORM[fqdn]}" + "$BASHTARD_ETCDIR/hosts.d/${BASHTARD_PLATFORM[fqdn]}" + "$BASHTARD_ETCDIR/os.d/${BASHTARD_PLATFORM[key]}" + ) + + for dir in "${dirs[@]}" + do + notice "bootstrap" "Creating $dir" + mkdir -p -- "$dir" + done + + for file in "${files[@]}" + do + notice "bootstrap" "Creating $file" + touch -- "$file" + done +} + +bootstrap_remote() +{ + notice "bootstrap" "Cloning $1 to $BASHTARD_ETCDIR" + git clone "$1" "$BASHTARD_ETCDIR" + + local files=( + "$BASHTARD_ETCDIR/hosts.d/${BASHTARD_PLATFORM[fqdn]}" + "$BASHTARD_ETCDIR/os.d/${BASHTARD_PLATFORM[key]}" + "$BASHTARD_ETCDIR/playbook-registry.d/${BASHTARD_PLATFORM[fqdn]}" + ) + + for file in "${files[@]}" + do + [[ -f "$file" ]] && continue + + notice "bootstrap" "Creating $file" + touch -- "$file" + done + + while read -r playbook url + do + notice bootstrap "Cloning $playbook from $url" + git clone "$url" "$BASHTARD_ETCDIR/playbooks.d" + done < "$BASHTARD_ETCDIR/playbooks" +} diff --git a/lib/subcommands/ssh.bash b/lib/subcommands/ssh.bash new file mode 100644 index 0000000..19ead9a --- /dev/null +++ b/lib/subcommands/ssh.bash @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +subcommand() +{ + local ssh="$(config app.ssh)" + + if [[ ! -d "$BASHTARD_ETCDIR/hosts.d" ]] + then + crit "$BASHTARD_NAME/ssh" "Could not find hosts file at $BASHTARD_ETCDIR/hosts.d" + return 3 + fi + + chgdir "$BASHTARD_ETCDIR/hosts.d" + + for node in * + do + local user + local host + local ip + + user="$(config_for "$node" "ssh.user" "root")" + + # Try IPv6 first + host="$(config_for "$node" "ssh.host" "$(config_for "$node" "vpn.ipv6")")" + + if [[ -z "$host" ]] + then + # Otherwise try IPv4 + host="$(config_for "$node" "ssh.host" "$(config_for "$node" "vpn.ipv4")")" + fi + + notice "ssh" "$user@$node ($host) > $*" + + $ssh "$user@$host" "$@" + + unset user + unset host + done +} diff --git a/lib/subcommands/sysinfo.bash b/lib/subcommands/sysinfo.bash new file mode 100644 index 0000000..5010798 --- /dev/null +++ b/lib/subcommands/sysinfo.bash @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +subcommand() { + printf "%-15s %s\n" "basedir" "$BASHTARD_BASEDIR" + printf "%-15s %s\n" "etcdir" "$BASHTARD_ETCDIR" + + for key in "${!BASHTARD_PLATFORM[@]}" + do + printf "%-15s %s\n" "$key" "${BASHTARD_PLATFORM[$key]}" + done +} diff --git a/lib/util.bash b/lib/util.bash new file mode 100644 index 0000000..2c10ef4 --- /dev/null +++ b/lib/util.bash @@ -0,0 +1,365 @@ +#!/usr/bin/env bash + +# Change the working directory. In usage, this is the same as using cd, +# however, it will make additional checks to ensure everything is going fine. +chgdir() { + debug "tyilnet/chgdir" "Changing workdir to $1" + cd -- "$1" || die "Failed to change directory to $1" +} + +# Read a particular value from a key/value configuration file. Using this +# function introduces a dependency on awk. +config() { + config_for "${BASHTARD_PLATFORM[fqdn]}" "$@" +} + +config_for() { + local default + local file + local files + local host + local value + local key + + host=$1 ; shift + key=$1 ; shift + default=$1 ; shift + + files=( + "$BASHTARD_ETCDIR/hosts.d/$host" + "$BASHTARD_ETCDIR/os.d/${BASHTARD_PLATFORM[key]}" + "$BASHTARD_ETCDIR/defaults" + "$BASHTARD_BASEDIR/etc/os.d/${BASHTARD_PLATFORM[key]}" + "$BASHTARD_BASEDIR/etc/defaults" + ) + + # Check configuration files + for file in "${files[@]}" + do + [[ ! -f $file ]] && continue + + value="$(awk -F= '$1 == "'"$key"'" { print $NF }' "$file")" + + if [[ -n $value ]] + then + debug "tyilnet/config_for" "Found $key=$value in $file" + + printf "%s" "$value" + return + fi + done + + # Return default value + if [[ -n $default ]] + then + printf "%s" "$default" + return + fi + + # Error + alert "tyilnet/config_for" "No configuration value for $key" +} + +# Create a datetime stamp. This is a wrapper around the date utility, ensuring +# that the date being formatted is always in UTC and respect SOURCE_DATE_EPOCH, +# if it is set. +datetime() { + local date_opts + + # Apply SOURCE_DATE_EPOCH as the date to base off of. + if [[ $SOURCE_DATE_EPOCH ]] + then + date_opts+=("-d@$SOURCE_DATE_EPOCH") + date_opts+=("-u") + fi + + date "${date_opts[@]}" +"${1:-%FT%T}" +} + +# Log a message as error, and exit the program. This is intended for serious +# issues that prevent the script from running correctly. The exit code can be +# specified with -i, or will default to 1. +die() { + local OPTIND + local code + + while getopts ":i:" opt + do + case "$opt" in + i) code=$OPTARG ;; + *) alert "tyilnet/die" "Unused argument specified: $opt" ;; + esac + done + + shift $(( OPTIND -1 )) + + alert "$@" + exit "${code:-1}" +} + +# Fetch a file from an URL. Using this function introduces a dependency on curl. +fetch_http() { + local OPTIND + local buffer + + while getopts ":o:" opt + do + case "$opt" in + o) buffer=$OPTARG ;; + *) alert "tyilnet/fetch_http" "Unused argument specified: $opt" ;; + esac + done + + shift $(( OPTIND -1 )) + + [[ -z $buffer ]] && buffer="$(tmpfile)" + + notice "tyilnet/fetch_http" "Downloading $1 to $buffer" + + for util in curl wget + do + command -v "$util" > /dev/null || continue + "fetch_http_$util" "$1" "$buffer" || continue + local exit_code=$? + + printf "%s" "$buffer" + return $exit_code + done + + die "tyilnet/fetch_http" "Unable to download file over HTTP!" +} + +fetch_http_curl() { + curl -Ls "$1" > "$2" +} + +fetch_http_wget() { + wget --quiet --output-document "$2" "$1" +} + +# Check if the first argument given appears in the list of all following +# arguments. +in_args() { + local needle="$1" + shift + + for arg in "$@" + do + [[ $needle == "$arg" ]] && return 0 + done + + return 1 +} + +# Join a list of arguments into a single string. By default, this will join +# using a ",", but you can set a different character using -c. Note that this +# only joins with a single character, not a string of characters. +join_args() { + local OPTIND + local IFS="," + + while getopts ":c:" opt + do + case "$opt" in + c) IFS="$OPTARG" ;; + *) warn "tyilnet/join_args" "Unused opt specified: $opt" ;; + esac + done + + shift $(( OPTIND - 1)) + + printf "%s" "$*" +} + +# OS independent package management +pkg() { + local system="tyilnet/pkg" + + local action=$1 ; shift + local app="$(config "pkg.$1" "$(config "app.$1")")" ; shift + + local type + + if [[ -z $app ]] + then + crit "$system" "No package name for $app" + return 1 + fi + + if [[ "$(type -t pkg_$action)" != "function" ]] + then + crit "$system" "Invalid package manager action $action" + return 1 + fi + + "pkg_$action" "$app" +} + +pkg_install() { + local system="tyilnet/pkg/install" + + local app=$1 ; shift + + case "${BASHTARD_PLATFORM[key]}" in + freebsd) set -- /usr/sbin/pkg install -y "$app" ;; + linux-debian*) set -- apt install -y "$app" ;; + linux-gentoo) set -- emerge --ask=n "$app" ;; + *) + crit "$system" "No package manager configured for ${BASHTARD_PLATFORM[key]}" + return 1 + ;; + esac + + notice "$system" "$*" + $@ +} + +# OS independent service management. +svc() { + local system="tyilnet/svc" + + local service + local action + + service="$(config svc.$1)" ; shift + action=$1 ; shift + + if [[ -z $service ]] + then + crit "$system" "No service name for $service" + return 1 + fi + + if [[ "$(type -t svc_$action)" != "function" ]] + then + crit "$system" "Invalid service manager action $action" + return 1 + fi + + "svc_$action" "$service" +} + +svc_enable() { + local system="tyilnet/svc/enable" + + local service=$1 + + case "${BASHTARD_PLATFORM[key]}" in + linux-gentoo) set -- /sbin/rc-update add "$service" ;; + linux-*) set -- systemctl enable "$service" ;; + *) + crit "$system" "No service manager configured for ${BASHTARD_PLATFORM[key]}" + return 1 + esac + + notice "$system" "$*" + $@ +} + +svc_restart() { + local system="tyilnet/svc/restart" + + local service=$1 + + case "${BASHTARD_PLATFORM[key]}" in + freebsd) set -- service "$service" restart ;; + linux-gentoo) set -- /sbin/rc-service "$service" restart ;; + linux-*) set -- systemctl restart "$service" ;; + *) + crit "$system" "No service manager configured for ${BASHTARD_PLATFORM[key]}" + return 1 + esac + + notice "$system" "$*" + $@ +} + +svc_start() { + local system="tyilnet/svc/start" + + local service=$1 + + case "${BASHTARD_PLATFORM[key]}" in + freebsd) set -- service "$service" start ;; + linux-gentoo) set -- /sbin/rc-service "$service" start ;; + linux-*) set -- systemctl start "$service" ;; + *) + crit "$system" "No service manager configured for ${BASHTARD_PLATFORM[key]}" + return 1 + esac + + notice "$system" "$*" + $@ +} + +template() +{ + local file="$BASEDIR/etc/templates/$1" ; shift + local sedfile="$(tmpfile)" + + if [[ ! -f $file ]] + then + crit "tyilnet/template" "Tried to render template from $file, but it doesn't exist" + return + fi + + for kv in "$@" + do + debug "tyilnet/template" "Adding $kv to sedfile at $sedfile" + + key="$(awk -F= '{ print $1 }' <<< $kv)" + + if [[ -z "$key" ]] + then + crit "tyilnet/template" "Empty key in '$kv' while rendering $file?" + fi + + value="$(awk -F= '{ print $NF }' <<< $kv)" + + if [[ -z "$value" ]] + then + crit "tyilnet/template" "Empty key in '$kv' while rendering $file?" + fi + + printf 's@${%s}@%s@g\n' "$key" "$value" >> "$sedfile" + done + + sed -f "$sedfile" "$file" +} + +# Create a temporary directory. Similar to tempfile, but you'll get a directory +# instead. +tmpdir() { + local dir + + dir="$(mktemp -d)" + + # Ensure the file was created succesfully + if [[ ! -d "$dir" ]] + then + die "tyilnet/tmpdir" "Failed to create a temporary directory at $dir" + fi + + debug "tyilnet/tmpdir" "Temporary file created at $dir" + + printf "%s" "$dir" +} + +# Create a temporary file. In usage, this is no different from mktemp itself, +# however, it will apply additional checks to ensure everything is going +# correctly, and the files will be cleaned up automatically at the end. +tmpfile() { + local file + + file="$(mktemp)" + + # Ensure the file was created succesfully + if [[ ! -f "$file" ]] + then + die "tyilnet/tmpfile" "Failed to create a temporary file at $file" + fi + + debug "tyilnet/tmpfile" "Temporary file created at $file" + + printf "%s" "$file" +} -- cgit v1.1