Files
pobsync/scripts/install-systemd
Peter van Arkel 90f28410ce (feature) Add host doctor checks and Django log viewer
Add host-level checks for address, enabled state, SSH credential
selection, and backup directory readiness, and show them on the host
detail page.

Create host backup directories during host creation and prefill new
hosts from the default global config.

Add a staff-only logs view backed by journalctl with filtering by
pobsync unit, priority, and message text.

Improve runtime checks for gunicorn in virtualenv installs and ensure
the native installer grants the service user access to the backup root.
2026-05-19 19:11:57 +02:00

507 lines
15 KiB
Bash
Executable File

#!/bin/sh
set -eu
SOURCE_DIR=${POBSYNC_SOURCE_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
APP_DIR=${POBSYNC_APP_DIR:-/opt/pobsync/app}
VENV_DIR=${POBSYNC_VENV_DIR:-/opt/pobsync/venv}
ENV_FILE=${POBSYNC_ENV_FILE:-/etc/pobsync/pobsync.env}
SERVICE_USER=${POBSYNC_SERVICE_USER:-pobsync}
SERVICE_GROUP=${POBSYNC_SERVICE_GROUP:-pobsync}
INSTALL_EXTRAS=${POBSYNC_INSTALL_EXTRAS:-}
SERVER_NAME=${POBSYNC_SERVER_NAME:-_}
ALLOWED_HOSTS=${POBSYNC_ALLOWED_HOSTS:-localhost,127.0.0.1}
CSRF_TRUSTED_ORIGINS=${POBSYNC_CSRF_TRUSTED_ORIGINS:-}
BACKUP_ROOT=${POBSYNC_BACKUP_ROOT:-/backups}
WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010}
FORCE_ENV=0
INSTALL_OS_PACKAGES=1
WITH_NGINX=0
VERBOSE=0
INTERACTIVE=0
CREATE_SUPERUSER=ask
SUPERUSER_USERNAME=${POBSYNC_SUPERUSER_USERNAME:-}
SUPERUSER_EMAIL=${POBSYNC_SUPERUSER_EMAIL:-}
SUPERUSER_PASSWORD=${POBSYNC_SUPERUSER_PASSWORD:-}
if [ -t 0 ]; then
INTERACTIVE=1
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--source-dir)
SOURCE_DIR=$2
shift 2
;;
--app-dir)
APP_DIR=$2
shift 2
;;
--venv-dir)
VENV_DIR=$2
shift 2
;;
--env-file)
ENV_FILE=$2
shift 2
;;
--service-user)
SERVICE_USER=$2
shift 2
;;
--service-group)
SERVICE_GROUP=$2
shift 2
;;
--backup-root)
BACKUP_ROOT=$2
shift 2
;;
--allowed-hosts)
ALLOWED_HOSTS=$2
shift 2
;;
--csrf-trusted-origins)
CSRF_TRUSTED_ORIGINS=$2
shift 2
;;
--web-bind)
WEB_BIND=$2
shift 2
;;
--force-env)
FORCE_ENV=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
--interactive)
INTERACTIVE=1
shift
;;
--non-interactive)
INTERACTIVE=0
shift
;;
--no-install-os-packages)
INSTALL_OS_PACKAGES=0
shift
;;
--install-extras)
INSTALL_EXTRAS=$2
shift 2
;;
--with-nginx)
WITH_NGINX=1
shift
;;
--server-name)
SERVER_NAME=$2
shift 2
;;
--create-superuser)
CREATE_SUPERUSER=1
shift
;;
--no-create-superuser)
CREATE_SUPERUSER=0
shift
;;
--superuser-username)
SUPERUSER_USERNAME=$2
shift 2
;;
--superuser-email)
SUPERUSER_EMAIL=$2
shift 2
;;
--superuser-password)
SUPERUSER_PASSWORD=$2
shift 2
;;
*)
echo "Unknown argument: $1" >&2
exit 2
;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
echo "Run this installer as root." >&2
exit 1
fi
run_step() {
label=$1
shift
if [ "$VERBOSE" -eq 1 ]; then
echo "==> $label"
"$@"
echo "OK: $label"
return
fi
printf '%-48s' "$label"
log_file=$(mktemp)
if "$@" >"$log_file" 2>&1; then
rm -f "$log_file"
echo "OK"
return
fi
echo "FAILED"
echo
echo "Output from failed step '$label':" >&2
cat "$log_file" >&2
rm -f "$log_file"
exit 1
}
note_step() {
label=$1
status=$2
printf '%-48s%s\n' "$label" "$status"
}
prompt_value() {
prompt=$1
default=$2
if [ "$INTERACTIVE" -ne 1 ]; then
printf '%s\n' "$default"
return
fi
printf '%s [%s]: ' "$prompt" "$default" >&2
read -r answer
if [ -n "$answer" ]; then
printf '%s\n' "$answer"
else
printf '%s\n' "$default"
fi
}
prompt_yes_no() {
prompt=$1
default=$2
if [ "$INTERACTIVE" -ne 1 ]; then
printf '%s\n' "$default"
return
fi
if [ "$default" -eq 1 ]; then
suffix=Y/n
else
suffix=y/N
fi
while :; do
printf '%s [%s]: ' "$prompt" "$suffix" >&2
read -r answer
case "$answer" in
"")
printf '%s\n' "$default"
return
;;
y|Y|yes|YES|Yes)
printf '1\n'
return
;;
n|N|no|NO|No)
printf '0\n'
return
;;
esac
done
}
prompt_secret() {
prompt=$1
if [ "$INTERACTIVE" -ne 1 ]; then
printf '\n'
return
fi
printf '%s: ' "$prompt" >&2
stty -echo
read -r secret
stty echo
printf '\n' >&2
printf '%s\n' "$secret"
}
if [ "$INTERACTIVE" -eq 1 ]; then
echo "pobsync native installer"
echo
echo "Press Enter to accept defaults. Existing command-line flags are already applied as defaults."
echo
SOURCE_DIR=$(prompt_value "Source checkout" "$SOURCE_DIR")
APP_DIR=$(prompt_value "Install app directory" "$APP_DIR")
VENV_DIR=$(prompt_value "Python virtualenv directory" "$VENV_DIR")
ENV_FILE=$(prompt_value "Environment file" "$ENV_FILE")
SERVICE_USER=$(prompt_value "Service user" "$SERVICE_USER")
SERVICE_GROUP=$(prompt_value "Service group" "$SERVICE_GROUP")
BACKUP_ROOT=$(prompt_value "Backup storage path" "$BACKUP_ROOT")
WEB_BIND=$(prompt_value "Gunicorn bind address" "$WEB_BIND")
ALLOWED_HOSTS=$(prompt_value "Allowed hosts" "$ALLOWED_HOSTS")
CSRF_TRUSTED_ORIGINS=$(prompt_value "CSRF trusted origins, comma-separated or blank" "$CSRF_TRUSTED_ORIGINS")
INSTALL_OS_PACKAGES=$(prompt_yes_no "Install required OS packages with apt-get" "$INSTALL_OS_PACKAGES")
use_mariadb=0
if [ "$INSTALL_EXTRAS" = "mariadb" ] || [ "$INSTALL_EXTRAS" = "[mariadb]" ] || [ "$INSTALL_EXTRAS" = ".[mariadb]" ]; then
use_mariadb=1
fi
use_mariadb=$(prompt_yes_no "Install MariaDB Python/client support" "$use_mariadb")
if [ "$use_mariadb" -eq 1 ]; then
INSTALL_EXTRAS=mariadb
else
INSTALL_EXTRAS=
fi
WITH_NGINX=$(prompt_yes_no "Install starter nginx reverse proxy config" "$WITH_NGINX")
if [ "$WITH_NGINX" -eq 1 ]; then
SERVER_NAME=$(prompt_value "Nginx server_name" "$SERVER_NAME")
fi
if [ "$CREATE_SUPERUSER" = "ask" ]; then
CREATE_SUPERUSER=$(prompt_yes_no "Create first Django superuser after install" 1)
fi
if [ "$CREATE_SUPERUSER" -eq 1 ]; then
SUPERUSER_USERNAME=$(prompt_value "Superuser username" "${SUPERUSER_USERNAME:-admin}")
SUPERUSER_EMAIL=$(prompt_value "Superuser email, blank allowed" "$SUPERUSER_EMAIL")
if [ -z "$SUPERUSER_PASSWORD" ]; then
SUPERUSER_PASSWORD=$(prompt_secret "Superuser password, leave blank to run createsuperuser interactively later")
fi
fi
echo
fi
if [ "$CREATE_SUPERUSER" = "ask" ]; then
if [ -n "$SUPERUSER_USERNAME" ] && [ -n "$SUPERUSER_PASSWORD" ]; then
CREATE_SUPERUSER=1
else
CREATE_SUPERUSER=0
fi
fi
install_os_packages() {
if [ "$INSTALL_OS_PACKAGES" -ne 1 ]; then
note_step "Install OS packages" "SKIPPED"
return
fi
if command -v apt-get >/dev/null 2>&1; then
packages="python3 python3-venv python3-pip rsync openssh-client"
if [ "$WITH_NGINX" -eq 1 ]; then
packages="$packages nginx"
fi
if [ "$INSTALL_EXTRAS" = "mariadb" ] || [ "$INSTALL_EXTRAS" = "[mariadb]" ] || [ "$INSTALL_EXTRAS" = ".[mariadb]" ]; then
packages="$packages default-libmysqlclient-dev build-essential pkg-config"
fi
run_step "Install OS packages" sh -c "apt-get update && apt-get install -y --no-install-recommends $packages"
return
fi
echo "No supported package manager found; install python3, python3-venv, rsync, and openssh-client manually." >&2
}
install_os_packages
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 is required." >&2
exit 1
fi
if ! command -v rsync >/dev/null 2>&1; then
echo "rsync is required." >&2
exit 1
fi
if ! command -v ssh >/dev/null 2>&1; then
echo "openssh-client is required." >&2
exit 1
fi
if [ ! -f "$SOURCE_DIR/manage.py" ]; then
echo "Source directory does not look like a pobsync checkout: $SOURCE_DIR" >&2
exit 1
fi
if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then
run_step "Create service group" groupadd --system "$SERVICE_GROUP"
else
note_step "Create service group" "OK"
fi
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
run_step "Create service user" useradd --system --home /var/lib/pobsync --shell /usr/sbin/nologin --gid "$SERVICE_GROUP" "$SERVICE_USER"
else
note_step "Create service user" "OK"
fi
run_step "Prepare directories" mkdir -p /etc/pobsync /var/lib/pobsync /var/log/pobsync "$(dirname "$VENV_DIR")" "$APP_DIR" "$BACKUP_ROOT"
run_step "Set state directory permissions" chown "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync "$BACKUP_ROOT"
run_step "Set private directory modes" chmod 0750 /var/lib/pobsync /var/log/pobsync
if [ "$SOURCE_DIR" != "$APP_DIR" ]; then
run_step "Sync application files" rsync -a --delete \
--exclude .git \
--exclude .venv \
--exclude __pycache__ \
--exclude .pytest_cache \
--exclude .mypy_cache \
--exclude var \
"$SOURCE_DIR"/ "$APP_DIR"/
else
note_step "Sync application files" "SKIPPED"
fi
run_step "Create Python virtualenv" python3 -m venv "$VENV_DIR"
run_step "Upgrade pip" "$VENV_DIR/bin/python" -m pip install --upgrade pip
case "$INSTALL_EXTRAS" in
"")
pip_target=$APP_DIR
;;
mariadb)
pip_target="$APP_DIR[mariadb]"
;;
\[*\])
pip_target="$APP_DIR$INSTALL_EXTRAS"
;;
.\[*\])
pip_target="$APP_DIR${INSTALL_EXTRAS#.}"
;;
*)
echo "Unsupported install extras: $INSTALL_EXTRAS" >&2
exit 2
;;
esac
run_step "Install Python package" "$VENV_DIR/bin/python" -m pip install -e "$pip_target"
if [ ! -f "$ENV_FILE" ] || [ "$FORCE_ENV" -eq 1 ]; then
secret=$("$VENV_DIR/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))")
cat > "$ENV_FILE" <<EOF
POBSYNC_DJANGO_DEBUG=0
POBSYNC_DJANGO_SECRET_KEY=$secret
POBSYNC_DJANGO_ALLOWED_HOSTS=$ALLOWED_HOSTS
POBSYNC_DJANGO_CSRF_TRUSTED_ORIGINS=$CSRF_TRUSTED_ORIGINS
POBSYNC_HOME=/var/lib/pobsync
POBSYNC_BACKUP_ROOT=$BACKUP_ROOT
POBSYNC_SQLITE_PATH=/var/lib/pobsync/pobsync.sqlite3
POBSYNC_STATIC_ROOT=/var/lib/pobsync/static
POBSYNC_WEB_BIND=$WEB_BIND
POBSYNC_GUNICORN_WORKERS=2
POBSYNC_GUNICORN_TIMEOUT=120
POBSYNC_WORKER_INTERVAL=15
POBSYNC_SCHEDULER_INTERVAL=60
EOF
chmod 0640 "$ENV_FILE"
chown "root:$SERVICE_GROUP" "$ENV_FILE"
note_step "Write environment file" "OK"
else
note_step "Write environment file" "SKIPPED"
echo "Keeping existing $ENV_FILE. Use --force-env to rewrite it."
fi
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
install_unit() {
src=$1
dest=$2
sed \
-e "s|@POBSYNC_APP_DIR@|$APP_DIR|g" \
-e "s|@POBSYNC_VENV_DIR@|$VENV_DIR|g" \
-e "s|@POBSYNC_ENV_FILE@|$ENV_FILE|g" \
-e "s|@POBSYNC_USER@|$SERVICE_USER|g" \
-e "s|@POBSYNC_GROUP@|$SERVICE_GROUP|g" \
"$src" > "$dest"
chmod 0644 "$dest"
}
install_units() {
install_unit "$APP_DIR/deploy/systemd/pobsync-web.service" /etc/systemd/system/pobsync-web.service
install_unit "$APP_DIR/deploy/systemd/pobsync-worker.service" /etc/systemd/system/pobsync-worker.service
install_unit "$APP_DIR/deploy/systemd/pobsync-scheduler.service" /etc/systemd/system/pobsync-scheduler.service
}
run_step "Install systemd units" install_units
run_step "Reload systemd" systemctl daemon-reload
run_step "Run database migrations" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" migrate --noinput
run_step "Collect static files" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" collectstatic --noinput --clear
run_step "Finalize state permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
superuser_exists=$("$VENV_DIR/bin/python" -c "import os; os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pobsync_server.settings'); import django; django.setup(); from django.contrib.auth import get_user_model; print('yes' if get_user_model().objects.filter(is_superuser=True).exists() else 'no')")
if [ "$CREATE_SUPERUSER" -eq 1 ]; then
if [ "$superuser_exists" = "yes" ]; then
note_step "Create Django superuser" "SKIPPED"
elif [ -n "$SUPERUSER_USERNAME" ] && [ -n "$SUPERUSER_PASSWORD" ]; then
run_step "Create Django superuser" env \
DJANGO_SUPERUSER_USERNAME="$SUPERUSER_USERNAME" \
DJANGO_SUPERUSER_EMAIL="$SUPERUSER_EMAIL" \
DJANGO_SUPERUSER_PASSWORD="$SUPERUSER_PASSWORD" \
"$VENV_DIR/bin/python" "$APP_DIR/manage.py" createsuperuser --noinput
run_step "Finalize superuser permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
else
note_step "Create Django superuser" "SKIPPED"
echo "No superuser password was provided; create one later with:"
echo " sudo -u $SERVICE_USER $VENV_DIR/bin/python $APP_DIR/manage.py createsuperuser"
fi
elif [ "$superuser_exists" != "yes" ]; then
note_step "Create Django superuser" "SKIPPED"
echo "No Django superuser exists yet. Create one with:"
echo " sudo -u $SERVICE_USER $VENV_DIR/bin/python $APP_DIR/manage.py createsuperuser"
else
note_step "Create Django superuser" "SKIPPED"
fi
run_step "Enable services" systemctl enable pobsync-web.service pobsync-worker.service pobsync-scheduler.service
run_step "Restart services" systemctl restart pobsync-web.service pobsync-worker.service pobsync-scheduler.service
if [ "$WITH_NGINX" -eq 1 ]; then
if ! command -v nginx >/dev/null 2>&1; then
note_step "Install nginx config" "SKIPPED"
echo "nginx is not installed; skipping nginx config." >&2
else
sed "s|@POBSYNC_SERVER_NAME@|$SERVER_NAME|g" "$APP_DIR/deploy/nginx/pobsync.conf" > /etc/nginx/sites-available/pobsync.conf
ln -sf /etc/nginx/sites-available/pobsync.conf /etc/nginx/sites-enabled/pobsync.conf
note_step "Install nginx config" "OK"
run_step "Validate nginx config" nginx -t
run_step "Reload nginx" systemctl reload nginx
fi
else
note_step "Install nginx config" "SKIPPED"
fi
if [ "$VERBOSE" -eq 1 ]; then
systemctl --no-pager --full status pobsync-web.service pobsync-worker.service pobsync-scheduler.service || true
fi
echo
echo "pobsync installation complete."
echo
echo "Open the Django control panel at:"
echo " http://$WEB_BIND/"
echo
echo "If pobsync is behind a reverse proxy, use your public hostname instead."
echo
echo "Recommended first steps:"
echo " 1. Log in to the Django control panel."
echo " 2. Open Self Check and resolve any warnings."
echo " 3. Configure global settings and backup storage."
echo " 4. Add an SSH key under SSH Keys."
echo " 5. Add a host and queue a dry-run backup."
echo
echo "Useful commands:"
echo " systemctl status pobsync-web pobsync-worker pobsync-scheduler"
echo " journalctl -u pobsync-worker -f"