1792 lines
60 KiB
Bash
Executable File
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 "$@"
|