#!/usr/bin/env bash # # OpenAVC Linux Installer # # One-line install: # curl -sSL https://get.openavc.com | bash # # What this script does: # 1. Detects your Linux distribution and architecture # 2. Installs Python 3.12+ if not present # 3. Creates an 'openavc' system user # 4. Downloads the latest OpenAVC release from GitHub # 5. Extracts to /opt/openavc/ with a Python venv # 6. Sets up the data directory at /var/lib/openavc/ # 7. Installs a systemd service (auto-start on boot) # 8. Configures the firewall (if ufw or firewalld is active) # 9. Starts the server # # Supports: Debian/Ubuntu, Fedora/RHEL/Rocky, Arch. x86_64 and arm64. # Requires: root (or sudo). systemd. # # Re-running this script on an existing install will upgrade in place. set -euo pipefail # --- Configuration --- GITHUB_REPO="open-avc/openavc" INSTALL_DIR="/opt/openavc" DATA_DIR="/var/lib/openavc" LOG_DIR="/var/log/openavc" SERVICE_NAME="openavc" SERVICE_USER="openavc" SERVICE_GROUP="openavc" HTTP_PORT=8080 MIN_PYTHON_MAJOR=3 MIN_PYTHON_MINOR=11 # Trusted release-signing public key (PEM), embedded for the fresh-install # bootstrap: install.sh is fetched over HTTPS from get.openavc.com, so this key # is as trusted as the installer itself (standard TOFU). When set, verify_checksum # verifies SHA256SUMS.txt.sig against it before trusting the checksums. # # EMPTY until the production key ceremony (installer/trusted-keys/README.md). # Empty = signing not yet armed: the signature check is skipped with a warning # and the existing checksum-only verification stands, so this ships without # breaking installs. Keep in sync with installer/trusted-keys/*.pem (that copy # ships in the tarball and protects self-updates; this one bootstraps install). TRUSTED_SIGNING_PUBKEY="" # --- Colors --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color info() { echo -e "${BLUE}[INFO]${NC} $*"; } ok() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } fatal() { error "$*"; exit 1; } # --- Pre-flight checks --- check_root() { if [ "$(id -u)" -ne 0 ]; then fatal "This script must be run as root. Try: sudo bash or curl ... | sudo bash" fi } check_systemd() { if ! command -v systemctl &>/dev/null; then fatal "systemd is required but not found. This script does not support init.d or other init systems." fi } check_curl_or_wget() { if command -v curl &>/dev/null; then DOWNLOADER="curl" elif command -v wget &>/dev/null; then DOWNLOADER="wget" else error "curl or wget is required but neither was found." error "On Debian/Ubuntu: sudo apt-get install -y curl ca-certificates" error "On Fedora/RHEL: sudo dnf install -y curl ca-certificates" error "On Arch: sudo pacman -S curl ca-certificates" exit 1 fi } # --- Platform detection --- detect_arch() { local arch arch=$(uname -m) case "$arch" in x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; *) fatal "Unsupported architecture: $arch. OpenAVC supports x86_64 and arm64." ;; esac ok "Architecture: $ARCH" } detect_distro() { if [ -f /etc/os-release ]; then . /etc/os-release DISTRO_ID="${ID:-unknown}" DISTRO_ID_LIKE="${ID_LIKE:-}" DISTRO_NAME="${PRETTY_NAME:-$DISTRO_ID}" else fatal "Cannot detect Linux distribution (/etc/os-release not found)." fi # Determine package manager if command -v apt-get &>/dev/null; then PKG_MANAGER="apt" elif command -v dnf &>/dev/null; then PKG_MANAGER="dnf" elif command -v pacman &>/dev/null; then PKG_MANAGER="pacman" else fatal "No supported package manager found (apt, dnf, or pacman required)." fi ok "Distribution: $DISTRO_NAME (package manager: $PKG_MANAGER)" } # --- Python --- python_version_ok() { local python_bin="$1" if ! command -v "$python_bin" &>/dev/null; then return 1 fi local version version=$("$python_bin" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null) || return 1 local major minor major=$(echo "$version" | cut -d. -f1) minor=$(echo "$version" | cut -d. -f2) if [ "$major" -gt "$MIN_PYTHON_MAJOR" ] || { [ "$major" -eq "$MIN_PYTHON_MAJOR" ] && [ "$minor" -ge "$MIN_PYTHON_MINOR" ]; }; then return 0 fi return 1 } find_python() { # Check common Python binary names for py in python3.13 python3.12 python3.11 python3; do if python_version_ok "$py"; then PYTHON_BIN=$(command -v "$py") ok "Python: $($PYTHON_BIN --version) at $PYTHON_BIN" return 0 fi done return 1 } install_python() { info "Installing Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+ and system prerequisites..." case "$PKG_MANAGER" in apt) # Try system Python first, then deadsnakes PPA for older Ubuntu. # ca-certificates + tar are pulled in defensively for minimal images # (cloud Ubuntu, slim containers) that don't include them. apt-get update -qq if apt-cache show python3 2>/dev/null | grep -qE "Version: 3\.(1[1-9]|[2-9][0-9])"; then apt-get install -y -qq python3 python3-venv python3-pip ca-certificates tar else info "System Python is too old. Adding deadsnakes PPA..." apt-get install -y -qq software-properties-common ca-certificates tar add-apt-repository -y ppa:deadsnakes/ppa apt-get update -qq apt-get install -y -qq python3.12 python3.12-venv fi ;; dnf) dnf install -y -q python3 python3-pip ca-certificates tar ;; pacman) pacman -Sy --noconfirm --quiet python python-pip ca-certificates tar ;; esac if ! find_python; then fatal "Failed to install Python ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+. Please install it manually and re-run this script." fi } ensure_python() { if find_python; then return 0 fi install_python } # Ensure ca-certificates + tar are present even when Python is already # installed. Minimal cloud / container Ubuntu images sometimes ship Python # but no CA bundle, which causes confusing TLS errors on the GitHub # download below rather than a clean "missing dependency" message. ensure_system_prereqs() { case "$PKG_MANAGER" in apt) if ! dpkg -s ca-certificates &>/dev/null || ! command -v tar &>/dev/null; then info "Installing system prerequisites (ca-certificates, tar)..." apt-get update -qq apt-get install -y -qq ca-certificates tar fi ;; dnf) if ! rpm -q ca-certificates &>/dev/null || ! command -v tar &>/dev/null; then info "Installing system prerequisites (ca-certificates, tar)..." dnf install -y -q ca-certificates tar fi ;; pacman) if ! pacman -Qi ca-certificates &>/dev/null || ! command -v tar &>/dev/null; then info "Installing system prerequisites (ca-certificates, tar)..." pacman -Sy --noconfirm --quiet ca-certificates tar fi ;; esac } # --- Download --- download() { local url="$1" local dest="$2" if [ "$DOWNLOADER" = "curl" ]; then curl -fsSL -o "$dest" "$url" else wget -q -O "$dest" "$url" fi } get_latest_release_url() { local asset_name="openavc-.*-linux-${ARCH}\\.tar\\.gz" info "Checking for latest release..." # Try /releases/latest first (stable releases only), then fall back to # /releases (includes prereleases) so beta testers can install too. local release_json="" local api_urls=( "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=1" ) for api_url in "${api_urls[@]}"; do if [ "$DOWNLOADER" = "curl" ]; then release_json=$(curl -fsSL -H "Accept: application/vnd.github.v3+json" "$api_url" 2>/dev/null) || true else release_json=$(wget -q -O - --header="Accept: application/vnd.github.v3+json" "$api_url" 2>/dev/null) || true fi if [ -n "$release_json" ]; then break fi done if [ -z "$release_json" ]; then return 1 fi # Parse JSON without jq (grep + sed) RELEASE_VERSION=$(echo "$release_json" | grep -o '"tag_name":\s*"[^"]*"' | head -1 | sed 's/.*"tag_name":\s*"\([^"]*\)".*/\1/' | sed 's/^v//') RELEASE_URL=$(echo "$release_json" | grep -o '"browser_download_url":\s*"[^"]*linux-'"${ARCH}"'[^"]*\.tar\.gz"' | head -1 | sed 's/"browser_download_url":\s*"\([^"]*\)"/\1/') CHECKSUMS_URL=$(echo "$release_json" | grep -o '"browser_download_url":\s*"[^"]*SHA256SUMS\.txt"' | head -1 | sed 's/"browser_download_url":\s*"\([^"]*\)"/\1/') CHECKSUMS_SIG_URL=$(echo "$release_json" | grep -o '"browser_download_url":\s*"[^"]*SHA256SUMS\.txt\.sig"' | head -1 | sed 's/"browser_download_url":\s*"\([^"]*\)"/\1/') if [ -n "$RELEASE_URL" ] && [ -n "$RELEASE_VERSION" ]; then ok "Latest release: v${RELEASE_VERSION}" return 0 fi return 1 } # Verify SHA256SUMS.txt's detached signature against the embedded trusted key # before trusting any hash in it. Without this, an attacker who can replace a # release asset could swap the tarball AND its SHA256SUMS.txt consistently and # still pass the checksum check. Fail-closed once signing is armed; skipped with # a warning while the key is empty (checksum-only, matching prior behavior). verify_checksums_signature() { local sums_file="$1" if [ -z "$TRUSTED_SIGNING_PUBKEY" ]; then warn "Release signing not yet armed — verifying checksum only (no signature)." return 0 fi if ! command -v openssl &>/dev/null; then fatal "openssl not found but release signing is armed — cannot verify authenticity. Install openssl and re-run." fi if [ -z "${CHECKSUMS_SIG_URL:-}" ]; then fatal "No SHA256SUMS.txt.sig in the release assets — refusing to install (release signing is armed)." fi local sig_file="/tmp/openavc-SHA256SUMS.txt.sig" local key_file="/tmp/openavc-release-pubkey.pem" if ! download "$CHECKSUMS_SIG_URL" "$sig_file"; then fatal "Failed to download SHA256SUMS.txt.sig — refusing unverified install." fi printf '%s\n' "$TRUSTED_SIGNING_PUBKEY" > "$key_file" if ! openssl dgst -sha256 -verify "$key_file" -signature "$sig_file" "$sums_file" >/dev/null 2>&1; then rm -f "$sig_file" "$key_file" fatal "SHA256SUMS.txt signature did not verify against the trusted key — refusing install." fi rm -f "$sig_file" "$key_file" ok "Release signature verified" } # Verify the downloaded archive against the release's SHA256SUMS.txt. # Fail-closed: refuse to install if the checksums file is missing, the # archive isn't listed, or the hash doesn't match — never install an # unverified artifact. verify_checksum() { local archive="$1" if [ -z "${CHECKSUMS_URL:-}" ]; then fatal "No SHA256SUMS.txt in the release assets — refusing to install an unverified download." fi if ! command -v sha256sum &>/dev/null; then fatal "sha256sum not found — cannot verify download integrity. Install coreutils and re-run." fi local sums_file="/tmp/openavc-SHA256SUMS.txt" info "Verifying download integrity..." if ! download "$CHECKSUMS_URL" "$sums_file"; then fatal "Failed to download SHA256SUMS.txt — refusing to install unverified." fi # Authenticate the checksums file itself before trusting any hash in it. verify_checksums_signature "$sums_file" local artifact_name expected actual artifact_name=$(basename "$archive") # Field 2 is the filename (strip the optional binary-mode '*'); print the # matching hash from field 1. Mirrors the in-app verifier's parser. expected=$(awk -v name="$artifact_name" '{ f=$2; sub(/^\*/, "", f); if (f == name) { print $1; exit } }' "$sums_file") rm -f "$sums_file" if [ -z "$expected" ]; then rm -f "$archive" fatal "Checksum for ${artifact_name} not found in SHA256SUMS.txt — refusing to install." fi actual=$(sha256sum "$archive" | awk '{print $1}') if [ "$actual" != "$expected" ]; then rm -f "$archive" fatal "Checksum mismatch for ${artifact_name}: expected ${expected}, got ${actual}. Aborting install." fi ok "Checksum verified" } download_release() { if ! get_latest_release_url; then fatal "Could not find a release for linux-${ARCH}. Check https://github.com/${GITHUB_REPO}/releases" fi local archive="/tmp/openavc-${RELEASE_VERSION}-linux-${ARCH}.tar.gz" info "Downloading v${RELEASE_VERSION} for linux-${ARCH}..." download "$RELEASE_URL" "$archive" ok "Downloaded: $(du -h "$archive" | cut -f1)" verify_checksum "$archive" ARCHIVE_PATH="$archive" } # --- Install --- create_user() { if id "$SERVICE_USER" &>/dev/null; then ok "User '$SERVICE_USER' already exists" return 0 fi info "Creating system user '$SERVICE_USER'..." useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_DIR" --create-home "$SERVICE_USER" ok "Created user: $SERVICE_USER" } install_files() { local is_upgrade=false if [ -d "$INSTALL_DIR/server" ]; then is_upgrade=true info "Existing installation found. Upgrading..." # Stop service before replacing files systemctl stop "$SERVICE_NAME" 2>/dev/null || true # Keep previous version for rollback if [ -d "${INSTALL_DIR}.previous" ]; then rm -rf "${INSTALL_DIR}.previous" fi cp -a "$INSTALL_DIR" "${INSTALL_DIR}.previous" fi info "Extracting to ${INSTALL_DIR}/..." mkdir -p "$INSTALL_DIR" tar -xzf "$ARCHIVE_PATH" -C "$INSTALL_DIR" ok "Extracted to ${INSTALL_DIR}/" # Place update helper script where systemd ExecStartPre expects it if [ -f "$INSTALL_DIR/installer/update-helper.sh" ]; then cp "$INSTALL_DIR/installer/update-helper.sh" "$INSTALL_DIR/update-helper.sh" chmod 755 "$INSTALL_DIR/update-helper.sh" ok "Update helper installed" fi # Firewall sync helper (ExecStartPre): keeps ufw/firewalld in step with # the configured listeners on every service start, so enabling HTTPS or # Short URLs in Settings opens the port without manual firewall edits. if [ -f "$INSTALL_DIR/installer/firewall-sync.sh" ]; then cp "$INSTALL_DIR/installer/firewall-sync.sh" "$INSTALL_DIR/firewall-sync.sh" chmod 755 "$INSTALL_DIR/firewall-sync.sh" ok "Firewall sync helper installed" fi # Clean up archive rm -f "$ARCHIVE_PATH" } create_venv() { info "Setting up Python virtual environment..." if [ -d "$INSTALL_DIR/venv" ]; then # Upgrade existing venv "$PYTHON_BIN" -m venv --upgrade "$INSTALL_DIR/venv" else "$PYTHON_BIN" -m venv "$INSTALL_DIR/venv" fi "$INSTALL_DIR/venv/bin/pip" install --quiet --upgrade pip "$INSTALL_DIR/venv/bin/pip" install --quiet -r "$INSTALL_DIR/requirements.txt" ok "Virtual environment ready" } setup_data_dir() { info "Setting up data directory at ${DATA_DIR}/..." mkdir -p "$DATA_DIR"/{projects/default,backups,logs} mkdir -p "$LOG_DIR" # Seed default project if not present if [ ! -f "$DATA_DIR/projects/default/project.avc" ]; then if [ -f "$INSTALL_DIR/installer/seed/default/project.avc" ]; then cp "$INSTALL_DIR/installer/seed/default/project.avc" "$DATA_DIR/projects/default/project.avc" info "Seeded default project" fi fi # Set ownership chown -R "$SERVICE_USER:$SERVICE_GROUP" "$DATA_DIR" chown -R "$SERVICE_USER:$SERVICE_GROUP" "$LOG_DIR" chown -R "$SERVICE_USER:$SERVICE_GROUP" "$INSTALL_DIR" # The recursive chown above just made the signing-key store and the # root-executed helpers writable by the service user. Re-assert root # ownership on them so a compromised openavc process can't swap in its own # key or rewrite the helper root runs (H-075 trust root). Mirrors # update-helper.sh's harden_privileged_paths. harden_trust_store ok "Data directory ready" } # Keep the signing-key store + the root-executed scripts root-owned and # unreachable-for-write by the service user. Owning a parent directory is enough # to rename/replace a root-owned child, so $INSTALL_DIR and installer/ are rooted # too; venv/server and the rest stay openavc-owned. No-op until a key ships. harden_trust_store() { local keys_dir="$INSTALL_DIR/installer/trusted-keys" [ -d "$keys_dir" ] || return 0 chown root:root "$INSTALL_DIR" 2>/dev/null || true chown -R root:root "$INSTALL_DIR/installer" 2>/dev/null || true chown root:root "$INSTALL_DIR/update-helper.sh" "$INSTALL_DIR/firewall-sync.sh" 2>/dev/null || true chmod 755 "$INSTALL_DIR" "$INSTALL_DIR/installer" "$keys_dir" 2>/dev/null || true chmod 644 "$keys_dir"/*.pem 2>/dev/null || true } # The shipped unit grants ambient CAP_NET_RAW so the unprivileged service can # run discovery's ICMP ping sweep. systemd refuses to start a unit whose # AmbientCapabilities aren't in the bounding set, so on the rare host that has # dropped CAP_NET_RAW (a container started with the capability stripped) the # line would make OpenAVC unbootable. Detect that and neutralize the line # instead — the server still starts; only the active ping sweep is limited # (passive mDNS/SSDP discovery and manual device entry are unaffected). host_has_cap_net_raw() { # CAP_NET_RAW is capability number 13. /proc/self/status CapBnd holds the # bounding-set mask in hex; test bit 13. If we can't read it (no /proc), # assume present — every systemd host exposes it, so this only triggers on # a genuinely capability-stripped environment. local capbnd capbnd=$(awk '/^CapBnd:/ {print $2}' /proc/self/status 2>/dev/null) || return 0 [ -n "$capbnd" ] || return 0 (( (0x$capbnd >> 13) & 1 )) } install_service() { info "Installing systemd service..." local service_file="/etc/systemd/system/${SERVICE_NAME}.service" if [ -f "$INSTALL_DIR/installer/openavc.service" ]; then cp "$INSTALL_DIR/installer/openavc.service" "$service_file" else # Fallback: write the service file inline (must match installer/openavc.service) cat > "$service_file" << 'UNIT' [Unit] Description=OpenAVC Room Control Server After=network-online.target Wants=network-online.target [Service] Type=exec User=openavc Group=openavc WorkingDirectory=/opt/openavc ExecStartPre=-+/opt/openavc/update-helper.sh /var/lib/openavc ExecStartPre=-+/opt/openavc/firewall-sync.sh /var/lib/openavc ExecStart=/opt/openavc/venv/bin/python -m server.main Restart=always RestartSec=5 Environment=OPENAVC_DATA_DIR=/var/lib/openavc Environment=OPENAVC_LOG_DIR=/var/log/openavc Environment=OPENAVC_PROJECT=/var/lib/openavc/projects/default/project.avc Environment=OPENAVC_BIND=0.0.0.0 Environment=OPENAVC_ALLOW_ANONYMOUS=false NoNewPrivileges=true # CAP_NET_RAW lets the discovery ping sweep open an ICMP/raw socket under the # unprivileged user; NoNewPrivileges strips /bin/ping's file cap at exec. # CAP_NET_BIND_SERVICE lets the optional port-80 redirect bind port 80. AmbientCapabilities=CAP_NET_RAW CAP_NET_BIND_SERVICE ProtectSystem=strict ReadWritePaths=/var/lib/openavc /var/log/openavc -/opt/openavc/driver_repo -/opt/openavc/plugin_repo ProtectHome=true PrivateTmp=true [Install] WantedBy=multi-user.target UNIT fi if ! host_has_cap_net_raw; then warn "CAP_NET_RAW is not in this host's capability bounding set (a container started with it dropped)." warn "Disabling it in the service so OpenAVC still starts. The discovery ping sweep will be limited;" warn "passive discovery (mDNS/SSDP) and adding devices by IP still work." sed -i 's/^AmbientCapabilities=CAP_NET_RAW.*/# AmbientCapabilities=CAP_NET_RAW (disabled by installer: CAP_NET_RAW unavailable here)/' "$service_file" fi systemctl daemon-reload systemctl enable "$SERVICE_NAME" ok "Service installed and enabled" } configure_firewall() { # One source of truth: the same helper that runs at every service start # (ExecStartPre) syncs ufw/firewalld with the configured listeners — the # HTTP port, the HTTPS port when TLS is on, and port 80 when Short URLs # are on. Running it here just opens the ports before the first start. # It no-ops when neither ufw nor firewalld is active. if [ -x "$INSTALL_DIR/firewall-sync.sh" ]; then info "Syncing firewall with configured ports..." "$INSTALL_DIR/firewall-sync.sh" "$DATA_DIR" ok "Firewall synced (ufw/firewalld if active; ports follow Settings from now on)" else warn "Firewall sync helper missing; if ufw/firewalld is active, allow port $HTTP_PORT/tcp manually." fi } # Remove the legacy /opt/openavc/{driver,plugin}_repo dirs once they're drained. # These predate user content moving to the data dir. The runtime migration # (server/system_config.migrate_legacy_repos) empties them on first start, but # leaves the empty dir behind — and the service unit keeps it writable via # ReadWritePaths, which makes systemd bind-mount it. An in-app update then fails # (EBUSY) trying to mv that mountpoint. Re-running the installer is the escape # path for such boxes, so drop the empty dirs here. Only remove them when empty # (rmdir refuses non-empty); a box with un-drained content keeps them until the # next server start drains them, then a later installer run clears them. cleanup_legacy_repos() { local dir for dir in "$INSTALL_DIR/driver_repo" "$INSTALL_DIR/plugin_repo"; do if [ -d "$dir" ] && rmdir "$dir" 2>/dev/null; then info "Removed drained legacy repo dir: $dir" fi done } start_service() { info "Starting OpenAVC..." systemctl start "$SERVICE_NAME" # Wait a moment and check if it's running sleep 3 if systemctl is-active --quiet "$SERVICE_NAME"; then ok "OpenAVC is running" else error "OpenAVC failed to start. Check: journalctl -u $SERVICE_NAME -n 50" return 1 fi } # --- Main --- main() { echo "" echo -e "${GREEN}============================================================${NC}" echo -e "${GREEN} OpenAVC Linux Installer${NC}" echo -e "${GREEN}============================================================${NC}" echo "" check_root check_systemd check_curl_or_wget detect_arch detect_distro ensure_system_prereqs ensure_python download_release create_user install_files create_venv setup_data_dir cleanup_legacy_repos install_service configure_firewall start_service # Get the server's IP address for the URL local ip ip=$(hostname -I 2>/dev/null | awk '{print $1}') if [ -z "$ip" ]; then ip="" fi echo "" echo -e "${GREEN}============================================================${NC}" echo -e "${GREEN} OpenAVC v${RELEASE_VERSION} installed successfully!${NC}" echo -e "${GREEN}============================================================${NC}" echo "" echo -e " Programmer IDE: ${BLUE}http://${ip}:${HTTP_PORT}/programmer${NC}" echo -e " Panel UI: ${BLUE}http://${ip}:${HTTP_PORT}/panel${NC}" echo -e " REST API: ${BLUE}http://${ip}:${HTTP_PORT}/api${NC}" echo "" echo -e " Service: systemctl {start|stop|restart|status} $SERVICE_NAME" echo -e " Logs: journalctl -u $SERVICE_NAME -f" echo -e " Data: $DATA_DIR/" echo "" echo -e " To upgrade later, re-run this script." echo "" } main "$@"