Files

297 lines
12 KiB
Bash

#!/bin/sh
# Install or upgrade chess-server (rc.d service `chessd`) with PostgreSQL
# inside a FreeBSD jail. Run as root inside the jail from a copy of this
# repository:
#
# CHESS_BINARY=/tmp/chess-server TRUSTED_PROXIES=<nginx-address> \
# sh deploy/freebsd/setup-jail.sh
#
# Prerequisites:
# - postgresql18-server, postgresql18-client, and stockfish packages
# - the jail has its own System V shared memory (deploy/freebsd/host.sh)
# - the release binary, built with `make server-freebsd` (or `make server`
# on FreeBSD)
#
# Inputs (environment):
# CHESS_BINARY required: chess-server binary to install
# TRUSTED_PROXIES address(es) the host nginx connects from, stored as
# chessd_trusted_proxies in rc.conf. Without it, every
# proxied client shares one rate-limit bucket.
# SPLIT_PRIVILEGES yes|no (default no); see deploy/postgresql/setup.sql
# ENABLE_LOG_ROTATION yes|no (default no): newsyslog entry for the log
# ENABLE_BACKUP yes|no (default no): nightly pg_dump via cron
#
# The service layout comes from rc.conf (chessd_*) with the defaults of
# deploy/freebsd/rc.d/chessd: account chess, home /home/chess, binary
# ~/bin/chess-server, log /var/log/chessd.log, 0.0.0.0:8080.
#
# Steps (idempotent; rerun to upgrade):
# 1. PostgreSQL: initdb only if no cluster exists; pg_hba.conf allows only
# postgres and chess over the Unix socket (peer); TCP listener disabled
# 2. role, database, and schema from deploy/postgresql/setup.sql (first run)
# 3. stop chessd; install the binary (previous kept as .prev) and the rc.d
# script (previous copy saved under /var/backups)
# 4. JWT key; schema migration; rc.conf settings
# 5. optional log rotation and backups
# 6. start chessd and check /health
# Files of a previous SQLite release are left untouched; remove them once the
# new release is verified.
set -eu
PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin
umask 022
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
: "${CHESS_BINARY:?set CHESS_BINARY to the chess-server binary to install}"
TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
SPLIT_PRIVILEGES=${SPLIT_PRIVILEGES:-no}
ENABLE_LOG_ROTATION=${ENABLE_LOG_ROTATION:-no}
ENABLE_BACKUP=${ENABLE_BACKUP:-no}
# System locations; overridable only to test the script.
RC_DIR=${RC_DIR:-/usr/local/etc/rc.d}
RC_BACKUP_DIR=${RC_BACKUP_DIR:-/var/backups}
NEWSYSLOG_DIR=${NEWSYSLOG_DIR:-/usr/local/etc/newsyslog.conf.d}
CRON_DIR=${CRON_DIR:-/usr/local/etc/cron.d}
SBIN_DIR=${SBIN_DIR:-/usr/local/sbin}
BACKUP_DIR=${BACKUP_DIR:-/var/db/postgres/backups}
log() { printf '==> %s\n' "$*"; }
die() { printf 'setup-jail: %s\n' "$*" >&2; exit 1; }
# SQL from stdin, run as the postgres superuser against database $1.
pg_sql() {
su -m postgres -c "psql -X -A -t -q -v ON_ERROR_STOP=1 -d $1"
}
# rc.conf value of $1, or the default $2.
rc_value() {
_value=$(sysrc -n "$1" 2>/dev/null) || _value=""
if [ -n "$_value" ]; then echo "$_value"; else echo "$2"; fi
}
yes_no() {
case $2 in yes | no) ;; *) die "$1 must be yes or no" ;; esac
}
# --- preflight -----------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "run as root"
[ "$(uname -s)" = FreeBSD ] || die "this script targets FreeBSD"
[ -f "$CHESS_BINARY" ] || die "CHESS_BINARY not found: $CHESS_BINARY"
for f in "$repo/deploy/postgresql/setup.sql" "$here/rc.d/chessd" "$repo/deploy/postgresql/chess-backup.sh"; do
[ -f "$f" ] || die "missing $f; run from a complete repository copy"
done
yes_no SPLIT_PRIVILEGES "$SPLIT_PRIVILEGES"
yes_no ENABLE_LOG_ROTATION "$ENABLE_LOG_ROTATION"
yes_no ENABLE_BACKUP "$ENABLE_BACKUP"
command -v postgres >/dev/null 2>&1 || die "PostgreSQL is not installed (pkg install postgresql18-server postgresql18-client)"
pg_major=$(postgres --version | sed -n 's/^postgres (PostgreSQL) \([0-9][0-9]*\).*/\1/p')
[ -n "$pg_major" ] && [ "$pg_major" -ge 17 ] || die "PostgreSQL 17 or newer is required (found: $(postgres --version))"
command -v stockfish >/dev/null 2>&1 || log "WARNING: stockfish not found in PATH; computer moves will fail"
user=$(rc_value chessd_user chess)
group=$(rc_value chessd_group chess)
[ "$user" = chess ] || die "chessd_user is $user; peer authentication requires the OS account and database role to both be chess"
if ! id "$user" >/dev/null 2>&1; then
log "creating account $user (home /home/$user, no shell, no password)"
pw useradd -n "$user" -c "Chess server" -d "/home/$user" -m -s /usr/sbin/nologin -w no
fi
# chessd_home: rc.conf, else the account's home. rc.d/chessd defaults to
# /home/chess, so any other home is recorded in rc.conf.
account_home=$(pw usershow "$user" | cut -d: -f9)
home=$(rc_value chessd_home "$account_home")
if [ "$home" != /home/chess ] && [ -z "$(rc_value chessd_home "")" ]; then
sysrc -q chessd_home="$home" >/dev/null
fi
bin=$(rc_value chessd_bin "$home/bin/chess-server")
host=$(rc_value chessd_host 0.0.0.0)
port=$(rc_value chessd_port 8080)
dsn=$(rc_value chessd_dsn 'postgres:///chess?host=/tmp')
key=$(rc_value chessd_jwt_key "$home/jwt.key")
case $(rc_value chessd_flags "") in
*-storage-path*) die "rc.conf chessd_flags contains -storage-path, which no longer exists; remove it (sysrc -x chessd_flags) and rerun" ;;
esac
# --- PostgreSQL -------------------------------------------------------------------
pgdata=$(rc_value postgresql_data "/var/db/postgres/data$pg_major")
if [ ! -f "$pgdata/PG_VERSION" ]; then
log "initializing PostgreSQL cluster in $pgdata"
sysrc -q postgresql_enable=YES >/dev/null
sysrc -q postgresql_initdb_flags="--encoding=UTF8 --locale-provider=builtin --builtin-locale=C.UTF-8 --auth-local=peer --auth-host=reject" >/dev/null
service postgresql initdb ||
die "initdb failed; a shared memory error means the jail needs sysvshm=new (deploy/freebsd/host.sh)"
fi
sysrc -q postgresql_enable=YES >/dev/null
if ! service postgresql status >/dev/null 2>&1; then
log "starting PostgreSQL"
service postgresql start
fi
# Only the postgres superuser and the chess role, each over the Unix socket
# with peer authentication. A cluster initialized with FreeBSD's default flags
# trusts every local connection; this replaces that.
hba="$pgdata/pg_hba.conf"
if ! grep -q '^# Managed by chess setup-jail.sh' "$hba" 2>/dev/null; then
[ -f "$hba.orig" ] || cp -p "$hba" "$hba.orig"
log "writing $hba (original kept as pg_hba.conf.orig)"
cat >"$hba" <<'HBA'
# Managed by chess setup-jail.sh; edits are kept on rerun.
# TYPE DATABASE USER METHOD
local all postgres peer
local chess chess peer
HBA
chown postgres:postgres "$hba"
chmod 600 "$hba"
service postgresql reload
fi
# Unix socket only. ALTER SYSTEM writes postgresql.auto.conf and leaves
# postgresql.conf untouched; the setting needs a restart.
if [ -n "$(echo 'SHOW listen_addresses' | pg_sql postgres)" ]; then
log "disabling the PostgreSQL TCP listener"
pg_sql postgres <<'SQL'
ALTER SYSTEM SET listen_addresses = '';
ALTER SYSTEM SET unix_socket_directories = '/tmp';
SQL
service postgresql restart
if [ -n "$(echo 'SHOW listen_addresses' | pg_sql postgres)" ]; then
log "WARNING: listen_addresses is still set, from a source that overrides postgresql.auto.conf (postgresql_flags?); PostgreSQL still listens on TCP"
fi
fi
have_role=$(echo "SELECT 1 FROM pg_roles WHERE rolname = 'chess'" | pg_sql postgres)
have_db=$(echo "SELECT 1 FROM pg_database WHERE datname = 'chess'" | pg_sql postgres)
case "$have_role$have_db" in
11) log "role and database chess exist; provisioning unchanged" ;;
"")
log "provisioning role, database, and schema (split privileges: $SPLIT_PRIVILEGES)"
split=false
[ "$SPLIT_PRIVILEGES" = yes ] && split=true
su -m postgres -c "psql -X -q -d postgres -v split=$split -f -" <"$repo/deploy/postgresql/setup.sql"
;;
*) die "only one of role chess and database chess exists; inspect with: su -m postgres -c psql" ;;
esac
# --- chessd --------------------------------------------------------------------
if service chessd onestatus >/dev/null 2>&1; then
log "stopping chessd"
service chessd onestop
fi
bindir=$(dirname "$bin")
[ -d "$bindir" ] || install -d -o root -g wheel -m 0755 "$bindir"
# Root-owned: the service account cannot replace its own executable file.
if [ -f "$bin" ] && cmp -s "$CHESS_BINARY" "$bin"; then
# Same content, possibly the installed file itself (a rerun with
# CHESS_BINARY pointing at it): install(1) refuses to copy a file onto
# itself, so only normalize ownership and mode.
chown root:wheel "$bin"
chmod 0555 "$bin"
else
if [ -f "$bin" ]; then
log "keeping the previous binary as $bin.prev"
cp -p "$bin" "$bin.prev"
fi
install -o root -g wheel -m 0555 "$CHESS_BINARY" "$bin"
fi
[ -d "$RC_DIR" ] || install -d "$RC_DIR"
if [ -f "$RC_DIR/chessd" ] && ! cmp -s "$here/rc.d/chessd" "$RC_DIR/chessd"; then
# Saved outside rc.d: rc(8) would run a second copy found there.
[ -d "$RC_BACKUP_DIR" ] || install -d -m 0750 "$RC_BACKUP_DIR"
saved="$RC_BACKUP_DIR/chessd.rc.$(date -u +%Y%m%dT%H%M%SZ)"
cp -p "$RC_DIR/chessd" "$saved"
log "previous rc.d script saved as $saved"
fi
install -o root -g wheel -m 0555 "$here/rc.d/chessd" "$RC_DIR/chessd"
if [ ! -f "$key" ]; then
log "generating JWT signing key $key"
(umask 077 && openssl rand -base64 48 >"$key")
fi
chown "$user:$group" "$key"
chmod 600 "$key"
log "creating or migrating the chess schema"
if [ "$SPLIT_PRIVILEGES" = yes ]; then
# The postgres account migrates as chess_owner. It may be unable to reach
# the chess home, so it runs a temporary copy of the binary.
migrator=$(mktemp /tmp/chess-migrate.XXXXXX)
install -o root -g wheel -m 0555 "$CHESS_BINARY" "$migrator"
su -m postgres -c "$migrator db init -dsn \"dbname=chess user=postgres options='-c role=chess_owner -c search_path=chess'\"" ||
{ rm -f "$migrator"; die "schema migration failed"; }
rm -f "$migrator"
else
su -m "$user" -c "$bin db init -dsn '$dsn'"
fi
sysrc -q chessd_enable=YES >/dev/null
if [ -n "$TRUSTED_PROXIES" ]; then
sysrc -q chessd_trusted_proxies="$TRUSTED_PROXIES" >/dev/null
fi
[ -n "$(rc_value chessd_trusted_proxies "")" ] ||
log "WARNING: chessd_trusted_proxies is unset; behind nginx every client shares one rate limit"
# Settings of the SQLite-era rc.d script that nothing reads any more.
for stale in chessd_storage_path chessd_dir; do
if sysrc -n "$stale" >/dev/null 2>&1; then
sysrc -q -x "$stale" >/dev/null
log "removed unused rc.conf setting $stale"
fi
done
# --- optional maintenance -------------------------------------------------------------
logs=$(rc_value chessd_logs /var/log/chessd.log)
if [ "$ENABLE_LOG_ROTATION" = yes ]; then
# Daily or at 1 MB, keep 7; SIGHUP makes daemon(8) (-H) reopen the log.
[ -d "$NEWSYSLOG_DIR" ] || install -d "$NEWSYSLOG_DIR"
printf '%s\n' \
"# Managed by chess setup-jail.sh" \
"$logs $user:$group 640 7 1000 @T00 JC /var/run/chessd.pid 1" \
>"$NEWSYSLOG_DIR/chessd.conf"
log "log rotation: $NEWSYSLOG_DIR/chessd.conf"
fi
if [ "$ENABLE_BACKUP" = yes ]; then
# Nightly pg_dump at 03:30 as postgres, keeping 14 days.
[ -d "$SBIN_DIR" ] || install -d "$SBIN_DIR"
install -o root -g wheel -m 0555 "$repo/deploy/postgresql/chess-backup.sh" "$SBIN_DIR/chess-backup"
[ -d "$BACKUP_DIR" ] || install -d -o postgres -g postgres -m 0700 "$BACKUP_DIR"
[ -d "$CRON_DIR" ] || install -d "$CRON_DIR"
printf '%s\n' \
"# Managed by chess setup-jail.sh" \
"SHELL=/bin/sh" \
"PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin" \
"30 3 * * * postgres CHESS_BACKUP_DIR=$BACKUP_DIR $SBIN_DIR/chess-backup" \
>"$CRON_DIR/chess-backup"
log "backups: $CRON_DIR/chess-backup -> $BACKUP_DIR"
fi
# --- start and verify -----------------------------------------------------------------
log "starting chessd"
service chessd start
health_host=$host
[ "$health_host" = 0.0.0.0 ] && health_host=127.0.0.1
url="http://$health_host:$port/health"
health=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if health=$(fetch -q -T 2 -o - "$url" 2>/dev/null); then
break
fi
sleep 2
done
case $health in
*'"storage":"ok"'*) log "healthy: $health" ;;
*) die "no healthy response from $url (got: ${health:-nothing}); see $logs, and check that the jail firewall allows loopback" ;;
esac
cat <<EOF
chessd is running with PostgreSQL storage ($url).
Administration inside the jail:
su -m $user -c '$bin db user list -dsn "$dsn"'
service chessd status|restart
tail -f $logs
EOF