#!/bin/sh # tofa Docker installer — https://get.tofa.tv/docker # # curl -fsSL https://get.tofa.tv/docker | sh # # Writes a docker-compose.yml for tofa Simple mode (single container, embedded # PostgreSQL — no database or secrets to manage), asks where your media lives, # starts it, and points you at http://localhost:33333 to claim it. Works on # Linux and macOS/Windows Docker Desktop. # # Readable on purpose — you're piping it to a shell, so read it first. set -eu IMAGE="ghcr.io/tofatv/tofa:beta" PORT=33333 # Pinned sha256 of the `tofa` management shim (scripts/get/manage.sh, served as # https://get.tofa.tv/manage). This hash ships inside the script you are # reading (the trust root of a curl|sh install) and both files update together # in one commit; CI (scripts/verify.sh) fails if they drift apart. A shim that # doesn't match is never installed. MANAGE_SHA256="e43f0b27406f2cde3241c230f166a11ba069ff00f942523ea82c2c950412f29c" # sha256 of a file; prints nothing when no hashing tool is available. sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' fi } say() { printf '\033[1;36m==>\033[0m %s\n' "$1"; } warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$1" >&2; } die() { printf '\033[1;31merror:\033[0m %s\n' "$1" >&2; exit 1; } # A setup token — a random secret baked into the container as SETUP_ACCESS_TOKEN. # It lets you claim the server from ANY device (not just localhost/LAN) via a # tokened setup URL; without it the server only accepts a claim from its own # local network. Only useful until the server is claimed. gen_token() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 24 else LC_ALL=C od -An -tx1 -N24 /dev/urandom | tr -d ' \n' fi } # Every non-loopback IPv4 address on this machine (all interfaces), deduped, so # the claim output can offer a real URL for each. Container/virtual bridges and # the docker0 range are skipped, and output is restricted to real dotted-quad # IPv4 (no malformed bracket-less IPv6). Empty output is fine — the caller falls # back to the SSH tip. list_ipv4s() { { if [ "$(uname -s)" = "Darwin" ]; then for _if in $(ifconfig -l 2>/dev/null); do ipconfig getifaddr "$_if" 2>/dev/null || true done elif command -v ip >/dev/null 2>&1; then ip -4 -o addr show scope global 2>/dev/null \ | awk '$2 !~ /^(docker|br-|veth|virbr|tofa|cni|flannel|kube|zt|tailscale)/ {print $4}' \ | cut -d/ -f1 elif command -v hostname >/dev/null 2>&1; then hostname -I 2>/dev/null | tr ' ' '\n' fi } 2>/dev/null \ | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$' \ | grep -vE '^(127\.|169\.254\.|172\.17\.|0\.0\.0\.0)' \ | awk '!seen[$0]++' } # HTTP liveness probe with a curl-or-wget fallback. Return 2 when neither exists. http_ok() { if command -v curl >/dev/null 2>&1; then curl -fsS "$1" >/dev/null 2>&1 elif command -v wget >/dev/null 2>&1; then wget -q -O /dev/null "$1" else return 2 fi } have_http() { command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; } # Install the `tofa` management shim on PATH so this server updates with one # command (`tofa update`) instead of the compose incantation. Best-effort: any # failure here only prints the manual fallback — it never fails the install. # Writes a deterministic hint (mode + compose dir) the shim reads back. install_manage_shim() { _shim_url="https://get.tofa.tv/manage" _manual="(cd \"$DIR\" && $DC pull && $DC up -d)" have_http || { warn "skipping the 'tofa' helper (no curl/wget). Update with: $_manual"; return 0; } # A bin dir on PATH we can write to: /usr/local/bin if writable, else via the # same sudo we already used for Docker, else the user's ~/.local/bin. if [ -w /usr/local/bin ]; then _bindir=/usr/local/bin; _shsudo="" elif [ -n "$DK" ]; then _bindir=/usr/local/bin; _shsudo="sudo" else _bindir="$HOME/.local/bin"; _shsudo=""; fi _tmp="$(mktemp)" || return 0 if command -v curl >/dev/null 2>&1; then curl -fsSL "$_shim_url" -o "$_tmp" 2>/dev/null || { rm -f "$_tmp"; warn "couldn't fetch the 'tofa' helper. Update with: $_manual"; return 0; } else wget -q -O "$_tmp" "$_shim_url" 2>/dev/null || { rm -f "$_tmp"; warn "couldn't fetch the 'tofa' helper. Update with: $_manual"; return 0; } fi # Integrity gate: the shim later escalates via sudo (docker group repair), # so it must match the hash pinned at the top of this script. if [ "$(sha256_of "$_tmp")" != "$MANAGE_SHA256" ]; then rm -f "$_tmp" warn "the 'tofa' helper failed its integrity check — skipping it. Update with: $_manual" return 0 fi $_shsudo mkdir -p "$_bindir" 2>/dev/null || true if $_shsudo cp "$_tmp" "$_bindir/tofa" 2>/dev/null; then $_shsudo chmod 0755 "$_bindir/tofa" 2>/dev/null || true else rm -f "$_tmp"; warn "couldn't install the 'tofa' helper to $_bindir. Update with: $_manual"; return 0 fi rm -f "$_tmp" _confdir="${XDG_CONFIG_HOME:-$HOME/.config}/tofa" mkdir -p "$_confdir" 2>/dev/null || true { printf 'TOFA_MANAGE_MODE=docker\n'; printf 'TOFA_COMPOSE_DIR=%s\n' "$DIR"; } \ > "$_confdir/manage.env" 2>/dev/null || true case ":$PATH:" in *":$_bindir:"*) say "Installed the 'tofa' helper — update anytime with: tofa update" ;; *) warn "Installed 'tofa' to $_bindir (not on your PATH — add it). Until then, update with: $_manual" ;; esac } # Poll health up to $1 seconds. 0=up, 1=timeout, 2=no HTTP client to check with. wait_health() { have_http || return 2 _i=0 until http_ok "http://localhost:${PORT}/api/v1/health"; do _i=$((_i + 1)) [ "$_i" -gt "$1" ] && return 1 sleep 1 done return 0 } # Print every way to reach the claim page. $1 = setup token (may be empty). print_claim() { _q="" [ -n "${1:-}" ] && _q="?setup_token=$1" _ssh_user="${SUDO_USER:-$(id -un 2>/dev/null || echo user)}" if [ "$(uname -s)" = "Darwin" ]; then _ssh_host="$(scutil --get LocalHostName 2>/dev/null || true).local" [ "$_ssh_host" = ".local" ] && _ssh_host="$(hostname 2>/dev/null || echo this-host)" else _ssh_host="$(hostname 2>/dev/null || echo this-host)" fi printf '\n\033[1;32mtofa is running.\033[0m Sign in to claim it to your account:\n' printf ' • On this machine: \033[1;36mhttp://localhost:%s/setup%s\033[0m\n' "$PORT" "$_q" _ips="$(list_ipv4s)" if [ -n "$_ips" ]; then printf ' • From another device on your network (try each until one loads):\n' printf '%s\n' "$_ips" | while IFS= read -r _ip; do [ -n "$_ip" ] && printf ' \033[1;36mhttp://%s:%s/setup%s\033[0m\n' "$_ip" "$PORT" "$_q" done fi printf ' • Headless / over SSH — forward the port from your own computer, then open the localhost URL there:\n' printf ' \033[1;36mssh -L %s:localhost:%s %s@%s\033[0m\n' "$PORT" "$PORT" "$_ssh_user" "$_ssh_host" firewall_hint "$PORT" # Same disclosure as the native installer: the beacon fires before anyone has # seen a screen they could consent on, so this is the earliest honest place. printf '\n A new install tells us it started and that it is running, with the platform\n' printf ' and version only. No IP, no paths, nothing about your library. Details:\n' printf ' \033[1;36mhttps://docs.tofa.tv/privacy-and-network.html\033[0m — opt out with TOFA_INSTALL_PING=0\n' if [ "$(uname -s)" = "Darwin" ]; then command -v open >/dev/null 2>&1 && open "http://localhost:${PORT}/setup${_q}" >/dev/null 2>&1 || true elif [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && command -v xdg-open >/dev/null 2>&1; then xdg-open "http://localhost:${PORT}/setup${_q}" >/dev/null 2>&1 || true fi } # If a host firewall is active it will block reaching tofa from other devices — # common on RHEL/Fedora (firewalld on by default) and Ubuntu (ufw). Detect it # read-only (no root needed) and print the exact command to open the port. firewall_hint() { _p="$1" if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then warn "firewalld is active — to reach tofa from other devices, open port ${_p}:" printf ' \033[1;36msudo firewall-cmd --permanent --add-port=%s/tcp && sudo firewall-cmd --reload\033[0m\n' "$_p" >&2 elif command -v ufw >/dev/null 2>&1 && grep -qi '^ENABLED=yes' /etc/ufw/ufw.conf 2>/dev/null; then warn "ufw is active — to reach tofa from other devices, open port ${_p}:" printf ' \033[1;36msudo ufw allow %s/tcp\033[0m\n' "$_p" >&2 fi } # --- prerequisites ----------------------------------------------------------- command -v docker >/dev/null 2>&1 || die "Docker isn't installed. Get it at https://docs.docker.com/get-docker/" # Compose flavor — a CLI-only check; needs neither the daemon nor privileges. if docker compose version >/dev/null 2>&1; then COMPOSE="docker compose" elif command -v docker-compose >/dev/null 2>&1; then COMPOSE="docker-compose" else die "Docker Compose isn't available. Install Docker Desktop or the compose plugin." fi # Daemon reachability + permissions. Distinguish the two real failure modes so we # don't (a) prompt for a sudo password when the daemon is simply DOWN, or (b) tell # a permission-blocked user the daemon is down. Only a genuine permission error on # Linux escalates to sudo; a down daemon gets the "start Docker" message directly. DK="" if docker info >/dev/null 2>&1; then : else _derr="$(docker info 2>&1 >/dev/null || true)" case "$_derr" in *"permission denied"*) if [ "$(uname -s)" != "Darwin" ] && command -v sudo >/dev/null 2>&1 \ && sudo -p 'Docker needs your password (sudo): ' docker info >/dev/null 2>&1; then warn "Using sudo for Docker (your user isn't in the 'docker' group yet)." warn "To drop sudo later: sudo usermod -aG docker \"\$USER\" then log out and back in." DK="sudo" else die "no permission to use Docker. Add yourself to the 'docker' group (sudo usermod -aG docker \"\$USER\", then log out and back in), or re-run with sudo." fi ;; *) die "can't reach the Docker daemon — is Docker running? Start it (Docker Desktop, or 'sudo systemctl start docker') and re-run." ;; esac fi DC="$COMPOSE" [ -n "$DK" ] && DC="$DK $COMPOSE" # Docker Desktop / colima / Rancher / WSL2 run the engine inside a VM: `uname` # reports Linux, but host networking would bind inside the VM (unreachable from # the host). Detect those and publish a port instead; keep host networking for a # native Linux daemon (real LAN IP, UPnP, direct local playback). DESKTOP=0 _engine="$($DK docker info --format '{{.OperatingSystem}}::{{.Name}}' 2>/dev/null || true)" case "$_engine" in *"Docker Desktop"*|*"::docker-desktop"*|*"::colima"*|*"::rancher-desktop"*) DESKTOP=1 ;; esac # --- where to install -------------------------------------------------------- DIR="${TOFA_DIR:-$(pwd)/tofa}" # Re-running the installer when tofa is already set up here is an UPDATE, not a # reinstall: pull the latest image and restart, preserving the existing compose # file. We must NOT rewrite it — that would regenerate the setup token (breaking # any claim link) and re-prompt for the media path. Only a first run scaffolds a # new compose file (below). if [ -f "$DIR/docker-compose.yml" ]; then say "Existing install found in $DIR — updating to the latest beta" cd "$DIR" $DC pull || die "docker pull failed — check your connection and try again." $DC up -d || die "docker compose up failed. Check the logs, then re-run." if wait_health 180; then : else rc=$? if [ "$rc" = 2 ]; then warn "Couldn't verify health (no curl/wget); if it's up, open http://localhost:${PORT}" else warn "tofa is taking longer than usual to answer on :${PORT} after the update — it may still be restarting." fi fi say "Updated to the latest beta." install_manage_shim TOKEN="$(grep -m1 SETUP_ACCESS_TOKEN docker-compose.yml 2>/dev/null | sed -E 's/.*"(.*)".*/\1/')" print_claim "${TOKEN:-}" exit 0 fi # --- media path (prompt via the terminal, not the pipe) ---------------------- MEDIA="" # Prompt only with a genuinely usable controlling terminal. `curl | sh` has no # stdin, and `[ -r /dev/tty ]` can pass even when opening /dev/tty fails (ENXIO), # which under `set -e` would abort the whole install — so probe openability. if { true > /dev/tty; } 2>/dev/null; then printf 'Path to your media folder (movies/shows), or leave blank to set it later: ' > /dev/tty IFS= read -r MEDIA < /dev/tty || MEDIA="" fi if [ -n "$MEDIA" ] && [ ! -d "$MEDIA" ]; then warn "'$MEDIA' doesn't exist yet — create it, or edit ${DIR}/docker-compose.yml later. Writing it in anyway." fi # --- write the compose file -------------------------------------------------- # Media path goes in via long-form volume syntax with a single-quoted source so # spaces, and Windows drive letters like C:\… (a colon that would wreck the # short `src:dst` form), survive intact. Blank → no media mount at all (add it # later) so `up` always succeeds instead of bind-mounting a bogus path. TOKEN="$(gen_token)" say "Writing ${DIR}/docker-compose.yml" mkdir -p "$DIR" MEDIA_ESC="" [ -n "$MEDIA" ] && MEDIA_ESC="$(printf '%s' "$MEDIA" | sed "s/'/''/g")" { printf 'services:\n' printf ' tofa:\n' printf ' image: %s\n' "$IMAGE" printf ' container_name: tofa\n' printf ' init: true\n' printf ' restart: unless-stopped\n' if [ "$(uname -s)" = "Linux" ] && [ "$DESKTOP" = 0 ]; then printf ' network_mode: host\n' else printf ' ports:\n - "%s:%s"\n' "$PORT" "$PORT" fi printf ' environment:\n' printf ' SETUP_ACCESS_TOKEN: "%s"\n' "$TOKEN" printf ' healthcheck:\n' printf ' test: ["CMD", "curl", "-f", "http://localhost:%s/api/v1/health"]\n' "$PORT" printf ' interval: 30s\n' printf ' timeout: 5s\n' printf ' retries: 3\n' printf ' start_period: 30s\n' printf ' volumes:\n' printf ' - tofa-data:/data\n' if [ -n "$MEDIA_ESC" ]; then printf " - type: bind\n source: '%s'\n target: /media\n bind:\n create_host_path: true\n" "$MEDIA_ESC" fi printf '\nvolumes:\n tofa-data:\n' } > "$DIR/docker-compose.yml" # --- pull + start ------------------------------------------------------------ say "Pulling ${IMAGE} and starting tofa" cd "$DIR" $DC pull || die "docker pull failed — check your connection and try again." $DC up -d || die "docker compose up failed. Check: (cd \"$DIR\" && $DC logs -f)" # --- wait for health, then point at the claim page --------------------------- say "Waiting for tofa to come up (first start sets up its database, ~1 min)…" if wait_health 180; then print_claim "$TOKEN" else rc=$? if [ "$rc" = 2 ]; then warn "Couldn't verify health automatically (no curl/wget on this machine)." else warn "tofa is taking longer than usual to answer on :${PORT}; it may still be starting." warn "If it doesn't come up, check: (cd \"$DIR\" && $DC logs -f)" fi print_claim "$TOKEN" fi install_manage_shim printf 'Manage it later: \033[1;36mtofa update\033[0m · \033[1;36mtofa logs\033[0m · \033[1;36mtofa status\033[0m (or from %s: %s down / %s logs -f).\n' "$DIR" "$DC" "$DC"