Files
DDI/scripts/install.sh
T
sir_melonchenandClaude Opus 5.5 2555028956
ci / Protobuf (push) Successful in 12s
ci / Web (lint, test, audit, build) (push) Successful in 2m44s
ci / Shell and licence headers (push) Successful in 21s
ci / Go (lint, test, vuln) (push) Successful in 6m22s
ci / Release build (dry run) (push) Successful in 3m51s
ci / Integration (real BIND and Kea) (push) Successful in 2m33s
ci / Development environment (compose) (push) Successful in 17s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 4m14s
install / Installation (debian:12) (push) Successful in 4m54s
install / Installation (debian:13) (push) Successful in 5m6s
install / Installation (ubuntu:22.04) (push) Successful in 5m16s
install / Installation (rocky:9) (push) Successful in 7m28s
install / Installation (ubuntu:24.04) (push) Successful in 8m58s
release / release (push) Successful in 4m11s
fix: agents enrolled by hand run as systemd services
An agent installed without token and enrolled with the command of the web
interface never ran as a service: the installer left the unit disabled and
the root-run enrollment left an identity the service user could not read.
The installer now always enables the agent unit (it waits for the
identity via ConditionPathExists), enroll as root hands the state over to
the service user and enables and starts the unit, and rerunning or
upgrading the installer repairs agents enrolled by hand. The web interface
shows the matching installer command next to the enroll command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:44:03 +02:00

1849 lines
62 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=""
SERVER_NAME=""
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
--server-name <name> TLS name of the server if it differs from the host of --server
(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 ;;
--server-name) need_arg "$@"; SERVER_NAME="$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_only UNIT enables a unit without starting it (idempotent).
enable_only() {
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
}
# enable_start UNIT enables and starts a unit (idempotent).
enable_start() {
local u="$1"
enable_only "$u"
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)"
# An identity written by root (manual enrollment) is not readable for
# the service user until agent_activate repairs it.
[[ "$st" == *"Agent ID:"* ]] || st="$(cd / && "$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" \
${SERVER_NAME:+--server-name "$SERVER_NAME"} --no-start ||
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
agent_activate "$role" "$user" "$enrolled"
}
# agent_activate ROLE USER ENROLLED repairs the ownership of the agent's
# state and enables the unit; an enrolled agent is also started. It runs on
# install and upgrade, so an agent enrolled by hand (as root, with the
# command of the web interface) becomes a working service.
agent_activate() {
local role="$1" user="$2" enrolled="$3" bin
bin="$(binary_of "$role")"
# Files written by an enrollment run as root must belong to the service
# user, or the agent cannot read its identity.
if [[ $DRY_RUN -eq 0 ]] && [[ -n "$(find "$STATE_DIR/$role" ! -user "$user" -print -quit 2>/dev/null)" ]]; then
run chown -R "$user:" "$STATE_DIR/$role"
changed "handed $STATE_DIR/$role over to $user"
fi
if [[ $enrolled -eq 1 ]]; then
enable_start "$bin"
else
# Enabled anyway: the unit waits for the identity (ConditionPathExists),
# so a later enrollment needs no further step after a reboot.
enable_only "$bin"
[[ $DRY_RUN -eq 1 ]] || RESULT_LINES+=("Agent $role is installed and enabled, but not enrolled yet. Enroll it with the command of the web interface (run as root; it starts the service), or rerun this installer with --role $role --server <host:port> --token <token> --ca-hash sha256:<hex>.")
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
# Agents enrolled by hand may not be enabled yet (before v0.8.2).
case "$r" in
agent-dns)
dns_detect
upgrade_agent "$r" "$BIND_USER"
;;
agent-dhcp)
kea_detect
upgrade_agent "$r" "$KEA_USER"
;;
esac
done
}
# upgrade_agent ROLE USER activates an agent during an upgrade.
upgrade_agent() {
local role="$1" user="$2" bin st="" enrolled=0
bin="$(binary_of "$role")"
if [[ $DRY_RUN -eq 0 ]]; then
st="$(cd / && as_user "$user" "$BINDIR/$bin" status --config "$CONFIG_DIR/$role.yaml" 2>/dev/null || true)"
# Before the repair the service user may not read an identity written
# by root: ask as root as well.
[[ "$st" == *"Agent ID:"* ]] || st="$(cd / && "$BINDIR/$bin" status --config "$CONFIG_DIR/$role.yaml" 2>/dev/null || true)"
fi
[[ "$st" != *"Agent ID:"* ]] || enrolled=1
agent_activate "$role" "$user" "$enrolled"
}
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 "$@"