#!/bin/sh # tofa native installer: https://get.tofa.tv # # curl -fsSL https://get.tofa.tv | sh # # Downloads the latest beta build for your OS/arch, installs it as a background # service, and points you at http://localhost:33333 to claim the server to your # tofa account. Supports Linux (x86_64 / arm64, systemd) and macOS (Apple # Silicon). For Docker, Windows, or Intel Macs, see: # https://docs.tofa.tv/run-your-server.html # # This is readable on purpose: you're piping it to a shell, so read it first. set -eu # Downloads come from https://dl.tofa.tv, whose key layout mirrors the release # tags one-for-one (beta/…, v/…), so the path after the host is the # same as it was on the GitHub release it was served from. DIST_BASE="https://dl.tofa.tv/beta" PORT=33333 # Install a specific version instead of the rolling beta (support/downgrade # tool): curl -fsSL https://get.tofa.tv | TOFA_VERSION=0.9.7 sh # Frozen releases publish versioned asset names under a v prefix. VERSION_TAG="beta" ASSET_PREFIX="tofa-beta" if [ -n "${TOFA_VERSION:-}" ]; then VERSION_TAG="v${TOFA_VERSION}" ASSET_PREFIX="tofa-${TOFA_VERSION}" DIST_BASE="https://dl.tofa.tv/${VERSION_TAG}" fi # Pinned sha256 of the `tofa` management shim (scripts/get/manage.sh, served as # https://get.tofa.tv/manage). The shim is later run with sudo, so it gets the # same integrity bar as the tarball: 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. MANAGE_SHA256="4511f73a6ce4d05df10f3e4efb2e030e302502546a4c3ae651a2f7c5cbf78f38" 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 (SETUP_ACCESS_TOKEN) lets you claim the server from ANY device # via a tokened setup URL; without it the server only accepts a claim from its # own local network. We wire it in for headless (systemd) installs, which are # the ones most often reached from another machine. 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 the box (all interfaces), most-likely-LAN # first, deduped — so the claim output can offer a real URL for each instead of # a fake placeholder. Container/virtual bridges are skipped (not reachable off # the box). Empty output is fine; the caller falls back to the SSH tip. list_ipv4s() { { if 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]++' } # 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)}" _ssh_host="$(hostname 2>/dev/null || echo this-server)" 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" # Disclosed here as well as in the docs and the privacy policy: the beacon # fires before anyone has seen a screen they could consent on, so the moment # of install is the earliest place we can actually tell you. printf '\n A new install tells us it started and that it is running, with the platform\n' printf ' and version. If it fails to start, it also says where and why, with names,\n' printf ' paths and addresses removed. No IP, 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 [ -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 } # --- privilege helper (installer touches /opt, systemd, /Applications) ------- SUDO="" if [ "$(id -u)" -ne 0 ]; then if command -v sudo >/dev/null 2>&1; then SUDO="sudo" else die "need root to install a system service. Re-run as root, or install sudo." fi fi command -v curl >/dev/null 2>&1 || die "curl is required to download tofa." command -v tar >/dev/null 2>&1 || die "tar is required to unpack tofa." # 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 } # Install the `tofa` management shim on PATH so this server updates with one # command (`tofa update`). Best-effort — a failure never fails the install, # but a shim that fails its integrity check is never installed (it runs with # sudo later, so it must match the hash pinned at the top of this script). # $1 = deployment mode (systemd|launchd) written into the shim's hint file. install_manage_shim() { _mode="$1" _tmp="$(mktemp)" || return 0 if curl -fsSL "https://get.tofa.tv/manage" -o "$_tmp" 2>/dev/null \ && [ "$(sha256_of "$_tmp")" = "$MANAGE_SHA256" ]; then $SUDO mkdir -p /usr/local/bin /etc/tofa 2>/dev/null || true if $SUDO cp "$_tmp" /usr/local/bin/tofa 2>/dev/null; then $SUDO chmod 0755 /usr/local/bin/tofa 2>/dev/null || true printf 'TOFA_MANAGE_MODE=%s\nTOFA_SERVICE=tofa.service\n' "$_mode" \ | $SUDO tee /etc/tofa/manage.env >/dev/null 2>&1 || true say "Installed the 'tofa' helper. Update anytime with: tofa update" else warn "couldn't install the 'tofa' helper (update later by re-running this installer)." fi else warn "couldn't fetch or verify the 'tofa' helper (update later by re-running this installer)." fi rm -f "$_tmp" } # Shared-library problems of one bundled binary, one per line: # missing or glibc # Prints nothing when it can't tell: only a definite problem may stop an install. # Without ldd, running the binary reports the first missing library, so callers # only rely on that fallback for binaries where --version is safe. binary_problems() { if command -v ldd >/dev/null 2>&1; then LC_ALL=C ldd "$1" 2>&1 | awk ' /=> not found/ { print "missing " $1 } /version .GLIBC_[0-9.]+. not found/ { if (match($0, /GLIBC_[0-9.]+/)) print "glibc " substr($0, RSTART + 6, RLENGTH - 6) }' else LC_ALL=C "$1" --version 2>&1 \ | sed -n 's/.*error while loading shared libraries: \([^:]*\):.*/missing \1/p' fi } # Install command for what is missing, on package managers we recognise. Prints # nothing when we don't recognise one or don't know one of the packages. # $1 = space-separated sonames, $2 = 1 if time zone data is missing. missing_packages_hint() { if command -v apt-get >/dev/null 2>&1; then _pm=apt-get elif command -v dnf >/dev/null 2>&1; then _pm=dnf elif command -v yum >/dev/null 2>&1; then _pm=yum elif command -v zypper >/dev/null 2>&1; then _pm=zypper elif command -v pacman >/dev/null 2>&1; then _pm=pacman else return 0 fi _pkgs="" for _lib in $1; do case "$_pm:$_lib" in apt-get:libreadline.so.8) _pkg=libreadline8 ;; apt-get:libz.so.1) _pkg=zlib1g ;; apt-get:libncursesw.so.6) _pkg=libncursesw6 ;; apt-get:libtinfo.so.6) _pkg=libtinfo6 ;; dnf:libreadline.so.8|yum:libreadline.so.8) _pkg=readline ;; dnf:libz.so.1|yum:libz.so.1) _pkg=zlib ;; dnf:libncursesw.so.6|yum:libncursesw.so.6|dnf:libtinfo.so.6|yum:libtinfo.so.6) _pkg=ncurses-libs ;; zypper:libreadline.so.8) _pkg=libreadline8 ;; zypper:libz.so.1) _pkg=libz1 ;; zypper:libncursesw.so.6|zypper:libtinfo.so.6) _pkg=libncurses6 ;; pacman:libreadline.so.8) _pkg=readline ;; pacman:libz.so.1) _pkg=zlib ;; pacman:libncursesw.so.6|pacman:libtinfo.so.6) _pkg=ncurses ;; *) return 0 ;; esac case " $_pkgs " in *" $_pkg "*) ;; *) _pkgs="$_pkgs $_pkg" ;; esac done if [ "$2" = 1 ]; then case "$_pm" in zypper) _pkgs="$_pkgs timezone" ;; *) _pkgs="$_pkgs tzdata" ;; esac fi _pre="" [ -n "$SUDO" ] && _pre="sudo " case "$_pm" in apt-get) printf '%sapt-get update && %sapt-get install -y%s\n' "$_pre" "$_pre" "$_pkgs" ;; dnf|yum) printf '%s%s install -y%s\n' "$_pre" "$_pm" "$_pkgs" ;; zypper) printf '%szypper install -y%s\n' "$_pre" "$_pkgs" ;; pacman) printf '%spacman -S --needed --noconfirm%s\n' "$_pre" "$_pkgs" ;; esac } # Check what the bundled binaries need before touching the system, so a machine # that can't run this build keeps whatever it has. $1 = unpacked archive dir. check_linux_deps() { _problems="" _pgdir="" # A server pointed at its own database never runs the bundled one. if [ -z "$(env_setting DATABASE_URL)" ]; then for _d in "$1"/lib/tofa-postgresql/*; do [ -d "$_d/bin" ] && _pgdir="$_d" done fi # The binaries the server actually runs. for _bin in "$_pgdir/bin/postgres" "$_pgdir/bin/psql" "$_pgdir/bin/pg_ctl" \ "$_pgdir/bin/initdb" "$_pgdir/bin/pg_dump" "$_pgdir/bin/pg_controldata"; do [ -f "$_bin" ] && _problems="$_problems $(binary_problems "$_bin")" done if command -v ldd >/dev/null 2>&1; then for _bin in "$1/tofa" "$1/tofa-updater" "$1/lib/tofa-transcoder" "$1/lib/tofa-probe"; do [ -f "$_bin" ] && _problems="$_problems $(binary_problems "$_bin")" done fi _libs="$(printf '%s\n' "$_problems" | sed -n 's/^missing //p' | sort -u | tr '\n' ' ')" _glibc="$(printf '%s\n' "$_problems" | sed -n 's/^glibc //p' | sort -V 2>/dev/null | tail -1)" # The bundled database reads time zones from the system unless it ships its # own, and refuses to start without them. It records the path it was built with. _tz_missing=0 _tz_dir="" if [ -n "$_pgdir" ] && [ ! -d "$_pgdir/share/timezone" ]; then if [ -x "$_pgdir/bin/pg_config" ]; then _tz_dir="$(LC_ALL=C "$_pgdir/bin/pg_config" --configure 2>/dev/null \ | sed -n "s/.*--with-system-tzdata=\([^' ]*\).*/\1/p")" fi [ -n "$_tz_dir" ] || _tz_dir="/usr/share/zoneinfo" [ -e "$_tz_dir/UTC" ] || _tz_missing=1 fi if [ -n "$_glibc" ]; then die "this version of tofa needs glibc ${_glibc} or newer, and this system is older. Nothing was changed. Update the system, or use Docker: https://docs.tofa.tv/run-your-server.html" fi if [ -n "$_libs" ] || [ "$_tz_missing" = 1 ]; then printf '\033[1;31merror:\033[0m tofa needs a few things this system does not have yet:\n' >&2 for _lib in $_libs; do printf ' - %s\n' "$_lib" >&2 done [ "$_tz_missing" = 1 ] && printf ' - time zone data (%s)\n' "$_tz_dir" >&2 _hint="$(missing_packages_hint "$_libs" "$_tz_missing")" if [ -n "$_hint" ]; then printf '\nInstall them with:\n \033[1;36m%s\033[0m\n' "$_hint" >&2 else printf '\nInstall them with your package manager.\n' >&2 fi printf '\nThen run this installer again. Nothing was changed on this system.\n' >&2 exit 1 fi } # A setting from an existing /etc/tofa/tofa.env, or nothing. $1 = name. env_setting() { $SUDO sed -n "s/^$1=//p" /etc/tofa/tofa.env 2>/dev/null | tail -1 | tr -d "\"'" } # Is something answering on the port right now? curl exit 7 = connection refused. port_in_use() { _rc=0 curl -s --noproxy '*' -o /dev/null --max-time 3 "http://localhost:$1/" 2>/dev/null || _rc=$? [ "$_rc" -ne 7 ] } service_restarts() { _n="$($SUDO systemctl show -p NRestarts --value tofa.service 2>/dev/null || true)" case "$_n" in ''|*[!0-9]*) echo 0 ;; *) echo "$_n" ;; esac } # The service is confirmed up only when systemd says it is active and it answers # its health check. service_healthy() { $SUDO systemctl is-active --quiet tofa.service 2>/dev/null \ && curl -fsS --noproxy '*' --max-time 5 "http://localhost:${PORT}/api/v1/health" >/dev/null 2>&1 } # Wait for the server. Prints healthy, crashed (it keeps exiting) or timeout. wait_for_service() { _base="$(service_restarts)" _i=0 _grace=0 while [ "$_i" -le 180 ]; do _restarts=$(( $(service_restarts) - _base )) if service_healthy; then # Answer once, then still be up without a restart a moment later. sleep 3 if service_healthy && [ "$(( $(service_restarts) - _base ))" -eq "$_restarts" ]; then echo healthy return 0 fi fi # The restart that reaches the limit may be the one that works, so give it # time to come up before calling this a crash. [ "$_restarts" -ge 3 ] && _grace=$((_grace + 1)) if [ "$_grace" -ge 30 ] || $SUDO systemctl is-failed --quiet tofa.service 2>/dev/null; then echo crashed return 0 fi _i=$((_i + 1)) sleep 1 done echo timeout } # --- detect platform --------------------------------------------------------- OS="$(uname -s)" ARCH="$(uname -m)" case "$OS" in Linux) case "$ARCH" in x86_64|amd64) PLATFORM="linux-x64" ;; aarch64|arm64) PLATFORM="linux-arm64" ;; *) die "unsupported Linux arch '$ARCH' (need x86_64 or aarch64)." ;; esac command -v systemctl >/dev/null 2>&1 || \ die "this installer targets systemd distros. For others, see https://docs.tofa.tv/run-your-server.html" ;; Darwin) case "$ARCH" in arm64) PLATFORM="macos-arm64" ;; *) die "Intel Macs aren't supported natively yet. Use Docker: https://docs.tofa.tv/run-your-server.html" ;; esac ;; *) die "unsupported OS '$OS'. See https://docs.tofa.tv/run-your-server.html" ;; esac TARBALL="${ASSET_PREFIX}-${PLATFORM}.tar.gz" URL="${DIST_BASE}/${TARBALL}" say "Installing tofa (${TOFA_VERSION:-beta}) for ${PLATFORM}" # --- download + verify checksum ---------------------------------------------- TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT cd "$TMP" say "Downloading ${TARBALL}" curl -fsSL "$URL" -o "$TARBALL" || die "download failed: $URL" curl -fsSL "${URL}.sha256" -o "sum" || die "checksum download failed." say "Verifying checksum" EXPECTED="$(awk '{print $1}' sum)" [ -n "$EXPECTED" ] || die "empty checksum file, refusing to install." ACTUAL="$(sha256_of "$TARBALL")" [ -n "$ACTUAL" ] || die "no sha256 tool (sha256sum/shasum), cannot verify the download." [ "$EXPECTED" = "$ACTUAL" ] || die "checksum mismatch, refusing to install." tar xzf "$TARBALL" || die "failed to unpack $TARBALL." # Every published archive so far unpacks to tofa-beta-/, including the # versioned ones, and published archives never change. Accept the versioned name # too in case a later release uses it. SRC="${ASSET_PREFIX}-${PLATFORM}" [ -d "$SRC" ] || SRC="tofa-beta-${PLATFORM}" [ -d "$SRC" ] || die "unexpected archive layout (no ${ASSET_PREFIX}-${PLATFORM}/ or tofa-beta-${PLATFORM}/)." # --- macOS: hand off to the bundled installer, which owns the whole macOS ----- # experience (app install, launchd service, health wait, claim output). if [ "$OS" = "Darwin" ]; then [ -f "${SRC}/install.sh" ] || die "bundled macOS installer missing from the archive." say "Installing tofa.app + launchd service (you may be asked for your password)" $SUDO sh "${SRC}/install.sh" install_manage_shim launchd exit 0 fi # --- Linux: install to /opt/tofa + systemd ----------------------------------- # An existing install may have moved the server to another port. _port="$(env_setting TOFA_PORT)" [ -n "$_port" ] || _port="$(env_setting PORT)" case "$_port" in ''|*[!0-9]*) ;; *) PORT="$_port" ;; esac check_linux_deps "$SRC" say "Installing to /opt/tofa + systemd service" # Stop any running instance first. A running executable can't be overwritten # with cp (ETXTBSY), which would otherwise abort an in-place upgrade under set -e. $SUDO systemctl stop tofa.service 2>/dev/null || true # With our service stopped nothing should be on the port. If something is, it # would answer our health check in place of the server we are about to start. if port_in_use "$PORT"; then die "another program is using port ${PORT}, so tofa can't start. If it is another tofa install (Docker, for example), stop that one first, then run this installer again." fi if ! getent passwd tofa >/dev/null 2>&1; then $SUDO useradd --system --user-group --shell /usr/sbin/nologin \ --home-dir /home/tofa --create-home tofa \ || die "failed to create the 'tofa' system user." fi # GPU access for hardware transcoding: /dev/dri/renderD* is root:render (and # card* root:video) on every current distro, so a fresh 'tofa' system user can # see the render node but not open it — VAAPI/QSV detection then reports no # hardware at all. Done on every run so existing installs pick it up on upgrade. # Guarded by getent: distros without a 'render' group must not fail the install. for _grp in render video; do if getent group "$_grp" >/dev/null 2>&1; then $SUDO usermod -aG "$_grp" tofa 2>/dev/null || true fi done $SUDO mkdir -p /opt/tofa/lib /home/tofa/.tofa /etc/tofa $SUDO cp "${SRC}/tofa" /opt/tofa/tofa [ -f "${SRC}/tofa-updater" ] && $SUDO cp "${SRC}/tofa-updater" /opt/tofa/tofa-updater [ -f "${SRC}/package_info" ] && $SUDO cp "${SRC}/package_info" /opt/tofa/package_info # Copy lib/ contents one entry at a time so an (unexpected) empty dir doesn't # trip `set -e` on an unexpanded glob. -R because lib/ contains directories # (the bundled PostgreSQL tree since 0.9.32). if [ -d "${SRC}/lib" ]; then for f in "${SRC}/lib"/*; do [ -e "$f" ] && $SUDO cp -R "$f" /opt/tofa/lib/ done fi $SUDO chmod 755 /opt/tofa/tofa [ -f /etc/tofa/tofa.env ] || printf '# tofa environment overrides (read by tofa.service)\n' | $SUDO tee /etc/tofa/tofa.env >/dev/null # Setup token for claiming from another device. Reuse an existing one so a # re-run keeps the same claim URL; otherwise mint and append it. if $SUDO grep -q '^SETUP_ACCESS_TOKEN=' /etc/tofa/tofa.env 2>/dev/null; then TOKEN="$($SUDO sh -c "grep '^SETUP_ACCESS_TOKEN=' /etc/tofa/tofa.env | head -1 | cut -d= -f2-")" else TOKEN="$(gen_token)" printf 'SETUP_ACCESS_TOKEN=%s\n' "$TOKEN" | $SUDO tee -a /etc/tofa/tofa.env >/dev/null fi $SUDO cp "${SRC}/systemd/tofa.service" /etc/systemd/system/tofa.service $SUDO chown -R tofa:tofa /opt/tofa /home/tofa/.tofa say "Starting the tofa service" $SUDO systemctl daemon-reload $SUDO systemctl enable tofa.service >/dev/null 2>&1 || true $SUDO systemctl restart tofa.service \ || die "tofa.service failed to start. Check: sudo journalctl -u tofa -n 50 --no-pager" # --- wait for health, then point at the claim page --------------------------- # Tell people the server runs only once it has answered. The helper installs # either way: it is how they look at a server that didn't come up. say "Waiting for tofa to start (the first run sets up its database, usually under a minute)" RESULT="$(wait_for_service)" install_manage_shim systemd case "$RESULT" in healthy) print_claim "${TOKEN:-}" ;; crashed) printf '\n\033[1;31mtofa did not start.\033[0m It keeps exiting, so it is not running.\n' >&2 printf 'See why with:\n \033[1;36msudo journalctl -u tofa -n 50 --no-pager\033[0m\n' >&2 exit 1 ;; *) printf '\n\033[1;33mtofa has not answered after 3 minutes,\033[0m so we cannot say it is running.\n' >&2 printf 'It may still be starting, as a big upgrade can take a while. Check with:\n' >&2 printf ' \033[1;36msudo systemctl status tofa\033[0m\n' >&2 printf ' \033[1;36msudo journalctl -u tofa -n 50 --no-pager\033[0m\n' >&2 printf 'Once it answers, claim it at:\n \033[1;36mhttp://localhost:%s/setup%s\033[0m\n' \ "$PORT" "${TOKEN:+?setup_token=$TOKEN}" >&2 exit 1 ;; esac