Files
57_Wolve c00ca055f2 feat(copyparty): add file-server deployment with SFTP/FTPS + security-notices updater
New deployments/copyparty/: copyparty (copyparty/ac) behind Caddy/LE for the
web UI/WebDAV, plus its own SFTP (password auth) and FTPS listeners published
directly. Ships update.sh, which drives container updates off copyparty's
security-advisories API (api.copyparty.eu/advisories) -- policies latest|security|off.

- Real client IP end-to-end: Caddy XFF/X-Real-IP + copyparty xff-src: lan.
- SFTP host key + self-signed FTPS cert generated/persisted in /cfg; admin
  password generated on first deploy; conf auto-included via the image's % /cfg.
- Firewall opens 80/443 + SFTP/FTPS + passive range (colon form for ports.d).
- Wired into automations.sh, README, .gitignore; cloud-init for fresh VMs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:56:24 -05:00

440 lines
17 KiB
Bash

#!/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() { # <KEY> <value>
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 <msg>
# VULN <max-patched-version>\n<comma-separated advisory ids>
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() { # <title> <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