Files
automations/deployments/copyparty
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
..

copyparty

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.

See .env.example for the rest. Accounts and shares live in copyparty.conf.example (deploy.sh generates the real cfg/copyparty.conf with a random admin password on first run).

Deploy

./automations.sh        # Deploy on this host → deploy: copyparty

Or build + run the self-contained artifact:

./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. 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:

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).
  • copyparty trusts that upstream via xff-src: lan in 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.

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.

    # docker-compose.yml, copyparty service:
    volumes:
      - caddy-data:/caddy:ro
    
    # 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-appvc-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 — reads the same feed to act, pinning the new version in .env and recreating the container (with health-checked rollback).
./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.