c00ca055f2
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>
440 lines
17 KiB
Bash
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
|