mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-16 15:46:16 +00:00
The on-device and Raspberry Pi installers hardcoded the release version, which had to be bumped on every release. Default VERSION to empty and resolve the newest tag by following GitHub's documented stable redirect (https://github.com/<repo>/releases/latest -> .../releases/tag/vX.Y.Z), reading the effective URL. This avoids the GitHub API rate limit and needs no jq. An explicit version (positional arg / VERSION= / --version) still pins a release. If the lookup fails (offline, rate-limited, or a curl without -w support), each script falls back to a pinned FALLBACK_VERSION so installs still work. The Pi self_update path runs after resolution, so it fetches the resolved tag's installer. Docs updated to state the default installs the latest release; the pinned-version examples remain as illustrations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
419 lines
12 KiB
Bash
419 lines
12 KiB
Bash
#!/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/<owner>/<repo>/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" <<EOF
|
|
[Unit]
|
|
Description=Bose SoundTouch Service
|
|
Wants=network-online.target
|
|
After=network-online.target
|
|
|
|
[Service]
|
|
Type=simple
|
|
User=${SERVICE_USER}
|
|
Group=${SERVICE_GROUP}
|
|
EnvironmentFile=${ENV_FILE}
|
|
WorkingDirectory=${DATA_DIR}
|
|
ExecStart=${BIN_PATH}
|
|
|
|
# Allow binding to privileged ports (80/443) without running as root
|
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
|
|
|
Restart=on-failure
|
|
RestartSec=2
|
|
|
|
# Sensible hardening (compatible with privileged-port binding)
|
|
PrivateTmp=true
|
|
ProtectSystem=full
|
|
ProtectHome=true
|
|
ReadWritePaths=${DATA_DIR}
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
}
|
|
|
|
reload_enable_start() {
|
|
log "Reloading systemd, enabling and starting service"
|
|
systemctl daemon-reload
|
|
systemctl enable "${SERVICE_NAME}.service"
|
|
systemctl restart "${SERVICE_NAME}.service"
|
|
|
|
log "Verifying service health..."
|
|
local health_url="http://localhost:${HTTP_PORT}/health"
|
|
local max_retries=5
|
|
local count=0
|
|
local success=false
|
|
|
|
while [[ $count -lt $max_retries ]]; do
|
|
if curl -fs "$health_url" >/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 <<EOF
|
|
|
|
Try from another machine:
|
|
${SERVER_URL}
|
|
${HTTPS_SERVER_URL}
|
|
|
|
If mDNS doesn't work, use the Pi's IP:
|
|
http://<pi-ip>/
|
|
https://<pi-ip>/
|
|
|
|
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 "$@"
|