#!/usr/bin/env bash # # deploy.sh -- deploy copyparty (caddy + copyparty file server) on Alpine, # Debian or Alma. Single-node; runs as root. # # What this does: # 1. Installs docker + compose if missing. # 2. Lays down the stack files in $STACK_DIR. # 3. Generates cfg/copyparty.conf on first run (random admin password) and a # self-signed cfg/ftps.pem for FTPS; neither is overwritten on re-run. # 4. Generates .env on first run, pinning COPYPARTY_TAG to the newest release. # 5. Prompts for required values not preset (COPYPARTY_DOMAIN, ACME_EMAIL). # 6. Opens 80/443 + SFTP/FTPS/passive ports on the host firewall if present. # 7. Pulls images, brings the stack up, waits for health. # 8. Installs + schedules update.sh (security-notices-aware) unless disabled. # # Caddy fronts the web UI / WebDAV over TLS; SFTP and FTPS are published directly # by copyparty (not proxied). copyparty trusts Caddy's X-Forwarded-For so logs # and bans use the real client IP (see cfg/copyparty.conf `xff-src`). # # Idempotent: re-run to apply config changes / pull new images. # # Self-contained: docker-compose.yml, Caddyfile, copyparty.conf.example, # .env.example and update.sh are embedded as a base64 tar.gz at the bottom of # this file. Rebuild with build.sh after editing the loose source files. # # Usage: # bash deploy.sh # interactive prompts # COPYPARTY_DOMAIN=files.example.com ACME_EMAIL=me@x.com \ # SKIP_PROMPTS=1 bash deploy.sh # non-interactive # DATA_DIR=/mnt/disk/files FTP_NAT=203.0.113.10 bash deploy.sh # SKIP_DOCKER_INSTALL=1 bash deploy.sh set -euo pipefail : "${STACK_DIR:=/srv/copyparty}" : "${SKIP_DOCKER_INSTALL:=0}" : "${FORCE:=0}" : "${SKIP_PROMPTS:=0}" [[ "$SKIP_PROMPTS" == "1" ]] && FORCE=1 : "${COPYPARTY_DOMAIN:=}" : "${ACME_EMAIL:=}" : "${COPYPARTY_IMAGE:=copyparty/ac}" : "${COPYPARTY_TAG:=latest}" : "${PUID:=1000}" : "${PGID:=1000}" : "${DATA_DIR:=/srv/copyparty/data}" : "${BIND_ADDR:=}" : "${SFTP_PORT:=3922}" : "${FTPS_PORT:=3990}" : "${FTP_PASV_RANGE:=12000-12099}" : "${FTP_NAT:=}" : "${UPDATE_POLICY:=latest}" : "${VC_FEED:=advisories}" : "${COPYPARTY_AUTOUPDATE:=1}" # 0 = install update.sh but don't schedule it : "${CADDY_TAG:=2-alpine}" log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "Run as root." # --------------------------------------------------------------------------- # OS detection + Docker install (Alpine / Debian / Alma). Inlined (this deploy.sh # is self-contained / scp'd standalone) rather than sourced from oslib.sh. # --------------------------------------------------------------------------- osfam() { local id="" like="" if [[ -r /etc/os-release ]]; then id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")" like="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")" fi case " $id $like " in *" alpine "*) echo alpine ;; *" debian "*|*" ubuntu "*) echo debian ;; *" rhel "*|*" fedora "*|*" centos "*) echo rhel ;; *) echo "${id:-unknown}" ;; esac } pkg_install() { # best-effort install of a package across the three families case "$(osfam)" in alpine) apk add -q "$@" || true ;; debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" || true ;; rhel) dnf install -y -q "$@" || true ;; esac } # fetch a URL to stdout with whatever's available (curl or wget). fetch() { if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" 2>/dev/null elif command -v wget >/dev/null 2>&1; then wget -qO- "$1" 2>/dev/null fi } install_docker() { if command -v docker >/dev/null 2>&1; then log "Docker already installed: $(docker --version)" else log "Installing Docker (OS: $(osfam))..." case "$(osfam)" in alpine) apk add -q docker docker-cli-compose openrc ;; debian|rhel) command -v curl >/dev/null 2>&1 || pkg_install curl curl -fsSL https://get.docker.com | sh ;; *) die "Unsupported OS for auto Docker install. Set SKIP_DOCKER_INSTALL=1 and install Docker yourself." ;; esac fi if command -v rc-update >/dev/null 2>&1; then rc-update add docker default >/dev/null 2>&1 || true rc-service docker status >/dev/null 2>&1 || rc-service docker start elif command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker >/dev/null 2>&1 || systemctl start docker || true fi } open_web_ports() { # Register web (80/443) + SFTP/FTPS + the passive-FTP range. Prefer the host # firewall (harden-firewall.sh); else ufw/firewalld if active. # # NOTE: published Docker ports reach the host via nat/FORWARD and BYPASS the # INPUT firewall, so this is belt-and-braces + self-documentation. Pin the # real exposure with BIND_ADDR and copyparty's own access control. local pasv_colon="${FTP_PASV_RANGE/-/:}" # 12000-12099 -> 12000:12099 (iptables/ufw/ports.d) local pasv_hyphen="${FTP_PASV_RANGE}" # 12000-12099 (firewalld) if [[ -d /etc/firewall/ports.d && -x /usr/local/sbin/firewall-apply ]]; then log "Registering 80,443,${SFTP_PORT},${FTPS_PORT},${pasv_colon}/tcp with host firewall..." printf '80/tcp\n443/tcp\n%s/tcp\n%s/tcp\n%s/tcp\n' \ "$SFTP_PORT" "$FTPS_PORT" "$pasv_colon" > /etc/firewall/ports.d/copyparty.rule /usr/local/sbin/firewall-apply elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q '^Status: active'; then log "ufw active -- allowing web + SFTP/FTPS..." local p for p in 80/tcp 443/tcp "${SFTP_PORT}/tcp" "${FTPS_PORT}/tcp" "${pasv_colon}/tcp"; do ufw allow "$p" >/dev/null done elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then log "firewalld active -- allowing web + SFTP/FTPS..." firewall-cmd -q --add-service=http --permanent firewall-cmd -q --add-service=https --permanent firewall-cmd -q --add-port="${SFTP_PORT}/tcp" --permanent firewall-cmd -q --add-port="${FTPS_PORT}/tcp" --permanent firewall-cmd -q --add-port="${pasv_hyphen}/tcp" --permanent firewall-cmd -q --reload fi } # ---------------------------------------------------------------------------- # Extract embedded archive # ---------------------------------------------------------------------------- SCRIPT_DIR=$(mktemp -d -t copyparty-deploy.XXXXXX) trap 'rm -rf "$SCRIPT_DIR"' EXIT extract_archive() { grep -a -A 9999999 '^__ARCHIVE_BELOW__$' "$0" \ | tail -n +2 \ | base64 -d \ | tar -xz -C "$SCRIPT_DIR" } if grep -q -a '^__ARCHIVE_BELOW__$' "$0"; then log "Extracting embedded deployment files..." extract_archive else die "No embedded archive found. Run build.sh to embed deployment files." fi EMBEDDED=(docker-compose.yml Caddyfile copyparty.conf.example .env.example update.sh) for f in "${EMBEDDED[@]}"; do [[ -f "$SCRIPT_DIR/$f" ]] || die "Embedded archive missing $f" done # ---------------------------------------------------------------------------- # Prompt for required vars # ---------------------------------------------------------------------------- prompt() { local varname="$1" message="$2" local -n ref="$varname" if [[ -z "${ref:-}" ]]; then [[ "$SKIP_PROMPTS" == "1" ]] && die "$varname required (set it in the environment; running with SKIP_PROMPTS=1)." read -r -p "$message: " ref [[ -n "$ref" ]] || die "$varname required." fi } prompt COPYPARTY_DOMAIN "Public hostname for the web UI (e.g. files.example.com)" prompt ACME_EMAIL "Let's Encrypt email" # ---------------------------------------------------------------------------- # Docker + firewall # ---------------------------------------------------------------------------- if [[ "$SKIP_DOCKER_INSTALL" != "1" ]]; then install_docker fi open_web_ports # ---------------------------------------------------------------------------- # Stack directory + files # ---------------------------------------------------------------------------- log "Setting up $STACK_DIR..." install -d -m 0750 "$STACK_DIR" install -m 0644 "$SCRIPT_DIR/docker-compose.yml" "$STACK_DIR/docker-compose.yml" install -m 0644 "$SCRIPT_DIR/Caddyfile" "$STACK_DIR/Caddyfile" install -m 0755 "$SCRIPT_DIR/update.sh" "$STACK_DIR/update.sh" install -d -m 0750 "$STACK_DIR/cfg" # Data root (bind-mounted at /w), owned by the runtime UID/GID. install -d -m 0750 "$DATA_DIR" chown "$PUID:$PGID" "$DATA_DIR" ENV_FILE="$STACK_DIR/.env" set_env() { # : update KEY in .env, or append if absent local key="$1" val="$2" esc esc=${val//\\/\\\\}; esc=${esc//|/\\|}; esc=${esc//&/\\&} if grep -qE "^${key}=" "$ENV_FILE"; then sed -i -e "s|^${key}=.*|${key}=${esc}|" "$ENV_FILE" else printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" fi } # Pin COPYPARTY_TAG to the newest release so the running version is explicit. if [[ "$COPYPARTY_TAG" == "latest" || -z "$COPYPARTY_TAG" ]]; then _v="$(fetch "https://api.github.com/repos/9001/copyparty/releases/latest" \ | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 \ | sed -E 's/.*"v?([0-9][^"]*)".*/\1/')" if [[ -n "${_v:-}" ]]; then COPYPARTY_TAG="$_v"; log "Pinned COPYPARTY_TAG=${_v} (newest release)." else warn "Could not resolve the newest release; using tag 'latest'."; COPYPARTY_TAG="latest"; fi fi # ---------------------------------------------------------------------------- # Generate copyparty.conf (admin password) + self-signed FTPS cert -- once. # ---------------------------------------------------------------------------- CONF="$STACK_DIR/cfg/copyparty.conf" if [[ ! -f "$CONF" ]]; then ADMIN_PW="$(head -c 32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24)" log "Generating $CONF (admin account)..." install -m 0640 "$SCRIPT_DIR/copyparty.conf.example" "$CONF" sed -i "s|__ADMIN_PW__|${ADMIN_PW}|" "$CONF" if [[ -n "$FTP_NAT" ]]; then sed -i -E "s|^[[:space:]]*#[[:space:]]*ftp-nat:.*| ftp-nat: ${FTP_NAT}|" "$CONF" fi else log "$CONF exists; leaving it alone." fi # Read back the admin password for the summary (works on first run + re-runs). # Strip the leading 'admin:', any trailing inline #comment, and surrounding space. ADMIN_PW="$(grep -E '^[[:space:]]*admin:' "$CONF" | head -n1 \ | sed -E 's/^[[:space:]]*admin:[[:space:]]*//; s/[[:space:]]*#.*$//; s/[[:space:]]*$//')" FTPS_PEM="$STACK_DIR/cfg/ftps.pem" if [[ ! -f "$FTPS_PEM" ]]; then command -v openssl >/dev/null 2>&1 || { log "Installing openssl for the FTPS cert..."; pkg_install openssl; } command -v openssl >/dev/null 2>&1 || die "openssl is required to mint the FTPS cert; install it and re-run." log "Generating self-signed FTPS certificate (CN=${COPYPARTY_DOMAIN})..." _k="$(mktemp)"; _c="$(mktemp)" if ! openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ -keyout "$_k" -out "$_c" -subj "/CN=${COPYPARTY_DOMAIN}" \ -addext "subjectAltName=DNS:${COPYPARTY_DOMAIN}" 2>/dev/null; then # older openssl without -addext openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ -keyout "$_k" -out "$_c" -subj "/CN=${COPYPARTY_DOMAIN}" fi cat "$_k" "$_c" > "$FTPS_PEM" # copyparty --cert wants key + chain concatenated rm -f "$_k" "$_c" chmod 0640 "$FTPS_PEM" else log "$FTPS_PEM exists; leaving it." fi # Everything copyparty (running as PUID) must read/write under /cfg. chown -R "$PUID:$PGID" "$STACK_DIR/cfg" # ---------------------------------------------------------------------------- # Seed .env (first run only) # ---------------------------------------------------------------------------- if [[ ! -f "$ENV_FILE" ]]; then log "Seeding $ENV_FILE..." install -m 0600 "$SCRIPT_DIR/.env.example" "$ENV_FILE" set_env COPYPARTY_DOMAIN "$COPYPARTY_DOMAIN" set_env ACME_EMAIL "$ACME_EMAIL" set_env COPYPARTY_IMAGE "$COPYPARTY_IMAGE" set_env COPYPARTY_TAG "$COPYPARTY_TAG" set_env PUID "$PUID" set_env PGID "$PGID" set_env DATA_DIR "$DATA_DIR" set_env BIND_ADDR "$BIND_ADDR" set_env SFTP_PORT "$SFTP_PORT" set_env FTPS_PORT "$FTPS_PORT" set_env FTP_PASV_RANGE "$FTP_PASV_RANGE" set_env FTP_NAT "$FTP_NAT" set_env UPDATE_POLICY "$UPDATE_POLICY" set_env VC_FEED "$VC_FEED" set_env CADDY_TAG "$CADDY_TAG" else log ".env exists; leaving it alone." fi # Validate required values landed. missing=() for var in COPYPARTY_DOMAIN ACME_EMAIL; do grep -E "^${var}=.+$" "$ENV_FILE" >/dev/null || missing+=("$var") done (( ${#missing[@]} == 0 )) || die "Missing values in $ENV_FILE: ${missing[*]}" # ---------------------------------------------------------------------------- # Confirm # ---------------------------------------------------------------------------- if [[ "$FORCE" != "1" ]]; then cat </dev/null || true) unhealthy=$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}') if [[ -z "$unhealthy" && -n "$status" ]]; then log "All services healthy." break fi sleep 5 done # ---------------------------------------------------------------------------- # Install + schedule the updater # ---------------------------------------------------------------------------- if [[ "$COPYPARTY_AUTOUPDATE" != "0" ]]; then log "Scheduling the copyparty updater (policy=${UPDATE_POLICY})..." STACK_DIR="$STACK_DIR" UPDATE_POLICY="$UPDATE_POLICY" VC_FEED="$VC_FEED" \ bash "$STACK_DIR/update.sh" install || warn "Updater scheduling failed (non-fatal)." else log "COPYPARTY_AUTOUPDATE=0 -- update.sh installed but not scheduled." fi echo log "Stack status:" docker compose ps echo cat <