diff --git a/.gitignore b/.gitignore index 17be119..d7c2b27 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,11 @@ id_ed25519_* # committed (the private key can MITM any client that trusts it). deployments/squid/ssl/ +# copyparty generated config -- cfg/copyparty.conf holds the admin password and +# cfg/ftps.pem the FTPS key; both are generated on the host at deploy time. The +# copyparty.conf.example template stays tracked. +deployments/copyparty/cfg/ + # ── Backups ───────────────────────────────────────────────────────────────── *.tar.gz.age *-backup-*.tar.gz* diff --git a/README.md b/README.md index 585aa8c..cdd32a0 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ deployments// # one folder per stack | [`headscale`](deployments/headscale/) | Self-hosted Tailscale control server, OIDC login. | pocket-id (OIDC) | | [`webfinger`](deployments/webfinger/) | Serves `/.well-known/webfinger` for OIDC discovery; redirects the rest. | pocket-id (issuer) | | [`squid`](deployments/squid/) | SSL-bump caching forward proxy — static-content cache + TLS interception via a local CA. **The exception: a forward proxy, not a Caddy/LE site.** | — | +| [`copyparty`](deployments/copyparty/) | Portable file server — web UI/WebDAV behind Caddy, plus direct **SFTP** + **FTPS**. Ships a security-notices-aware updater. | — | | [`simplex`](deployments/simplex/) | SimpleX SMP + XFTP relay with Tor hidden services + encrypted backups. | globals/age-pubkey.txt | ## Conventions @@ -142,8 +143,8 @@ instance user-data, and the host configures itself on first boot. ## Multi-OS notes The host-provisioning scripts (`setup-host`, `harden-ssh`, `harden-jumphost`, -`sshuser`) and the five Docker stacks (pocket-id, beszel, headscale, webfinger, -squid) run on Alpine, Debian, and Alma. Distro differences live in +`sshuser`) and the six Docker stacks (pocket-id, beszel, headscale, webfinger, +squid, copyparty) run on Alpine, Debian, and Alma. Distro differences live in [`scripts/oslib.sh`](scripts/oslib.sh) — package manager (`apk`/`apt`/`dnf`), init system (OpenRC/systemd), sshd service name, the per-distro `sftp-server` path, hostname, boot hooks, and the sshguard log source/backend. diff --git a/automations.sh b/automations.sh index 893d233..65c5197 100644 --- a/automations.sh +++ b/automations.sh @@ -13,8 +13,9 @@ # # It opens a Gum wizard (auto-installed) that lets you: # • Mode: deploy on THIS host, or build deploy.sh artifacts locally. -# • Pick any deployment (pocket-id, beszel, headscale, webfinger, simplex) -# or any generic script (harden-ssh, harden-jumphost, sshuser). +# • Pick any deployment (pocket-id, beszel, headscale, webfinger, squid, +# copyparty, simplex) or any generic script (harden-ssh, harden-jumphost, +# sshuser, auto-update). # Shared defaults come from globals/ (see globals/README.md). # # Non-interactive: set SKIP_PROMPTS=1 plus the needed vars and pipe the menu @@ -62,7 +63,7 @@ fi . "$ROOT/scripts/lib.sh" load_globals -DEPLOYMENTS=(pocket-id beszel headscale webfinger squid simplex) +DEPLOYMENTS=(pocket-id beszel headscale webfinger squid copyparty simplex) SCRIPTS=(setup-host harden-ssh harden-jumphost sshuser auto-update) # ---------------------------------------------------------------------------- @@ -113,6 +114,12 @@ ask_deployment_vars() { ask BIND_ADDR "Host IP to bind the proxy on (blank = 0.0.0.0)" optional ask CACHE_SIZE_MB "On-disk cache size in MB (blank = 5000)" optional ask CACHE_ONLY_LISTED "Cache ONLY listed domains? (1=yes, blank=boost mode)" optional ;; + copyparty) + ask COPYPARTY_DOMAIN "Public hostname for the web UI (e.g. files.example.com)" + ask ACME_EMAIL "Let's Encrypt email" + ask DATA_DIR "Host data folder shared as the root (blank = /srv/copyparty/data)" optional + ask FTP_NAT "Public IP for passive FTPS via NAT (blank = none)" optional + ask UPDATE_POLICY "Auto-update policy: latest | security | off (blank = latest)" optional ;; simplex) ask DOMAIN "Apex domain (creates smp.DOMAIN, xftp.DOMAIN)" ask ACME_EMAIL "Let's Encrypt email" diff --git a/deployments/copyparty/.env.example b/deployments/copyparty/.env.example new file mode 100644 index 0000000..d78b637 --- /dev/null +++ b/deployments/copyparty/.env.example @@ -0,0 +1,50 @@ +# Copy to .env and fill in. docker compose picks .env up automatically. + +# --- Public hostname (web UI / WebDAV via Caddy) ---------------------------- +# Bare hostname (no scheme) where the copyparty web UI is reached. +COPYPARTY_DOMAIN=files.example.com + +# Email for Let's Encrypt registration / expiry notifications. +ACME_EMAIL=admin@example.com + +# --- Image ----------------------------------------------------------------- +# `ac` is the recommended edition (Pillow + FFmpeg thumbnails/transcode). +# Use ghcr.io/9001/copyparty-ac to pull from GitHub instead of Docker Hub. +COPYPARTY_IMAGE=copyparty/ac +# Pinned version, e.g. 1.20.11. deploy.sh resolves "latest" to the newest +# release on first deploy so the running version is always explicit; update.sh +# bumps this tag when it updates. +COPYPARTY_TAG=latest + +# --- Data + identity -------------------------------------------------------- +# Host directory shared as the data root (mounted at /w). Created by deploy.sh +# and chowned to PUID:PGID. Point this at a big disk / mount for real use. +DATA_DIR=/srv/copyparty/data +PUID=1000 +PGID=1000 + +# --- SFTP / FTPS (direct listeners, bypass Caddy) --------------------------- +# Host interface to publish the direct listeners on (blank = 0.0.0.0 = all). +# A published Docker port bypasses the host firewall, so pin this to a trusted +# interface (e.g. a Tailscale IP) on multi-homed boxes. +BIND_ADDR= +SFTP_PORT=3922 +FTPS_PORT=3990 +# Passive-FTP TCP port range (hyphen form; published as-is in docker-compose). +FTP_PASV_RANGE=12000-12099 +# Public IP for passive FTPS through NAT. Leave blank if clients reach the +# server directly; set it to the server's external IP otherwise. +FTP_NAT= + +# --- Updates (scripts: update.sh) ------------------------------------------- +# How the scheduled updater behaves: +# latest - update to the newest release whenever one exists (default) +# security - update ONLY when the running version has a known advisory +# off - never change the running version (check/notify only) +UPDATE_POLICY=latest +# Advisory feed used by `security` policy + `update.sh check`: +# advisories-panic (critical only) | advisories (important) | advisories-all +VC_FEED=advisories + +# --- Image tags ------------------------------------------------------------- +CADDY_TAG=2-alpine diff --git a/deployments/copyparty/Caddyfile b/deployments/copyparty/Caddyfile new file mode 100644 index 0000000..57a44d8 --- /dev/null +++ b/deployments/copyparty/Caddyfile @@ -0,0 +1,42 @@ +# Caddyfile for the copyparty stack. +# +# Auto-issues a Let's Encrypt cert for $COPYPARTY_DOMAIN and reverse-proxies the +# web UI / WebDAV to copyparty on :3923. SFTP and FTPS do NOT pass through here +# -- they are published straight from the copyparty container. + +{ + email {$ACME_EMAIL} +} + +{$COPYPARTY_DOMAIN} { + encode zstd gzip + + reverse_proxy copyparty:3923 { + # Real client IP: Caddy already appends the genuine remote address to + # X-Forwarded-For (it is the internet edge); copyparty trusts it via + # `xff-src` in copyparty.conf. X-Real-IP is set too for good measure. + header_up X-Real-IP {http.request.remote.host} + + # Stream uploads/downloads (no buffering) and allow long-lived large + # transfers -- copyparty resumable uploads can run for a long time. + # Caddy imposes no request-body size limit by default, so big uploads + # stream straight through. + flush_interval -1 + transport http { + read_timeout 1h + write_timeout 1h + } + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } + + log { + output stdout + format console + } +} diff --git a/deployments/copyparty/README.md b/deployments/copyparty/README.md new file mode 100644 index 0000000..b18a358 --- /dev/null +++ b/deployments/copyparty/README.md @@ -0,0 +1,155 @@ +# copyparty + +[copyparty](https://github.com/9001/copyparty) — a portable file server with +resumable uploads, dedup, WebDAV, **SFTP**, **FTPS**, media indexing and +thumbnails — behind Caddy with automatic Let's Encrypt TLS. Ships with a +**security-notices-aware updater** (`update.sh`). + +The web UI / WebDAV is fronted by Caddy on 443; **SFTP and FTPS are served +directly by copyparty** (Caddy is HTTP-only and not in that path). + +## Required `.env` values + +| Variable | Notes | +|----------|-------| +| `COPYPARTY_DOMAIN` | Public hostname for the web UI (e.g. `files.example.com`). | +| `ACME_EMAIL` | Let's Encrypt registration email. | +| `DATA_DIR` | Host folder shared as the data root (mounted at `/w`). Point at a data disk. | +| `COPYPARTY_IMAGE` / `COPYPARTY_TAG` | Image edition (`copyparty/ac`) + pinned version. | +| `SFTP_PORT` / `FTPS_PORT` / `FTP_PASV_RANGE` | Direct-listener ports (`3922` / `3990` / `12000-12099`). | +| `FTP_NAT` | This host's public IP, for passive FTPS through NAT (blank if direct). | +| `UPDATE_POLICY` | `latest` (default) / `security` / `off` — see [Updates](#updates--security-notices). | + +See [`.env.example`](.env.example) for the rest. Accounts and shares live in +[`copyparty.conf.example`](copyparty.conf.example) (deploy.sh generates the real +`cfg/copyparty.conf` with a random admin password on first run). + +## Deploy + +```bash +./automations.sh # Deploy on this host → deploy: copyparty +``` + +Or build + run the self-contained artifact: + +```bash +./build.sh +scp deploy.sh root@host: +ssh root@host 'bash deploy.sh' +# non-interactive: +# COPYPARTY_DOMAIN=files.example.com ACME_EMAIL=me@example.com SKIP_PROMPTS=1 bash deploy.sh +``` + +Unattended provisioning: [`cloud-init.yml`](cloud-init.yml). On first run deploy.sh +pins `COPYPARTY_TAG` to the newest release, generates `cfg/copyparty.conf` (random +admin password, printed once) and a self-signed `cfg/ftps.pem`, opens the ports, +brings the stack up, and schedules `update.sh`. + +## Accounts & shares + +The default config is **private**: one `admin` account with full access to the +whole data root, nothing public. Edit `cfg/copyparty.conf` (commented examples +included) to add users or open read-only / anonymous-upload shares, then hot-reload: + +```bash +docker compose exec copyparty kill -s USR1 1 +``` + +## Real client IP (behind Caddy) + +For the web UI, copyparty must see the genuine client IP (for its logs and abuse +bans), which means **both** sides are configured: + +- **Caddy** is the internet edge, so it appends the real address to + `X-Forwarded-For` and also sets `X-Real-IP` (see [`Caddyfile`](Caddyfile)). +- **copyparty** trusts that upstream via `xff-src: lan` in + [`copyparty.conf.example`](copyparty.conf.example) — without it copyparty + ignores the header and logs/bans the Docker-network address. + +`lan` (trust private ranges) is safe here because copyparty's HTTP port is never +published — only Caddy can reach it. If copyparty prints an `xff-src` warning at +startup, narrow it to the exact subnet it names. SFTP/FTPS are direct TCP, so +they already see the real client IP (no XFF involved). + +## SFTP & FTPS + +Both authenticate against the same `[accounts]` as the web UI. + +```bash +sftp -P 3922 admin@files.example.com # SFTP (password auth enabled) +lftp -u admin -e 'set ftp:ssl-force true; set ssl:verify-certificate no' \ + ftp://files.example.com:3990 # explicit-TLS FTPS +``` + +- **SFTP host key** is generated on first start and persisted under `/cfg`, so + clients don't get "host key changed" warnings across restarts. +- **FTPS cert** defaults to the self-signed `cfg/ftps.pem` (clients must accept + it). To serve a publicly-trusted cert, reuse Caddy's Let's Encrypt cert: mount + the `caddy-data` volume read-only into the copyparty service and point + copyparty at it, e.g. + + ```yaml + # docker-compose.yml, copyparty service: + volumes: + - caddy-data:/caddy:ro + ``` + ```ini + # cfg/copyparty.conf [global]: + cert: /caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/files.example.com/files.example.com.crt + certkey: /caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/files.example.com/files.example.com.key + ``` + (copyparty re-reads the cert on `kill -s USR1`; refresh after a renewal.) +- **Passive FTPS** needs the `FTP_PASV_RANGE` ports published (they are) **and** + `FTP_NAT` set to the server's public IP when clients connect through NAT. +- A published Docker port bypasses the host `INPUT` firewall, so set `BIND_ADDR` + to pin the listeners to a trusted interface on multi-homed hosts. + +## Updates & security notices + +copyparty doesn't self-update, but it publishes a machine-readable +**security-advisories** feed (the same one its built-in `--vc-url` check uses). +This deployment uses it on **two** levels: + +1. **In-app** — `vc-url` + `vc-age` in `cfg/copyparty.conf` make copyparty itself + check whether the running version has a known advisory and warn in the log / + control panel. (`vc-exit`, opt-in, makes it shut down if vulnerable.) +2. **[`update.sh`](update.sh)** — reads the same feed to *act*, pinning the new + version in `.env` and recreating the container (with health-checked rollback). + +```bash +./update.sh check # report current vs latest + any advisory; change nothing +./update.sh update # update now (TARGET_VERSION=1.20.11 to pin a version) +./update.sh install # (re)schedule the daily run / uninstall to stop +``` + +`UPDATE_POLICY` (in `.env` or `/etc/copyparty-update.conf`) controls the +scheduled `run`: + +| Policy | Behaviour | +|--------|-----------| +| `latest` *(default)* | Update to the newest release whenever one exists. | +| `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). | + +`VC_FEED` selects the feed (`advisories-panic` / `advisories` / `advisories-all`). +Update results reuse the ntfy config at `/etc/ssh-notify.conf` (same as the SSH +login notifier / host auto-update), when present. + +## Files + +| File | Purpose | +|---|---| +| `docker-compose.yml` | caddy + copyparty; publishes SFTP/FTPS/passive ports. | +| `Caddyfile` | TLS + reverse proxy for the web UI/WebDAV (real-IP headers). | +| `copyparty.conf.example` | Config template (globals, security check, SFTP/FTPS, accounts, volumes). | +| `update.sh` | Security-notices-aware container updater. | +| `.env.example` | Stack tunables. | +| `deploy.sh` / `build.sh` | Self-contained installer + archive embedder. | +| `cloud-init.yml` | Fresh-VM bootstrap (harden SSH, then deploy). | + +## Notes + +- No Anubis PoW gate — it would break WebDAV / API / upload clients. +- The web UI/WebDAV go through Caddy; SFTP/FTPS bypass it by design. +- DNS for `COPYPARTY_DOMAIN` must resolve to the host and 80/443 be reachable + before deploy for the LE cert to issue. diff --git a/deployments/copyparty/build.sh b/deployments/copyparty/build.sh new file mode 100644 index 0000000..a694d30 --- /dev/null +++ b/deployments/copyparty/build.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# +# build.sh -- (re)embed the loose deployment files into deploy.sh as a base64 +# tar.gz payload after __ARCHIVE_BELOW__. Idempotent: strips any existing +# payload first. +# +# Run this after editing ANY embedded file below, then re-stage deploy.sh -- +# the deployed stack uses the EMBEDDED copies, not the loose files. + +set -euo pipefail + +DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SCRIPT="$DIR/deploy.sh" +MARKER="__ARCHIVE_BELOW__" + +# README, cloud-init and the build/deploy scripts are NOT embedded. The generated +# cfg/ (copyparty.conf + ftps.pem) is produced on the host at deploy time. +FILES=( + docker-compose.yml + Caddyfile + copyparty.conf.example + .env.example + update.sh +) + +[[ -f "$SCRIPT" ]] || { echo "deploy.sh not found at $SCRIPT" >&2; exit 1; } +for f in "${FILES[@]}"; do + [[ -f "$DIR/$f" ]] || { echo "Missing $DIR/$f" >&2; exit 1; } +done + +PAYLOAD=$(tar -czf - -C "$DIR" "${FILES[@]}" | base64) + +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +sed "/^${MARKER}\$/,\$d" "$SCRIPT" > "$TMP" +{ + echo "$MARKER" + echo "$PAYLOAD" +} >> "$TMP" + +mv "$TMP" "$SCRIPT" +chmod +x "$SCRIPT" +trap - EXIT + +size=$(wc -c < "$SCRIPT") +echo "Built $SCRIPT (${size} bytes)" diff --git a/deployments/copyparty/cloud-init.yml b/deployments/copyparty/cloud-init.yml new file mode 100644 index 0000000..c914e5e --- /dev/null +++ b/deployments/copyparty/cloud-init.yml @@ -0,0 +1,41 @@ +#cloud-config +# +# copyparty (file server) — harden SSH, then deploy, on a fresh host. +# +# Fill in REPO_URL and the values in the runcmd block, then paste this as the +# instance user-data. DNS for COPYPARTY_DOMAIN must point at this host and ports +# 80/443 must be reachable before boot, or the Let's Encrypt cert request fails. +# SFTP/FTPS (3922/3990 + the passive range) are opened too. + +packages: + - git + +runcmd: + - hostnamectl set-hostname copyparty || true + - | + set -e + REPO_URL=https://git.anomalous.dev/57_Wolve/automations.git + REPO_BRANCH=main + HARDEN_SSH=1 # harden SSH on this fresh VM (set 0 to skip) + SSH_PORT=22 + ALLOWED_IP= # optional: whitelist your client IP in sshguard + git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" /opt/automations + cd /opt/automations + + # Harden SSH first (PQ KEX, key-only auth, sshguard + deny-by-default + # firewall). The firewall it installs is what deploy.sh registers the + # copyparty ports with. + if [ "$HARDEN_SSH" = 1 ]; then + SSH_PORT="$SSH_PORT" ALLOWED_IP="$ALLOWED_IP" SKIP_PROMPTS=1 FORCE=1 \ + bash scripts/harden-ssh.sh + fi + + # Deploy copyparty. DATA_DIR is the shared folder (point it at a data disk); + # FTP_NAT should be this host's public IP if you want passive FTPS via NAT. + COPYPARTY_DOMAIN=files.example.com \ + ACME_EMAIL=admin@example.com \ + DATA_DIR=/srv/copyparty/data \ + UPDATE_POLICY=latest \ + FTP_NAT= \ + SKIP_PROMPTS=1 \ + bash deployments/copyparty/deploy.sh diff --git a/deployments/copyparty/copyparty.conf.example b/deployments/copyparty/copyparty.conf.example new file mode 100644 index 0000000..e9adf17 --- /dev/null +++ b/deployments/copyparty/copyparty.conf.example @@ -0,0 +1,62 @@ +# copyparty configuration (TEMPLATE). +# +# deploy.sh copies this to cfg/copyparty.conf on first deploy, replacing +# __ADMIN_PW__ with a generated password (and uncommenting ftp-nat if FTP_NAT is +# set). The image auto-includes every *.conf in /cfg (its baked-in config ends +# with `% /cfg`), so this file is loaded automatically -- no -c flag needed. +# +# Reload after editing, no restart: docker compose exec copyparty kill -s USR1 1 +# Permission letters: r=read w=write/upload m=move/rename d=delete a=admin g=get-only + +[global] + # -- Behind Caddy (real client IP) -- + # Trust the reverse proxy's X-Forwarded-For. Only Caddy can reach :3923 (it is + # not published), so `lan` (trust private nets) is safe and robust; narrow it + # to the exact docker subnet copyparty names in a startup warning if you like. + xff-src: lan + + # -- Security notices (copyparty's built-in vulnerability check) -- + # Periodically checks whether THIS version has a known advisory and warns in + # the log / control panel. The companion update.sh acts on the same feed. + vc-url: https://api.copyparty.eu/advisories + vc-age: 3 + # vc-exit # opt-in: shut down if running a vulnerable version -- only + # enable together with the security updater, or it will loop. + + # -- Indexing / media -- + e2dsa # up2k database + filesystem scan (dedup, search) + e2ts # parse media tags for the indexer + + # -- SFTP + FTPS (direct listeners; NOT proxied by Caddy) -- + sftp: 3922 # SFTP server + sftp-pw # allow password auth (not just ssh keys) + ftps: 3990 # explicit-TLS FTPS (plaintext `ftp` intentionally omitted) + ftp-pr: 12000-12099 # passive TCP port range (also published in docker-compose.yml) + cert: /cfg/ftps.pem # self-signed by deploy.sh; swap in Caddy's LE cert to taste + # ftp-nat: 203.0.113.10 # set to the server's public IP for passive FTPS via NAT + +[accounts] + # deploy.sh replaces __ADMIN_PW__ with a generated password on first run. + admin: __ADMIN_PW__ + +# Default volume: the whole data root, private to admin. Open it up below. +[/] + /w + accs: + rwmda: admin + +# --- examples (uncomment + adjust) ----------------------------------------- +# Public read-only share of /w/pub for anyone (no login): +# [/pub] +# /w/pub +# accs: +# r: * +# +# Anonymous write-only dropbox (uploads, no listing or download): +# [/inbox] +# /w/inbox +# accs: +# w: * +# +# Add another account: put `bob: s3cret` under [accounts] above, then grant it +# access in a volume, e.g. `rw: bob` inside that volume's accs: block. diff --git a/deployments/copyparty/deploy.sh b/deployments/copyparty/deploy.sh new file mode 100644 index 0000000..f6372c1 --- /dev/null +++ b/deployments/copyparty/deploy.sh @@ -0,0 +1,583 @@ +#!/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 < caddy:443 --> copyparty:3923 (HTTP, internal) +# SFTP / FTPS --> copyparty:{3922,3990,passive} (direct, NOT via caddy) +# +# Caddy terminates TLS and reverse-proxies the web/WebDAV UI. copyparty's SFTP +# and FTPS listeners are their own TCP services, so they are published straight +# from the copyparty container -- Caddy is HTTP-only and not in that path. +# +# The image's baked-in config does `chdir /w` + `% /cfg` (include every *.conf in +# /cfg), so our ./cfg/copyparty.conf is picked up automatically -- no `command:` +# override needed. XDG_CONFIG_HOME=/cfg in the image, so generated SSH host keys +# live under /cfg and persist across restarts (the bind-mount is read-write). + +name: copyparty + +volumes: + caddy-data: + caddy-config: + +services: + # --------------------------------------------------------------------------- + # Caddy -- TLS termination + reverse proxy for the web/WebDAV UI. The only + # service on 80/443. Auto-issues a Let's Encrypt cert for ${COPYPARTY_DOMAIN}. + # --------------------------------------------------------------------------- + caddy: + image: caddy:${CADDY_TAG:-2-alpine} + container_name: caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + environment: + COPYPARTY_DOMAIN: "${COPYPARTY_DOMAIN}" + ACME_EMAIL: "${ACME_EMAIL}" + depends_on: + - copyparty + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:2019/config/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + # --------------------------------------------------------------------------- + # copyparty -- the file server. HTTP 3923 stays internal (Caddy fronts it); + # only the direct SFTP/FTPS listeners are published. Runs as PUID:PGID so + # files on the host bind-mount have sane ownership. + # + # A published Docker port BYPASSES the host INPUT firewall, so set BIND_ADDR + # to pin these listeners to a trusted interface if the box is multi-homed. + # --------------------------------------------------------------------------- + copyparty: + image: ${COPYPARTY_IMAGE:-copyparty/ac}:${COPYPARTY_TAG:-latest} + container_name: copyparty + restart: unless-stopped + user: "${PUID:-1000}:${PGID:-1000}" + ports: + - "${BIND_ADDR:-0.0.0.0}:${SFTP_PORT:-3922}:3922" # SFTP + - "${BIND_ADDR:-0.0.0.0}:${FTPS_PORT:-3990}:3990" # FTPS (explicit TLS) + - "${BIND_ADDR:-0.0.0.0}:${FTP_PASV_RANGE:-12000-12099}:${FTP_PASV_RANGE:-12000-12099}" # FTPS passive + volumes: + - ${DATA_DIR:-/srv/copyparty/data}:/w + - ./cfg:/cfg + healthcheck: + # copyparty's own python -- no extra deps in the image. A successful TCP + # connect to the HTTP listener means the server is up. + test: ["CMD", "python3", "-c", "import socket; socket.create_connection(('127.0.0.1',3923),3).close()"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s diff --git a/deployments/copyparty/update.sh b/deployments/copyparty/update.sh new file mode 100644 index 0000000..f17eb56 --- /dev/null +++ b/deployments/copyparty/update.sh @@ -0,0 +1,439 @@ +#!/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