#!/usr/bin/env bash set -euo pipefail # ============================================================================== # Bose-SoundTouch soundtouch-service installer (systemd, headless) # # Usage: # sudo bash install.sh [vX.Y.Z] # # Examples (override defaults via env vars): # # sudo \ # VERSION=v0.111.3 \ # HOSTNAME_FQDN=soundtouch.local \ # HTTP_PORT=80 \ # HTTPS_PORT=443 \ # DATA_DIR=/var/lib/soundtouch-service \ # bash install.sh # # Or with a version argument to perform an update: # sudo bash install.sh v0.111.3 # # Notes: # - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64). # - It installs a systemd unit that can bind privileged ports (80/443) using: # AmbientCapabilities=CAP_NET_BIND_SERVICE # so you do NOT need setcap and do NOT need to run as root. # - Safe to re-run; it will update binary/config/unit and restart the service. # ============================================================================== # Release to install. Empty means "resolve the latest release" (see # resolve_version). Pass a tag/number as $1 or VERSION=... to pin a release. VERSION="${1:-${VERSION:-}}" # Normalize version prefix for an explicitly provided version. if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then VERSION="v${VERSION}" fi GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}" # Used only when the latest-release lookup fails (offline / rate-limited). FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}" SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}" BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}" CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-service}" ENV_FILE="${ENV_FILE:-$CONFIG_DIR/soundtouch-service.env}" DATA_DIR="${DATA_DIR:-/var/lib/soundtouch-service}" SERVICE_USER="${SERVICE_USER:-soundtouch}" SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}" # Ports HTTP_PORT="${HTTP_PORT:-80}" HTTPS_PORT="${HTTPS_PORT:-443}" # URLs (default uses current hostname + .local) HOSTNAME_FQDN="${HOSTNAME_FQDN:-$(hostname).local}" SERVER_URL="${SERVER_URL:-http://${HOSTNAME_FQDN}}" HTTPS_SERVER_URL="${HTTPS_SERVER_URL:-https://${HOSTNAME_FQDN}}" # Additional env vars (mirrors the project's docker-compose.yml) LOG_PROXY_BODY="${LOG_PROXY_BODY:-false}" REDACT_PROXY_LOGS="${REDACT_PROXY_LOGS:-true}" RECORD_INTERACTIONS="${RECORD_INTERACTIONS:-true}" DISCOVERY_INTERVAL="${DISCOVERY_INTERVAL:-5m}" # Spotify OAuth config (optional) SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}" SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}" SPOTIFY_REDIRECT_URI="${SPOTIFY_REDIRECT_URI:-}" # Management API credentials MGMT_USERNAME="${MGMT_USERNAME:-admin}" MGMT_PASSWORD="${MGMT_PASSWORD:-change_me!}" # Override if you want to force a specific asset suffix: # ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64 ARCH_ASSET="${ARCH_ASSET:-}" # Internal variables SCRIPT_PATH="$(realpath "$0" 2>/dev/null || echo "$0")" IS_SELF_UPDATE="${IS_SELF_UPDATE:-false}" log() { printf "\n==> %s\n" "$*"; } die() { echo "ERROR: $*" >&2; exit 1; } need_root() { [[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)." } ensure_cmd() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" } apt_install_if_missing() { log "Installing dependencies: $*" apt-get update -y apt-get install -y --no-install-recommends "$@" } detect_arch_asset() { # Upstream release naming expects: linux-armv7, linux-arm64, linux-amd64 # Map uname -m to those. local m m="$(uname -m)" case "$m" in armv7l|armv6l) echo "linux-armv7" ;; aarch64) echo "linux-arm64" ;; x86_64|amd64) echo "linux-amd64" ;; *) die "Unsupported architecture from uname -m: $m (set ARCH_ASSET manually)" ;; esac } download_url_for() { local asset="$1" # Release asset pattern used by you earlier: # soundtouch-service-v0.111.3-linux-armv7 echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}" } ensure_user_group() { log "Ensuring service user/group exist: ${SERVICE_USER}:${SERVICE_GROUP}" if ! getent group "${SERVICE_GROUP}" >/dev/null; then groupadd --system "${SERVICE_GROUP}" fi if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then useradd --system \ --home "${DATA_DIR}" \ --create-home \ --shell /usr/sbin/nologin \ --gid "${SERVICE_GROUP}" \ "${SERVICE_USER}" fi } ensure_dirs() { log "Creating directories" mkdir -p "${CONFIG_DIR}" "${DATA_DIR}" # Optimized ownership check: only chown if not already owned by service user if [[ "$(stat -c '%U:%G' "${DATA_DIR}")" != "${SERVICE_USER}:${SERVICE_GROUP}" ]]; then log "Adjusting ownership of ${DATA_DIR} to ${SERVICE_USER}:${SERVICE_GROUP}" chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}" fi chmod 0755 "${CONFIG_DIR}" "${DATA_DIR}" } download_binary() { local asset url tmp="" asset="${ARCH_ASSET:-$(detect_arch_asset)}" url="$(download_url_for "$asset")" log "Downloading binary for ${asset}: ${url}" tmp="$(mktemp -d)" trap 'rm -rf "${tmp}"' EXIT if command -v curl >/dev/null 2>&1; then curl -fsSL -o "${tmp}/soundtouch-service" "${url}" else wget -qO "${tmp}/soundtouch-service" "${url}" fi chmod +x "${tmp}/soundtouch-service" # Backup existing binary if it exists if [[ -f "${BIN_PATH}" ]]; then log "Backing up existing binary to ${BIN_PATH}.old" cp -p "${BIN_PATH}" "${BIN_PATH}.old" fi install -m 0755 "${tmp}/soundtouch-service" "${BIN_PATH}" log "Installed binary to ${BIN_PATH}" } resolve_version() { # When no explicit version was given, resolve the latest release tag by # following the documented stable redirect: # https://github.com///releases/latest # which 302-redirects to .../releases/tag/vX.Y.Z. We read the final URL and # take the tag from it. Falls back to FALLBACK_VERSION on any failure # (offline, rate-limited, no usable curl/wget). if [[ -n "$VERSION" ]]; then return fi local latest_url="https://github.com/${GH_REPO}/releases/latest" log "Resolving latest release via ${latest_url}" local effective="" tag="" if command -v curl >/dev/null 2>&1; then effective="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null)" || true else # wget: don't follow the redirect, read the Location header instead. effective="$(wget -S --max-redirect=0 -O /dev/null "$latest_url" 2>&1 \ | awk 'tolower($1) ~ /location:/ {print $2}' | tr -d '\r' | tail -1)" || true fi tag="${effective##*/}" if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+ ]]; then [[ "$tag" =~ ^v ]] || tag="v${tag}" VERSION="$tag" log "Latest release is ${VERSION}" else VERSION="$FALLBACK_VERSION" log "⚠️ Could not resolve latest release; falling back to ${VERSION}" fi } self_update() { # If we are already a self-update re-exec, don't do it again if [[ "$IS_SELF_UPDATE" == "true" ]]; then return fi local url="https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/${VERSION}/scripts/raspberry-pi/install.sh" local tmp_script="/tmp/soundtouch-install-${VERSION}.sh" log "Checking for installer updates for ${VERSION}..." log "URL: ${url}" if command -v curl >/dev/null 2>&1; then if ! curl -fsSL -o "${tmp_script}" "${url}"; then log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script." return fi else if ! wget -qO "${tmp_script}" "${url}"; then log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script." return fi fi # Compare scripts to see if we actually need to re-exec if diff -q "${SCRIPT_PATH}" "${tmp_script}" >/dev/null 2>&1; then log "Installer is already up to date." rm -f "${tmp_script}" return fi log "Newer installer found for ${VERSION}. Updating ${SCRIPT_PATH} and re-executing..." install -m 0755 "${tmp_script}" "${SCRIPT_PATH}" rm -f "${tmp_script}" # Export current env vars to the new script export IS_SELF_UPDATE="true" export VERSION HOSTNAME_FQDN HTTP_PORT HTTPS_PORT DATA_DIR BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP export SPOTIFY_CLIENT_ID SPOTIFY_CLIENT_SECRET SPOTIFY_REDIRECT_URI MGMT_USERNAME MGMT_PASSWORD exec "${SCRIPT_PATH}" "$@" } write_env_file() { log "Updating env file: ${ENV_FILE}" # 1. Start with a list of all variables we want to manage local vars=( "PORT=${HTTP_PORT}" "HTTPS_PORT=${HTTPS_PORT}" "DATA_DIR=${DATA_DIR}" "LOG_PROXY_BODY=${LOG_PROXY_BODY}" "REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}" "RECORD_INTERACTIONS=${RECORD_INTERACTIONS}" "DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}" "SERVER_URL=${SERVER_URL}" "HTTPS_SERVER_URL=${HTTPS_SERVER_URL}" "SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}" "SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}" "SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}" "MGMT_USERNAME=${MGMT_USERNAME}" "MGMT_PASSWORD=${MGMT_PASSWORD}" ) if [[ ! -f "${ENV_FILE}" ]]; then for entry in "${vars[@]}"; do echo "${entry}" >> "${ENV_FILE}" done else for entry in "${vars[@]}"; do local key="${entry%%=*}" local val="${entry#*=}" if ! grep -q "^${key}=" "${ENV_FILE}"; then echo "${key}=${val}" >> "${ENV_FILE}" fi done fi chmod 0640 "${ENV_FILE}" # group-readable so you can add yourself to the group if desired chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true } write_systemd_unit() { log "Writing systemd unit: /etc/systemd/system/${SERVICE_NAME}.service" cat > "/etc/systemd/system/${SERVICE_NAME}.service" </dev/null 2>&1; then success=true break fi echo "Waiting for service to respond at $health_url... ($((count+1))/$max_retries)" sleep 2 count=$((count+1)) done if [[ "$success" = true ]]; then log "✅ Service is healthy and responding!" else log "⚠️ Service started but did not respond to health check at $health_url within timeout." log "Check logs with: journalctl -u ${SERVICE_NAME}.service -n 50" fi } show_status() { log "Service status" systemctl --no-pager --full status "${SERVICE_NAME}.service" || true log "Listening sockets (${HTTP_PORT}/${HTTPS_PORT})" ss -tulpn | grep -E ":((${HTTP_PORT})|(${HTTPS_PORT}))\b" || true if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then log "Firewall check (UFW is active)" if ! ufw status | grep -qE "${HTTP_PORT}.*ALLOW|${HTTPS_PORT}.*ALLOW"; then log "⚠️ UFW is active but ports ${HTTP_PORT}/${HTTPS_PORT} might be blocked." log "Run: sudo ufw allow ${HTTP_PORT}/tcp && sudo ufw allow ${HTTPS_PORT}/tcp" else log "✅ UFW rules for service ports appear to be in place." fi fi cat </ https:/// Logs: journalctl -u ${SERVICE_NAME}.service -e --no-pager EOF } main() { need_root ensure_cmd systemctl ensure_cmd ss if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then apt_install_if_missing curl fi resolve_version self_update "$@" ensure_user_group ensure_dirs download_binary write_env_file write_systemd_unit reload_enable_start show_status } main "$@"