Files
PacketStation/deploy/vm/install.sh
T

511 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
# Packet Station native VM install. Run by Spencer as root on a new test or production VM, from an
# unpacked release (make-release.sh). Idempotent: run it again after a change and it only fixes
# what differs. It never overwrites the env file or a secret, never starts the app or the worker,
# and never touches VISUAL, M-Files or AD.
#
# Usage:
# install.sh --stack test|prod [--ca-bundle FILE] [--generate-secrets] [--skip-packages]
# install.sh --files-only
#
# --stack S test or prod. Picks the env file example. Read from the env file when it exists.
# --ca-bundle FILE the PESCO CA chain (PEM, scripts/Export-PescoCaBundle.ps1). Installed as
# /etc/packetstation/ca/pesco-ca.pem and into the system trust store.
# --generate-secrets create the four internal secrets that are missing (both PostgreSQL role
# passwords, session_key, seal_key) from /dev/urandom. Never replaces a file.
# --skip-packages leave apt alone (repositories and packages already in place).
# --files-only only the systemd units, the ps-* commands, backup.conf and the firewall
# table. ps-upgrade runs this from the new release.
#
# What it does, in order:
# 1. apt: PostgreSQL 16 (PGDG), ODBC Driver 18 (Microsoft), Python 3.12, nftables, nfs-common.
# 2. User and group packetstation, uid and gid 10001 (the image's uid, and the NFS share's).
# 3. Folders: /etc/packetstation, /var/lib/packetstation/assets, /var/backups/packetstation,
# /opt/packetstation, the archive mount point /mnt/packetstation-archive.
# 4. /etc/packetstation/packetstation.env from the stack's example, if missing.
# 5. The PESCO CA, when --ca-bundle is given.
# 6. Secret files: owner root:packetstation, mode 0440, one line, no carriage return.
# 7. PostgreSQL: socket only (conf.d/packetstation.conf), scram for ps_app and ps_migrate on the
# socket (pg_hba.conf), the roles and the database as deploy/postgres/initdb makes them.
# 8. Units, commands, backup timer, firewall (the files-only part).
set -euo pipefail
umask 022
PS_USER=packetstation
PS_ID=10001
PG_VERSION=16
PG_CLUSTER=main
ETC=/etc/packetstation
SECRETS="$ETC/secrets"
ENV_FILE="$ETC/packetstation.env"
CA_FILE="$ETC/ca/pesco-ca.pem"
NFT_FILE="$ETC/packetstation.nft"
BACKUP_CONF="$ETC/backup.conf"
ASSETS=/var/lib/packetstation/assets
BACKUPS=/var/backups/packetstation
ARCHIVE_MOUNT=/mnt/packetstation-archive
OPT=/opt/packetstation
SBIN=/usr/local/sbin
UNIT_DIR=/etc/systemd/system
PG_ETC="/etc/postgresql/$PG_VERSION/$PG_CLUSTER"
PG_UNIT="postgresql@$PG_VERSION-$PG_CLUSTER.service"
PG_BIN="/usr/lib/postgresql/$PG_VERSION/bin"
DB_LOCALE=en_US.UTF-8
# Signing keys, checked by fingerprint before apt trusts them.
PGDG_KEY_URL=https://www.postgresql.org/media/keys/ACCC4CF8.asc
PGDG_KEY_FPR=B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8
MS_KEY_URL=https://packages.microsoft.com/keys/microsoft.asc
MS_KEY_FPR=BC528686B50D79E339D3721CEB3E94ADBE1229CF
ALL_SECRETS=(postgres_migrate_password postgres_app_password visualkit_password mfileskit_password
ldap_bind_password session_key seal_key)
GENERATED_SECRETS=(postgres_migrate_password postgres_app_password session_key seal_key)
here="$(cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" && pwd -P)"
release="$(cd -- "$here/../.." && pwd -P)"
log() { printf 'install: %s\n' "$*" >&2; }
die() { printf 'install: ERROR: %s\n' "$*" >&2; exit 1; }
usage() { sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 2; }
stack=""
ca_bundle=""
generate=0
skip_packages=0
files_only=0
while [[ $# -gt 0 ]]; do
case "$1" in
--stack) [[ $# -ge 2 ]] || usage; stack="$2"; shift 2 ;;
--ca-bundle) [[ $# -ge 2 ]] || usage; ca_bundle="$2"; shift 2 ;;
--generate-secrets) generate=1; shift ;;
--skip-packages) skip_packages=1; shift ;;
--files-only) files_only=1; shift ;;
-h|--help) usage ;;
*) log "unknown argument $1"; usage ;;
esac
done
[[ "$(id -u)" == "0" ]] || die "run as root"
[[ -z "$stack" || "$stack" == "test" || "$stack" == "prod" ]] || die "--stack must be test or prod"
# ------------------------------------------------------------------ helpers
# A KEY=value from the env file, with surrounding quotes removed. Empty when unset.
env_value() {
local key="$1" line value
[[ -r "$ENV_FILE" ]] || return 0
line="$(grep -E "^[[:space:]]*$key=" "$ENV_FILE" | tail -n 1 || true)"
value="${line#*=}"
value="${value%$'\r'}"
value="${value#\"}"; value="${value%\"}"
value="${value#\'}"; value="${value%\'}"
printf '%s' "$value"
}
# Install a text file only when it differs. Strips carriage returns (a Windows checkout).
# Prints "changed" when it wrote the file.
put_file() {
local src="$1" dst="$2" owner="$3" mode="$4" tmp
tmp="$(mktemp)"
sed 's/\r$//' -- "$src" > "$tmp"
if [[ -f "$dst" ]] && cmp -s -- "$tmp" "$dst"; then
chown "$owner" -- "$dst"; chmod "$mode" -- "$dst"
rm -f -- "$tmp"
return 0
fi
install -o "${owner%%:*}" -g "${owner##*:}" -m "$mode" -- "$tmp" "$dst"
rm -f -- "$tmp"
echo changed
}
as_postgres() {
setpriv --reuid=postgres --regid=postgres --init-groups -- "$@"
}
is_ipv4() { [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; }
is_ipv6() { [[ "$1" =~ ^[0-9A-Fa-f:]+$ && "$1" == *:* ]]; }
# ------------------------------------------------------------------ 1. packages
os_id=""
os_version=""
os_codename=""
read_os() {
# 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:-}")"
}
fetch_key() {
local url="$1" fpr="$2" dst="$3" tmp found
tmp="$(mktemp -d)"
curl -fsSL --proto '=https' "$url" -o "$tmp/key.asc"
found="$(gpg --batch --show-keys --with-colons "$tmp/key.asc" 2>/dev/null | awk -F: '$1 == "fpr" && !seen { print $10; seen = 1 }')"
if [[ "$found" != "$fpr" ]]; then
rm -rf -- "$tmp"
die "the key from $url has fingerprint '${found:-none}', expected $fpr. Check it before going on."
fi
gpg --batch --yes --dearmor -o "$tmp/key.gpg" "$tmp/key.asc"
install -o root -g root -m 0644 "$tmp/key.gpg" "$dst"
rm -rf -- "$tmp"
}
step_packages() {
local ms_repo python_pkgs=()
case "$os_id $os_version" in
"ubuntu 24.04")
ms_repo="https://packages.microsoft.com/ubuntu/24.04/prod $os_codename main"
python_pkgs=(python3.12 python3.12-venv) ;;
"debian 12")
ms_repo="https://packages.microsoft.com/debian/12/prod $os_codename main"
log "Debian 12 ships Python 3.11. Packet Station needs 3.12: give ps-upgrade PYTHON=/path/to/python3.12." ;;
*) die "unsupported OS '$os_id $os_version'. Supported: Ubuntu 24.04, Debian 12." ;;
esac
export DEBIAN_FRONTEND=noninteractive
log "apt: base packages"
apt-get update -q
apt-get install -y -q --no-install-recommends \
ca-certificates curl gnupg locales tzdata nftables nfs-common util-linux coreutils findutils
# The database locale. Generated before PostgreSQL so its first cluster knows it too.
if ! locale -a 2>/dev/null | grep -qiE '^en_US\.utf-?8$'; then
log "generating locale $DB_LOCALE"
if [[ -f /etc/locale.gen ]]; then
sed -i 's/^# *\(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen
fi
locale-gen en_US.UTF-8 >/dev/null
fi
log "apt: PGDG and Microsoft repositories"
fetch_key "$PGDG_KEY_URL" "$PGDG_KEY_FPR" /usr/share/keyrings/pgdg.gpg
fetch_key "$MS_KEY_URL" "$MS_KEY_FPR" /usr/share/keyrings/microsoft-prod.gpg
printf 'deb [arch=amd64 signed-by=/usr/share/keyrings/pgdg.gpg] https://apt.postgresql.org/pub/repos/apt %s-pgdg main\n' \
"$os_codename" > /etc/apt/sources.list.d/pgdg.list
printf 'deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] %s\n' "$ms_repo" \
> /etc/apt/sources.list.d/microsoft-prod.list
apt-get update -q
log "apt: PostgreSQL $PG_VERSION, ODBC Driver 18, Python"
ACCEPT_EULA=Y apt-get install -y -q --no-install-recommends \
"postgresql-$PG_VERSION" "postgresql-client-$PG_VERSION" msodbcsql18 unixodbc "${python_pkgs[@]}"
odbcinst -q -d -n "ODBC Driver 18 for SQL Server" >/dev/null \
|| die "ODBC Driver 18 for SQL Server is not registered with unixODBC"
}
# ------------------------------------------------------------------ 2. user
step_user() {
local g u
g="$(getent group "$PS_USER" | cut -d: -f3 || true)"
if [[ -z "$g" ]]; then
if getent group "$PS_ID" >/dev/null; then
die "gid $PS_ID is taken by group $(getent group "$PS_ID" | cut -d: -f1)"
fi
groupadd --system --gid "$PS_ID" "$PS_USER"
log "created group $PS_USER ($PS_ID)"
elif [[ "$g" != "$PS_ID" ]]; then
die "group $PS_USER has gid $g, expected $PS_ID (the NFS share and the backups expect $PS_ID)"
fi
u="$(id -u "$PS_USER" 2>/dev/null || true)"
if [[ -z "$u" ]]; then
if getent passwd "$PS_ID" >/dev/null; then
die "uid $PS_ID is taken by user $(getent passwd "$PS_ID" | cut -d: -f1)"
fi
useradd --system --uid "$PS_ID" --gid "$PS_ID" --home-dir /var/lib/packetstation --no-create-home \
--shell /usr/sbin/nologin --comment "Packet Station" "$PS_USER"
log "created user $PS_USER ($PS_ID)"
elif [[ "$u" != "$PS_ID" ]]; then
die "user $PS_USER has uid $u, expected $PS_ID"
fi
}
# ------------------------------------------------------------------ 3. folders
step_dirs() {
install -d -o root -g "$PS_USER" -m 0750 "$ETC" "$SECRETS"
install -d -o root -g root -m 0755 "$ETC/ca" "$OPT"
install -d -o root -g "$PS_USER" -m 0750 /var/lib/packetstation "$BACKUPS"
install -d -o "$PS_USER" -g "$PS_USER" -m 0750 "$ASSETS"
# The mount point stays root's and read-only to packetstation, so while the share is not mounted
# nothing can be written into the VM's own disk by mistake.
if mountpoint -q "$ARCHIVE_MOUNT" 2>/dev/null; then
log "$ARCHIVE_MOUNT is mounted; left as it is"
else
install -d -o root -g root -m 0755 "$ARCHIVE_MOUNT"
fi
}
# ------------------------------------------------------------------ 4. env file
step_env() {
local example have
if [[ -e "$ENV_FILE" ]]; then
have="$(env_value STACK)"
if [[ -n "$stack" && "$have" != "$stack" ]]; then
die "$ENV_FILE says STACK=$have, but --stack $stack was given"
fi
chown root:"$PS_USER" "$ENV_FILE"; chmod 0640 "$ENV_FILE"
log "kept $ENV_FILE (STACK=$have)"
return 0
fi
[[ -n "$stack" ]] || die "no $ENV_FILE yet: give --stack test or --stack prod"
example="$here/$stack.env.example"
[[ -r "$example" ]] || die "missing $example"
put_file "$example" "$ENV_FILE" "root:$PS_USER" 0640 >/dev/null
log "wrote $ENV_FILE from $(basename -- "$example"). Read it through before the first start."
}
# ------------------------------------------------------------------ 5. CA
step_ca() {
if [[ -z "$ca_bundle" ]]; then
[[ -f "$CA_FILE" ]] || log "no PESCO CA yet: run again with --ca-bundle FILE (LDAP and SMTP need it)"
return 0
fi
[[ -r "$ca_bundle" ]] || die "cannot read $ca_bundle"
grep -q 'BEGIN CERTIFICATE' "$ca_bundle" || die "$ca_bundle holds no PEM certificate"
if grep -q 'PRIVATE KEY' "$ca_bundle"; then
die "$ca_bundle holds a private key. Export only the certificates."
fi
put_file "$ca_bundle" "$CA_FILE" root:root 0644 >/dev/null
# update-ca-certificates wants one certificate per .crt file.
local dir=/usr/local/share/ca-certificates/pesco tmp
tmp="$(mktemp -d)"
sed 's/\r$//' -- "$CA_FILE" | awk -v d="$tmp" '
/-----BEGIN CERTIFICATE-----/ { n++; f = sprintf("%s/pesco-ca-%02d.crt", d, n) }
f { print > f }
/-----END CERTIFICATE-----/ { close(f); f = "" }'
rm -rf -- "$dir"
install -d -o root -g root -m 0755 "$dir"
install -o root -g root -m 0644 "$tmp"/*.crt "$dir"/
rm -rf -- "$tmp"
update-ca-certificates >/dev/null
log "PESCO CA installed: $CA_FILE and the system trust store ($(find "$dir" -name '*.crt' | wc -l) certificates)"
}
# ------------------------------------------------------------------ 6. secrets
new_secret() {
# 32 random bytes as 64 hex characters. Held in a variable and written by a builtin, never on argv.
local name="$1" value tmp
value="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
[[ ${#value} -eq 64 ]] || die "could not read /dev/urandom"
tmp="$(mktemp "$SECRETS/.$name.XXXXXX")"
printf '%s' "$value" > "$tmp"
unset value
chown root:"$PS_USER" "$tmp"; chmod 0440 "$tmp"
mv -- "$tmp" "$SECRETS/$name"
log "generated secret $name"
}
missing_secrets=()
step_secrets() {
local name f g
missing_secrets=()
for name in "${ALL_SECRETS[@]}"; do
f="$SECRETS/$name"
if [[ ! -e "$f" && $generate -eq 1 ]]; then
for g in "${GENERATED_SECRETS[@]}"; do
if [[ "$g" == "$name" ]]; then new_secret "$name"; fi
done
fi
if [[ ! -e "$f" ]]; then
missing_secrets+=("$name")
continue
fi
chown root:"$PS_USER" -- "$f"; chmod 0440 -- "$f"
[[ -s "$f" ]] || die "secret $name is empty"
if [[ "$(wc -l < "$f")" -gt 1 ]] || grep -q $'\r' -- "$f"; then
die "secret $name must be one line with no carriage return"
fi
done
if [[ ${#missing_secrets[@]} -gt 0 ]]; then
log "secrets still missing in $SECRETS: ${missing_secrets[*]}"
fi
}
have_secret() { [[ -s "$SECRETS/$1" ]]; }
# ------------------------------------------------------------------ 7. PostgreSQL
step_postgres() {
local changed="" hba="$PG_ETC/pg_hba.conf" tmp
[[ -d "$PG_ETC" ]] || die "no PostgreSQL cluster $PG_VERSION/$PG_CLUSTER in $PG_ETC"
grep -Eq "^[[:space:]]*include_dir[[:space:]]*=[[:space:]]*'conf\.d'" "$PG_ETC/postgresql.conf" \
|| die "$PG_ETC/postgresql.conf has no include_dir = 'conf.d'"
install -d -o postgres -g postgres -m 0755 "$PG_ETC/conf.d"
changed="$(put_file "$here/postgres/packetstation.conf" "$PG_ETC/conf.d/packetstation.conf" postgres:postgres 0644)"
# ps_app and ps_migrate log in on the socket with their passwords (scram). The block goes first,
# before Debian's "local all all peer", which would refuse them (the OS user is packetstation).
if ! grep -q '^# packetstation begin' "$hba"; then
tmp="$(mktemp)"
awk '
!done && $0 !~ /^[[:space:]]*(#|$)/ {
print "# packetstation begin (deploy/vm/install.sh)"
print "local packetstation ps_app,ps_migrate scram-sha-256"
print "# packetstation end"
print ""
done = 1
}
{ print }' "$hba" > "$tmp"
install -o postgres -g postgres -m 0640 "$tmp" "$hba"
rm -f -- "$tmp"
log "pg_hba.conf: ps_app and ps_migrate use scram-sha-256 on the socket"
[[ -n "$changed" ]] || changed=reload
fi
systemctl enable --quiet "$PG_UNIT" 2>/dev/null || true
if [[ "$changed" == "changed" ]]; then
log "restarting PostgreSQL (conf.d/packetstation.conf changed)"
systemctl restart "$PG_UNIT"
elif [[ "$changed" == "reload" ]]; then
systemctl start "$PG_UNIT"
systemctl reload "$PG_UNIT"
else
systemctl start "$PG_UNIT"
fi
if ! have_secret postgres_migrate_password || ! have_secret postgres_app_password; then
log "skipping roles and database: the PostgreSQL secrets are missing (or use --generate-secrets)"
return 0
fi
log "PostgreSQL roles ps_migrate and ps_app, database packetstation"
# The passwords reach psql through its environment (\getenv), never through argv or a file.
# A failed statement is not logged with its text (log_min_error_statement), so a password
# cannot land in the server log.
PS_MIGRATE_PW="$(<"$SECRETS/postgres_migrate_password")"
PS_APP_PW="$(<"$SECRETS/postgres_app_password")"
export PS_MIGRATE_PW PS_APP_PW
as_postgres "$PG_BIN/psql" -X -q -v ON_ERROR_STOP=1 -d postgres <<SQL
SET log_min_error_statement = panic;
SET log_statement = 'none';
\\getenv migrate_pw PS_MIGRATE_PW
\\getenv app_pw PS_APP_PW
SELECT 'CREATE ROLE ps_migrate' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ps_migrate') \\gexec
SELECT 'CREATE ROLE ps_app' WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ps_app') \\gexec
ALTER ROLE ps_migrate WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD :'migrate_pw';
ALTER ROLE ps_app WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD :'app_pw';
\\unset migrate_pw
\\unset app_pw
SELECT format('CREATE DATABASE packetstation OWNER ps_migrate ENCODING %L LOCALE %L TEMPLATE template0',
'UTF8', '$DB_LOCALE')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'packetstation') \\gexec
REVOKE ALL ON DATABASE packetstation FROM PUBLIC;
GRANT CONNECT, TEMPORARY ON DATABASE packetstation TO ps_app;
\\connect packetstation
REVOKE ALL ON SCHEMA public FROM PUBLIC;
ALTER SCHEMA public OWNER TO ps_migrate;
SQL
unset PS_MIGRATE_PW PS_APP_PW
}
# ------------------------------------------------------------------ 8. files
write_firewall() {
local proxy rule tmp
proxy="$(env_value TRUSTED_PROXY_HOST)"
if is_ipv4 "$proxy"; then
rule="ip saddr $proxy"
elif is_ipv6 "$proxy"; then
rule="ip6 saddr $proxy"
else
die "TRUSTED_PROXY_HOST in $ENV_FILE must be NPM's IP address (it is '${proxy}')"
fi
tmp="$(mktemp)"
cat > "$tmp" <<NFT
#!/usr/sbin/nft -f
# Written by deploy/vm/install.sh from TRUSTED_PROXY_HOST in $ENV_FILE. Do not edit:
# change the env file and run install.sh --files-only. Loaded by packetstation-firewall.service.
# Port 8000 (the app) answers only NPM on docker-1 and the VM itself. Nothing else is filtered here.
table inet packetstation
delete table inet packetstation
table inet packetstation {
chain input {
type filter hook input priority filter; policy accept;
tcp dport 8000 iifname "lo" accept
tcp dport 8000 $rule accept
tcp dport 8000 counter drop
}
}
NFT
if command -v nft >/dev/null 2>&1; then
nft -c -f "$tmp" || { rm -f -- "$tmp"; die "nft refused the generated table"; }
fi
put_file "$tmp" "$NFT_FILE" root:root 0644 >/dev/null
rm -f -- "$tmp"
}
step_files() {
local f st
[[ -r "$ENV_FILE" ]] || die "no $ENV_FILE: run install.sh --stack test|prod first"
st="$(env_value STACK)"
[[ "$st" == "test" || "$st" == "prod" ]] || die "STACK in $ENV_FILE must be test or prod"
for f in "$here"/systemd/*.service; do
put_file "$f" "$UNIT_DIR/$(basename -- "$f")" root:root 0644 >/dev/null
done
put_file "$release/scripts/backup/packetstation-backup.service" "$UNIT_DIR/packetstation-backup.service" root:root 0644 >/dev/null
put_file "$release/scripts/backup/packetstation-backup.timer" "$UNIT_DIR/packetstation-backup.timer" root:root 0644 >/dev/null
put_file "$here/ps-run.sh" "$SBIN/ps-run" root:root 0755 >/dev/null
put_file "$here/ps-doctor.sh" "$SBIN/ps-doctor" root:root 0755 >/dev/null
put_file "$here/upgrade.sh" "$SBIN/ps-upgrade" root:root 0755 >/dev/null
put_file "$release/scripts/backup/ps-backup.sh" "$SBIN/ps-backup" root:root 0755 >/dev/null
put_file "$release/scripts/backup/ps-restore.sh" "$SBIN/ps-restore" root:root 0755 >/dev/null
if [[ ! -e "$BACKUP_CONF" ]]; then
printf '# name database folder\n%s packetstation %s\n' "$st" "$BACKUPS" > "$BACKUP_CONF"
chmod 0644 "$BACKUP_CONF"
log "wrote $BACKUP_CONF"
fi
write_firewall
systemctl daemon-reload
systemctl enable --quiet packetstation-firewall.service packetstation-backup.timer
if systemctl is-active --quiet packetstation-firewall.service; then
systemctl reload packetstation-firewall.service
else
systemctl start packetstation-firewall.service
fi
systemctl start packetstation-backup.timer
log "units, ps-run, ps-doctor, ps-upgrade, ps-backup, ps-restore and the firewall table are in place"
}
# ------------------------------------------------------------------ main
if [[ $files_only -eq 1 ]]; then
step_files
exit 0
fi
read_os
if [[ $skip_packages -eq 0 ]]; then
step_packages
fi
for tool in setpriv systemctl install mountpoint; do
command -v "$tool" >/dev/null 2>&1 || die "$tool is not installed"
done
step_user
step_dirs
step_env
step_ca
step_secrets
step_postgres
step_files
cat >&2 <<EOF
install: done for STACK=$(env_value STACK).
install: next:
- read $ENV_FILE through
- secrets still missing: ${missing_secrets[*]:-none}
- install a release: ps-upgrade /path/to/packetstation-<build>.tar.gz
- the app and the worker are not enabled or started. Starting them is a separate step (docs/DEPLOY.md).
EOF