#!/usr/bin/env bash set -euo pipefail REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git" BUILD_DIR="/tmp/nextwks-build" TARGET_DIR="/opt/nextworkspace" BACKUP_DIR="/opt/backup" NETWORK_NAME="nextwks-net" HEALTH_CHECK_RETRIES=15 HEALTH_CHECK_INTERVAL=3 usage() { echo "Usage: $0 [--install|--update|--destroy]" echo " --install First-time setup on a bare VM (prompts for config)" echo " --update Smart update: pull, build, copy, bounce containers" echo " --destroy Full greenfield redeploy (uses saved secrets)" exit 1 } [ $# -eq 0 ] && usage MODE="${1#--}" case "$MODE" in install|update|destroy) ;; *) usage ;; esac # MUST NOT run as root — podman must be rootless if [ "$(id -u)" -eq 0 ]; then echo "ERROR: Do NOT run this script with sudo or as root." echo " Run it as your normal user: ./nextwks.sh --$MODE" echo " The script will prompt for sudo only where needed (apt, /opt/, iptables)." exit 1 fi # Helper: run with sudo for operations that need root maybe_sudo() { sudo "$@" } # --- Load existing env (if any), skip if unreadable --- # Temporarily disable -u because .env may contain $ signs (bcrypt hashes) set +u if [ -r "$BACKUP_DIR/.env" ]; then set -a; source "$BACKUP_DIR/.env"; set +a elif [ -r "$TARGET_DIR/.env" ]; then set -a; source "$TARGET_DIR/.env"; set +a fi set -u DOMAIN="${DOMAIN:-nextwks.eu}" echo "=== NextWorkspace ${MODE} ===" # ============================================================ # 1. INSTALL MODE — first-time setup (only on bare VM) # ============================================================ if [ "$MODE" = "install" ]; then if [ -f "$TARGET_DIR/nextworkspace" ]; then echo "=================================================================" echo " NextWorkspace is already installed at $TARGET_DIR" echo "" echo " Use --update to rebuild and restart:" echo " ./nextwks.sh --update" echo "" echo " Use --destroy for a full greenfield redeploy:" echo " ./nextwks.sh --destroy" echo "=================================================================" exit 0 fi echo "[*] Installing system dependencies..." maybe_sudo apt-get update -qq maybe_sudo apt-get install -y -qq git build-essential curl podman podman-compose iptables-persistent if ! command -v go &>/dev/null; then echo "[*] Installing Go..." GO_VERSION=$(curl -sL https://go.dev/VERSION?m=text) GO_URL="https://go.dev/dl/${GO_VERSION}.linux-amd64.tar.gz" curl -sL "$GO_URL" -o /tmp/go.tar.gz maybe_sudo rm -rf /usr/local/go maybe_sudo tar -C /usr/local -xzf /tmp/go.tar.gz rm /tmp/go.tar.gz maybe_sudo sh -c 'echo "export PATH=\$PATH:/usr/local/go/bin" > /etc/profile.d/go.sh' maybe_sudo chmod +x /etc/profile.d/go.sh export PATH=$PATH:/usr/local/go/bin fi # Enable user lingering — containers stay alive after logout maybe_sudo loginctl enable-linger "$USER" 2>/dev/null || true # Clean up any old rootful containers from a previous deploy echo "[*] Cleaning up old rootful containers (if any)..." maybe_sudo podman stop caddy authelia launcher 2>/dev/null || true maybe_sudo podman rm caddy authelia launcher 2>/dev/null || true maybe_sudo podman network rm "$NETWORK_NAME" 2>/dev/null || true echo "" echo "--- NextWorkspace Configuration ---" read -p "Domain [nextwks.eu]: " input; DOMAIN="${input:-$DOMAIN}" read -p "TLS email (Let's Encrypt): " TLS_EMAIL while [ -z "$TLS_EMAIL" ]; do read -p "TLS email (required): " TLS_EMAIL; done while echo "$TLS_EMAIL" | grep -qv '@'; do read -p "Invalid email: " TLS_EMAIL; done read -p "Admin username: " ADMIN_USERNAME while [ -z "$ADMIN_USERNAME" ]; do read -p "Admin username (required): " ADMIN_USERNAME; done # 24 chars, mixed case + numbers, no special chars (safe for .env) ADMIN_PASSWORD=$(openssl rand -base64 30 | tr -dc 'A-Za-z0-9') ADMIN_PASSWORD="${ADMIN_PASSWORD:0:24}" echo "" echo "========================================" echo " Domain: $DOMAIN" echo " TLS email: $TLS_EMAIL" echo " Admin username: $ADMIN_USERNAME" echo " Admin password: $ADMIN_PASSWORD" echo " Save this password — it won't be shown again!" echo "========================================" echo "" read -p "SMTP host [smtp.openxchange.eu]: " SMTP_HOST; SMTP_HOST="${SMTP_HOST:-smtp.openxchange.eu}" read -p "SMTP port [587]: " SMTP_PORT; SMTP_PORT="${SMTP_PORT:-587}" read -p "SMTP user [post@nextwks.eu]: " SMTP_USER; SMTP_USER="${SMTP_USER:-post@nextwks.eu}" read -sp "SMTP password: " SMTP_PASS; echo "" [ -z "$SMTP_PASS" ] && echo "ERROR: SMTP password required" && exit 1 read -p "IMAP host [imap.openxchange.eu]: " IMAP_HOST; IMAP_HOST="${IMAP_HOST:-imap.openxchange.eu}" read -p "IMAP port [993]: " IMAP_PORT; IMAP_PORT="${IMAP_PORT:-993}" # Persist config to backup vault (single-quote values to protect $ signs) maybe_sudo mkdir -p "$BACKUP_DIR" maybe_sudo sh -c "cat > '$BACKUP_DIR/.env' <<'ENVEOF' # NextWorkspace Configuration — auto-generated by nextwks.sh --install DOMAIN='$DOMAIN' TLS_EMAIL='$TLS_EMAIL' ADMIN_USERNAME='$ADMIN_USERNAME' ADMIN_PASSWORD='$ADMIN_PASSWORD' SMTP_HOST='$SMTP_HOST' SMTP_PORT='$SMTP_PORT' SMTP_USER='$SMTP_USER' SMTP_PASS='$SMTP_PASS' IMAP_HOST='$IMAP_HOST' IMAP_PORT='$IMAP_PORT' ENVEOF" maybe_sudo chmod 600 "$BACKUP_DIR/.env" fi # ============================================================ # 2. CLONE fresh (every mode — ensures latest code) # ============================================================ echo "[*] Cloning repository..." rm -rf "$BUILD_DIR" git clone --depth 1 "$REPO_URL" "$BUILD_DIR" cd "$BUILD_DIR" # Save script to user's home for easy future access (--install only) if [ "$MODE" = "install" ]; then cp "$BUILD_DIR/tools/nextwks.sh" "$HOME/nextwks.sh" chmod +x "$HOME/nextwks.sh" echo "[*] Saved to $HOME/nextwks.sh — use it for future updates" fi # ============================================================ # 3. FIREWALL SETUP (all modes) # ============================================================ if [ "$MODE" = "install" ]; then echo "[*] Applying firewall and port redirects (80→8080, 443→8443)..." maybe_sudo bash "$BUILD_DIR/tools/firewall-routing.sh" elif [ "$MODE" = "update" ] || [ "$MODE" = "destroy" ]; then # Lightweight: ensure redirects exist without flushing existing rules echo "[*] Ensuring port redirects (80→8080, 443→8443)..." maybe_sudo iptables -t nat -C PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080 2>/dev/null || \ maybe_sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 8080 maybe_sudo iptables -t nat -C PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443 2>/dev/null || \ maybe_sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443 maybe_sudo iptables -t nat -C OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-ports 8080 2>/dev/null || \ maybe_sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-ports 8080 maybe_sudo iptables -t nat -C OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443 2>/dev/null || \ maybe_sudo iptables -t nat -A OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-ports 8443 fi # Persist across reboots (always) if command -v netfilter-persistent &>/dev/null; then maybe_sudo netfilter-persistent save 2>/dev/null || true else maybe_sudo mkdir -p /etc/iptables maybe_sudo sh -c 'iptables-save > /etc/iptables/rules.v4' fi # ============================================================ # 4. BUILD static binary # ============================================================ echo "[*] Building static binary..." export PATH=$PATH:/usr/local/go/bin CGO_ENABLED=0 go build -o nextworkspace . # ============================================================ # 5. STOP containers (all modes — binary is mounted, must stop before copy) # ============================================================ echo "[*] Stopping containers..." podman stop caddy authelia launcher 2>/dev/null || true sleep 1 # ============================================================ # 6. CREATE target & backup directories (as root) # ============================================================ maybe_sudo mkdir -p "$TARGET_DIR/config/caddy" "$TARGET_DIR/config/authelia" \ "$TARGET_DIR/data/caddy" "$TARGET_DIR/data/authelia" \ "$TARGET_DIR/compose" "$TARGET_DIR/www" \ "$TARGET_DIR/config/nextworkspace" "$TARGET_DIR/logs" \ "$BACKUP_DIR" # ============================================================ # 7. TEARDOWN (destroy mode only — wipes target dir) # ============================================================ if [ "$MODE" = "destroy" ]; then echo "[*] Full teardown..." # Stop rootless containers podman stop caddy authelia launcher 2>/dev/null || true podman rm caddy authelia launcher 2>/dev/null || true podman network rm "$NETWORK_NAME" 2>/dev/null || true # Wipe target maybe_sudo rm -rf "$TARGET_DIR" maybe_sudo mkdir -p "$TARGET_DIR/config/caddy" "$TARGET_DIR/config/authelia" \ "$TARGET_DIR/data/caddy" "$TARGET_DIR/data/authelia" \ "$TARGET_DIR/compose" "$TARGET_DIR/www" \ "$TARGET_DIR/config/nextworkspace" "$TARGET_DIR/logs" fi # ============================================================ # 8. COPY artifacts to target (as root) # ============================================================ echo "[*] Copying artifacts..." maybe_sudo cp nextworkspace "$TARGET_DIR/nextworkspace" maybe_sudo cp "$BUILD_DIR/VERSION" "$TARGET_DIR/VERSION" if [ -d "$BUILD_DIR/config/www" ]; then maybe_sudo cp -r "$BUILD_DIR/config/www"/* "$TARGET_DIR/www/" fi if [ -d "$BUILD_DIR/lng" ]; then maybe_sudo rm -rf "$TARGET_DIR/lng" maybe_sudo cp -r "$BUILD_DIR/lng" "$TARGET_DIR/lng" fi if [ -d "$BUILD_DIR/config/nextworkspace" ]; then maybe_sudo cp -r "$BUILD_DIR/config/nextworkspace"/* "$TARGET_DIR/config/nextworkspace/" fi # Restore .env from backup if [ -f "$BACKUP_DIR/.env" ]; then maybe_sudo cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env" maybe_sudo chmod 644 "$TARGET_DIR/.env" fi # ============================================================ # 9. GENERATE config files with placeholder substitution # Write to /tmp first, then sudo cp to target # ============================================================ GEN_DIR=$(mktemp -d) trap "rm -rf '$GEN_DIR'" EXIT echo "[*] Generating config files..." # Caddyfile sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{TLS_EMAIL}|${TLS_EMAIL:-admin@$DOMAIN}|g" \ "$BUILD_DIR/config/caddy/Caddyfile" > "$GEN_DIR/Caddyfile" # Authelia config — preserve existing secrets if present JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}" SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}" STORAGE_ENCRYPTION_KEY="${STORAGE_ENCRYPTION_KEY:-$(openssl rand -hex 32)}" sed -e "s|{DOMAIN}|$DOMAIN|g" -e "s|{JWT_SECRET}|$JWT_SECRET|g" \ -e "s|{SESSION_SECRET}|$SESSION_SECRET|g" \ -e "s|{STORAGE_ENCRYPTION_KEY}|$STORAGE_ENCRYPTION_KEY|g" \ -e "s|{SMTP_HOST}|${SMTP_HOST:-smtp.openxchange.eu}|g" \ -e "s|{SMTP_PORT}|${SMTP_PORT:-587}|g" \ -e "s|{SMTP_USER}|${SMTP_USER:-post@nextwks.eu}|g" \ -e "s|{SMTP_PASS}|$SMTP_PASS|g" \ "$BUILD_DIR/config/authelia/configuration.yml" > "$GEN_DIR/configuration.yml" # Users database — regenerate hash if ADMIN_PASSWORD is available if [ -n "${ADMIN_PASSWORD:-}" ]; then ADMIN_PASSWORD_HASH=$(cd "$BUILD_DIR" && go run ./tools/hash-password/ "$ADMIN_PASSWORD" 2>/dev/null || echo "$ADMIN_PASSWORD_HASH") fi sed -e "s|{ADMIN_PASSWORD_HASH}|$ADMIN_PASSWORD_HASH|g" \ -e "s|{TLS_EMAIL}|${TLS_EMAIL:-admin@$DOMAIN}|g" \ "$BUILD_DIR/config/authelia/users_database.yml" > "$GEN_DIR/users_database.yml" # Copy generated configs to target maybe_sudo cp "$GEN_DIR/Caddyfile" "$TARGET_DIR/config/caddy/Caddyfile" maybe_sudo cp "$GEN_DIR/configuration.yml" "$TARGET_DIR/config/authelia/configuration.yml" maybe_sudo cp "$GEN_DIR/users_database.yml" "$TARGET_DIR/config/authelia/users_database.yml" # Persist generated secrets so --destroy is idempotent if [ -f "$BACKUP_DIR/.env" ]; then maybe_sudo sed -i "/^JWT_SECRET=/d; /^SESSION_SECRET=/d; /^STORAGE_ENCRYPTION_KEY=/d; /^ADMIN_PASSWORD_HASH=/d" "$BACKUP_DIR/.env" 2>/dev/null || true fi # Use pipe to avoid bash -c re-expanding $ signs (bcrypt hashes contain $2a$10$...) echo "JWT_SECRET='$JWT_SECRET'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null echo "SESSION_SECRET='$SESSION_SECRET'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null echo "STORAGE_ENCRYPTION_KEY='$STORAGE_ENCRYPTION_KEY'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null [ -n "$ADMIN_PASSWORD_HASH" ] && echo "ADMIN_PASSWORD_HASH='$ADMIN_PASSWORD_HASH'" | maybe_sudo tee -a "$BACKUP_DIR/.env" >/dev/null maybe_sudo chmod 600 "$BACKUP_DIR/.env" # ============================================================ # 10. FIX OWNERSHIP — all files in TARGET_DIR/BACKUP_DIR to user # ============================================================ RUN_USER="${SUDO_USER:-${USER}}" echo "[*] Setting file ownership to $RUN_USER..." maybe_sudo chown -R "$RUN_USER:" "$TARGET_DIR" 2>/dev/null || true maybe_sudo chown -R "$RUN_USER:" "$BACKUP_DIR" 2>/dev/null || true # ============================================================ # 11. DEPLOY stack (rootless podman — no sudo!) # ============================================================ echo "[*] Deploying containers on $NETWORK_NAME..." podman network rm "$NETWORK_NAME" 2>/dev/null || true podman network create --subnet 172.18.0.0/24 "$NETWORK_NAME" 2>/dev/null || true # AUTHELIA_SECRET is SESSION_SECRET (Authelia session.secret) AUTHELIA_SECRET="${SESSION_SECRET:-}" if [ -z "$AUTHELIA_SECRET" ]; then AUTHELIA_SECRET=$(sed -n '/^session:/,/^[a-z]/p' "$TARGET_DIR/config/authelia/configuration.yml" \ | grep 'secret:' | awk '{print $2}' 2>/dev/null || echo "") fi # Generate compose file with substituted secret sed -e "s|{AUTHELIA_SECRET}|$AUTHELIA_SECRET|g" \ "$BUILD_DIR/compose/stack.yaml" > "$GEN_DIR/stack.yaml" cp "$GEN_DIR/stack.yaml" "$TARGET_DIR/compose/stack.yaml" podman-compose -f "$TARGET_DIR/compose/stack.yaml" down 2>/dev/null || true sleep 1 podman-compose -f "$TARGET_DIR/compose/stack.yaml" up -d 2>&1 || echo "[WARN] Stack deploy had issues" # ============================================================ # 12. HEALTH CHECK # ============================================================ echo "[*] Running health check..." for i in $(seq 1 $HEALTH_CHECK_RETRIES); do HEALTH=$(podman exec launcher curl -sf http://127.0.0.1:9000/health 2>/dev/null || echo "") if [ "$HEALTH" = "OK" ]; then echo "[OK] NextWorkspace launcher is healthy" echo "[OK] https://$DOMAIN/" exit 0 fi sleep $HEALTH_CHECK_INTERVAL done echo "[FAIL] Health check failed — launcher did not respond" echo "" echo "--- Container status ---" podman ps -a --filter "name=caddy|authelia|launcher" 2>/dev/null || true echo "" echo "--- Launcher logs (last 20 lines) ---" podman logs launcher --tail 20 2>/dev/null || echo " (no logs)" exit 1