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