diff options
author | Cullum Smith <cullum@sacredheartsc.com> | 2024-07-11 10:55:45 -0400 |
---|---|---|
committer | Cullum Smith <cullum@sacredheartsc.com> | 2024-07-11 10:55:45 -0400 |
commit | 85007db580ccf662a45cf2aaeb83518ad2ddb85a (patch) | |
tree | d692c5bdbaf33c5b9791d538982b17ab4dd808ee | |
parent | de8305223b6079d14ac854ee067ffd069cb38ec7 (diff) | |
download | infrastructure-85007db580ccf662a45cf2aaeb83518ad2ddb85a.tar.gz |
initial boxconf scaffolding
-rw-r--r-- | .gitignore | 6 | ||||
-rw-r--r-- | LICENSE | 2 | ||||
-rw-r--r-- | README.md | 209 | ||||
-rwxr-xr-x | boxconf | 40 | ||||
-rw-r--r-- | hostclasses | 26 | ||||
-rw-r--r-- | lib/10-core | 340 | ||||
-rw-r--r-- | lib/20-strings | 14 | ||||
-rw-r--r-- | lib/30-files | 175 | ||||
-rw-r--r-- | lib/40-os | 64 | ||||
-rw-r--r-- | lib/50-net | 37 | ||||
-rw-r--r-- | lib/50-zfs | 15 | ||||
-rwxr-xr-x | pki | 358 | ||||
-rwxr-xr-x | vault | 121 |
13 files changed, 1405 insertions, 2 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eac3be3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.ca_password +.vault_password +*.swp +*.swo +.nfs* +site @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2024, Cullum Smith +Copyright (c) 2024, Cullum Smith <cullum@sacredheartsc.com> Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -1,2 +1,209 @@ # infrastructure -Config management for self-hosted infrastructure. + +A shell-based configuration management framework for unix-like systems. + +## boxconf + +[boxconf](./boxconf) is an extremely simple config management system written in shell. As +long as you can SSH to the remote system (or "box") as root, the only requirement +is a POSIX-compliant sh(1) and coreutils. It was inspired by many years of +frustration with Ansible. + +### Running boxconf + +To execute boxconf on a target host, just run the following: + + ./boxconf $TARGET_HOSTNAME + +A deployment tarball will be generated and SCP'd to the remote box, where `boxconf` +will re-exec itself. After gathering some information about the target system (such +as the operating system, IP address, etc), `boxconf` will source your scripts in the following order: + + vars/common + site/vars/common + vars/os/${os} + site/vars/os/${os} + vars/distro/${distro} + site/vars/distro/${distro} + vars/hostclass/${hostclass} + site/vars/hostclass/${hostclass} + vars/hostname/${hostname} + site/vars/hostname/${hostname} + + scripts/common + site/scripts/common + scripts/os/${os} + site/scripts/os/${os} + scripts/distro/${distro} + site/scripts/distro/${distro} + scripts/hostclass/${hostclass} + site/scripts/hostclass/${hostclass} + scripts/hostname/${hostname} + site/scripts/hostname/${hostname} + +If any of those paths point to a directory, boxconf will source all files in +that directory in glob order. + +The `site/` directory does not exist in this repo. Its purpose is to hold personal +site-specific variables and scripts that you would rather not share in a public git repo. +Ideally, you would use git submodules for this. + +The `hostname` value is taken from the short hostname of the remote system. +If the remote hostname is incorrect (or unset), you can override the hostname +detection by passing the `-o $HOSTNAME` flag to boxconf. + +The `hostclass` value is matched based on the regular expressions listed in +the [hostclasses](./hostclasses) file. + +### Encrypting source files + +`boxconf` supports encrypting any script or file using OpenSSL's [pbkdf2](https://www.openssl.org/docs/man3.0/man1/openssl-enc.html). +The encrypted file will be automatically decrypted when generating the deployment tarball. +The encryption password is read from the `BOXCONF_VAULT_PASSWORD` environment +variable or the `.vault_password` file. If nether is set, you will be prompted for +the password interactively. + +The [vault](./vault) script in the root of this directory can be used to manage +encrypted files. + +### Copying files to the remote host + +From your `boxconf` scripts, you can copy files in the `files/` (or `site/files/`) +directory to the target system using the `install_file` function. The source file +should have the same path as the remote path, and it can be tailored to the remote +system by adding a custom suffix. For example, if you ran the following code: + + install_file -m 0644 /etc/passwd + +Then the following paths would be searched to find a suitable file to copy into +the target system (the first match wins): + + site/files/etc/passwd.${hostname} + files/etc/passwd.${hostname} + site/files/etc/passwd.${hostclass}.${distro} + files/etc/passwd.${hostclass}.${distro} + site/files/etc/passwd.${distro}.${hostclass} + files/etc/passwd.${distro}.${hostclass} + site/files/etc/passwd.${hostclass}.${os} + files/etc/passwd.${hostclass}.${os} + site/files/etc/passwd.${os}.${hostclass} + files/etc/passwd.${os}.${hostclass} + site/files/etc/passwd.${hostclass} + files/etc/passwd.${hostclass} + site/files/etc/passwd.${distro} + files/etc/passwd.${distro} + site/files/etc/passwd.${os} + files/etc/passwd.${os} + site/files/etc/passwd.common + files/etc/passwd.common + +If you use the `install_template` function, then the same file matching logic +applies. However, the content of the matched file will be treated like a +heredoc, allowing you to do things like interpolate `${shell_variables}` and perform +`$(process_substitution)` within the file content. Note that if you do this, you +must esacape any shell characters (like `$`) as needed. + +### Copying TLS certificates + +The `install_certificate` and `install_certificate_key` functions can be used +to copy certificates from the `site/ca` directory to the remote host. The certificates +should be created and managed using the included [pki](./pki) script. + +Note that certificate keys are also encrypted with `$BOXCONF_VAULT_PASSWORD`. They +are automatically decrypted when generating the configuration tarball. + + +## vault + +The [vault](./vault) script is used to manage encrypted files using OpenSSL's [pbkdf2](https://www.openssl.org/docs/man3.0/man1/openssl-enc.html). +The encryption password is read from the `BOXCONF_VAULT_PASSWORD` environment variable +or the `.vault_password` file. + +### Create a new encrypted file + +The following command will invoke `$EDITOR` to create a new encrypted file at the +specified path. + + ./vault create passwords.txt + +### Decrypt file(s) + +The plaintext content of the file(s) will be written to stdout. + + ./vault decrypt secrets.txt + +### Edit an encrypted file + +The file will be decrypted to a temporary file before being opened with `$EDITOR`. +When the editor is closed, the file is encrypted again. + + ./vault edit passwords.txt + +### Encrypt an existing file + +Encrypt an existing file in place: + + ./vault encrypt plain.txt + +### Re-encrypt file(s) with a different password + +The new password is read from the `VAULT_NEW_PASSWORD` environment variable. +If this variable is unset, you will be prompted interactively. + + ./vault reencrypt secrets.txt + + +## pki + +The [pki](./pki) script is used to manage an internal certificate authority using OpenSSL. + +Certificates and private keys are stored in the 'site/ca' directory with human-readable +names. The certificatess are mapped to their OpenSSL serial number via symlinks. + +The private keys are encrypted with the `BOXCONF_VAULT_PASSWORD` variable, as +described previously. The private key of the CA itself is acquired from the `CA_PASSWORD` +environment variable, or the `.ca_password` file. + +Every certificate is associated with a single `boxconf` hostname, along with a unique certificate +name. This allows you to store multiple certificates per host. + +### Initialize the CA + +`pki init` will create the CA certificate and private key, along with an OpenSSL +configuration file. [Name constraints](https://www.openssl.org/docs/man3.0/man5/x509v3_config.html) +for the CA can be added with the `-c` option. + +For example, this command creates a CA for the `example.com` domain. This CA can sign +certificates for all subdomains of `example.com` and `example.net`, as well as plain IP +addresses in the 192.168.0.0/24 subnet: + + ./pki init -c 192.168.0.0/255.255.255.0 -c example.net example.com + +### Create a server certificate + +`pki cert` creates a **server** certificate keypair signed by the CA. + +For example, this command creates a certificate pair for `nginx` for the host +`webserver1` with a 365 day expiration (`-d`). After the hostname and certificate +name, each additional argument is added to the Subject Alternative Names field. + +The Common Name is taken from the first specified SAN. If you don't specify a type +for the SAN, `DNS` is assumed. + + ./pki cert -d 365 webserver1 nginx www.example.com DNS:example.com IP:192.168.0.5 + +### Create a client certificate + +`pki client-cert` creates a **client** certificate keypair signed by the CA. + +After the hostname and certificate name, the first argument must be an LDAP-style DN +for the certificate's Common Name value. SANs can be specified using additional arguments +in same way as described previously. + + ./pki client-cert -d 3650 ldap1 replicator cn=replicator,dc=idm,dc=example,dc=com + +### Renew a certificate + +`pki renew` is used to renew an existing certificate. + + ./pki renew -d 365 webserver1 nginx @@ -0,0 +1,40 @@ +#!/bin/sh +# +# Shell-based configuration management framework for unix-like systems. + +set -eu + +PROGNAME=boxconf +USAGE="${PROGNAME} [-d] [-e VAR=VALUE]... [-o HOSTNAME] TARGET" +BOXCONF_ROOT=$(dirname "$(readlink -f "$0")") + +usage(){ + [ $# -gt 0 ] && printf '%s\n' "$1" 2>&1 + printf 'usage: %s\n' "$USAGE" 2>&1 + exit 2 +} + +while getopts :hde:o:X _bc_opt; do + case $_bc_opt in + h) usage ;; + d) set -x ;; + e) eval "$OPTARG" ;; + o) BOXCONF_HOSTNAME=$OPTARG ;; + X) _bc_run=1 ;; + :) usage "missing option value: -${OPTARG}" ;; + ?) usage "unknown option: -${OPTARG}" ;; + esac +done + +shift $((OPTIND - 1)) +[ $# -eq 1 ] || usage + +for _bc_lib in "${BOXCONF_ROOT}/lib"/*; do + . "$_bc_lib" +done + +if [ -n "${_bc_run:-}" ]; then + _boxconf_run +else + _boxconf_deploy "$1" "${BOXCONF_HOSTNAME:-$1}" "$@" +fi diff --git a/hostclasses b/hostclasses new file mode 100644 index 0000000..7d4274e --- /dev/null +++ b/hostclasses @@ -0,0 +1,26 @@ +freebsd_hypervisor ^alcatraz[0-9] +pkg_repository ^pkg[0-9] +idm_server ^idm[0-9] +smtp_server ^mx[0-9] +imap_server ^imap[0-9] +dev_server ^dev[0-9] +radius_server ^radius[0-9] +laptop ^laptop[0-9] +postgresql_server ^postgres[0-9] +dav_server ^dav[0-9] +bitwarden_server ^bitwarden[0-9] +ttrss_server ^ttrss[0-9] +znc_server ^znc[0-9] +cups_server ^cups[0-9] +unifi_controller ^unifi[0-9] +invidious_server ^invidious[0-9] +git_server ^git[0-9] +xmpp_server ^xmpp[0-9] +internal_webserver ^web[0-9] +public_webserver ^www[0-9] +authoritative_nameserver ^ns[0-9] +asterisk_server ^pbx[0-9] +nfs_server ^nas[0-9] +turn_server ^turn[0-9] +syncthing_server ^syncthing[0-9] +icinga_server ^icinga[0-9] diff --git a/lib/10-core b/lib/10-core new file mode 100644 index 0000000..8e01afc --- /dev/null +++ b/lib/10-core @@ -0,0 +1,340 @@ +#!/bin/sh + +BOXCONF_REMOTE_PATH=/root/boxconf +BOXCONF_SSH_ARGS='-l root -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ControlPath=~/.ssh/%r@%h:%p -o ControlMaster=auto -o ControlPersist=10m' +BOXCONF_SUPPORTED_OS='linux freebsd' +BOXCONF_SUPPORTED_DISTROS='debian' +BOXCONF_SCRIPT_DIR="${BOXCONF_ROOT}/scripts" +BOXCONF_VAR_DIR="${BOXCONF_ROOT}/vars" +BOXCONF_FILE_DIR="${BOXCONF_ROOT}/files" +BOXCONF_SITE_VAR_DIR="${BOXCONF_ROOT}/site/vars" +BOXCONF_SITE_FILE_DIR="${BOXCONF_ROOT}/site/files" +BOXCONF_SITE_SCRIPT_DIR="${BOXCONF_ROOT}/site/scripts" +BOXCONF_CA_DIR="${BOXCONF_ROOT}/site/ca" +BOXCONF_VAULT_PASSWORD_FILE="${BOXCONF_ROOT}/.vault_password" +BOXCONF_VAULT_CIPHER=aes256 + +log(){ + printf '%s: %s\n' "$PROGNAME" "$1" +} + +debug(){ + printf '%s: DEBUG: %s\n' "$PROGNAME" "$1" 1>&2 +} + +warn(){ + printf '%s: WARNING: %s\n' "$PROGNAME" "$1" 1>&2 +} + +die(){ + printf '%s: ERROR: %s\n' "$PROGNAME" "$1" 1>&2 + exit "${2:-1}" +} + +bug(){ + printf '%s: BUG: %s\n' "$PROGNAME" "$1" 1>&2 + exit 255 +} + +_boxconf_read_password(){ + # Read a password from stdin with TTY echo disabled. + # $1 = prompt + # $2 = upvar + if [ -t 0 ]; then + _bcrp_stty=$(stty -g) + stty -echo + fi + + printf '%s ' "$1" 1>&2 + read -r "$2" + + if [ -t 0 ]; then + stty "$_bcrp_stty" + echo + fi +} + +_boxconf_get_vault_password(){ + # Acquire the vault password. + # If the BOXCONF_VAULT_PASSWORD environment variable is set, use that. + # Next, try reading the password from the .vault_password file. + # If all else fails, prompt interactively. + if [ -z "${BOXCONF_VAULT_PASSWORD:-}" ]; then + if [ -f "${BOXCONF_VAULT_PASSWORD_FILE}" ]; then + BOXCONF_VAULT_PASSWORD=$(cat "${BOXCONF_VAULT_PASSWORD_FILE}") + else + _boxconf_read_password 'Enter vault password:' BOXCONF_VAULT_PASSWORD + fi + fi +} + +_boxconf_decrypt(){ + # Decrypt a file using the vault password. + # $1 = encrypted file + # $2 = plaintext output file (or stdout if unset) + _boxconf_get_vault_password + + if [ $# -gt 1 ]; then + PASS=$BOXCONF_VAULT_PASSWORD openssl enc -in "$1" -out "$2" -d "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + else + PASS=$BOXCONF_VAULT_PASSWORD openssl enc -in "$1" -d "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + fi +} + +_boxconf_is_encrypted(){ + # Check if a given file is encrypted. + head -n1 "$1" | grep -q '^Salted__' +} + +_boxconf_include(){ + # Source the script (or scripts) at the given path. + # If the path is a directory, source all its files in glob order. + # $1 = path + while [ $# -gt 0 ]; do + if [ -f "$1" ]; then + log "sourcing ${1#${BOXCONF_ROOT}/}" + BOXCONF_SOURCE=$1 + . "$BOXCONF_SOURCE" + elif [ -d "$1" ]; then + for _bci_file in "$1"/*; do + if [ -f "$_bci_file" ]; then + log "sourcing ${1#${BOXCONF_ROOT}/}" + BOXCONF_SOURCE=$_bci_file + . "$BOXCONF_SOURCE" + fi + done + fi + shift + done +} + +_boxconf_get_hostclass(){ + # For a given hostname, find its hostclass and store it in $BOXCONF_HOSTCLASS. + # Hostclass regexes are specified in the hostclasses file. + # If no hostclass matches, the hostclass is 'undefined'. + # $1 = hostname + BOXCONF_HOSTCLASS=undefined + while read -r _bcc_hostclass _bcc_regex; do + if printf '%s\n' "$1" | grep -Eq "$_bcc_regex"; then + BOXCONF_HOSTCLASS=$_bcc_hostclass + break + fi + done < "${BOXCONF_ROOT}/hostclasses" + log "using hostclass ${BOXCONF_HOSTCLASS}" +} + +_boxconf_stage(){ + # Construct a directory tree containing all files required to configure a host. + # Encrypted files will be copied in plaintext. + # $1 = target hostnmae + # $2 = staging directory path + _bcs_hostname=$1 + _bcs_stagedir=$2 + log "generating configuration tarball for ${_bcs_hostname} at ${_bcs_stagedir}" + _boxconf_get_hostclass "$_bcs_hostname" + + cp -RpL \ + "${BOXCONF_ROOT}/boxconf" \ + "${BOXCONF_ROOT}/hostclasses" \ + "${BOXCONF_ROOT}/lib" \ + "$_bcs_stagedir" + + # Compex find expression to only copy files necessary for the target host. + # This avoids leaking site-wide secrets to hosts that don't require them. + _bcs_relevant_files=$(find "${BOXCONF_ROOT}" -type f -and \( \ + -path "${BOXCONF_CA_DIR}/${_bcs_hostname}" \ + -or -path "${BOXCONF_VAR_DIR}/common" \ + -or -path "${BOXCONF_VAR_DIR}/common/*" \ + -or -path "${BOXCONF_VAR_DIR}/os/*" \ + -or -path "${BOXCONF_VAR_DIR}/distro/*" \ + -or -path "${BOXCONF_VAR_DIR}/hostclass/${BOXCONF_HOSTCLASS}" \ + -or -path "${BOXCONF_VAR_DIR}/hostclass/${BOXCONF_HOSTCLASS}/*" \ + -or -path "${BOXCONF_VAR_DIR}/hostname/${_bcs_hostname}" \ + -or -path "${BOXCONF_VAR_DIR}/hostname/${_bcs_hostname}/*" \ + -or -path "${BOXCONF_SITE_VAR_DIR}/common" \ + -or -path "${BOXCONF_SITE_VAR_DIR}/common/*" \ + -or -path "${BOXCONF_SITE_VAR_DIR}/os/*" \ + -or -path "${BOXCONF_SITE_VAR_DIR}/distro/*" \ + -or -path "${BOXCONF_SITE_VAR_DIR}/hostclass/${BOXCONF_HOSTCLASS}" \ + -or -path "${BOXCONF_SITE_VAR_DIR}/hostclass/${BOXCONF_HOSTCLASS}/*" \ + -or -path "${BOXCONF_SITE_VAR_DIR}/hostname/${_bcs_hostname}" \ + -or -path "${BOXCONF_SITE_VAR_DIR}/hostname/${_bcs_hostname}/*" \ + -or -path "${BOXCONF_SCRIPT_DIR}/common" \ + -or -path "${BOXCONF_SCRIPT_DIR}/common/*" \ + -or -path "${BOXCONF_SCRIPT_DIR}/os/*" \ + -or -path "${BOXCONF_SCRIPT_DIR}/distro/*" \ + -or -path "${BOXCONF_SCRIPT_DIR}/hostclass/${BOXCONF_HOSTCLASS}" \ + -or -path "${BOXCONF_SCRIPT_DIR}/hostclass/${BOXCONF_HOSTCLASS}/*" \ + -or -path "${BOXCONF_SCRIPT_DIR}/hostname/${_bcs_hostname}" \ + -or -path "${BOXCONF_SCRIPT_DIR}/hostname/${_bcs_hostname}/*" \ + -or -path "${BOXCONF_SITE_SCRIPT_DIR}/common" \ + -or -path "${BOXCONF_SITE_SCRIPT_DIR}/common/*" \ + -or -path "${BOXCONF_SITE_SCRIPT_DIR}/os/*" \ + -or -path "${BOXCONF_SITE_SCRIPT_DIR}/distro/*" \ + -or -path "${BOXCONF_SITE_SCRIPT_DIR}/hostclass/${BOXCONF_HOSTCLASS}" \ + -or -path "${BOXCONF_SITE_SCRIPT_DIR}/hostclass/${BOXCONF_HOSTCLASS}/*" \ + -or -path "${BOXCONF_SITE_SCRIPT_DIR}/hostname/${_bcs_hostname}" \ + -or -path "${BOXCONF_SITE_SCRIPT_DIR}/hostname/${_bcs_hostname}/*" \ + -or -path "${BOXCONF_FILE_DIR}/*.common" \ + $(printf -- "-or -path ${BOXCONF_FILE_DIR}/*.%s " ${BOXCONF_SUPPORTED_OS}) \ + $(printf -- "-or -path ${BOXCONF_FILE_DIR}/*.%s " ${BOXCONF_SUPPORTED_DISTROS}) \ + $(printf -- "-or -path ${BOXCONF_FILE_DIR}/*.${BOXCONF_HOSTCLASS}.%s " ${BOXCONF_SUPPORTED_OS}) \ + $(printf -- "-or -path ${BOXCONF_FILE_DIR}/*.%s.${BOXCONF_HOSTCLASS} " ${BOXCONF_SUPPORTED_OS}) \ + $(printf -- "-or -path ${BOXCONF_FILE_DIR}/*.${BOXCONF_HOSTCLASS}.%s " ${BOXCONF_SUPPORTED_DISTROS}) \ + $(printf -- "-or -path ${BOXCONF_FILE_DIR}/*.%s.${BOXCONF_HOSTCLASS} " ${BOXCONF_SUPPORTED_DISTROS}) \ + -or -path "${BOXCONF_FILE_DIR}/*.${BOXCONF_HOSTCLASS}" \ + -or -path "${BOXCONF_FILE_DIR}/*.${_bcs_hostname}" \ + -or -path "${BOXCONF_SITE_FILE_DIR}/*.common" \ + $(printf -- "-or -path ${BOXCONF_SITE_FILE_DIR}/*.%s " ${BOXCONF_SUPPORTED_OS}) \ + $(printf -- "-or -path ${BOXCONF_SITE_FILE_DIR}/*.%s " ${BOXCONF_SUPPORTED_DISTROS}) \ + $(printf -- "-or -path ${BOXCONF_SITE_FILE_DIR}/*.${BOXCONF_HOSTCLASS}.%s " ${BOXCONF_SUPPORTED_OS}) \ + $(printf -- "-or -path ${BOXCONF_SITE_FILE_DIR}/*.%s.${BOXCONF_HOSTCLASS} " ${BOXCONF_SUPPORTED_OS}) \ + $(printf -- "-or -path ${BOXCONF_SITE_FILE_DIR}/*.${BOXCONF_HOSTCLASS}.%s " ${BOXCONF_SUPPORTED_DISTROS}) \ + $(printf -- "-or -path ${BOXCONF_SITE_FILE_DIR}/*.%s.${BOXCONF_HOSTCLASS} " ${BOXCONF_SUPPORTED_DISTROS}) \ + -or -path "${BOXCONF_SITE_FILE_DIR}/*.${BOXCONF_HOSTCLASS}" \ + -or -path "${BOXCONF_SITE_FILE_DIR}/*.${_bcs_hostname}" \ + \) ) + + OIFS=$IFS; IFS=$'\n' + set -- $_bcs_relevant_files + IFS=$OIFS + + for _bc_stage_fullpath; do + # Calculate the file's path relative to the BOXCONF_ROOT. + _bc_stage_relpath=${_bc_stage_fullpath#${BOXCONF_ROOT}/} + + # Create the file's parent directories (if any) in the stage dir. + mkdir -p "${_bcs_stagedir}/$(dirname "$_bc_stage_relpath")" + + # Copy the file to the stage dir, decrypting if necessary. + if _boxconf_is_encrypted "$_bc_stage_fullpath"; then + _boxconf_decrypt "$_bc_stage_fullpath" "${_bcs_stagedir}/${_bc_stage_relpath}" + else + cp -p "$_bc_stage_fullpath" "${_bcs_stagedir}/${_bc_stage_relpath}" + fi + done +} + +_boxconf_deploy(){ + # Build a configuration tarball and SCP it to a host, then extract and run it. + # $1 = target hostname/IP + # $2 = hostname used for configuration + # $3..$N = original boxconf CLI args + _bc_deploy_target=$1; shift + _bc_deploy_hostname=$2; shift + + _bc_stagedir=$(mktemp -d -t "boxconf-${_bc_deploy_hostname}") + trap 'rm -rf -- "$_bc_stagedir"' HUP INT QUIT TERM ABRT EXIT + + _boxconf_stage "$_bc_deploy_hostname" "$_bc_stagedir" + + log "deploying tarball for ${_bc_deploy_hostname} to ${_bc_deploy_target}:${BOXCONF_REMOTE_PATH}" + + # Create the boxconf directory with mode 700 on the target host. + ssh ${BOXCONF_SSH_ARGS} "$_bc_deploy_target" -- rm -rf "$BOXCONF_REMOTE_PATH" '&&' install -d -m 700 "$BOXCONF_REMOTE_PATH" + + # Send the boxconf tarball to the target host, and extract it. + tar -C "$_bc_stagedir" -czf - ./ \ + | ssh ${BOXCONF_SSH_ARGS} "$_bc_deploy_target" -- tar -xzf - -C "$BOXCONF_REMOTE_PATH" + + # Re-exec boxconf on the target host. + ssh ${BOXCONF_SSH_ARGS} "$_bc_deploy_target" -- "${BOXCONF_REMOTE_PATH}/boxconf" -X "$@" +} + +_boxconf_run(){ + # This is the main entry point for boxconf when running on the target host. + # Gather basic info about the target system, then source all the vars and scripts + # files, in order of lowest to highest precedence. + + log "now running on target host (current hostname: $(hostname -s))" + + # Determine OS family. + case "$(uname)" in + Linux) BOXCONF_OS=linux ;; + FreeBSD) BOXCONF_OS=freebsd ;; + *) die "unsupported os family: $(uname)" ;; + esac + log "detected os ${BOXCONF_OS}" + + # Determine default interface and IPv4 address. + case $BOXCONF_OS in + freebsd) + BOXCONF_DEFAULT_INTERFACE=$(route -4n get default | awk '$1 == "interface:" { print $2 }') + BOXCONF_DEFAULT_IPV4=$(ifconfig "$BOXCONF_DEFAULT_INTERFACE" | awk '$1 == "inet" { print $2 }') + ;; + linux) + BOXCONF_DEFAULT_INTERFACE=$(ip -4 -o route get to 1 | awk '{print $5}') + BOXCONF_DEFAULT_IPV4=$(ip -4 -o route get to 1 | awk '{print $7}') + ;; + esac + log "detected default interface ${BOXCONF_DEFAULT_INTERFACE}" + log "detected default ip ${BOXCONF_DEFAULT_IPV4}" + + # Determine OS distribution. + if [ -f /etc/os-release ]; then + BOXCONF_DISTRO=$(. /etc/os-release; printf '%s' "$ID") + BOXCONF_OS_VERSION=$(. /etc/os-release; printf '%s' "$VERSION_ID") + else + die 'unknown os distribution' + fi + log "detected distro ${BOXCONF_DISTRO}" + log "detected os version ${BOXCONF_OS_VERSION}" + + case $BOXCONF_DISTRO in + freebsd|debian) : ;; # supported + *) die "unsupported os distribution: ${BOXCONF_DISTRO}" ;; + esac + + # Determine virtualization type. + BOXCONF_VIRTUALIZATION_TYPE=none + case $BOXCONF_OS in + linux) + grep -q '^flags.* hypervisor' /proc/cpuinfo && BOXCONF_VIRTUALIZATION_TYPE=vm + ;; + freebsd) + if [ "$(sysctl -n security.jail.jailed)" = 1 ]; then + BOXCONF_VIRTUALIZATION_TYPE=jail + elif [ -n "$(sysctl -n hw.hv_vendor)" ]; then + BOXCONF_VIRTUALIZATION_TYPE=vm + fi + ;; + esac + log "detected virtualization type ${BOXCONF_VIRTUALIZATION_TYPE}" + + # Determine hostname. + : "${BOXCONF_HOSTNAME:=$(hostname -s)}" + log "using hostname ${BOXCONF_HOSTNAME}" + + # Determine hostclass. + _boxconf_get_hostclass "$BOXCONF_HOSTNAME" + [ "$BOXCONF_HOSTCLASS" = undefined ] && warn 'unable to determine hostclass' + + # Source vars, then scripts, each in order of lowest to highest precedence. + _boxconf_include \ + "${BOXCONF_VAR_DIR}/common" \ + "${BOXCONF_SITE_VAR_DIR}/common" \ + "${BOXCONF_VAR_DIR}/os/${BOXCONF_OS}" \ + "${BOXCONF_SITE_VAR_DIR}/os/${BOXCONF_OS}" \ + "${BOXCONF_VAR_DIR}/distro/${BOXCONF_DISTRO}" \ + "${BOXCONF_SITE_VAR_DIR}/distro/${BOXCONF_DISTRO}" \ + "${BOXCONF_VAR_DIR}/hostclass/${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_SITE_VAR_DIR}/hostclass/${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_VAR_DIR}/hostname/${BOXCONF_HOSTNAME}" \ + "${BOXCONF_SITE_VAR_DIR}/hostname/${BOXCONF_HOSTNAME}" \ + "${BOXCONF_SCRIPT_DIR}/common" \ + "${BOXCONF_SITE_SCRIPT_DIR}/common" \ + "${BOXCONF_SCRIPT_DIR}/os/${BOXCONF_OS}" \ + "${BOXCONF_SITE_SCRIPT_DIR}/os/${BOXCONF_OS}" \ + "${BOXCONF_SCRIPT_DIR}/distro/${BOXCONF_DISTRO}" \ + "${BOXCONF_SITE_SCRIPT_DIR}/distro/${BOXCONF_DISTRO}" \ + "${BOXCONF_SCRIPT_DIR}/hostclass/${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_SITE_SCRIPT_DIR}/hostclass/${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_SCRIPT_DIR}/hostname/${BOXCONF_HOSTNAME}" \ + "${BOXCONF_SITE_SCRIPT_DIR}/hostname/${BOXCONF_HOSTNAME}" + + # Reboot the target host if requested. + if [ "${BOXCONF_NEED_REBOOT:-}" = true ]; then + log '$BOXCONF_NEED_REBOOT was set. Rebooting host...' + reboot + fi +} diff --git a/lib/20-strings b/lib/20-strings new file mode 100644 index 0000000..f04f68d --- /dev/null +++ b/lib/20-strings @@ -0,0 +1,14 @@ +#!/bin/sh + +join(){ + # Join strings by a given delimiter. + # $1 = delimiter + # $2..$N = strings + _bcj_delim=$1; shift + _bcj_result='' + while [ $# -gt 0 ]; do + _bcj_result="${_bcj_result:+${_bcj_result}${_bcj_delim}}${1}" + shift + done + printf '%s' "$_bcj_result" +} diff --git a/lib/30-files b/lib/30-files new file mode 100644 index 0000000..c7b2000 --- /dev/null +++ b/lib/30-files @@ -0,0 +1,175 @@ +#!/bin/sh + +_boxconf_try_files(){ + # Get the highest precedence file for a given path. + # $1 = target file path + for _bcsf_file in \ + "${BOXCONF_SITE_FILE_DIR}${1}.${BOXCONF_HOSTNAME}" \ + "${BOXCONF_FILE_DIR}${1}.${BOXCONF_HOSTNAME}" \ + "${BOXCONF_SITE_FILE_DIR}${1}.${BOXCONF_HOSTCLASS}.${BOXCONF_OS_DISTRIBUTION}" \ + "${BOXCONF_FILE_DIR}${1}.${BOXCONF_HOSTCLASS}.${BOXCONF_OS_DISTRIBUTION}" \ + "${BOXCONF_SITE_FILE_DIR}${1}.${BOXCONF_OS_DISTRIBUTION}.${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_FILE_DIR}${1}.${BOXCONF_OS_DISTRIBUTION}.${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_SITE_FILE_DIR}${1}.${BOXCONF_HOSTCLASS}.${BOXCONF_OS_FAMILY}" \ + "${BOXCONF_FILE_DIR}${1}.${BOXCONF_HOSTCLASS}.${BOXCONF_OS_FAMILY}" \ + "${BOXCONF_SITE_FILE_DIR}${1}.${BOXCONF_OS_FAMILY}.${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_FILE_DIR}${1}.${BOXCONF_OS_FAMILY}.${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_SITE_FILE_DIR}${1}.${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_FILE_DIR}${1}.${BOXCONF_HOSTCLASS}" \ + "${BOXCONF_SITE_FILE_DIR}${1}.${BOXCONF_OS_DISTRIBUTION}" \ + "${BOXCONF_FILE_DIR}${1}.${BOXCONF_OS_DISTRIBUTION}" \ + "${BOXCONF_SITE_FILE_DIR}${1}.${BOXCONF_OS_FAMILY}" \ + "${BOXCONF_FILE_DIR}${1}.${BOXCONF_OS_FAMILY}" \ + "${BOXCONF_SITE_FILE_DIR}${1}.common" \ + "${BOXCONF_FILE_DIR}${1}.common" + do + if [ -f "$_bcsf_file" ]; then + echo "$_bcsf_file" + return + fi + done + + bug "no source file found for ${1}" +} + +install_file(){ + # Install the files at the given paths into the target system. + # The source file is chosen from the matching file in the boxconf directory with + # the highest-precedence suffix. + # Takes options similar to the `install` command. + _bcif_install_args='-Cv' + _bcif_mode=0644 + + while getopts m:o:g: _bcif_opt; do + case $_bcif_opt in + m) _bcif_mode=$OPTARG ;; + o) _bcif_install_args="${_bcif_install_args} -o ${OPTARG}" ;; + g) _bcif_install_args="${_bcif_install_args} -g ${OPTARG}" ;; + esac + done + shift $((OPTIND - 1)) + + while [ $# -gt 0 ]; do + _bcif_src=$(_boxconf_try_files "$1") + install -m "$_bcif_mode" $_bcif_install_args "$_bcif_src" "$1" + shift + done +} + +install_directory(){ + # Create the specified directories in the target system. + # Takes options similar to the `install` command. + _bcid_install_args='-Cdv' + _bcid_mode=0755 + + while getopts m:o:g: _bcid_opt; do + case $_bcid_opt in + m) _bcid_mode=$OPTARG ;; + o) _bcid_install_args="${_bcid_install_args} -o ${OPTARG}" ;; + g) _bcid_install_args="${_bcid_install_args} -g ${OPTARG}" ;; + esac + done + shift $((OPTIND - 1)) + + while [ $# -gt 0 ]; do + install -m "$_bcid_mode" $_bcid_install_args "$1" + shift + done +} + +install_template(){ + # Install the templatess at the given paths into the target system. + # The source template is chosen from the matching file in the boxconf directory + # with the highest-precedence suffix. Template is rendered as a shell heredoc. + # Takes options similar to the `install` command. + _bcit_install_args='-Cv' + _bcit_mode=0644 + + while getopts m:o:g: _bcit_opt; do + case $_bcit_opt in + m) _bcit_mode=$OPTARG ;; + o) _bcit_install_args="${_bcit_install_args} -o ${OPTARG}" ;; + g) _bcit_install_args="${_bcit_install_args} -g ${OPTARG}" ;; + esac + done + shift $((OPTIND - 1 )) + + while [ $# -gt 0 ]; do + _bcit_src=$(_boxconf_try_files "$1") + + eval "cat <<__BOXCONF_EOF__ >${_bcit_src}.render +$(cat "$_bcit_src") +__BOXCONF_EOF__ +" + [ -s "${_bcit_src}.render" ] || bug "failed to render template: ${_bcit_src}" + install -m "$_bcit_mode" $_bcit_install_args "${_bcit_src}.render" "$1" + shift + done +} + +install_certificate(){ + # Install a certificate from the CA dir into the target system. + # Takes options similar to the `install` command. + # $1 = certificate name + # $2 = target path + _bcic_install_args='-Cv' + _bcic_mode=0644 + + while getopts m:o:g: _bcic_opt; do + case $_bcic_opt in + m) _bcic_mode=$OPTARG ;; + o) _bcic_install_args="${_bcic_install_args} -o ${OPTARG}" ;; + g) _bcic_install_args="${_bcic_install_args} -g ${OPTARG}" ;; + esac + done + shift $((OPTIND - 1)) + + [ -f "${BOXCONF_CA_DIR}/${BOXCONF_HOSTNAME}/${1}.fullchain.crt" ] \ + || bug "no certificate exists for ${BOXCONF_HOSTNAME}/${1}" + + install -m "$_bcic_mode" $_bcic_install_args "${BOXCONF_CA_DIR}/${BOXCONF_HOSTNAME}/${1}.fullchain.crt" "$2" +} + +install_certificate_key(){ + # Install a certificate's private key from the CA dir into the target system. + # Takes options similar to the `install` command. + # $1 = certificate name + # $2 = target path + _bcick_install_args='-Cv' + _bcick_mode=0600 + + while getopts m:o:g: _bcick_opt; do + case $_bcick_opt in + m) _bcick_mode=$OPTARG ;; + o) _bcick_install_args="${_bcick_install_args} -o ${OPTARG}" ;; + g) _bcick_install_args="${_bcick_install_args} -g ${OPTARG}" ;; + esac + done + shift $((OPTIND - 1)) + + [ -f "${BOXCONF_CA_DIR}/${BOXCONF_HOSTNAME}/${1}.key" ] \ + || bug "no key exists for ${BOXCONF_HOSTNAME}/${1}" + + install -m "$_bcick_mode" $_bcick_install_args "${BOXCONF_CA_DIR}/${BOXCONF_HOSTNAME}/${1}.key" "$2" +} + +install_ca_certificate(){ + # Install a the root CA from the CA dir into the target system. + # Takes options similar to the `install` command. + # $1 = target path + _bcicc_install_args='-Cv' + _bcicc_mode=0644 + + while getopts m:o:g: _bcicc_opt; do + case $_bcicc_opt in + m) _bcicc_mode=$OPTARG ;; + o) _bcicc_install_args="${_bcicc_install_args} -o ${OPTARG}" ;; + g) _bcicc_install_args="${_bcicc_install_args} -g ${OPTARG}" ;; + esac + done + shift $((OPTIND - 1)) + + [ -f "${BOXCONF_CA_DIR}/ca.crt" ] || bug 'CA certificate not found' + + install -m "$_bcicc_mode" $_bcicc_install_args "${BOXCONF_CA_DIR}/ca.crt" "$1" +} diff --git a/lib/40-os b/lib/40-os new file mode 100644 index 0000000..cedeb86 --- /dev/null +++ b/lib/40-os @@ -0,0 +1,64 @@ +#!/bin/sh + +set_sysctl(){ + # Set sysctl value(s) and persist them to /etc/sysctl.conf. + # $1..$N = sysctl values (as "name=value" strings) + while [ $# -gt 0 ]; do + sysctl "$1" + sed -i.bak "/^${1%%=*}=/{ +h +s/=.*/=${1#*=}/ +} +\${ +x +/^\$/{ +s//${1}/ +H +} +x +}" /etc/sysctl.conf + shift + done + rm -f /etc/sysctl.conf.bak +} + +set_loader_conf(){ + # Set the FreeBSD bootloader options in /boot/loader.conf. + # The host will be rebooted if the file is changed. + # $1..$N = bootloader options (as "name=value" strings) + [ "$BOXCONF_OS_FAMILY" = freebsd ] || bug 'set_loader_conf can only be used on FreeBSD' + + while [ $# -gt 0 ]; do + grep -qxF "${1%%=*}=\"${1#*=}\"" /boot/loader.conf || BOXCONF_NEED_REBOOT=true + sed -i.bak "/^${1%%=*}=/{ +h +s/=.*/=\"${1#*=}\"/ +} +\${ +x +/^\$/{ +s//${1%%=*}=\"${1#*=}\"/ +H +} +x +}" /boot/loader.conf + shift + done + rm -f /boot/loader.conf.bak +} + +load_kernel_module(){ + # Ensure the given kernel modules are loaded. + # $1..$N = bootloader options (as "name=value" strings) + case $BOXCONF_OS_FAMILY in + freebsd) + while [ $# -gt 0 ]; do + kldstat -qn "$1" || kldload -v "$1" + shift + done + ;; + *) + die "load_kernel_module unimplemented for ${BOXCONF_OS_FAMILY}" + ;; + esac +} diff --git a/lib/50-net b/lib/50-net new file mode 100644 index 0000000..37b1cb3 --- /dev/null +++ b/lib/50-net @@ -0,0 +1,37 @@ +#!/bin/sh + +_boxconf_ip2dec(){ + # helper function for ip_in_subnet + while [ $# -gt 0 ]; do + echo "$1" | { + IFS=./ read -r _bcipd_a _bcipd_b _bcipd_c _bcipd_d _bcipd_e + [ -n "$_bcipd_e" ] || _bcipd_e=32 + printf '%s %s ' "$((_bcipd_a<<24|_bcipd_b<<16|_bcipd_c<<8|_bcipd_d))" "$((-1<<(32-_bcipd_e)))" + } + shift + done +} + +ip_in_subnet(){ + # Check if an IP address is contained within a subnet. + # $1 = IPv4 address + # $2 = network cidr + _boxconf_ip2dec "$1" "$2" | { + read -r _bciis_a1 _bciis_m1 _bciis_a2 _bciis_m2 ||: + test "$(( (_bciis_a1 & _bciis_m2) == (_bciis_a2 & _bciis_m2) && _bciis_m1 >= _bciis_m2 ))" -eq 1 + } +} + +prefix2netmask(){ + # Convert a network prefix to its netmask address. + # For example, 24 returns '255.255.255.0' + # $1 = network prefix value + _bcp2n_val=$(( 0xffffffff ^ ((1 << (32 - $1)) - 1) )) + echo "$(( (_bcp2n_val >> 24) & 0xff )).$(( (_bcp2n_val >> 16) & 0xff )).$(( (_bcp2n_val >> 8) & 0xff )).$(( _bcp2n_val & 0xff ))" +} + +ip2rdns(){ + # Convert an IPv4 address to its in-addr.arpa reverse DNS name. + # $1 = IPv4 address + echo "$1" | awk -F. '{print $4"."$3"."$2"."$1".in-addr.arpa"}' +} diff --git a/lib/50-zfs b/lib/50-zfs new file mode 100644 index 0000000..2948601 --- /dev/null +++ b/lib/50-zfs @@ -0,0 +1,15 @@ +#!/bin/sh + +dataset_exists(){ + # Check if a ZFS dataset exists. + # $1 = dataset name + zfs list "$1" > /dev/null 2>&1 +} + +create_dataset(){ + # Create a ZFS dataset if it doesn't already exists. + # All options are passed directly to `zfs create`. Assumes the dataset name is + # passed as the final argument. + eval "_bccd_dataset=\$$#" + dataset_exists "$_bccd_dataset" || zfs create -v "$@" +} @@ -0,0 +1,358 @@ +#!/bin/sh +# +# Utility to manage an internal certificate authority using OpenSSL. + +set -eu + +PROGNAME=pki +USAGE="<init|cert|client-cert|renew>" +BOXCONF_ROOT=$(dirname "$(readlink -f "$0")") + +BOXCONF_CA_PASSWORD_FILE="${BOXCONF_ROOT}/.ca_password" +CA_VALID_DAYS=3650 +DEFAULT_VALID_DAYS=3650 +EC_CURVE=prime256v1 +DIGEST=sha256 +CIPHER=aes256 + +usage(){ + printf 'usage: %s %s\n' "$PROGNAME" "$USAGE" 2>&1 + exit 2 +} + +_pki_get_ca_password(){ + # Acquire the CA password. + # If the BOXCONF_CA_PASSWORD environment variable is set, use that. + # Next, try reading the password from the .ca_password file. + # If all else fails, prompt interactively. + if [ -n "${BOXCONF_CA_PASSWORD:-}" ]; then + return + elif [ -f "$BOXCONF_CA_PASSWORD_FILE" ]; then + BOXCONF_CA_PASSWORD=$(cat "$BOXCONF_CA_PASSWORD_FILE") + else + _boxconf_read_password 'Enter CA password: ' BOXCONF_CA_PASSWORD + fi +} + +_pki_dn2cnf() { + # Convert an LDAP DN to its OpenSSL .cnf file representation. + echo "$1" \ + | tr ',' '\n' \ + | awk '{a[i++]=$0} END {for (j=i-1; j>=0;) print a[j--] }' \ + | sed \ + -e 's/^[Cc][Nn]=/commonName=/' \ + -e 's/^[Oo]=/organizationName=/' \ + -e 's/^[Oo][Uu]=/organizationalUnitName=/' \ + -e 's/^[Dd][Cc]=/domainComponent=/' \ + -e 's/^[Cc]=/countryName=/' \ + -e 's/^[Ss][Tt]=/stateOrProvinceName=/' \ + -e 's/^[Ll]=/locality=/' \ + -e 's/^[Ss][Nn]=/surName=/' \ + -e 's/^[Gg][Nn]=/givenName=/' \ + -e 's/^[Uu][Ii][Dd]=/userId=/' \ + | awk '{print NR-1 "." $0}' +} + +_pki_postsign(){ + # Create symlinks and delete temp files after signing a certificate. + # $1 = certificate path + + # Create symlink with human-readable name. + serial=$(awk 'END{print $3}' "${BOXCONF_CA_DIR}/index.txt") + ln -sf "../certs/${serial}.pem" "${BOXCONF_CA_DIR}/${1}.crt" + + # Create fullchain certificate. + cat "${BOXCONF_CA_DIR}/${1}.crt" "${BOXCONF_CA_DIR}/ca.crt" > "${BOXCONF_CA_DIR}/${1}.fullchain.crt" + + # Delete useless files. + rm -f \ + "${BOXCONF_CA_DIR}/index.txt.old" \ + "${BOXCONF_CA_DIR}/index.txt.attr.old" \ + "${BOXCONF_CA_DIR}/serial.old" +} + +_pki_sign(){ + # Given an OpenSSL config file, generate a signed certificate keypair. + # $1 = certificate path + # $2 = validity time (days) + + # Generate encrypted private key for the server certificate. + PASS="$BOXCONF_VAULT_PASSWORD" openssl genpkey \ + -algorithm ec \ + -pkeyopt "ec_paramgen_curve:${EC_CURVE}" \ + "-${CIPHER}" \ + -pass env:PASS \ + -out "${BOXCONF_CA_DIR}/${1}.key" + + # Generate the CSR. + PASS="$BOXCONF_VAULT_PASSWORD" openssl req -new \ + -key "${BOXCONF_CA_DIR}/${1}.key" \ + "-${DIGEST}" \ + -passin env:PASS \ + -config "${BOXCONF_CA_DIR}/${1}.cnf" \ + -out "${BOXCONF_CA_DIR}/${1}.csr" + + # Sign the certificate. + PASS="$BOXCONF_CA_PASSWORD" openssl ca -batch \ + -config "${BOXCONF_CA_DIR}/ca.cnf" \ + -passin env:PASS \ + ${2:+-days $2} \ + -notext \ + -out /dev/null \ + -outdir "${BOXCONF_CA_DIR}/certs" \ + -infiles "${BOXCONF_CA_DIR}/${1}.csr" + + _pki_postsign "$1" +} + +_pki_renew(){ + # Re-sign an existing certificate. + # $1 = certificate path + # $2 = validity time (time) + _pki_get_ca_password + + # Sign the certificate. + PASS="$BOXCONF_CA_PASSWORD" openssl ca -batch \ + -config "${BOXCONF_CA_DIR}/ca.cnf" \ + -passin env:PASS \ + ${2:+-days $2} \ + -notext \ + -out /dev/null \ + -outdir "${BOXCONF_CA_DIR}/certs" \ + -infiles "${BOXCONF_CA_DIR}/${1}.csr" + + _pki_postsign "$1" +} + +pki_init(){ + # Initialize the CA. Create CA cert, private key, and OpenSSL configuration. + USAGE='init [-c CONSTRAINT]... DOMAIN' + + constraints='' + while getopts :c: opt; do + case $opt in + c) constraints="${constraints}, permitted;${OPTARG}" ;; + :) usage ;; + ?) usage ;; + esac + done + shift $((OPTIND - 1 )) + + [ $# -eq 1 ] || usage + domain=$1 + + [ -d "$BOXCONF_CA_DIR" ] && die 'CA already exists' + _pki_get_ca_password + mkdir -p "${BOXCONF_CA_DIR}/certs" + + # Generate encrypted private key for CA. + PASS="$BOXCONF_CA_PASSWORD" openssl genpkey \ + -algorithm ec \ + -pkeyopt "ec_paramgen_curve:${EC_CURVE}" \ + "-${CIPHER}" \ + -pass env:PASS \ + -out "${BOXCONF_CA_DIR}/ca.key" + + # Create a config file for the CA certificate. + cat > "${BOXCONF_CA_DIR}/ca.cnf" <<EOF +[ req ] +x509_extensions = v3_req +distinguished_name = req_distinguished_name +prompt = no + +[ v3_req ] +basicConstraints = critical, CA:TRUE, pathlen:0 +keyUsage = critical, keyCertSign, cRLSign +nameConstraints = permitted;DNS:${domain}, permitted;DNS:.${domain}, permitted;email:.${domain}${constraints} + +[ req_distinguished_name ] +O = ${domain} +CN = Certificate Authority + +[ ca ] +preserve = yes +default_ca = CA_own + +[ CA_own ] +dir = .${BOXCONF_CA_DIR#${BOXCONF_ROOT}} +new_certs_dir = \$dir/certs +database = \$dir/index.txt +rand_serial = yes +unique_subject = no +certificate = \$dir/ca.crt +private_key = \$dir/ca.key +default_days = ${DEFAULT_VALID_DAYS} +default_crl_days = 30 +default_md = ${DIGEST} +preserve = yes +policy = policy_anything +copy_extensions = copy +x509_extensions = v3 + +[ v3 ] +basicConstraints = critical, CA:FALSE + +[ policy_anything ] +countryName = optional +stateOrProvinceName = optional +localityName = optional +organizationName = optional +organizationalUnitName = optional +commonName = optional +emailAddress = optional +EOF + + # Self-sign the CA certificate. + PASS="$BOXCONF_CA_PASSWORD" openssl req -new -x509 \ + -days "$CA_VALID_DAYS" \ + "-${DIGEST}" \ + -passin env:PASS \ + -config "${BOXCONF_CA_DIR}/ca.cnf" \ + -key "${BOXCONF_CA_DIR}/ca.key" \ + -out "${BOXCONF_CA_DIR}/ca.crt" + + # Create empty index db. + [ -f "${BOXCONF_CA_DIR}/index.txt" ] || touch "${BOXCONF_CA_DIR}/index.txt" +} + +pki_server(){ + # Create a server certificate keypair. + USAGE='server-cert [-d DAYS] HOSTNAME CERTNAME SAN...' + + while getopts :d: opt; do + case $opt in + d) days=$OPTARG ;; + :|?) usage ;; + esac + done + shift $((OPTIND - 1 )) + + [ $# -ge 3 ] || usage + hostname=$1; certname=$2; cn=${3#*:} + shift 2 + + [ -e "${BOXCONF_CA_DIR}/${hostname}/${certname}.cnf" ] && die "certificate already exists: ${hostname}/${certname}" + + _pki_get_ca_password + _boxconf_get_vault_password + + # Generate the SAN list. If the arg contains a ':', pass along as-is. + # If no ':' is present, assume type 'DNS:'. + while [ $# -gt 0 ]; do + if [ "${1#*:}" = "$1" ]; then + sans="${sans:+${sans},}DNS:${1}" + else + sans="${sans:+${sans},}${1}" + fi + shift + done + + # Create host directory. + mkdir -p "${BOXCONF_CA_DIR}/${hostname}" + + # Create a config file for the server certificate. + cat > "${BOXCONF_CA_DIR}/${hostname}/${certname}.cnf" <<EOF +[ req ] +req_extensions = v3_req +distinguished_name = req_distinguished_name +prompt = no + +[ v3_req ] +basicConstraints = critical,CA:FALSE +extendedKeyUsage = serverAuth +subjectAltName = ${sans} + +[ req_distinguished_name ] +CN = ${cn} +EOF + + # Generate and sign the certificate. + _pki_sign "${hostname}/${certname}" "${days:-}" +} + +pki_client(){ + # Create a client certificate keypair. + USAGE='client-cert HOSTNAME CERTNAME DN [SAN...]' + + while getopts :d: opt; do + case $opt in + d) days=$OPTARG ;; + :|?) usage ;; + esac + done + shift $((OPTIND - 1 )) + + [ $# -ge 3 ] || usage + hostname=$1; certname=$2; dn=$3 + shift 3 + + [ -e "${BOXCONF_CA_DIR}/${hostname}/${certname}.cnf" ] && die "certificate already exists: ${hostname}/${certname}" + + _pki_get_ca_password + _boxconf_get_vault_password + + # Generate the SAN list. + if [ $# -gt 0 ]; then + sans=$1; shift + while [ $# -gt 0 ]; do + sans="${sans}, ${1}" + shift + done + fi + + # Create host directory. + mkdir -p "${BOXCONF_CA_DIR}/${hostname}" + + # Create a config file for the client certificate. + cat > "${BOXCONF_CA_DIR}/${hostname}/${certname}.cnf" <<EOF +[ req ] +req_extensions = v3_req +distinguished_name = req_distinguished_name +prompt = no + +[ v3_req ] +basicConstraints = critical,CA:FALSE +extendedKeyUsage = clientAuth +${sans:+subjectAltName = $sans} + +[ req_distinguished_name ] +$(_pki_dn2cnf "$dn") +EOF + + # Generate and sign the certificate. + _pki_sign "${hostname}/${certname}" "${days:-}" +} + + +pki_renew(){ + # Renew an existing certificate. + USAGE='renew [-d DAYS] HOSTNAME CERTNAME' + + while getopts :d: opt; do + case $opt in + d) days=$OPTARG ;; + :|?) usage ;; + esac + done + shift $((OPTIND - 1 )) + [ $# -eq 2 ] || usage + + [ -f "${BOXCONF_CA_DIR}/${1}/${2}.csr" ] || die "CSR does not exist: ${1}/${2}.csr" + + _pki_renew "${1}/${2}" "${days:-}" +} + +[ $# -ge 1 ] || usage +action=$1; shift + +for _bc_lib in "${BOXCONF_ROOT}/lib"/*; do + . "$_bc_lib" +done + +case $action in + init) pki_init "$@" ;; + server-cert|server|cert) pki_server "$@" ;; + client-cert|client) pki_client "$@" ;; + renew) pki_renew "$@" ;; + *) usage ;; +esac @@ -0,0 +1,121 @@ +#!/bin/sh +# +# Utility to manage encrypted files using OpenSSL's pbkdf2. + +set -eu + +PROGNAME=vault +USAGE="${PROGNAME} <check|create|decrypt|edit|encrypt|reencrypt|> FILE..." +BOXCONF_ROOT=$(dirname "$(readlink -f "$0")") + +usage(){ + printf 'usage: %s\n' "$USAGE" 2>&1 + exit 2 +} + +vault_check(){ + while [ $# -gt 0 ]; do + if [ ! -f "$1" ]; then + warn "file does not exist: ${1}" + elif _boxconf_is_encrypted "$1"; then + echo "${1} is encrypted" + else + echo "${1} is not encrypted" + fi + shift + done +} + +vault_create(){ + _boxconf_get_vault_password + if [ -e "$1" ]; then + die "file already exists: ${1}" + else + "$EDITOR" "$TMPFILE" + PASS=$BOXCONF_VAULT_PASSWORD openssl enc -in "$TMPFILE" -out "$1" -e "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + fi +} + +vault_decrypt(){ + _boxconf_get_vault_password + while [ $# -gt 0 ]; do + if [ ! -f "$1" ]; then + warn "file does not exist: ${1}" + elif ! _boxconf_is_encrypted "$1"; then + warn "file is not encrypted: ${1}" + else + PASS=$BOXCONF_VAULT_PASSWORD openssl enc -in "$1" -d "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + fi + shift + done +} + +vault_edit(){ + _boxconf_get_vault_password + while [ $# -gt 0 ]; do + if [ ! -f "$1" ]; then + warn "file does not exist: ${1}" + elif ! _boxconf_is_encrypted "$1"; then + warn "file is not encrypted: ${1}" + else + PASS=$BOXCONF_VAULT_PASSWORD openssl enc -in "$1" -out "$TMPFILE" -d "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + "$EDITOR" "$TMPFILE" + PASS=$BOXCONF_VAULT_PASSWORD openssl enc -in "$TMPFILE" -out "$1" -e "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + fi + shift + done +} + +vault_encrypt(){ + _boxconf_get_vault_password + while [ $# -gt 0 ]; do + if [ ! -f "$1" ]; then + warn "file does not exist: ${1}" + elif _boxconf_is_encrypted "$1"; then + warn "file is already encrypted, refusing: ${1}" + else + PASS=$BOXCONF_VAULT_PASSWORD openssl enc -in "$1" -out "$TMPFILE" -e "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + cp "$TMPFILE" "$1" + fi + shift + done +} + +vault_reencrypt(){ + _boxconf_get_vault_password + + [ -n "${VAULT_NEW_PASSWORD:-}" ] \ + || _boxconf_read_password 'Enter new vault password: ' VAULT_NEW_PASSWORD + + while [ $# -gt 0 ]; do + if [ ! -f "$1" ]; then + warn "file does not exist: ${1}" + elif ! _boxconf_is_encrypted "$1"; then + warn "file is not encrypted: ${1}" + else + PASS=$BOXCONF_VAULT_PASSWORD openssl enc -in "$1" -out "$TMPFILE" -d "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + PASS=$VAULT_NEW_PASSWORD openssl enc -in "$TMPFILE" -out "$1" -e "-${BOXCONF_VAULT_CIPHER}" -pass env:PASS -pbkdf2 + fi + shift + done +} + +[ $# -gt 1 ] || usage +action=$1; shift + +for _bc_lib in "${BOXCONF_ROOT}/lib"/*; do + . "$_bc_lib" +done + +TMPFILE=$(mktemp) +trap 'rm -f "$TMPFILE"' HUP INT QUIT TERM EXIT + +case $action in + check) vault_check "$@" ;; + create) vault_create "$@" ;; + decrypt) vault_decrypt "$@" ;; + edit) vault_edit "$@" ;; + encrypt) vault_encrypt "$@" ;; + reencrypt) vault_reencrypt "$@" ;; + *) usage ;; +esac |