#!/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} BACKUP_ROOT_EXPLICIT=0 if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then BACKUP_ROOT_EXPLICIT=1 fi WEB_BIND=${POBSYNC_WEB_BIND:-127.0.0.1:8010} TIME_ZONE=${POBSYNC_TIME_ZONE:-} 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 BACKUP_ROOT_EXPLICIT=1 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 ;; --time-zone) TIME_ZONE=$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 if [ -f "$ENV_FILE" ] && [ "$FORCE_ENV" -ne 1 ] && [ "$BACKUP_ROOT_EXPLICIT" -ne 1 ]; then set -a # shellcheck disable=SC1090 . "$ENV_FILE" set +a if [ -n "${POBSYNC_BACKUP_ROOT:-}" ]; then BACKUP_ROOT=$POBSYNC_BACKUP_ROOT fi fi detect_time_zone() { if [ -n "$TIME_ZONE" ]; then printf '%s\n' "$TIME_ZONE" return fi if [ -n "${POBSYNC_TIME_ZONE:-}" ]; then printf '%s\n' "$POBSYNC_TIME_ZONE" return fi if command -v timedatectl >/dev/null 2>&1; then detected=$(timedatectl show -p Timezone --value 2>/dev/null || true) if [ -n "$detected" ]; then printf '%s\n' "$detected" return fi fi if [ -f /etc/timezone ]; then detected=$(sed -n '1p' /etc/timezone | tr -d '[:space:]') if [ -n "$detected" ]; then printf '%s\n' "$detected" return fi fi printf 'UTC\n' } TIME_ZONE=$(detect_time_zone) 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") TIME_ZONE=$(prompt_value "Scheduler time zone" "$TIME_ZONE") 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 ! env POBSYNC_INSTALL_TIME_ZONE="$TIME_ZONE" python3 -c "import os; from zoneinfo import ZoneInfo; ZoneInfo(os.environ['POBSYNC_INSTALL_TIME_ZONE'])" >/dev/null 2>&1; then echo "Invalid time zone: $TIME_ZONE" >&2 echo "Use an IANA timezone such as UTC or Europe/Amsterdam." >&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 grant_journal_access() { for group in systemd-journal adm; do if getent group "$group" >/dev/null 2>&1; then usermod -a -G "$group" "$SERVICE_USER" fi done } run_step "Grant journal access" grant_journal_access 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 "$BACKUP_ROOT" 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" < "$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 install_manage_wrapper() { 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" \ "$APP_DIR/deploy/bin/pobsync-manage" > /usr/local/bin/pobsync-manage chmod 0755 /usr/local/bin/pobsync-manage } run_step "Install manage wrapper" install_manage_wrapper run_step "Reload systemd" systemctl daemon-reload run_step "Run database migrations" /usr/local/bin/pobsync-manage migrate --noinput run_step "Ensure default SSH key" /usr/local/bin/pobsync-manage ensure_pobsync_ssh_key --name default --set-global-default run_step "Collect static files" /usr/local/bin/pobsync-manage 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" \ /usr/local/bin/pobsync-manage 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 pobsync-manage 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 pobsync-manage 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" echo " sudo -u $SERVICE_USER pobsync-manage check" echo " sudo -u $SERVICE_USER pobsync-manage check_pobsync_install"