aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCullum Smith <cullum@sacredheartsc.com>2024-07-11 10:55:45 -0400
committerCullum Smith <cullum@sacredheartsc.com>2024-07-11 10:55:45 -0400
commit85007db580ccf662a45cf2aaeb83518ad2ddb85a (patch)
treed692c5bdbaf33c5b9791d538982b17ab4dd808ee
parentde8305223b6079d14ac854ee067ffd069cb38ec7 (diff)
downloadinfrastructure-85007db580ccf662a45cf2aaeb83518ad2ddb85a.tar.gz
initial boxconf scaffolding
-rw-r--r--.gitignore6
-rw-r--r--LICENSE2
-rw-r--r--README.md209
-rwxr-xr-xboxconf40
-rw-r--r--hostclasses26
-rw-r--r--lib/10-core340
-rw-r--r--lib/20-strings14
-rw-r--r--lib/30-files175
-rw-r--r--lib/40-os64
-rw-r--r--lib/50-net37
-rw-r--r--lib/50-zfs15
-rwxr-xr-xpki358
-rwxr-xr-xvault121
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
diff --git a/LICENSE b/LICENSE
index 57c0367..2ff1a6f 100644
--- a/LICENSE
+++ b/LICENSE
@@ -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:
diff --git a/README.md b/README.md
index a899786..8f21338 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/boxconf b/boxconf
new file mode 100755
index 0000000..52d8500
--- /dev/null
+++ b/boxconf
@@ -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 "$@"
+}
diff --git a/pki b/pki
new file mode 100755
index 0000000..9a94121
--- /dev/null
+++ b/pki
@@ -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
diff --git a/vault b/vault
new file mode 100755
index 0000000..5f0094f
--- /dev/null
+++ b/vault
@@ -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