Files
DDI/scripts/install.sh
T

1792 lines
60 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# Installer of the DDI suite (docs/adr/0024-auslieferung-installer-m6.md,
# docs/INSTALL.md): management server, DNS agent with BIND and DHCP agent
# with Kea on Debian 12/13, Ubuntu 22.04/24.04 and RHEL-family 9 (amd64,
# arm64). Download it, read it, then run it as root:
#
# ./install.sh --role server
# ./install.sh --role agent-dns --server <host:port> --token <token> --ca-hash sha256:<hex>
#
# Run "./install.sh --help" for all options.
set -euo pipefail
umask 022
# --- names (ADR 0018) ---
PRODUCT="ddi-suite"
DISPLAY_NAME="DDI-Suite"
BIN_SERVER="ddi-server"
BIN_AGENT_DNS="ddi-agent-dns"
BIN_AGENT_DHCP="ddi-agent-dhcp"
ENV_PREFIX="DDI_"
CONFIG_DIR="/etc/ddi-suite"
STATE_DIR="/var/lib/ddi-suite"
LOG_DIR="/var/log/ddi-suite"
DOC_DIR="/usr/local/share/doc/ddi-suite"
# Files the server's generator writes (fixed names) and the paths of its
# default settings; agents of all distributions use the same ones.
BIND_INCLUDE_MAIN="ddi-suite.conf"
BIND_INCLUDE_OPTIONS="ddi-suite-options.conf"
BIND_INCLUDE_KEYS="ddi-suite-keys.conf"
ZONE_DIR="/var/lib/bind/ddi-suite"
DEB_DNS_CONF_DIR="/etc/bind/ddi-suite"
EL_DNS_CONF_DIR="/etc/named/ddi-suite"
KEA_HA_FILES="/etc/kea/ddi-suite-ha-user /etc/kea/ddi-suite-ha-password"
# Ed25519 public key (PEM) that signs SHA256SUMS of the releases
# (docs/RELEASE.md). Empty: the releases are not signed yet; only the
# checksums are verified.
RELEASE_PUBLIC_KEY_PEM=""
# --- end names ---
BINDIR="/usr/local/bin"
UNIT_DIR="/etc/systemd/system"
KEA_CONF="/etc/kea/kea-dhcp4.conf"
KEA_CTRL_SOCKET="/run/kea/kea4-ctrl-socket"
KEA_SOCKET_ENV="KEA_CONTROL_SOCKET_DIR=${KEA_CTRL_SOCKET%/*}"
KEA_LEASE_FILE="/var/lib/kea/kea-leases4.csv"
HTTP_PORT=8443
AGENT_API_PORT=8444
# Release source (ADR 0024). DDI_RELEASE_BASE_URL may contain "<tag>".
GIT_HOST="https://git.beugsiud.de"
GIT_REPO="beug.k/DDI"
DEFAULT_RELEASE_BASE_URL="${GIT_HOST}/${GIT_REPO}/releases/download/<tag>/"
RELEASES_API="${GIT_HOST}/api/v1/repos/${GIT_REPO}/releases/latest"
LICENSE_SERVER_URL="https://license.beugsiud.de"
# ISC Kea packages (Cloudsmith); the key is trusted only with this
# fingerprint.
KEA_REPO="isc/kea-3-2"
KEA_REPO_NAME="isc-kea-3-2"
KEA_REPO_URL="https://dl.cloudsmith.io/public/${KEA_REPO}"
KEA_KEY_ID="720DD962343B440F"
KEA_KEY_FINGERPRINT="360D0B03DFE219DB0F9FEA4C720DD962343B440F"
# Pre-installation check (docs/SYSTEM-REQUIREMENTS.md, section 3): below a
# warning threshold the installer warns, below an abort threshold it stops
# (--force turns that into a warning). RAM and disk in MiB; with several
# roles on one host RAM and disk add up, CPU takes the maximum.
declare -A WARN_CPU=([server]=2 [agent-dns]=1 [agent-dhcp]=1)
declare -A WARN_RAM=([server]=3584 [agent-dns]=900 [agent-dhcp]=900)
declare -A ABORT_RAM=([server]=1843 [agent-dns]=450 [agent-dhcp]=450)
declare -A WARN_DISK=([server]=20480 [agent-dns]=5120 [agent-dhcp]=5120)
declare -A ABORT_DISK=([server]=4096 [agent-dns]=1024 [agent-dhcp]=1024)
ABORT_BINDIR_DISK=200
MIN_POSTGRESQL=13
WARN_BIND="9.18"
WARN_KEA="3.0"
ABORT_KEA="2.6"
KEA_HA_PORT=8001
# ---------------------------------------------------------------- options
ROLES=()
VERSION=""
SERVER_ADDR=""
ENROLL_TOKEN=""
CA_HASH=""
PUBLIC_URL=""
TLS_CERT=""
TLS_KEY=""
LICENSE_KEY_FILE=""
KEA_SOURCE="isc"
FROM_DIR=""
GIT_TOKEN="${DDI_GIT_TOKEN:-}"
MODE="install"
PURGE=0
DRY_RUN=0
NON_INTERACTIVE=0
ASSUME_YES=0
FORCE=0
usage() {
cat <<EOF
Usage: install.sh [options]
Installs, upgrades or removes the ${DISPLAY_NAME} roles on this host.
Roles:
--role <role> server, agent-dns or agent-dhcp; repeat the option
or separate roles with commas
Release:
--version <tag> release to install (default: the latest release)
--from-dir <dir> take the release artifacts from <dir> instead of
downloading them (tests, offline installation)
--git-token <token> access token for the release downloads (or
DDI_GIT_TOKEN); never written to the log
Agents:
--server <host:port> agent API of the management server
--token <token> one-time enrollment token
--ca-hash sha256:<hex> fingerprint of the agent CA
(all three are part of the enrollment command
shown in the web interface)
--kea-source isc|distro Kea from the ISC repository (default, Kea 3.x with
HA) or from the distribution (without HA)
Server:
--public-url <url> URL of the web interface
(default https://<fqdn>:${HTTP_PORT})
--tls-cert <file> TLS certificate (PEM, with chain) and
--tls-key <file> its key; default: a self-signed certificate
--license-key-file <file> license key to activate (optional; asked for
interactively otherwise)
Modes:
--upgrade replace binaries and units, restart the services
--uninstall remove the roles (configuration and data stay)
--purge with --uninstall: also remove configuration,
data, database, keys and the service user
--dry-run show what would be done, change nothing
--non-interactive never ask (needs --yes to make changes)
--yes answer yes to the confirmation
--force only warn when the system requirements are not met
-h, --help show this help
Environment: DDI_RELEASE_BASE_URL (default ${DEFAULT_RELEASE_BASE_URL}),
DDI_GIT_TOKEN.
EOF
}
need_arg() {
[[ $# -ge 2 && -n "$2" ]] || {
echo "option $1 needs a value" >&2
exit 2
}
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--role)
need_arg "$@"
local r
IFS=',' read -r -a parts <<<"$2"
for r in "${parts[@]}"; do
case "$r" in
server | agent-dns | agent-dhcp) [[ " ${ROLES[*]} " == *" $r "* ]] || ROLES+=("$r") ;;
*)
echo "unknown role: $r" >&2
exit 2
;;
esac
done
shift
;;
--version) need_arg "$@"; VERSION="$2"; shift ;;
--server) need_arg "$@"; SERVER_ADDR="$2"; shift ;;
--token) need_arg "$@"; ENROLL_TOKEN="$2"; shift ;;
--ca-hash) need_arg "$@"; CA_HASH="$2"; shift ;;
--public-url) need_arg "$@"; PUBLIC_URL="$2"; shift ;;
--tls-cert) need_arg "$@"; TLS_CERT="$2"; shift ;;
--tls-key) need_arg "$@"; TLS_KEY="$2"; shift ;;
--license-key-file) need_arg "$@"; LICENSE_KEY_FILE="$2"; shift ;;
--kea-source) need_arg "$@"; KEA_SOURCE="$2"; shift ;;
--from-dir) need_arg "$@"; FROM_DIR="$2"; shift ;;
--git-token) need_arg "$@"; GIT_TOKEN="$2"; shift ;;
--upgrade) MODE="upgrade" ;;
--uninstall) MODE="uninstall" ;;
--purge) PURGE=1 ;;
--dry-run) DRY_RUN=1 ;;
--non-interactive) NON_INTERACTIVE=1 ;;
--yes | -y) ASSUME_YES=1 ;;
--force) FORCE=1 ;;
-h | --help)
usage
exit 0
;;
*)
echo "unknown option: $1 (see --help)" >&2
exit 2
;;
esac
shift
done
local bad=""
[[ "$KEA_SOURCE" == isc || "$KEA_SOURCE" == distro ]] || bad="--kea-source must be isc or distro"
[[ -z "$VERSION" || "$VERSION" =~ ^[A-Za-z0-9._+-]+$ ]] || bad="invalid --version"
[[ $PURGE -eq 0 || "$MODE" == uninstall ]] || bad="--purge needs --uninstall"
if [[ -n "$TLS_CERT" && -z "$TLS_KEY" ]] || [[ -z "$TLS_CERT" && -n "$TLS_KEY" ]]; then
bad="--tls-cert and --tls-key belong together"
fi
if [[ -n "$SERVER_ADDR$ENROLL_TOKEN$CA_HASH" ]]; then
[[ -n "$SERVER_ADDR" && -n "$ENROLL_TOKEN" && -n "$CA_HASH" ]] ||
bad="--server, --token and --ca-hash belong together"
[[ "$CA_HASH" =~ ^sha256:[0-9a-fA-F]{64}$ ]] || bad="--ca-hash must look like sha256:<64 hex digits>"
local hostport='^[][A-Za-z0-9.:-]+:[0-9]+$'
[[ "$SERVER_ADDR" =~ $hostport ]] || bad="--server must be host:port"
[[ "$ENROLL_TOKEN" =~ ^[A-Za-z0-9._~+/=-]+$ ]] || bad="--token contains invalid characters"
local agents=0 r
for r in "${ROLES[@]}"; do [[ "$r" == agent-* ]] && agents=$((agents + 1)); done
[[ $agents -eq 1 ]] || bad="--token enrolls exactly one agent role per run"
fi
if [[ -n "$PUBLIC_URL" ]]; then
[[ "$PUBLIC_URL" =~ ^https?://[A-Za-z0-9.-]+(:[0-9]+)?/?$ ]] || bad="--public-url must look like https://host[:port]"
fi
if [[ "$MODE" == install && ${#ROLES[@]} -eq 0 ]]; then
bad="no role given (--role server|agent-dns|agent-dhcp)"
fi
if [[ -n "$bad" ]]; then
echo "$bad" >&2
exit 2
fi
}
# ---------------------------------------------------------------- logging
LOG_FILE=""
WORK=""
CHANGES=0
WARNINGS=()
RESULT_LINES=()
ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
logf() { [[ -z "$LOG_FILE" ]] || printf '%s %s\n' "$(ts)" "$*" >>"$LOG_FILE"; }
log() {
printf '%s\n' "$*"
logf "$*"
}
step() { log "==> $*"; }
warn() {
WARNINGS+=("$*")
log "WARNING: $*"
}
die() {
log "ERROR: $*"
exit 1
}
changed() {
CHANGES=$((CHANGES + 1))
log " * $*"
}
# run CMD... runs a command; its output goes to the log file. In dry-run
# mode it only prints the command.
run() {
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] $*"
return 0
fi
logf "+ $*"
if [[ -n "$LOG_FILE" ]]; then
"$@" >>"$LOG_FILE" 2>&1
else
"$@"
fi
}
# run_quiet DESCRIPTION CMD... is run for commands with secrets in their
# arguments: only the description is logged.
run_quiet() {
local what="$1"
shift
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] $what"
return 0
fi
logf "+ $what (arguments not logged)"
"$@" >>"${LOG_FILE:-/dev/null}" 2>&1
}
init_log() {
if [[ $DRY_RUN -eq 1 ]]; then
return
fi
if [[ ! -d "$LOG_DIR" ]]; then
install -d -m 0750 "$LOG_DIR"
fi
LOG_FILE="$LOG_DIR/install.log"
touch "$LOG_FILE"
chmod 0640 "$LOG_FILE"
{
printf '\n%s ---- install.sh %s\n' "$(ts)" "$(redacted_args)"
} >>"$LOG_FILE"
}
ORIG_ARGS=()
redacted_args() {
local out=() skip=0 a
for a in "${ORIG_ARGS[@]}"; do
if [[ $skip -eq 1 ]]; then
out+=("***")
skip=0
continue
fi
out+=("$a")
case "$a" in --token | --git-token) skip=1 ;; esac
done
printf '%s' "${out[*]}"
}
# ---------------------------------------------------------------- rollback
#
# Files and directories created by this run are removed and files changed by
# this run are restored when the run fails (ADR 0024). Packages stay.
CREATED=()
BACKUPS=()
ROLLBACK=()
track_new() { CREATED+=("$1"); }
on_rollback() { ROLLBACK+=("$(printf '%q ' "$@")"); }
backup_file() {
local f="$1" b
[[ -e "$f" ]] || return 0
b="$WORK/backup/${#BACKUPS[@]}"
mkdir -p "$WORK/backup"
cp -a "$f" "$b"
BACKUPS+=("$f|$b")
}
rollback() {
local i entry f b
log "Rolling back the changes of this run (packages stay installed)."
for ((i = ${#ROLLBACK[@]} - 1; i >= 0; i--)); do
eval "${ROLLBACK[$i]}" >>"${LOG_FILE:-/dev/null}" 2>&1 || true
done
for ((i = ${#BACKUPS[@]} - 1; i >= 0; i--)); do
entry="${BACKUPS[$i]}"
f="${entry%%|*}"
b="${entry#*|}"
cp -a "$b" "$f" 2>/dev/null || true
done
for ((i = ${#CREATED[@]} - 1; i >= 0; i--)); do
rm -rf -- "${CREATED[$i]}" 2>/dev/null || true
done
systemctl daemon-reload >/dev/null 2>&1 || true
}
on_exit() {
local rc=$?
set +e
if [[ $rc -ne 0 && $DRY_RUN -eq 0 && "$MODE" != uninstall ]]; then
rollback
if [[ -n "$LOG_FILE" ]]; then
log "Installation failed (exit $rc). Log: $LOG_FILE"
fi
fi
[[ -z "$WORK" ]] || rm -rf -- "$WORK"
exit "$rc"
}
# ---------------------------------------------------------------- helpers
confirm() {
local prompt="$1" answer=""
if [[ $ASSUME_YES -eq 1 || $DRY_RUN -eq 1 ]]; then
return 0
fi
if [[ $NON_INTERACTIVE -eq 1 ]]; then
die "$prompt: confirmation needed, rerun with --yes"
fi
read -r -p "$prompt [y/N] " answer </dev/tty || true
[[ "$answer" == [yYjJ]* ]] || die "aborted"
}
have() { command -v "$1" >/dev/null 2>&1; }
# same_file A B: equal content (cmp is not installed everywhere).
same_file() { [[ -f "$1" && -f "$2" && "$(sha256sum <"$1")" == "$(sha256sum <"$2")" ]]; }
# write_new FILE MODE OWNER GROUP writes stdin to FILE unless it exists
# (configuration is never overwritten).
write_new() {
local f="$1" mode="$2" owner="$3" group="$4" tmp
if [[ -e "$f" ]]; then
logf "kept existing $f"
cat >/dev/null
return 0
fi
if [[ $DRY_RUN -eq 1 ]]; then
cat >/dev/null
log " [dry-run] write $f ($mode $owner:$group)"
return 0
fi
tmp="$(mktemp "$f.XXXXXX")"
cat >"$tmp"
chmod "$mode" "$tmp"
chown "$owner:$group" "$tmp"
mv -f "$tmp" "$f"
track_new "$f"
changed "wrote $f"
}
# ensure_dir DIR MODE OWNER GROUP creates DIR or fixes its mode and owner.
ensure_dir() {
local d="$1" mode="$2" owner="$3" group="$4" cur
if [[ ! -d "$d" ]]; then
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] create directory $d ($mode $owner:$group)"
return 0
fi
install -d -m "$mode" -o "$owner" -g "$group" "$d"
chmod "$mode" "$d"
track_new "$d"
changed "created $d"
return 0
fi
# Numeric chmod keeps the setgid bit of directories (inherited from a
# setgid parent); compare it only where the wanted mode names it.
local want="${mode#0}" cmode
cmode="$(stat -c '%a' "$d")"
[[ ${#want} -gt 3 ]] || cmode="${cmode: -3}"
cur="$cmode $(stat -c '%U %G' "$d")"
if [[ "$cur" != "$want $owner $group" ]]; then
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] set $d to $mode $owner:$group (is $cur)"
return 0
fi
on_rollback chown "$(stat -c '%U:%G' "$d")" "$d"
on_rollback chmod "$(stat -c '%a' "$d")" "$d"
chown "$owner:$group" "$d"
chmod "$mode" "$d"
changed "set $d to $mode $owner:$group"
fi
}
# install_file SRC DEST MODE installs a release file (binary, unit). An
# existing different file is replaced only with --upgrade.
install_file() {
local src="$1" dest="$2" mode="$3"
if [[ -e "$dest" ]] && same_file "$src" "$dest"; then
return 0
fi
if [[ -e "$dest" && "$MODE" != upgrade ]]; then
warn "$dest differs from the release; kept (use --upgrade to replace it)"
return 0
fi
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] install $dest"
return 0
fi
if [[ -e "$dest" ]]; then
backup_file "$dest"
else
track_new "$dest"
fi
install -m "$mode" -o root -g root "$src" "$dest.new.$$"
mv -f "$dest.new.$$" "$dest"
changed "installed $dest"
return 0
}
APT_UPDATED=0
pkg_installed() {
case "$PKG" in
apt) [[ "$(dpkg-query -W -f='${Status}' "$1" 2>/dev/null)" == "install ok installed" ]] ;;
dnf) rpm -q "$1" >/dev/null 2>&1 ;;
esac
}
pkg_install() {
local missing=() p
for p in "$@"; do pkg_installed "$p" || missing+=("$p"); done
[[ ${#missing[@]} -gt 0 ]] || return 0
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] install packages: ${missing[*]}"
return 0
fi
log " installing packages: ${missing[*]}"
case "$PKG" in
apt)
if [[ $APT_UPDATED -eq 0 ]]; then
run apt-get update -q
APT_UPDATED=1
fi
run env DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends "${missing[@]}" ||
die "package installation failed: ${missing[*]} (see $LOG_FILE)"
;;
dnf)
run dnf -y -q install "${missing[@]}" || die "package installation failed: ${missing[*]} (see $LOG_FILE)"
;;
esac
changed "installed packages ${missing[*]}"
}
pkg_available() {
case "$PKG" in
apt)
local out
out="$(apt-cache policy "$1" 2>/dev/null)"
[[ "$out" == *"Candidate: "* && "$out" != *"Candidate: (none)"* ]]
;;
dnf) dnf -q list --available "$1" >/dev/null 2>&1 || rpm -q "$1" >/dev/null 2>&1 ;;
esac
}
unit_exists() { [[ -n "$(systemctl list-unit-files --no-legend "$1.service" 2>/dev/null)" ]]; }
daemon_reload() { run systemctl daemon-reload; }
# enable_start UNIT enables and starts a unit (idempotent).
enable_start() {
local u="$1"
if ! systemctl is-enabled --quiet "$u" 2>/dev/null; then
run systemctl enable "$u"
on_rollback systemctl disable "$u"
changed "enabled $u"
fi
if ! systemctl is-active --quiet "$u"; then
run systemctl start "$u" || {
[[ $DRY_RUN -eq 1 ]] || systemctl status --no-pager "$u" >>"$LOG_FILE" 2>&1 || true
die "$u did not start (journalctl -u $u)"
}
on_rollback systemctl stop "$u"
changed "started $u"
fi
}
# as_user USER CMD... runs a command as USER from / (root's working
# directory is not readable for service users).
as_user() {
local user="$1"
shift
(cd / && runuser -u "$user" -- "$@")
}
# ---------------------------------------------------------------- system
OS_ID=""
OS_VERSION=""
OS_CODENAME=""
OS_NAME=""
FAMILY=""
PKG=""
ARCH=""
detect_os() {
[[ -r /etc/os-release ]] || die "cannot read /etc/os-release: unsupported system"
# shellcheck disable=SC1091
OS_ID="$(. /etc/os-release && printf '%s' "${ID:-}")"
# shellcheck disable=SC1091
OS_VERSION="$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")"
# shellcheck disable=SC1091
OS_CODENAME="$(. /etc/os-release && printf '%s' "${VERSION_CODENAME:-}")"
# shellcheck disable=SC1091
OS_NAME="$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-$ID}")"
case "$OS_ID:$OS_VERSION" in
debian:12 | debian:13 | ubuntu:22.04 | ubuntu:24.04) FAMILY="debian" PKG="apt" ;;
rocky:9* | almalinux:9* | rhel:9*) FAMILY="el" PKG="dnf" ;;
*) die "unsupported system: $OS_NAME (supported: Debian 12/13, Ubuntu 22.04/24.04, Rocky/Alma/RHEL 9)" ;;
esac
case "$(uname -m)" in
x86_64 | amd64) ARCH="amd64" ;;
aarch64 | arm64) ARCH="arm64" ;;
*) die "unsupported architecture: $(uname -m) (supported: amd64, arm64)" ;;
esac
have systemctl && [[ -d /run/systemd/system ]] || die "systemd is required"
}
binary_of() {
case "$1" in
server) printf '%s' "$BIN_SERVER" ;;
agent-dns) printf '%s' "$BIN_AGENT_DNS" ;;
agent-dhcp) printf '%s' "$BIN_AGENT_DHCP" ;;
esac
}
role_installed() { [[ -f "$UNIT_DIR/$(binary_of "$1").service" ]]; }
# port_owners PROTO PORT prints "<address> <process>" for every listening
# TCP socket (state 0A) or bound UDP socket (07) on PORT, from /proc (no
# extra tools needed). The process name comes from the socket inode.
port_owners() {
local proto="$1" hex inodes addr inode comm pid fd
hex="$(printf '%04X' "$2")"
inodes="$(awk -v p="$hex" -v st="$([[ $proto == tcp ]] && echo 0A || echo 07)" '
FNR > 1 {
n = split($2, a, ":")
if (a[n] == p && $4 == st) print a[1], $10
}' "/proc/net/$proto" "/proc/net/${proto}6" 2>/dev/null || true)"
[[ -n "$inodes" ]] || return 0
while read -r addr inode; do
comm="unknown"
for fd in /proc/[0-9]*/fd/*; do
if [[ "$(readlink "$fd" 2>/dev/null)" == "socket:[$inode]" ]]; then
pid="${fd#/proc/}"
pid="${pid%%/*}"
comm="$(cat "/proc/$pid/comm" 2>/dev/null || echo unknown)"
break
fi
done
printf '%s %s\n' "$addr" "$comm"
done <<<"$inodes"
}
# check_port PROTO PORT ALLOWED SEVERITY: a port taken by a process other
# than ALLOWED (regex of process names) is a problem (SEVERITY abort) or a
# warning. systemd-resolved on 127.0.0.53/54 only warns.
check_port() {
local proto="$1" port="$2" allowed="$3" severity="$4" addr comm
while read -r addr comm; do
[[ -n "$addr" ]] || continue
[[ ! "$comm" =~ ^($allowed)$ ]] || continue
if [[ "$addr" == 3500007F || "$addr" == 3600007F ]]; then
warn "port $port/$proto is used by $comm on 127.0.0.53/54 (systemd-resolved); BIND listens on the other addresses"
elif [[ "$severity" == abort ]]; then
PROBLEMS+=("port $port/$proto is in use by $comm")
else
warn "port $port/$proto is in use by $comm"
fi
done <<<"$(port_owners "$proto" "$port")"
}
# free_mib DIR prints the free space of the file system holding DIR.
free_mib() {
local d="$1"
while [[ ! -d "$d" ]]; do d="$(dirname "$d")"; done
df -Pm "$d" | awk 'NR == 2 { print $4 }'
}
# version_lt A B: version A is older than B.
version_lt() { [[ "$1" != "$2" ]] && [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | sed -n 1p)" == "$1" ]]; }
# requirement MESSAGE stops the installation unless --force is given.
requirement() {
if [[ $FORCE -eq 1 ]]; then
warn "requirement not met, continuing because of --force: $1"
else
die "requirement not met: $1 (--force continues anyway)"
fi
}
# reachable HOST PORT: a TCP connection succeeds within 5 seconds.
# shellcheck disable=SC2016 # expanded by the inner bash
reachable() { timeout 5 bash -c 'exec 3<>"/dev/tcp/$1/$2"' _ "$1" "$2" 2>/dev/null; }
PROBLEMS=()
precheck() {
step "Checking system requirements"
local cpu ram disk bindisk want_cpu=0 warn_ram=0 abort_ram=0 warn_disk=0 abort_disk=0 r
cpu="$(nproc 2>/dev/null || echo 1)"
ram="$(awk '/^MemTotal:/ { print int($2 / 1024) }' /proc/meminfo)"
disk="$(free_mib /var/lib)"
bindisk="$(free_mib "$BINDIR")"
for r in "${ROLES[@]}"; do
[[ ${WARN_CPU[$r]} -le $want_cpu ]] || want_cpu=${WARN_CPU[$r]}
warn_ram=$((warn_ram + WARN_RAM[$r]))
abort_ram=$((abort_ram + ABORT_RAM[$r]))
warn_disk=$((warn_disk + WARN_DISK[$r]))
abort_disk=$((abort_disk + ABORT_DISK[$r]))
done
log " CPU cores: $cpu, RAM: ${ram} MiB, free in /var/lib: ${disk} MiB, free for $BINDIR: ${bindisk} MiB"
[[ $cpu -ge $want_cpu ]] || warn "fewer CPU cores than recommended ($cpu < $want_cpu)"
if [[ $ram -lt $abort_ram ]]; then
PROBLEMS+=("only ${ram} MiB RAM (at least $abort_ram MiB)")
elif [[ $ram -lt $warn_ram ]]; then
warn "less RAM than recommended (${ram} < $warn_ram MiB)"
fi
if [[ $disk -lt $abort_disk ]]; then
PROBLEMS+=("only ${disk} MiB free in /var/lib (at least $abort_disk MiB)")
elif [[ $disk -lt $warn_disk ]]; then
warn "less free disk space in /var/lib than recommended (${disk} < $warn_disk MiB)"
fi
[[ $bindisk -ge $ABORT_BINDIR_DISK ]] || PROBLEMS+=("only ${bindisk} MiB free for $BINDIR (at least $ABORT_BINDIR_DISK MiB)")
for r in "${ROLES[@]}"; do
case "$r" in
server)
check_port tcp "$HTTP_PORT" "$BIN_SERVER" abort
check_port tcp "$AGENT_API_PORT" "$BIN_SERVER" abort
;;
agent-dns)
check_port udp 53 named abort
check_port tcp 53 named abort
;;
agent-dhcp)
check_port udp 67 "kea-dhcp4" abort
check_port tcp "$KEA_HA_PORT" "kea-dhcp4" warn
;;
esac
done
local sync=""
if have timedatectl; then
sync="$(timedatectl show -p NTPSynchronized --value 2>/dev/null || true)"
fi
if [[ "$sync" != yes ]]; then
warn "the clock is not synchronized (NTP is required: certificates, licenses, Kea HA)"
fi
if [[ " ${ROLES[*]} " == *" agent-dhcp "* && "$KEA_SOURCE" == isc ]] && have curl &&
! curl -fsS -o /dev/null --max-time 15 "$KEA_REPO_URL/gpg.${KEA_KEY_ID}.key" 2>/dev/null; then
PROBLEMS+=("the ISC Kea repository $KEA_REPO_URL is not reachable (or use --kea-source distro)")
fi
if [[ " ${ROLES[*]} " == *" server "* ]] && have curl &&
! curl -sS -o /dev/null --max-time 10 "$LICENSE_SERVER_URL" 2>/dev/null; then
warn "the license server $LICENSE_SERVER_URL is not reachable from this host (licenses need it; the server runs without)"
fi
if [[ -n "$SERVER_ADDR" ]] && ! reachable "${SERVER_ADDR%:*}" "${SERVER_ADDR##*:}"; then
warn "the agent API $SERVER_ADDR is not reachable from this host; the enrollment will probably fail"
fi
if [[ ${#PROBLEMS[@]} -gt 0 ]]; then
local p
for p in "${PROBLEMS[@]}"; do log " requirement not met: $p"; done
if [[ $FORCE -eq 1 ]]; then
warn "requirements not met, continuing because of --force: ${PROBLEMS[*]}"
else
die "system requirements not met (see above; --force continues anyway)"
fi
fi
}
base_packages() {
step "Installing base packages"
local pkgs=() c
# By command: EL images ship curl-minimal, which conflicts with curl.
for c in curl openssl tar gzip; do have "$c" || pkgs+=("$c"); done
[[ -e /etc/ssl/certs/ca-certificates.crt || -e /etc/pki/tls/certs/ca-bundle.crt ]] || pkgs+=(ca-certificates)
pkg_install "${pkgs[@]}"
}
# ---------------------------------------------------------------- release
ARTIFACT_DIR=""
curl_auth() {
local args=(-fsSL --retry 3 --retry-delay 2 --connect-timeout 20)
if [[ -n "$GIT_TOKEN" ]]; then
args+=(-H "@$WORK/auth-header")
fi
curl "${args[@]}" "$@"
}
resolve_version() {
[[ -z "$VERSION" ]] || return 0
local bin
bin="$(binary_of "${ROLES[0]}")"
if [[ -n "$FROM_DIR" ]]; then
local found=() f
for f in "$FROM_DIR/${bin}_"*"_linux_${ARCH}.tar.gz"; do
[[ ! -f "$f" ]] || found+=("${f##*/}")
done
[[ ${#found[@]} -eq 1 ]] || die "found ${#found[@]} ${bin} tarballs for linux/${ARCH} in $FROM_DIR; choose one with --version"
VERSION="${found[0]#"${bin}_"}"
VERSION="${VERSION%"_linux_${ARCH}.tar.gz"}"
else
local json
json="$(curl_auth "$RELEASES_API")" || die "cannot query the latest release ($RELEASES_API); use --version or --from-dir"
VERSION="$(printf '%s' "$json" | grep -o '"tag_name": *"[^"]*"' | head -n1 | sed 's/.*"\([^"]*\)"$/\1/' || true)"
[[ -n "$VERSION" ]] || die "no latest release found; use --version"
fi
[[ "$VERSION" =~ ^[A-Za-z0-9._+-]+$ ]] || die "invalid version $VERSION"
}
fetch_artifacts() {
step "Release $VERSION"
local files=("SHA256SUMS") r f base
for r in "${ROLES[@]}"; do files+=("$(binary_of "$r")_${VERSION}_linux_${ARCH}.tar.gz"); done
if [[ -n "$FROM_DIR" ]]; then
ARTIFACT_DIR="$(cd "$FROM_DIR" && pwd)"
for f in "${files[@]}"; do [[ -f "$ARTIFACT_DIR/$f" ]] || die "$f missing in $FROM_DIR"; done
else
ARTIFACT_DIR="$WORK/release"
mkdir -p "$ARTIFACT_DIR"
base="${DDI_RELEASE_BASE_URL:-$DEFAULT_RELEASE_BASE_URL}"
base="${base//<tag>/$VERSION}"
base="${base%/}"
for f in "${files[@]}"; do
log " downloading $base/$f"
curl_auth -o "$ARTIFACT_DIR/$f" "$base/$f" || die "download of $base/$f failed"
done
if [[ -n "$RELEASE_PUBLIC_KEY_PEM" ]]; then
curl_auth -o "$ARTIFACT_DIR/SHA256SUMS.sig" "$base/SHA256SUMS.sig" || die "download of SHA256SUMS.sig failed"
fi
fi
verify_artifacts "${files[@]:1}"
}
verify_artifacts() {
local f want got
if [[ -n "$RELEASE_PUBLIC_KEY_PEM" ]]; then
[[ -f "$ARTIFACT_DIR/SHA256SUMS.sig" ]] || die "SHA256SUMS.sig missing: the release is not signed"
printf '%s\n' "$RELEASE_PUBLIC_KEY_PEM" >"$WORK/release-key.pem"
openssl pkeyutl -verify -pubin -inkey "$WORK/release-key.pem" -rawin \
-in "$ARTIFACT_DIR/SHA256SUMS" -sigfile "$ARTIFACT_DIR/SHA256SUMS.sig" >/dev/null 2>&1 ||
die "the signature of SHA256SUMS is invalid"
log " signature of SHA256SUMS verified"
else
warn "no release signing key is embedded in this installer: only the SHA-256 checksums are verified"
fi
for f in "$@"; do
want="$(awk -v f="$f" '{ n = $2; sub(/^\*/, "", n) } n == f { print $1; exit }' "$ARTIFACT_DIR/SHA256SUMS")"
[[ -n "$want" ]] || die "$f is not listed in SHA256SUMS"
got="$(sha256sum "$ARTIFACT_DIR/$f" | awk '{ print $1 }')"
[[ "$want" == "$got" ]] || die "checksum mismatch for $f"
done
log " checksums verified"
}
# unpack ROLE extracts the tarball of a role and prints its directory.
unpack() {
local bin tarball dir
bin="$(binary_of "$1")"
tarball="$ARTIFACT_DIR/${bin}_${VERSION}_linux_${ARCH}.tar.gz"
dir="$WORK/unpacked/$bin"
if [[ ! -d "$dir" ]]; then
mkdir -p "$dir"
tar -xzf "$tarball" -C "$dir" --strip-components=1 --no-same-owner
[[ -x "$dir/$bin" && -f "$dir/$bin.service" ]] || die "$tarball lacks $bin or $bin.service"
fi
printf '%s' "$dir"
}
install_release_files() {
local role="$1" bin dir
bin="$(binary_of "$role")"
dir="$(unpack "$role")"
install_file "$dir/$bin" "$BINDIR/$bin" 0755
install_file "$dir/$bin.service" "$UNIT_DIR/$bin.service" 0644
ensure_dir "$DOC_DIR" 0755 root root
if [[ ! -d "$DOC_DIR/LICENSES" ]] && [[ $DRY_RUN -eq 0 ]]; then
cp -r "$dir/LICENSES" "$DOC_DIR/LICENSES"
cp "$dir/LICENSE" "$DOC_DIR/LICENSE"
fi
}
# ---------------------------------------------------------------- server
SERVER_PASSWORD=""
server_install() {
step "Role server"
local pg_unit="postgresql"
case "$FAMILY" in
debian) pkg_install postgresql ;;
el)
if ! pkg_installed postgresql-server; then
local stream
stream="$(dnf -q module list postgresql 2>/dev/null | awk '$1 == "postgresql" && $2 ~ /^[0-9]+$/ && $2 >= 15 { print $2 }' | sort -n | tail -n1 || true)"
if [[ -n "$stream" ]]; then
run dnf -y -q module reset postgresql
run dnf -y -q module enable "postgresql:$stream"
else
warn "no PostgreSQL module stream >= 15 found; using the default PostgreSQL"
fi
fi
pkg_install postgresql-server
if [[ $DRY_RUN -eq 0 && ! -f /var/lib/pgsql/data/PG_VERSION ]]; then
run postgresql-setup --initdb || die "PostgreSQL initdb failed"
changed "initialized the PostgreSQL cluster"
fi
;;
esac
enable_start "$pg_unit"
if [[ $DRY_RUN -eq 0 ]]; then
local i
for i in $(seq 60); do
as_user postgres psql -qtAc 'SELECT 1' >/dev/null 2>&1 && break
[[ $i -lt 60 ]] || die "PostgreSQL does not accept connections"
sleep 1
done
local pgv
pgv="$(as_user postgres psql -qtAc 'SHOW server_version_num' 2>/dev/null || echo 0)"
[[ "$pgv" -ge $((MIN_POSTGRESQL * 10000)) ]] || requirement "PostgreSQL $pgv is older than $MIN_POSTGRESQL"
fi
ensure_user "$PRODUCT" "$STATE_DIR"
ensure_dir "$CONFIG_DIR" 0755 root root
ensure_dir "$CONFIG_DIR/tls" 0750 root "$PRODUCT"
ensure_dir "$STATE_DIR" 0755 root root
ensure_dir "$STATE_DIR/server" 0750 "$PRODUCT" "$PRODUCT"
# Database with peer authentication over the Unix socket.
if [[ $DRY_RUN -eq 0 ]]; then
if [[ "$(as_user postgres psql -qtAc "SELECT 1 FROM pg_roles WHERE rolname = '$PRODUCT'")" != 1 ]]; then
run as_user postgres createuser "$PRODUCT"
on_rollback runuser -u postgres -- dropuser --if-exists "$PRODUCT"
changed "created database role $PRODUCT"
fi
if [[ "$(as_user postgres psql -qtAc "SELECT 1 FROM pg_database WHERE datname = '$PRODUCT'")" != 1 ]]; then
run as_user postgres createdb -O "$PRODUCT" "$PRODUCT"
# Runs before the role is dropped (reverse order).
on_rollback runuser -u postgres -- dropdb --if-exists "$PRODUCT"
changed "created database $PRODUCT"
fi
else
log " [dry-run] create database role and database $PRODUCT (if missing)"
fi
install_release_files server
# Master key: the server accepts only files without group or other
# access, so it belongs to the service user (mode 0400).
if [[ ! -f "$CONFIG_DIR/master.key" ]]; then
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] create $CONFIG_DIR/master.key"
else
run "$BINDIR/$BIN_SERVER" gen-master-key -out "$CONFIG_DIR/master.key"
track_new "$CONFIG_DIR/master.key"
chown "$PRODUCT:$PRODUCT" "$CONFIG_DIR/master.key"
chmod 0400 "$CONFIG_DIR/master.key"
changed "created the master key $CONFIG_DIR/master.key (back it up)"
fi
fi
local fqdn url host
fqdn="$(hostname -f 2>/dev/null || uname -n)"
url="${PUBLIC_URL:-https://${fqdn}:${HTTP_PORT}}"
url="${url%/}"
host="${url#*://}"
host="${host%%:*}"
host="${host%%/*}"
server_tls "$host"
write_new "$CONFIG_DIR/server.yaml" 0640 root "$PRODUCT" <<EOF
# ${DISPLAY_NAME} management server, written by install.sh. Options:
# docs/ARCHITECTURE.md; environment variables ${ENV_PREFIX}* override them.
http:
listen: ":${HTTP_PORT}"
tls_cert_file: ${CONFIG_DIR}/tls/server.crt
tls_key_file: ${CONFIG_DIR}/tls/server.key
public_url: ${url}
agent_api:
listen: ":${AGENT_API_PORT}"
public_address: "${host}:${AGENT_API_PORT}"
server_names: ["${host}"]
database:
# Peer authentication over the Unix socket: no password.
url: "postgres:///${PRODUCT}?host=/var/run/postgresql"
master_key_file: ${CONFIG_DIR}/master.key
state_dir: ${STATE_DIR}/server
log:
level: info
format: json
EOF
daemon_reload
local cfg_env="${ENV_PREFIX}CONFIG_FILE=$CONFIG_DIR/server.yaml"
if [[ $DRY_RUN -eq 0 ]]; then
# Migrations and the first administrator (only if there is none yet).
local out
if out="$(cd / && as_user "$PRODUCT" env "$cfg_env" "$BINDIR/$BIN_SERVER" bootstrap-admin 2>"$WORK/bootstrap.err")"; then
SERVER_PASSWORD="$(printf '%s\n' "$out" | sed -n 's/^password: //p')"
changed "created the first administrator"
elif grep -q "users already exist" "$WORK/bootstrap.err"; then
logf "administrator exists already"
else
cat "$WORK/bootstrap.err" >>"$LOG_FILE"
die "bootstrap-admin failed (see $LOG_FILE)"
fi
cat "$WORK/bootstrap.err" >>"$LOG_FILE"
fi
server_license "$cfg_env"
enable_start "$BIN_SERVER"
if [[ $DRY_RUN -eq 0 ]]; then
local i
for i in $(seq 90); do
curl -fsSk -o /dev/null "https://127.0.0.1:${HTTP_PORT}/readyz" 2>/dev/null && break
[[ $i -lt 90 ]] || die "the server does not become ready (journalctl -u $BIN_SERVER)"
sleep 1
done
fi
RESULT_LINES+=("Web interface: $url")
if [[ -n "$SERVER_PASSWORD" ]]; then
RESULT_LINES+=("First login: user admin, one-time password (change it at the first login):")
RESULT_LINES+=("username: admin")
RESULT_LINES+=("password: $SERVER_PASSWORD")
fi
}
server_tls() {
local host="$1" crt="$CONFIG_DIR/tls/server.crt" key="$CONFIG_DIR/tls/server.key"
if [[ -n "$TLS_CERT" ]]; then
[[ -r "$TLS_CERT" && -r "$TLS_KEY" ]] || die "cannot read $TLS_CERT or $TLS_KEY"
if [[ -f "$crt" ]] && same_file "$TLS_CERT" "$crt" && same_file "$TLS_KEY" "$key"; then
return 0
fi
[[ $DRY_RUN -eq 1 ]] && { log " [dry-run] install the TLS certificate"; return 0; }
backup_file "$crt"
backup_file "$key"
[[ -f "$crt" ]] || track_new "$crt"
[[ -f "$key" ]] || track_new "$key"
install -m 0644 -o root -g "$PRODUCT" "$TLS_CERT" "$crt"
install -m 0640 -o root -g "$PRODUCT" "$TLS_KEY" "$key"
changed "installed the TLS certificate"
return 0
fi
[[ -f "$crt" && -f "$key" ]] && return 0
[[ $DRY_RUN -eq 1 ]] && { log " [dry-run] create a self-signed TLS certificate for $host"; return 0; }
local san="DNS:$host"
[[ "$host" =~ ^[0-9.]+$ ]] && san="IP:$host"
run openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 825 \
-subj "/CN=$host" -addext "subjectAltName=$san" -keyout "$key" -out "$crt" ||
die "creating the TLS certificate failed"
track_new "$crt"
track_new "$key"
chown root:"$PRODUCT" "$crt" "$key"
chmod 0644 "$crt"
chmod 0640 "$key"
changed "created a self-signed TLS certificate for $host"
warn "the web interface uses a self-signed certificate for $host; install a trusted one with --tls-cert/--tls-key"
}
server_license() {
local cfg_env="$1" keyfile="$LICENSE_KEY_FILE" key=""
if [[ -z "$keyfile" && $NON_INTERACTIVE -eq 0 && $DRY_RUN -eq 0 && -n "$SERVER_PASSWORD" && -t 0 ]]; then
read -r -s -p "License key (optional, Enter to skip; can be entered later in the web interface): " key </dev/tty || true
echo
if [[ -n "$key" ]]; then
keyfile="$WORK/license.key"
printf '%s\n' "$key" >"$keyfile"
fi
fi
[[ -n "$keyfile" ]] || return 0
[[ -r "$keyfile" ]] || die "cannot read $keyfile"
[[ $DRY_RUN -eq 1 ]] && { log " [dry-run] activate the license key"; return 0; }
local out rc=0
# Exit codes of "license set-key": 0 activated, 1 error, 2 bad input,
# 3 key rejected, 4 license server unreachable. A license problem never
# stops the installation: the key can be entered in the web interface.
out="$(cd / && as_user "$PRODUCT" env "$cfg_env" "$BINDIR/$BIN_SERVER" license set-key <"$keyfile" 2>&1)" || rc=$?
logf "license set-key: exit $rc"
if [[ $rc -eq 0 ]]; then
changed "activated the license key"
elif [[ "$out" == *"unknown command"* ]]; then
warn "this server version cannot activate licenses from the command line; enter the key in the web interface"
elif [[ $rc -eq 3 ]]; then
warn "the license key was rejected (invalid or in use); enter a valid key in the web interface"
elif [[ $rc -eq 4 ]]; then
warn "the license server is not reachable; the key was not stored, enter it later in the web interface"
else
warn "activating the license key failed (exit $rc); enter it in the web interface"
fi
}
ensure_user() {
local user="$1" home="$2" nologin
getent passwd "$user" >/dev/null && return 0
nologin="$(command -v nologin || echo /usr/sbin/nologin)"
[[ $DRY_RUN -eq 1 ]] && { log " [dry-run] create system user $user"; return 0; }
run useradd --system --user-group --home-dir "$home" --no-create-home --shell "$nologin" "$user"
on_rollback userdel "$user"
changed "created system user $user"
}
# ---------------------------------------------------------------- agents
# agent_common ROLE USER GROUP [LINES]: release files, unit drop-in (with
# extra [Service] LINES) and state directory of an agent; the agent runs as
# the user of its service.
agent_common() {
local role="$1" user="$2" group="$3" extra="${4:-}" bin
bin="$(binary_of "$role")"
install_release_files "$role"
ensure_dir "$CONFIG_DIR" 0755 root root
ensure_dir "$STATE_DIR" 0755 root root
ensure_dir "$STATE_DIR/$role" 0700 "$user" "$group"
ensure_dir "$UNIT_DIR/$bin.service.d" 0755 root root
local dropin="$UNIT_DIR/$bin.service.d/10-user.conf" want
want="$(printf '# Written by install.sh: the agent runs as the user of the service it manages.\n[Service]\nUser=%s\nGroup=%s\n%s\n' "$user" "$group" "$extra")"
if [[ ! -f "$dropin" ]] || [[ "$(cat "$dropin")" != "$want" ]]; then
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] write $dropin (User=$user)"
else
if [[ -f "$dropin" ]]; then backup_file "$dropin"; else track_new "$dropin"; fi
printf '%s\n' "$want" >"$dropin"
changed "wrote $dropin"
fi
fi
daemon_reload
}
# agent_enroll ROLE USER registers the agent (as its service user) and
# starts it. Without a token it stays stopped.
agent_enroll() {
local role="$1" user="$2" bin enrolled=0
bin="$(binary_of "$role")"
local st=""
if [[ $DRY_RUN -eq 0 ]]; then
st="$(cd / && as_user "$user" "$BINDIR/$bin" status --config "$CONFIG_DIR/$role.yaml" 2>/dev/null || true)"
fi
[[ "$st" != *"Agent ID:"* ]] || enrolled=1
if [[ -n "$ENROLL_TOKEN" ]]; then
if [[ $enrolled -eq 1 ]]; then
warn "$role is enrolled already; the token was not used"
else
run_quiet "$bin enroll --server $SERVER_ADDR (as $user)" \
runuser -u "$user" -- "$BINDIR/$bin" enroll --config "$CONFIG_DIR/$role.yaml" \
--server "$SERVER_ADDR" --token "$ENROLL_TOKEN" --ca-hash "$CA_HASH" ||
die "enrollment of $role failed (see $LOG_FILE)"
enrolled=1
changed "enrolled $role with $SERVER_ADDR"
RESULT_LINES+=("Agent $role registered with $SERVER_ADDR.")
fi
fi
if [[ $enrolled -eq 1 ]]; then
enable_start "$bin"
elif [[ $DRY_RUN -eq 0 ]]; then
RESULT_LINES+=("Agent $role is installed but not enrolled: rerun with --role $role --server <host:port> --token <token> --ca-hash sha256:<hex> (enrollment command of the web interface).")
fi
}
# --- DNS
BIND_USER=""
BIND_GROUP=""
NAMED_CONF=""
NAMED_UNIT=""
DNS_CONF_DIR=""
dns_detect() {
case "$FAMILY" in
debian) NAMED_CONF="/etc/bind/named.conf" DNS_CONF_DIR="$DEB_DNS_CONF_DIR" BIND_USER="bind" ;;
el) NAMED_CONF="/etc/named.conf" DNS_CONF_DIR="$EL_DNS_CONF_DIR" BIND_USER="named" ;;
esac
BIND_GROUP="$(id -gn "$BIND_USER" 2>/dev/null || echo "$BIND_USER")"
NAMED_UNIT="named"
if ! unit_exists named && unit_exists bind9; then NAMED_UNIT="bind9"; fi
}
dns_packages() {
case "$FAMILY" in
debian) pkg_install bind9 bind9-utils bind9-dnsutils ;;
el)
if pkg_installed bind; then
pkg_install bind bind-utils
elif pkg_installed bind9.18 || pkg_available bind9.18; then
pkg_install bind9.18 bind9.18-utils
else
pkg_install bind bind-utils
fi
;;
esac
local v
v="$(named -v 2>/dev/null || true)"
v="$(awk '{ print $2 }' <<<"$v" | cut -d- -f1)"
if [[ -n "$v" ]] && version_lt "$v" "$WARN_BIND"; then
warn "BIND $v is older than $WARN_BIND; the generated configuration needs BIND $WARN_BIND or newer"
fi
}
# The options block lives in named.conf (EL) or in a file it includes
# (Debian: named.conf.options).
dns_options_file() {
local f
for f in "$NAMED_CONF" "$(dirname "$NAMED_CONF")/named.conf.options"; do
if [[ -f "$f" ]] && grep -Eq '^[[:space:]]*options[[:space:]]*\{' "$f"; then
printf '%s' "$f"
return 0
fi
done
return 1
}
# dns_patch_options FILE inserts the options include into the options block
# and comments out the statements the generator sets (recursion,
# allow-recursion, forwarders, allow-transfer) and listen-on/allow-query
# statements that restrict BIND to localhost.
dns_patch_options() {
local f="$1" tmp
tmp="$(mktemp)"
awk -v mark="$PRODUCT" -v inc="$DNS_CONF_DIR/$BIND_INCLUDE_OPTIONS" '
function n(s, c) { return gsub(c, "", s) }
{
line = $0
code = line
sub(/\/\/.*/, "", code)
sub(/#.*/, "", code)
}
!inopt && !done && code ~ /^[ \t]*options[ \t]*[{]/ {
print line
print "\t// BEGIN " mark " options (managed by install.sh, removed on uninstall)"
print "\tinclude \"" inc "\";"
print "\t// END " mark " options"
inopt = 1
done = 1
depth = n(code, "[{]") - n(code, "[}]")
next
}
inopt && skip {
print "//" mark "-disabled: " line
d = n(code, "[{]") - n(code, "[}]")
sd += d
depth += d
if (sd <= 0 && code ~ /;/) skip = 0
next
}
inopt && depth == 1 && code ~ /^[ \t]*(recursion|allow-recursion|forwarders|allow-transfer)([ \t;]|[{]|$)/ {
print "//" mark "-disabled: " line
sd = n(code, "[{]") - n(code, "[}]")
depth += sd
if (!(sd <= 0 && code ~ /;/)) skip = 1
next
}
inopt && depth == 1 && code ~ /^[ \t]*(listen-on|listen-on-v6|allow-query)([ \t]+port[ \t]+53)?[ \t]*[{][ \t]*(127\.0\.0\.1|::1|localhost)[ \t]*;[ \t]*[}][ \t]*;[ \t]*$/ {
print "//" mark "-disabled: " line
next
}
inopt {
depth += n(code, "[{]") - n(code, "[}]")
if (depth <= 0) inopt = 0
}
{ print line }
' "$f" >"$tmp"
cat "$tmp" >"$f"
rm -f "$tmp"
}
dns_unpatch() {
local f
for f in "$@"; do
[[ -f "$f" ]] || continue
grep -q "BEGIN $PRODUCT \|^//$PRODUCT-disabled: " "$f" || continue
run sed -i -e "/\/\/ BEGIN $PRODUCT /,/\/\/ END $PRODUCT /d" -e "s|^//$PRODUCT-disabled: ||" "$f"
changed "removed the $PRODUCT includes from $f"
done
}
dns_named_conf() {
local opts changed_conf=0
if ! opts="$(dns_options_file)"; then
[[ $DRY_RUN -eq 0 ]] || { log " [dry-run] include the agent files in $NAMED_CONF (after installing BIND)"; return 0; }
die "no options block found in $NAMED_CONF or named.conf.options"
fi
if ! grep -q "BEGIN $PRODUCT options" "$opts"; then
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] include $BIND_INCLUDE_OPTIONS in the options block of $opts"
else
backup_file "$opts"
dns_patch_options "$opts"
changed "included $BIND_INCLUDE_OPTIONS in $opts"
changed_conf=1
fi
fi
if ! grep -q "BEGIN $PRODUCT includes" "$NAMED_CONF"; then
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] include $BIND_INCLUDE_KEYS and $BIND_INCLUDE_MAIN in $NAMED_CONF"
else
[[ "$opts" == "$NAMED_CONF" ]] || backup_file "$NAMED_CONF"
cat >>"$NAMED_CONF" <<EOF
// BEGIN $PRODUCT includes (managed by install.sh, removed on uninstall)
include "$DNS_CONF_DIR/$BIND_INCLUDE_KEYS";
include "$DNS_CONF_DIR/$BIND_INCLUDE_MAIN";
// END $PRODUCT includes
EOF
changed "included $BIND_INCLUDE_KEYS and $BIND_INCLUDE_MAIN in $NAMED_CONF"
changed_conf=1
fi
fi
if [[ $DRY_RUN -eq 0 ]]; then
named-checkconf "$NAMED_CONF" >>"$LOG_FILE" 2>&1 ||
die "named-checkconf rejects $NAMED_CONF after the changes; restored (details in $LOG_FILE)"
fi
if [[ $changed_conf -eq 1 ]] && systemctl is-active --quiet "$NAMED_UNIT"; then
run systemctl restart "$NAMED_UNIT"
on_rollback systemctl restart "$NAMED_UNIT"
fi
}
selinux_label_zone_dir() {
have selinuxenabled && selinuxenabled || return 0
pkg_install policycoreutils-python-utils
local parent
parent="$(dirname "$ZONE_DIR")"
if [[ "$(semanage fcontext -l 2>/dev/null || true)" != *"$parent(/.*)?"* ]]; then
run semanage fcontext -a -t named_cache_t "$parent(/.*)?"
changed "SELinux: labeled $parent as named_cache_t"
fi
run restorecon -R "$parent"
}
dns_install() {
step "Role agent-dns (BIND)"
ensure_dir "$CONFIG_DIR" 0755 root root
dns_packages
dns_detect
[[ $DRY_RUN -eq 1 ]] || getent passwd "$BIND_USER" >/dev/null || die "BIND user $BIND_USER not found"
ensure_dir "$DNS_CONF_DIR" 0750 "$BIND_USER" "$BIND_GROUP"
local f
for f in "$BIND_INCLUDE_MAIN" "$BIND_INCLUDE_OPTIONS" "$BIND_INCLUDE_KEYS"; do
write_new "$DNS_CONF_DIR/$f" 0640 "$BIND_USER" "$BIND_GROUP" \
<<<"// Empty until the ${DISPLAY_NAME} DNS agent writes the first revision."
done
local parent
parent="$(dirname "$ZONE_DIR")"
if [[ ! -d "$parent" ]]; then
ensure_dir "$parent" 0750 root "$BIND_GROUP"
fi
ensure_dir "$ZONE_DIR" 2770 "$BIND_USER" "$BIND_GROUP"
ensure_dir "$ZONE_DIR/secondary" 2770 "$BIND_USER" "$BIND_GROUP"
selinux_label_zone_dir
dns_named_conf
enable_start "$NAMED_UNIT"
write_new "$CONFIG_DIR/agent-dns.yaml" 0640 root "$BIND_GROUP" <<EOF
# ${DISPLAY_NAME} DNS agent, written by install.sh (docs/INSTALL.md).
log:
level: info
dns:
conf_dir: ${DNS_CONF_DIR}
# Must equal the zone directory of the server's generator settings.
zone_dir: ${ZONE_DIR}
named_conf: ${NAMED_CONF}
dns_address: 127.0.0.1:53
EOF
agent_common agent-dns "$BIND_USER" "$BIND_GROUP"
agent_enroll agent-dns "$BIND_USER"
}
# --- DHCP
KEA_UNIT=""
KEA_USER=""
KEA_GROUP=""
kea_detect() {
local u
KEA_UNIT=""
for u in isc-kea-dhcp4-server isc-kea-dhcp4 kea-dhcp4-server kea-dhcp4; do
if unit_exists "$u"; then
KEA_UNIT="$u"
break
fi
done
KEA_USER=""
[[ -z "$KEA_UNIT" ]] || KEA_USER="$(systemctl show -P User "$KEA_UNIT.service" 2>/dev/null || true)"
if [[ -z "$KEA_USER" || "$KEA_USER" == root ]]; then
for u in _kea kea; do
if getent passwd "$u" >/dev/null; then
KEA_USER="$u"
break
fi
done
fi
[[ -n "$KEA_USER" ]] || KEA_USER="$([[ $FAMILY == debian ]] && echo _kea || echo kea)"
KEA_GROUP="$(id -gn "$KEA_USER" 2>/dev/null || echo "$KEA_USER")"
}
kea_active() {
local u
for u in isc-kea-dhcp4-server isc-kea-dhcp4 kea-dhcp4-server kea-dhcp4; do
systemctl is-active --quiet "$u" 2>/dev/null && return 0
done
return 1
}
# kea_repo sets up the ISC repository after checking the fingerprint of
# its signing key.
kea_repo() {
local key="$WORK/kea-key.asc" fpr
case "$FAMILY" in
debian) pkg_install gpg ;;
el) pkg_install gnupg2 ;;
esac
local keyring list
case "$FAMILY" in
debian)
keyring="/usr/share/keyrings/${KEA_REPO_NAME}-archive-keyring.asc"
list="/etc/apt/sources.list.d/${KEA_REPO_NAME}.list"
;;
el)
keyring="/etc/pki/rpm-gpg/RPM-GPG-KEY-${KEA_REPO_NAME}"
list="/etc/yum.repos.d/${KEA_REPO_NAME}.repo"
;;
esac
[[ -f "$keyring" && -f "$list" ]] && return 0
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] add the ISC repository $KEA_REPO (key $KEA_KEY_FINGERPRINT)"
return 0
fi
curl -fsSL --retry 3 -o "$key" "$KEA_REPO_URL/gpg.${KEA_KEY_ID}.key" || die "cannot download the ISC repository key"
mkdir -m 0700 "$WORK/gnupg"
fpr="$(gpg --homedir "$WORK/gnupg" --batch --show-keys --with-colons --with-fingerprint "$key" 2>/dev/null |
awk -F: '$1 == "fpr" { print $10; exit }' || true)"
[[ "$fpr" == "$KEA_KEY_FINGERPRINT" ]] ||
die "the ISC repository key has fingerprint '$fpr', expected $KEA_KEY_FINGERPRINT"
log " ISC repository key verified ($fpr)"
install -m 0644 "$key" "$keyring"
track_new "$keyring"
case "$FAMILY" in
debian)
printf 'deb [signed-by=%s] %s/deb/%s %s main\n' "$keyring" "$KEA_REPO_URL" "$OS_ID" "$OS_CODENAME" >"$list"
APT_UPDATED=0
;;
el)
run rpm --import "$keyring"
cat >"$list" <<EOF
[${KEA_REPO_NAME}]
name=ISC Kea (${KEA_REPO})
baseurl=${KEA_REPO_URL}/rpm/el/9/\$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=file://${keyring}
sslverify=1
metadata_expire=300
EOF
;;
esac
track_new "$list"
changed "added the ISC repository $KEA_REPO"
}
kea_packages() {
if [[ "$KEA_SOURCE" == isc ]]; then
kea_repo
if [[ "$FAMILY" == el ]] && ! pkg_installed log4cplus && ! pkg_available log4cplus; then
# The ISC packages for EL need log4cplus from EPEL.
case "$OS_ID" in
rhel) run dnf -y -q install "https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm" ;;
*) pkg_install epel-release ;;
esac
warn "enabled EPEL: the ISC Kea packages need log4cplus from it"
fi
pkg_install isc-kea-dhcp4 isc-kea-hooks
else
case "$FAMILY" in
debian) pkg_install kea-dhcp4-server ;;
el) pkg_available kea || die "no Kea package in the configured repositories; use --kea-source isc"; pkg_install kea ;;
esac
warn "Kea from the distribution: high availability (HA) needs Kea 3.x (--kea-source isc)"
fi
if [[ $DRY_RUN -eq 0 ]]; then
local kv
kv="$(kea-dhcp4 -v 2>/dev/null || true)"
kv="${kv%%[^0-9.]*}"
if [[ -z "$kv" ]] || version_lt "$kv" "$ABORT_KEA"; then
requirement "Kea ${kv:-(unknown version)} is older than $ABORT_KEA"
elif version_lt "$kv" "$WARN_KEA"; then
warn "Kea $kv: high availability (HA) needs Kea $WARN_KEA or newer"
fi
fi
}
# The initial configuration: no subnets, the control socket and the lease
# commands hook that the agent needs (paths as in the generator settings).
kea_initial_config() {
local hook="libdhcp_lease_cmds.so"
if [[ "$KEA_SOURCE" == distro ]]; then
# Kea before 3.0 needs the full path of the hook library.
hook="$(find /usr/lib /usr/lib64 -name libdhcp_lease_cmds.so 2>/dev/null | head -n1 || true)"
[[ -n "$hook" ]] || hook="libdhcp_lease_cmds.so"
fi
cat <<EOF
// Initial configuration written by install.sh: the ${DISPLAY_NAME} DHCP agent
// replaces it with the first revision.
{
"Dhcp4": {
"interfaces-config": { "interfaces": [] },
"control-socket": {
"socket-type": "unix",
"socket-name": "${KEA_CTRL_SOCKET}"
},
"lease-database": {
"type": "memfile",
"persist": true,
"name": "${KEA_LEASE_FILE}"
},
"hooks-libraries": [{ "library": "${hook}" }],
"subnet4": []
}
}
EOF
}
dhcp_install() {
step "Role agent-dhcp (Kea)"
ensure_dir "$CONFIG_DIR" 0755 root root
kea_packages
kea_detect
if [[ -z "$KEA_UNIT" && $DRY_RUN -eq 1 ]]; then
# Not installed yet: the unit names of the packages.
case "$FAMILY:$KEA_SOURCE" in
debian:isc) KEA_UNIT="isc-kea-dhcp4-server" ;;
debian:distro) KEA_UNIT="kea-dhcp4-server" ;;
*) KEA_UNIT="kea-dhcp4" ;;
esac
fi
[[ $DRY_RUN -eq 1 || -n "$KEA_UNIT" ]] || die "no Kea DHCPv4 systemd unit found"
log " Kea unit $KEA_UNIT, user $KEA_USER"
# Kea 3.x accepts the control socket only in its socket directory (mode
# 0750). The ISC packages compile in /var/run/kea and compare paths
# literally, so the generator path /run/kea needs KEA_CONTROL_SOCKET_DIR
# (for Kea and for the agent, which runs "kea-dhcp4 -t").
local d="$UNIT_DIR/$KEA_UNIT.service.d" rd="" rdm="" runtime=""
if [[ $DRY_RUN -eq 0 ]]; then
rd="$(systemctl show -P RuntimeDirectory "$KEA_UNIT.service" 2>/dev/null || true)"
rdm="$(systemctl show -P RuntimeDirectoryMode "$KEA_UNIT.service" 2>/dev/null || true)"
fi
if [[ " $rd " != *" kea "* || "$rdm" != 0750 ]] && [[ ! -f "$d/10-$PRODUCT.conf" ]]; then
runtime="RuntimeDirectory=kea
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes"
fi
ensure_dir "$d" 0755 root root
write_new "$d/10-$PRODUCT.conf" 0644 root root <<EOF
# Written by install.sh (${PRODUCT}): Kea control socket directory.
[Service]
Environment=${KEA_SOCKET_ENV}
${runtime}
EOF
daemon_reload
# The agent (as the Kea user) writes the configuration and the HA files.
if [[ $DRY_RUN -eq 0 ]] && ! as_user "$KEA_USER" test -w /etc/kea; then
on_rollback chmod "$(stat -c '%a' /etc/kea)" /etc/kea
on_rollback chgrp "$(stat -c '%G' /etc/kea)" /etc/kea
run chgrp "$KEA_GROUP" /etc/kea
run chmod 2770 /etc/kea
changed "made /etc/kea writable for $KEA_USER"
fi
local orig="$KEA_CONF.$PRODUCT-orig" conf_changed=0
if [[ ! -e "$orig" ]]; then
if [[ $DRY_RUN -eq 1 ]]; then
log " [dry-run] save $KEA_CONF as $orig and write the initial configuration"
else
if [[ -f "$KEA_CONF" ]]; then cp -a "$KEA_CONF" "$orig"; else : >"$orig"; fi
track_new "$orig"
backup_file "$KEA_CONF"
[[ -f "$KEA_CONF" ]] || track_new "$KEA_CONF"
kea_initial_config >"$KEA_CONF.new.$$"
chown "$KEA_USER:$KEA_GROUP" "$KEA_CONF.new.$$"
chmod 0640 "$KEA_CONF.new.$$"
mv -f "$KEA_CONF.new.$$" "$KEA_CONF"
changed "wrote the initial Kea configuration (original saved as $orig)"
conf_changed=1
(as_user "$KEA_USER" env "$KEA_SOCKET_ENV" kea-dhcp4 -t "$KEA_CONF") >>"$LOG_FILE" 2>&1 || die "kea-dhcp4 rejects the initial configuration (see $LOG_FILE)"
fi
fi
enable_start "$KEA_UNIT"
if [[ $conf_changed -eq 1 ]]; then
run systemctl restart "$KEA_UNIT"
on_rollback systemctl restart "$KEA_UNIT"
fi
if [[ $DRY_RUN -eq 0 ]]; then
local i
for i in $(seq 30); do
[[ -S "$KEA_CTRL_SOCKET" ]] && break
[[ $i -lt 30 ]] || die "Kea did not create its control socket $KEA_CTRL_SOCKET (journalctl -u $KEA_UNIT)"
sleep 1
done
fi
write_new "$CONFIG_DIR/agent-dhcp.yaml" 0640 root "$KEA_GROUP" <<EOF
# ${DISPLAY_NAME} DHCP agent, written by install.sh (docs/INSTALL.md).
log:
level: info
dhcp:
# The file kea-dhcp4 is started with ($KEA_UNIT).
config_file: ${KEA_CONF}
# Must equal the control socket of the server's generator settings.
control_socket: ${KEA_CTRL_SOCKET}
EOF
agent_common agent-dhcp "$KEA_USER" "$KEA_GROUP" "Environment=$KEA_SOCKET_ENV"
agent_enroll agent-dhcp "$KEA_USER"
}
# ---------------------------------------------------------------- modes
installed_roles() {
local r
for r in server agent-dns agent-dhcp; do
role_installed "$r" && printf '%s\n' "$r"
done
return 0
}
do_install() {
# curl first: the precheck tests the ISC and license servers.
base_packages
precheck
resolve_version
fetch_artifacts
local r
for r in "${ROLES[@]}"; do
case "$r" in
server) server_install ;;
agent-dns) dns_install ;;
agent-dhcp) dhcp_install ;;
esac
done
}
do_upgrade() {
base_packages
resolve_version
fetch_artifacts
local r bin before
for r in "${ROLES[@]}"; do
step "Upgrading $r"
bin="$(binary_of "$r")"
before=$CHANGES
install_release_files "$r"
if [[ $CHANGES -ne $before ]]; then
daemon_reload
if systemctl is-enabled --quiet "$bin" 2>/dev/null; then
run systemctl restart "$bin"
changed "restarted $bin"
fi
else
log " $bin is up to date"
fi
done
}
do_uninstall() {
local r bin
for r in "${ROLES[@]}"; do
step "Removing $r"
bin="$(binary_of "$r")"
if unit_exists "$bin"; then
run systemctl disable --now "$bin" || true
changed "stopped $bin"
fi
if [[ -e "$UNIT_DIR/$bin.service" || -d "$UNIT_DIR/$bin.service.d" ]]; then
run rm -rf "$UNIT_DIR/$bin.service" "$UNIT_DIR/$bin.service.d"
changed "removed the unit $bin.service"
fi
if [[ -e "$BINDIR/$bin" ]]; then
run rm -f "$BINDIR/$bin"
changed "removed $BINDIR/$bin"
fi
case "$r" in
server) [[ $PURGE -eq 0 ]] || server_purge ;;
agent-dns) dns_uninstall ;;
agent-dhcp) dhcp_uninstall ;;
esac
done
daemon_reload
if [[ -z "$(installed_roles)" ]]; then
[[ ! -d "$DOC_DIR" ]] || run rm -rf "$DOC_DIR"
if [[ $PURGE -eq 1 ]]; then
[[ ! -d "$CONFIG_DIR" ]] || run rm -rf "$CONFIG_DIR"
[[ ! -d "$STATE_DIR" ]] || run rm -rf "$STATE_DIR"
fi
fi
}
server_purge() {
if have psql && id postgres >/dev/null 2>&1 && systemctl is-active --quiet postgresql; then
run runuser -u postgres -- dropdb --if-exists "$PRODUCT"
run runuser -u postgres -- dropuser --if-exists "$PRODUCT"
changed "dropped database and role $PRODUCT"
fi
run rm -rf "$CONFIG_DIR/server.yaml" "$CONFIG_DIR/master.key" "$CONFIG_DIR/tls" "$STATE_DIR/server"
if getent passwd "$PRODUCT" >/dev/null; then
run userdel "$PRODUCT"
changed "removed user $PRODUCT"
fi
changed "removed configuration, master key, TLS files and state of the server"
}
dns_uninstall() {
dns_detect
local opts
opts="$(dns_options_file || true)"
dns_unpatch "$NAMED_CONF" "$opts"
if [[ $DRY_RUN -eq 0 && -f "$NAMED_CONF" ]]; then
named-checkconf "$NAMED_CONF" >>"$LOG_FILE" 2>&1 || warn "named-checkconf reports problems in $NAMED_CONF"
if systemctl is-active --quiet "$NAMED_UNIT"; then run systemctl restart "$NAMED_UNIT" || warn "restart of $NAMED_UNIT failed"; fi
fi
if [[ $PURGE -eq 1 ]]; then
run rm -rf "$DNS_CONF_DIR" "$ZONE_DIR" "$CONFIG_DIR/agent-dns.yaml" "$STATE_DIR/agent-dns"
if have selinuxenabled && selinuxenabled && have semanage; then
run semanage fcontext -d "$(dirname "$ZONE_DIR")(/.*)?" || true
fi
changed "removed BIND includes, zone files, configuration and state of the DNS agent"
fi
}
dhcp_uninstall() {
kea_detect
if [[ $PURGE -eq 1 ]]; then
local orig="$KEA_CONF.$PRODUCT-orig" f
if [[ -f "$orig" ]]; then
if [[ -s "$orig" ]]; then run cp -a "$orig" "$KEA_CONF"; fi
run rm -f "$orig"
fi
for f in $KEA_HA_FILES; do run rm -f "$f"; done
[[ -z "$KEA_UNIT" ]] || run rm -rf "$UNIT_DIR/$KEA_UNIT.service.d/10-$PRODUCT.conf"
run rm -rf "$CONFIG_DIR/agent-dhcp.yaml" "$STATE_DIR/agent-dhcp"
daemon_reload
if [[ -n "$KEA_UNIT" ]] && systemctl is-active --quiet "$KEA_UNIT"; then
run systemctl restart "$KEA_UNIT" || warn "restart of $KEA_UNIT failed"
fi
changed "restored the original Kea configuration, removed configuration and state of the DHCP agent"
fi
}
# ---------------------------------------------------------------- main
main() {
ORIG_ARGS=("$@")
parse_args "$@"
if [[ $EUID -ne 0 && $DRY_RUN -eq 0 ]]; then
echo "install.sh must run as root (try: sudo $0 ...)" >&2
exit 1
fi
detect_os
if [[ "$MODE" != install && ${#ROLES[@]} -eq 0 ]]; then
mapfile -t ROLES < <(installed_roles)
# --purge also cleans up after an earlier --uninstall.
[[ ${#ROLES[@]} -gt 0 || $PURGE -eq 0 ]] || ROLES=(server agent-dns agent-dhcp)
[[ ${#ROLES[@]} -gt 0 ]] || {
echo "no installed role found" >&2
exit 1
}
fi
if [[ "$MODE" == upgrade ]]; then
local r
for r in "${ROLES[@]}"; do role_installed "$r" || {
echo "$r is not installed; install it without --upgrade" >&2
exit 1
}; done
fi
WORK="$(mktemp -d)"
chmod 0700 "$WORK"
trap on_exit EXIT
if [[ -n "$GIT_TOKEN" ]]; then
printf 'Authorization: token %s\n' "$GIT_TOKEN" >"$WORK/auth-header"
fi
init_log
log "${DISPLAY_NAME} installer: $MODE of ${ROLES[*]} on $OS_NAME ($ARCH)$([[ $DRY_RUN -eq 1 ]] && echo ', dry run')"
case "$MODE" in
install) confirm "Install ${ROLES[*]}?" ;;
upgrade) confirm "Upgrade ${ROLES[*]}?" ;;
uninstall)
if [[ $PURGE -eq 1 ]]; then
confirm "Remove ${ROLES[*]} INCLUDING configuration, data, database and keys?"
else
confirm "Remove ${ROLES[*]} (configuration and data stay)?"
fi
;;
esac
case "$MODE" in
install) do_install ;;
upgrade) do_upgrade ;;
uninstall) do_uninstall ;;
esac
log ""
if [[ $DRY_RUN -eq 1 ]]; then
log "Dry run finished: nothing was changed."
elif [[ $CHANGES -eq 0 ]]; then
log "Done: nothing to change (0 changes)."
else
log "Done: $CHANGES changes."
fi
local w l
for w in "${WARNINGS[@]}"; do log "Warning: $w"; done
for l in "${RESULT_LINES[@]}"; do
case "$l" in
password:*) printf '%s\n' "$l" ;; # never in the log file
*) log "$l" ;;
esac
done
[[ -z "$LOG_FILE" ]] || log "Log: $LOG_FILE"
}
main "$@"