Files
automations/deployments/copyparty/update.sh
T
57_WolveandClaude Opus 5 947c89931d fix(deployments): stop interpolating .env values into a sed script
The set_env/env_set helper escaped its value with
`esc=${val//\/\\}; esc=${esc//|/\|}; esc=${esc//&/\&}` and then
interpolated it into `sed -i -e "s|^KEY=.*|KEY=${esc}|"`. The escaping does
not do what it looks like. Tested on bash 5.2:

  set_env K 'a&b'   ->  K=aK=seedb        (sed expanded & to the whole match)
  set_env K 'a|b'   ->  sed: unknown option to `s'   (rc!=0, aborts under set -e)

So any value containing & is silently corrupted and any value containing the
s||| delimiter kills the run. That is reachable: headscale writes
OIDC_CLIENT_SECRET through this, pocket-id writes REDIRECT_URL, copyparty
writes DATA_DIR. A generated secret or a URL query string hits both cases.

The copies in headscale and pocket-id were additionally mangled when they
were introduced -- `${val//\/\}` (pattern `\/`, a literal SLASH) and a raw
newline inside `printf '%s=%s\n'`. The mangled form is a no-op rather than a
corrupter, so the practical failure mode was the same as the original.

Replace all of them with an awk rewrite that passes the key and value through
the ENVIRONMENT, so the value is never parsed as part of a script and needs no
escaping at all. ENVIRON and index() are POSIX, so busybox awk handles them.
Output goes to a temp file and is copied back with `cat >`, which preserves the
original mode and owner -- a .env holding secrets stays 0600. If awk fails,
set -e aborts before .env is touched, which `sed -i` could not promise.

Verified against plain, a&b, a|b, a\b, p@ss&w|rd\x, R&D, a URL with a query
string, s/foo/bar/, a trailing space and the empty string; plus the
append-when-key-absent path, the file-does-not-exist path, non-target lines
left intact, no line-count drift, and mode preservation.

copyparty/update.sh and the rebuilt copyparty payload are included because
update.sh is embedded; regenerated with build.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:21:07 -05:00

452 lines
18 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>: update KEY in .env, or append if absent
# The value goes through the ENVIRONMENT, never interpolated into a sed
# script. Interpolating it corrupts any value containing & (sed expands it to
# the whole match) and aborts the run on one containing the s||| delimiter --
# which is reachable for an OIDC secret, a password or a URL query string.
local key="$1" val="$2" tmp
if [[ ! -f "$ENV_FILE" ]]; then
printf '%s=%s
' "$key" "$val" >> "$ENV_FILE"
return 0
fi
tmp="$(mktemp)"
_SE_KEY="$key" _SE_VAL="$val" awk '
BEGIN { k = ENVIRON["_SE_KEY"]; v = ENVIRON["_SE_VAL"]; seen = 0 }
!seen && index($0, k "=") == 1 { print k "=" v; seen = 1; next }
{ print }
END { if (!seen) print k "=" v }
' "$ENV_FILE" > "$tmp"
cat "$tmp" > "$ENV_FILE" # rewrite in place: keeps the original mode/owner
rm -f "$tmp"
}
# 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