#!/usr/bin/env bash # # update.sh -- update the copyparty container, optionally driven by copyparty's # security-notices API. Companion to deploy.sh; installed alongside the stack. # # copyparty does NOT self-update, but it DOES publish a machine-readable feed of # security advisories (the same one its built-in `--vc-url` check uses). This # script reads that feed to decide when to act, pins the new version in .env, # and recreates the container -- so the running version is always explicit and # a bad release can be rolled back by editing .env. # # Subcommands: # check (default) report current vs latest + any matching advisory; no changes # run apply per UPDATE_POLICY, then notify (this is what the schedule runs) # update force an update now (to latest, or TARGET_VERSION=x.y.z) # install schedule the daily `run` # uninstall remove the schedule # # Policy (UPDATE_POLICY): # latest update to the newest release whenever one exists (default) # security update ONLY when the running version has a known advisory, to the # patched release named in the feed # off never change the running version (check/notify only) # # Env (also read from /etc/copyparty-update.conf; environment wins): # STACK_DIR=/srv/copyparty UPDATE_POLICY=latest VC_FEED=advisories # NOTIFY=1 SSH_NOTIFY_CONF=/etc/ssh-notify.conf DRY_RUN=0 # TARGET_VERSION= GH_REPO=9001/copyparty set -euo pipefail SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" # Load defaults from the conf for any var not already set in the environment # (precedence: environment > conf > built-in). : "${COPYPARTY_UPDATE_CONF:=/etc/copyparty-update.conf}" if [[ -r "$COPYPARTY_UPDATE_CONF" ]]; then while IFS= read -r _line; do [[ "$_line" =~ ^[[:space:]]*# || -z "${_line//[[:space:]]/}" ]] && continue _k="${_line%%=*}"; _v="${_line#*=}"; _k="${_k//[[:space:]]/}" [[ "$_k" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue [[ -n "${!_k:-}" ]] && continue _v="${_v%\"}"; _v="${_v#\"}" printf -v "$_k" '%s' "$_v" done < "$COPYPARTY_UPDATE_CONF" fi : "${STACK_DIR:=/srv/copyparty}" : "${UPDATE_POLICY:=latest}" : "${VC_FEED:=advisories}" : "${NOTIFY:=1}" : "${SSH_NOTIFY_CONF:=/etc/ssh-notify.conf}" : "${DRY_RUN:=0}" : "${TARGET_VERSION:=}" : "${GH_REPO:=9001/copyparty}" : "${LOG:=/var/log/copyparty-update.log}" 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; } run() { if [[ "$DRY_RUN" == "1" ]]; then echo "DRY: $*"; else eval "$@"; fi; } # docker compose, scoped to the stack dir (so ./Caddyfile, ./cfg resolve and # .env is auto-loaded). dc() { ( cd "$STACK_DIR" && docker compose "$@" ); } 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 } # --------------------------------------------------------------------------- # .env helpers # --------------------------------------------------------------------------- ENV_FILE="$STACK_DIR/.env" env_get() { [[ -f "$ENV_FILE" ]] && grep -E "^$1=" "$ENV_FILE" | head -n1 | cut -d= -f2- || true; } env_set() { # 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 } # strip a leading v and anything after the X.Y.Z core normver() { printf '%s' "$1" | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1; } # $1 <= $2 ? ver_le() { [[ "$1" == "$2" ]] || [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" == "$1" ]]; } ver_gt() { ! ver_le "$1" "$2"; } # --------------------------------------------------------------------------- # Version discovery # --------------------------------------------------------------------------- # Currently-running version: prefer the live container, fall back to the pinned # tag in .env. current_version() { local v="" v="$(dc exec -T copyparty python3 -m copyparty --versionb 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)" if [[ -z "$v" ]]; then v="$(normver "$(env_get COPYPARTY_TAG)")"; fi printf '%s' "$v" } # Newest released version from the GitHub releases API (tag_name, e.g. v1.20.11). latest_version() { command -v curl >/dev/null 2>&1 || { warn "curl missing; cannot resolve latest release."; return 0; } curl -fsSL "https://api.github.com/repos/${GH_REPO}/releases/latest" 2>/dev/null \ | grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n1 \ | sed -E 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v?([^"]+)".*/\1/' } # Advisory scan: ask copyparty's own python (inside the container -- no host jq/ # python dependency) whether $1 is covered by any advisory in $VC_FEED. Prints: # OK # ERR # VULN \n advisory_scan() { local ver="$1" feed="https://api.copyparty.eu/${VC_FEED}" dc exec -T -e CPV="$ver" -e FEED="$feed" copyparty python3 - <<'PY' 2>/dev/null || echo "ERR exec" import json, os, re, sys, urllib.request cpv = os.environ.get("CPV", "") feed = os.environ.get("FEED", "") def vt(s): out = [] for p in re.split(r'[.\-]', s.strip().lstrip("vV")): m = re.match(r'\d+', p) out.append(int(m.group(0)) if m else 0) return tuple(out) def cmp(a, b): ta, tb = vt(a), vt(b) n = max(len(ta), len(tb)) ta += (0,) * (n - len(ta)); tb += (0,) * (n - len(tb)) return (ta > tb) - (ta < tb) def satisfies(ver, rng): for part in rng.split(","): part = part.strip() if not part: continue m = re.match(r'(<=|>=|==|=|<|>)?\s*v?([0-9][0-9A-Za-z.\-]*)', part) if not m: return False op = m.group(1) or "=" c = cmp(ver, m.group(2)) ok = ((op in ("=", "==") and c == 0) or (op == "<" and c < 0) or (op == "<=" and c <= 0) or (op == ">" and c > 0) or (op == ">=" and c >= 0)) if not ok: return False return True if not cpv: print("ERR no-version"); sys.exit(0) try: raw = urllib.request.urlopen(feed, timeout=10).read() data = json.loads(raw) except Exception as e: print("ERR " + str(e)[:80]); sys.exit(0) if isinstance(data, dict): data = data.get("advisories") or data.get("data") or [data] hits, patched = [], [] for adv in data: for v in (adv.get("vulnerabilities") or []): name = ((v.get("package") or {}).get("name") or "").lower() if name and name != "copyparty": continue rng = v.get("vulnerable_version_range") or "" if rng and satisfies(cpv, rng): hits.append(adv.get("ghsa_id") or adv.get("cve_id") or "advisory") pv = v.get("patched_versions") or v.get("first_patched_version") or "" if isinstance(pv, dict): pv = pv.get("identifier", "") pv = re.sub(r'^[^0-9]*', '', str(pv)) if pv: patched.append(pv) if not hits: print("OK") else: best = "" for p in patched: if not best or cmp(p, best) > 0: best = p print("VULN " + best) print(",".join(sorted(set(hits)))) PY } # --------------------------------------------------------------------------- # Notify (reuse the login-notifier's ntfy config, like scripts/auto-update.sh) # --------------------------------------------------------------------------- send_notice() { # <priority> <body> [[ "$NOTIFY" == "1" ]] || return 0 [[ -r "$SSH_NOTIFY_CONF" ]] || return 0 # shellcheck disable=SC1090 . "$SSH_NOTIFY_CONF" [[ -n "${NTFY_URL:-}" ]] || return 0 command -v curl >/dev/null 2>&1 || return 0 local title="$1" prio="$2" body="$3" host host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)" set -- -fsS -m 5 -H "X-Title: ${title}" -H "X-Priority: ${prio}" [[ -n "${NTFY_TOKEN:-}" ]] && set -- "$@" -H "Authorization: Bearer ${NTFY_TOKEN}" [[ -n "${NTFY_EMAIL:-}" ]] && set -- "$@" -H "X-Email: ${NTFY_EMAIL}" local t="copyparty"; [[ -n "${NTFY_REGION:-}" ]] && t="${t},${NTFY_REGION}" set -- "$@" -H "X-Tags: ${t}" if [[ "$DRY_RUN" == "1" ]]; then echo "DRY: curl ntfy ($prio): $body" else curl "$@" -d "${body} [${host}]" "$NTFY_URL" >/dev/null 2>&1 || true fi return 0 } # --------------------------------------------------------------------------- # Health wait (same shape as deploy.sh) # --------------------------------------------------------------------------- wait_health() { local deadline; deadline=$(( $(date +%s) + 120 )) while (( $(date +%s) < deadline )); do local status unhealthy status="$(dc ps --format '{{.Service}} {{.Health}}' 2>/dev/null || true)" unhealthy="$(echo "$status" | awk '$2 != "healthy" && $2 != "" {print $1}')" [[ -z "$unhealthy" && -n "$status" ]] && return 0 sleep 5 done return 1 } # --------------------------------------------------------------------------- # Apply an update: pin the tag, pull, recreate, verify; roll back on failure. # --------------------------------------------------------------------------- apply_update() { # <from> <to> [advisory-ids] local from="$1" to="$2" advs="${3:-}" log "Updating copyparty ${from:-?} -> ${to}..." if [[ "$DRY_RUN" == "1" ]]; then echo "DRY: set COPYPARTY_TAG=${to}; docker compose pull copyparty; docker compose up -d" return 0 fi cp -a "$ENV_FILE" "${ENV_FILE}.bak.$(date -u +%Y%m%d%H%M%S)" 2>/dev/null || true env_set COPYPARTY_TAG "$to" if ! dc pull copyparty; then warn "pull failed; restoring COPYPARTY_TAG=${from}." env_set COPYPARTY_TAG "$from" send_notice "copyparty update FAILED" "high" "pull of ${to} failed; staying on ${from}" return 1 fi dc up -d --remove-orphans if wait_health; then log "copyparty ${to} is healthy." local b="updated ${from:-?} -> ${to}"; [[ -n "$advs" ]] && b="${b} (advisory: ${advs})" send_notice "copyparty updated" "default" "$b" return 0 fi warn "copyparty ${to} did not become healthy; rolling back to ${from}." env_set COPYPARTY_TAG "$from" dc up -d --remove-orphans || true send_notice "copyparty update FAILED" "high" "${to} unhealthy; rolled back to ${from}" return 1 } # --------------------------------------------------------------------------- # Decide the target version for the current policy. Sets globals TARGET, # TARGET_IDS and SCAN_NOTE (NOT via stdout -- a $() subshell would lose them). # --------------------------------------------------------------------------- TARGET=""; TARGET_IDS=""; SCAN_NOTE="" resolve_target() { # <current-version> local cur="$1" TARGET=""; TARGET_IDS=""; SCAN_NOTE="" if [[ -n "$TARGET_VERSION" ]]; then TARGET="$(normver "$TARGET_VERSION")"; SCAN_NOTE="target override ${TARGET}"; return 0 fi case "$UPDATE_POLICY" in off) SCAN_NOTE="policy=off (no changes)"; return 0 ;; security) local scan first rest scan="$(advisory_scan "$cur")" first="$(printf '%s' "$scan" | head -n1)" rest="$(printf '%s' "$scan" | sed -n '2p')" case "$first" in VULN*) TARGET="$(printf '%s' "$first" | awk '{print $2}')" TARGET_IDS="$rest" SCAN_NOTE="VULNERABLE (${rest:-?}); patched in ${TARGET:-?}" # if the feed names no patched version, fall forward to latest [[ -z "$TARGET" ]] && TARGET="$(normver "$(latest_version)")" ;; OK) SCAN_NOTE="no advisory matches ${cur}" ;; *) SCAN_NOTE="advisory check unavailable (${first})" ;; esac ;; latest|*) TARGET="$(normver "$(latest_version)")" SCAN_NOTE="policy=latest" ;; esac } # --------------------------------------------------------------------------- # Subcommands # --------------------------------------------------------------------------- preflight() { [[ $EUID -eq 0 ]] || die "Run as root." [[ -f "$STACK_DIR/docker-compose.yml" ]] || die "No stack at $STACK_DIR (set STACK_DIR)." command -v docker >/dev/null 2>&1 || die "docker not found." } do_check() { preflight local cur latest scan cur="$(current_version)" latest="$(normver "$(latest_version)")" log "Running version: ${cur:-unknown} | latest release: ${latest:-unknown} | policy: ${UPDATE_POLICY}" scan="$(advisory_scan "$cur")" local first ids; first="$(printf '%s' "$scan" | head -n1)"; ids="$(printf '%s' "$scan" | sed -n '2p')" local prio="min" note="" case "$first" in VULN*) note="VULNERABLE: ${ids} (patched ${first#VULN })"; prio="high"; warn "$note" ;; OK) note="no known advisory affects ${cur:-?}"; log "$note" ;; *) note="advisory check unavailable (${first})"; warn "$note" ;; esac if [[ -n "$cur" && -n "$latest" ]] && ver_gt "$latest" "$cur"; then log "A newer release is available: ${cur} -> ${latest}" [[ "$prio" == "min" ]] && prio="default" note="${note}; newer release ${latest} available" fi send_notice "copyparty check" "$prio" "${note}" } do_run() { preflight [[ "$DRY_RUN" == "1" ]] || { install -d -m 0755 "$(dirname "$LOG")" 2>/dev/null || true; echo "=== copyparty-update $(date -u +%FT%TZ) ===" >> "$LOG"; } local cur cur="$(current_version)" resolve_target "$cur" log "current=${cur:-?} | ${SCAN_NOTE}" if [[ -z "$TARGET" ]]; then log "No update to apply." return 0 fi if [[ -n "$cur" ]] && ! ver_gt "$TARGET" "$cur"; then log "Target ${TARGET} is not newer than ${cur}; nothing to do." return 0 fi apply_update "$cur" "$TARGET" "$TARGET_IDS" } do_update() { # force: default to latest unless TARGET_VERSION/policy says otherwise preflight local cur cur="$(current_version)" if [[ -z "$TARGET_VERSION" && "$UPDATE_POLICY" == "off" ]]; then UPDATE_POLICY=latest; fi resolve_target "$cur" [[ -n "$TARGET" ]] || die "Could not determine a target version (${SCAN_NOTE})." if [[ -n "$cur" ]] && ! ver_gt "$TARGET" "$cur" && [[ -z "$TARGET_VERSION" ]]; then log "Already on ${cur} (latest ${TARGET}); nothing to do." return 0 fi apply_update "$cur" "$TARGET" "$TARGET_IDS" } # Write /etc/copyparty-update.conf so the scheduled run inherits these. write_conf() { cat > "$COPYPARTY_UPDATE_CONF" <<CONF # Defaults for the scheduled copyparty updater (deployments/copyparty/update.sh). # Environment variables still override these at runtime. STACK_DIR="${STACK_DIR}" UPDATE_POLICY="${UPDATE_POLICY}" VC_FEED="${VC_FEED}" NOTIFY="${NOTIFY}" CONF chmod 644 "$COPYPARTY_UPDATE_CONF" log "Wrote $COPYPARTY_UPDATE_CONF" } do_install() { [[ $EUID -eq 0 ]] || die "Run as root." write_conf case "$(osfam)" in alpine) install -d -m 0755 /etc/periodic/daily cat > /etc/periodic/daily/copyparty-update <<EOF #!/bin/sh exec bash "$SELF" run EOF chmod +x /etc/periodic/daily/copyparty-update command -v rc-update >/dev/null 2>&1 && { rc-update add crond default >/dev/null 2>&1 || true; rc-service crond start >/dev/null 2>&1 || true; } log "Scheduled daily via /etc/periodic/daily/copyparty-update (policy=${UPDATE_POLICY})." ;; *) cat > /etc/systemd/system/copyparty-update.service <<EOF [Unit] Description=copyparty container updater After=docker.service [Service] Type=oneshot ExecStart=/usr/bin/env bash $SELF run EOF cat > /etc/systemd/system/copyparty-update.timer <<EOF [Unit] Description=Daily copyparty update check [Timer] OnCalendar=daily Persistent=true RandomizedDelaySec=1h [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable --now copyparty-update.timer >/dev/null 2>&1 || true log "Scheduled daily via systemd timer copyparty-update.timer (policy=${UPDATE_POLICY})." ;; esac } do_uninstall() { [[ $EUID -eq 0 ]] || die "Run as root." rm -f /etc/periodic/daily/copyparty-update if command -v systemctl >/dev/null 2>&1; then systemctl disable --now copyparty-update.timer >/dev/null 2>&1 || true rm -f /etc/systemd/system/copyparty-update.timer /etc/systemd/system/copyparty-update.service systemctl daemon-reload >/dev/null 2>&1 || true fi log "Removed the scheduled copyparty updater." } case "${1:-check}" in check) do_check ;; run) do_run ;; update) do_update ;; install) do_install ;; uninstall) do_uninstall ;; *) die "Usage: update.sh [check|run|update|install|uninstall]" ;; esac