#!/usr/bin/env bash # # Bivious one-command installer and updater. # # Usage: # curl -fsSL https://install.bivious.net | sudo bash # # Or locally: # sudo ./scripts/install.sh # # Supported hosts: # Ubuntu 26.04.x LTS (amd64, arm64) — requires Docker + Compose v2 (>= 2.33.1) # Debian 13.x (amd64, arm64) — requires Docker + Compose v2 (>= 2.33.1) # # Missing prerequisites are provisioned automatically. Package requirements # are planned first, repositories are prepared together, and APT installs the # resulting plan in one transaction when bootstrap tooling is already present. # # Environment overrides: # BIVIOUS_MANIFEST_URL — override manifest URL # BIVIOUS_RELEASE_BASE — override release download base URL # BIVIOUS_VERSION — force a specific version instead of latest # case "$-" in *x*) set +x ;; esac set -euo pipefail # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME readonly MANIFEST_URL="${BIVIOUS_MANIFEST_URL:-https://install.bivious.net/latest.json}" readonly RELEASE_BASE_URL="${BIVIOUS_RELEASE_BASE:-https://install.bivious.net/releases}" readonly BIVIOUS_PKG="bivious" readonly BIVIOUS_SERVICE="bivious-manager.service" readonly BOOTSTRAP_BIN="/opt/bivious/bin/bivious-bootstrap" readonly BOOTSTRAP_PENDING="/etc/bivious/bootstrap-pending" readonly BOOTSTRAP_MARKER="/etc/bivious/bootstrap-required" readonly SUPPORTED_UBUNTU_VERSIONS=("26.04") readonly SUPPORTED_DEBIAN_VERSIONS=("13") readonly SUPPORTED_PYTHON_MAJOR=3 readonly SUPPORTED_PYTHON_MIN=9 readonly SUPPORTED_PYTHON_MAX=14 readonly SERVICE_WAIT_TIMEOUT=30 readonly DOCKER_SERVICE="docker.service" readonly DOCKER_WAIT_TIMEOUT=45 readonly COMPOSE_MIN_VERSION="2.33.1" readonly DOCKER_APT_KEYRING="/etc/apt/keyrings/docker.asc" readonly DOCKER_APT_LIST="/etc/apt/sources.list.d/docker.list" readonly DOCKER_APT_KEY_URL="https://download.docker.com/linux" readonly CADDY_SERVICE="caddy.service" readonly CADDY_SETUP_SERVICE="caddy-setup.service" readonly CADDY_WAIT_TIMEOUT=60 readonly CADDY_APT_LIST="/etc/apt/sources.list.d/caddy-stable.list" readonly CADDY_DISABLED_APT_LIST="/etc/apt/sources.list.d/caddy-stable.list.bivious-disabled" readonly CADDY_RELEASE_BASE_URL="https://github.com/caddyserver/caddy/releases/download" # --------------------------------------------------------------------------- # Bridge netfilter isolation # --------------------------------------------------------------------------- # When the br_netfilter module is loaded with bridge-nf-call-iptables=1, # same-bridge container traffic traverses the host firewall, where Docker's # default DROP policy silently discards Bivious device forwarding between the # ingress and Connector namespaces. Keep bridged traffic out of iptables. ensure_bridge_netfilter_off() { if [ -d /proc/sys/net/bridge ]; then sysctl -w net.bridge.bridge-nf-call-iptables=0 >/dev/null || true fi # Persist across reboots; the '-' prefix tolerates an unloaded module. printf '%s\n' '-net.bridge.bridge-nf-call-iptables = 0' \ > /etc/sysctl.d/90-bivious-bridge.conf # Prevent silent autoload by other software; explicit loads still work. printf '%s\n' 'blacklist br_netfilter' \ > /etc/modprobe.d/bivious-no-br-netfilter.conf # Docker's daemon explicitly modprobes br_netfilter on every start — after # systemd-sysctl has run at boot — so re-apply the correction from inside the # unit itself. Guarded so a missing module never fails docker.service. install -d -m 0755 /etc/systemd/system/docker.service.d cat > /etc/systemd/system/docker.service.d/bivious-bridge.conf <<'EOF' [Service] ExecStartPost=-/bin/sh -c '[ -d /proc/sys/net/bridge ] && sysctl -w net.bridge.bridge-nf-call-iptables=0 || true' EOF systemctl daemon-reload 2>/dev/null || true } # Populated by preflight checks and installation planning. DEB_ARCH="" OS_ID="" OS_VERSION="" OS_CODENAME="" BOOTSTRAP_PACKAGES=() PACKAGES=() NEED_DOCKER_REPOSITORY=0 NEED_CADDY_PACKAGE=0 BIVIOUS_ACTION="none" BIVIOUS_FORCE_REINSTALL=0 BIVIOUS_TARGET_VERSION="" BIVIOUS_APT_DEB="" CADDY_TARGET_VERSION="" CADDY_TARGET_SHA512="" CADDY_APT_DEB="" STAGED_FILES=() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- die() { printf 'error: %s\n' "$1" >&2 exit 1 } info() { printf ':: %s\n' "$1" } array_contains() { local needle="$1" shift local item for item in "$@"; do if [ "$item" = "$needle" ]; then return 0 fi done return 1 } add_bootstrap_package() { local package="$1" if [ "${#BOOTSTRAP_PACKAGES[@]}" -eq 0 ] || \ ! array_contains "$package" "${BOOTSTRAP_PACKAGES[@]}"; then BOOTSTRAP_PACKAGES+=("$package") fi } add_package() { local package="$1" if [ "${#PACKAGES[@]}" -eq 0 ] || ! array_contains "$package" "${PACKAGES[@]}"; then PACKAGES+=("$package") fi } package_is_installed() { [ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null || true)" = "installed" ] } add_package_if_missing() { local package="$1" if ! package_is_installed "$package"; then add_package "$package" fi } add_bootstrap_package_if_missing() { local package="$1" if ! package_is_installed "$package"; then add_bootstrap_package "$package" fi } openssl_available() { [ -x /usr/bin/openssl ] } python_available() { [ -x /usr/bin/python3 ] } downloader_available() { command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 } # --------------------------------------------------------------------------- # Cleanup # --------------------------------------------------------------------------- WORK_DIR="" BOOTSTRAP_MODE="" BOOTSTRAP_TOKEN="" BOOTSTRAP_TOKEN_FILE="" BOOTSTRAP_SHOW_TOKEN=0 BOOTSTRAP_RESTART_MANAGER=0 cleanup() { local staged status=$? if [ "${#STAGED_FILES[@]}" -gt 0 ]; then for staged in "${STAGED_FILES[@]}"; do if [ -f "$staged" ]; then rm -f "$staged" fi done fi if [ -n "$BIVIOUS_APT_DEB" ] && [ -f "$BIVIOUS_APT_DEB" ]; then rm -f "$BIVIOUS_APT_DEB" fi if [ -n "$CADDY_APT_DEB" ] && [ -f "$CADDY_APT_DEB" ]; then rm -f "$CADDY_APT_DEB" fi if [ -n "$WORK_DIR" ] && [ -d "$WORK_DIR" ]; then rm -rf "$WORK_DIR" fi if [ "$status" -ne 0 ] && [ "$BOOTSTRAP_RESTART_MANAGER" -eq 1 ] && \ [ ! -e "$BOOTSTRAP_PENDING" ]; then systemctl start "$BIVIOUS_SERVICE" >/dev/null 2>&1 || true fi } bootstrap_error() { printf 'error: %s\n' "$1" >&2 exit 2 } parse_installer_options() { while [ "$#" -gt 0 ]; do case "$1" in --generate-bootstrap-token|--bootstrap-token-file|--bootstrap-token) [ -z "$BOOTSTRAP_MODE" ] || bootstrap_error "bootstrap options are mutually exclusive" BOOTSTRAP_MODE="${1#--}" if [ "$BOOTSTRAP_MODE" = "generate-bootstrap-token" ]; then shift else [ "$#" -ge 2 ] && [ -n "${2:-}" ] || bootstrap_error "bootstrap option requires a value" if [ "$BOOTSTRAP_MODE" = "bootstrap-token-file" ]; then BOOTSTRAP_TOKEN_FILE="$2" else BOOTSTRAP_TOKEN="$2" [[ "$BOOTSTRAP_TOKEN" =~ ^[0-9a-f]{64}$ ]] || bootstrap_error "bootstrap token format is invalid" fi shift 2 fi ;; --show-bootstrap-token) BOOTSTRAP_SHOW_TOKEN=1 shift ;; *) bootstrap_error "unsupported installer option" ;; esac done if [ "$BOOTSTRAP_SHOW_TOKEN" -eq 1 ] && [ "$BOOTSTRAP_MODE" != "generate-bootstrap-token" ]; then bootstrap_error "--show-bootstrap-token requires --generate-bootstrap-token" fi if [ "$BOOTSTRAP_SHOW_TOKEN" -eq 1 ] && [ ! -t 1 ]; then bootstrap_error "explicit token display requires a terminal" fi } validate_bootstrap_file() { [ -n "$BOOTSTRAP_TOKEN_FILE" ] || return 0 command -v python3 >/dev/null 2>&1 || bootstrap_error "Python is required to validate the bootstrap token file" python3 - "$BOOTSTRAP_TOKEN_FILE" <<'PY' import os, re, stat, sys try: fd = os.open(sys.argv[1], os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) with os.fdopen(fd, "rb") as f: info = os.fstat(f.fileno()) data = f.read(67) if not stat.S_ISREG(info.st_mode) or len(data) > 66: raise ValueError if data.endswith(b"\r\n"): data = data[:-2] elif data.endswith(b"\n"): data = data[:-1] if not re.fullmatch(rb"[0-9a-f]{64}", data): raise ValueError except Exception: print("error: bootstrap token file is unreadable or invalid", file=sys.stderr) raise SystemExit(2) PY [ "$?" -eq 0 ] || exit 2 } bootstrap_state_exists() { [ -f /var/lib/bivious/bridge-manager.json ] || return 1 python3 - <<'PY' import json try: state = json.load(open("/var/lib/bivious/bridge-manager.json", encoding="utf-8")) bootstrap = state.get("bootstrap") raise SystemExit(0 if state.get("version") == 2 and isinstance(bootstrap, dict) and bootstrap.get("required") is True else 1) except SystemExit: raise except Exception: raise SystemExit(1) PY } prepare_bootstrap_guard() { [ -n "$BOOTSTRAP_MODE" ] || return 0 install -d -o root -g root -m 0755 /etc/bivious if systemctl is-active --quiet "$BIVIOUS_SERVICE"; then BOOTSTRAP_RESTART_MANAGER=1 systemctl stop "$BIVIOUS_SERVICE" || bootstrap_error "could not stop Bridge Manager safely" fi if [ -e "$BOOTSTRAP_MARKER" ]; then return 0 fi if python_available; then if bootstrap_state_exists; then return 0 fi local result if python3 - <<'PY' from pathlib import Path path = Path("/var/lib/bivious/bridge-manager.json") if path.exists(): try: state = __import__("json").loads(path.read_text()) if state.get("appliance", {}).get("setup_complete") or state.get("administrator") is not None: raise SystemExit(3) except SystemExit: raise except Exception: print("error: appliance state is unreadable; refusing bootstrap setup") raise SystemExit(2) PY then result=0; else result=$?; fi if [ "$result" -eq 3 ]; then [ "$BOOTSTRAP_RESTART_MANAGER" -eq 0 ] || systemctl start "$BIVIOUS_SERVICE" >/dev/null 2>&1 || true BOOTSTRAP_RESTART_MANAGER=0 bootstrap_error "appliance is already claimed" fi [ "$result" -eq 0 ] || bootstrap_error "appliance state is unreadable" fi if [ ! -e "$BOOTSTRAP_MARKER" ] && [ ! -e "$BOOTSTRAP_PENDING" ]; then (umask 077; printf 'provisioning\n' > "$BOOTSTRAP_PENDING") chown root:root "$BOOTSTRAP_PENDING" chmod 0600 "$BOOTSTRAP_PENDING" fi } provision_bootstrap() { if [ -z "$BOOTSTRAP_MODE" ]; then [ -e "$BOOTSTRAP_PENDING" ] || return 0 fi [ -x "$BOOTSTRAP_BIN" ] || bootstrap_error "bootstrap management helper is unavailable" if [ -z "$BOOTSTRAP_MODE" ]; then BOOTSTRAP_RESTART_MANAGER=1 if systemctl is-active --quiet "$BIVIOUS_SERVICE"; then systemctl stop "$BIVIOUS_SERVICE" || bootstrap_error "could not stop Bridge Manager safely" fi "$BOOTSTRAP_BIN" recover || bootstrap_error "existing bootstrap protection could not be verified; setup remains blocked" rm -f "$BOOTSTRAP_PENDING" systemctl daemon-reload || bootstrap_error "could not release Bridge Manager startup guard" return 0 fi local arguments=(provision) case "$BOOTSTRAP_MODE" in generate-bootstrap-token) arguments+=(--generate-bootstrap-token) ;; bootstrap-token-file) arguments+=(--bootstrap-token-file "$BOOTSTRAP_TOKEN_FILE") ;; bootstrap-token) arguments+=(--bootstrap-token "$BOOTSTRAP_TOKEN") ;; esac [ "$BOOTSTRAP_SHOW_TOKEN" -eq 0 ] || arguments+=(--show-bootstrap-token) "$BOOTSTRAP_BIN" "${arguments[@]}" || bootstrap_error "bootstrap provisioning failed; setup remains protected" if [ -e "$BOOTSTRAP_PENDING" ]; then rm -f "$BOOTSTRAP_PENDING" systemctl daemon-reload || bootstrap_error "could not release Bridge Manager startup guard" fi } # --------------------------------------------------------------------------- # Preflight: root # --------------------------------------------------------------------------- check_root() { if [ "$(id -u)" -ne 0 ]; then printf 'error: this script must be run as root (use: sudo %s)\n' "$SCRIPT_NAME" >&2 return 1 fi } # --------------------------------------------------------------------------- # Preflight: operating system # --------------------------------------------------------------------------- check_os() { if [ ! -f /etc/os-release ]; then printf 'error: cannot determine OS — /etc/os-release not found\n' >&2 return 1 fi # shellcheck disable=SC1091 . /etc/os-release local id="${ID:-}" local version_id="${VERSION_ID:-}" case "$id" in ubuntu) local match=0 for v in "${SUPPORTED_UBUNTU_VERSIONS[@]}"; do if [ "$version_id" = "$v" ] || [[ "$version_id" == "$v".* ]]; then match=1 break fi done if [ "$match" -eq 0 ]; then printf 'error: unsupported Ubuntu version: %s (supported: %s.x)\n' \ "$version_id" "${SUPPORTED_UBUNTU_VERSIONS[*]}" >&2 return 1 fi OS_ID="ubuntu" OS_VERSION="$version_id" ;; debian) local match=0 for v in "${SUPPORTED_DEBIAN_VERSIONS[@]}"; do if [ "$version_id" = "$v" ] || [[ "$version_id" == "$v".* ]]; then match=1 break fi done if [ "$match" -eq 0 ]; then printf 'error: unsupported Debian version: %s (supported: %s.x)\n' \ "$version_id" "${SUPPORTED_DEBIAN_VERSIONS[*]}" >&2 return 1 fi OS_ID="debian" OS_VERSION="$version_id" ;; *) printf 'error: unsupported OS: %s (supported: Ubuntu %s.x, Debian %s.x)\n' \ "${id:-unknown}" "${SUPPORTED_UBUNTU_VERSIONS[*]}" "${SUPPORTED_DEBIAN_VERSIONS[*]}" >&2 return 1 ;; esac OS_CODENAME="${VERSION_CODENAME:-}" if [ -z "$OS_CODENAME" ]; then printf 'error: cannot determine OS codename — VERSION_CODENAME is missing\n' >&2 return 1 fi info "detected ${OS_ID} ${OS_VERSION} (${OS_CODENAME})" } # --------------------------------------------------------------------------- # Preflight: architecture # --------------------------------------------------------------------------- check_arch() { local raw_arch="" if command -v dpkg >/dev/null 2>&1; then raw_arch="$(dpkg --print-architecture)" elif command -v uname >/dev/null 2>&1; then raw_arch="$(uname -m)" else printf 'error: cannot determine architecture — neither dpkg nor uname available\n' >&2 return 1 fi case "$raw_arch" in amd64|x86_64) DEB_ARCH="amd64" ;; arm64|aarch64) DEB_ARCH="arm64" ;; *) printf 'error: unsupported architecture: %s (supported: amd64/x86_64, arm64/aarch64)\n' \ "$raw_arch" >&2 return 1 ;; esac info "detected architecture: ${DEB_ARCH}" } # --------------------------------------------------------------------------- # Preflight: /dev/net/tun # --------------------------------------------------------------------------- check_tun() { if [ ! -c /dev/net/tun ]; then printf 'error: /dev/net/tun is not a character device — TUN support is required\n' >&2 return 1 fi } # --------------------------------------------------------------------------- # Preflight: python3 # --------------------------------------------------------------------------- validate_python() { if [ ! -x /usr/bin/python3 ]; then printf 'error: /usr/bin/python3 not found after package installation\n' >&2 return 1 fi local py_version py_version="$(/usr/bin/python3 -c " import sys print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}') ")" local py_major py_minor py_patch py_major="${py_version%%.*}" py_patch="${py_version##*.}" py_minor="${py_version#*.}" py_minor="${py_minor%%.*}" if [ "$py_major" -eq 3 ] && [ "$py_minor" -eq 9 ] && [ "$py_patch" -lt 2 ]; then printf 'error: Python 3.9 requires patch release 3.9.2 or newer\n' >&2 return 1 fi if [ "$py_major" -ne "$SUPPORTED_PYTHON_MAJOR" ]; then printf 'error: Python %s is not supported (requires %d.%d–%d.%d)\n' \ "$py_version" "$SUPPORTED_PYTHON_MAJOR" "$SUPPORTED_PYTHON_MIN" \ "$SUPPORTED_PYTHON_MAJOR" "$SUPPORTED_PYTHON_MAX" >&2 return 1 fi if [ "$py_minor" -lt "$SUPPORTED_PYTHON_MIN" ] || [ "$py_minor" -gt "$SUPPORTED_PYTHON_MAX" ]; then printf 'error: Python %s is not supported (requires %d.%d–%d.%d)\n' \ "$py_version" "$SUPPORTED_PYTHON_MAJOR" "$SUPPORTED_PYTHON_MIN" \ "$SUPPORTED_PYTHON_MAJOR" "$SUPPORTED_PYTHON_MAX" >&2 return 1 fi info "detected Python ${py_version}" } # --------------------------------------------------------------------------- # Preflight: systemd # --------------------------------------------------------------------------- check_systemd() { if [ ! -d /run/systemd/system ]; then printf 'error: systemd not detected (/run/systemd/system missing)\n' >&2 return 1 fi } # --------------------------------------------------------------------------- # Preflight: package management tools # --------------------------------------------------------------------------- check_package_manager() { local missing="" if ! command -v dpkg >/dev/null 2>&1; then missing="${missing} dpkg" fi if ! command -v apt-get >/dev/null 2>&1; then missing="${missing} apt-get" fi if [ -n "$missing" ]; then printf 'error: missing required tools:%s\n' "$missing" >&2 return 1 fi } # --------------------------------------------------------------------------- # Docker Compose plugin detection # --------------------------------------------------------------------------- # Print the installed Compose plugin version as x.y.z, or nothing when the # plugin is missing or reports an unparseable version. compose_plugin_version() { local raw raw="$(docker compose version --short 2>/dev/null)" || return 0 printf '%s\n' "$raw" | \ sed -n 's/^[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' } # --------------------------------------------------------------------------- # Preflight: run all checks # --------------------------------------------------------------------------- run_preflight() { info "running preflight checks..." local failures=0 for check in check_root check_package_manager check_os check_arch check_tun \ check_systemd; do if ! "$check"; then failures=$((failures + 1)) fi done if [ "$failures" -gt 0 ]; then die "${failures} preflight check(s) failed — fix the issues above and retry" fi if python_available && ! validate_python; then die "installed Python version is not supported" fi info "all preflight checks passed" } # --------------------------------------------------------------------------- # Version management # --------------------------------------------------------------------------- get_installed_version() { dpkg-query -W -f '${Version}' "$BIVIOUS_PKG" 2>/dev/null || echo "" } # Compare two Debian version strings. # Returns 0 if $1 > $2, 1 otherwise. version_gt() { dpkg --compare-versions "$1" gt "$2" } # Compare two dotted version strings (e.g. Compose x.y.z). # Returns 0 if $1 >= $2, 1 otherwise. version_ge() { dpkg --compare-versions "$1" ge "$2" } fetch_latest_version() { local manifest_content="" if command -v curl >/dev/null 2>&1; then manifest_content="$(curl -fsSL --connect-timeout 10 --max-time 30 "$MANIFEST_URL" 2>&1)" || \ die "failed to fetch version manifest from ${MANIFEST_URL} — check network connectivity" elif command -v wget >/dev/null 2>&1; then manifest_content="$(wget -qO- --timeout=30 "$MANIFEST_URL" 2>&1)" || \ die "failed to fetch version manifest from ${MANIFEST_URL} — check network connectivity" else die "neither curl nor wget available — cannot fetch version manifest" fi if [ -z "$manifest_content" ]; then die "failed to fetch version manifest from ${MANIFEST_URL}" fi # Parse "latest" field from JSON without jq. # Anchored to line start; version value restricted to valid Debian version chars. local version version="$(printf '%s' "$manifest_content" | \ sed -n 's/^[[:space:]]*"latest"[[:space:]]*:[[:space:]]*"\([0-9][0-9a-zA-Z.+~-]*\)".*/\1/p' | \ head -n1)" if [ -z "$version" ]; then die "could not parse latest version from manifest" fi echo "$version" } # --------------------------------------------------------------------------- # Download and checksum verification # --------------------------------------------------------------------------- download_file() { local url="$1" local dest="$2" if command -v curl >/dev/null 2>&1; then curl -fsSL --retry 3 --connect-timeout 10 --max-time 120 -o "$dest" "$url" || \ die "failed to download ${url}" elif command -v wget >/dev/null 2>&1; then wget -q --timeout=120 -O "$dest" "$url" || \ die "failed to download ${url}" else die "neither curl nor wget available" fi } verify_checksum() { local file_path="$1" local checksum_url="$2" local checksum_content="" download_file "$checksum_url" "${file_path}.sha256" checksum_content="$(cat "${file_path}.sha256")" # The .sha256 file contains the hex hash, possibly followed by whitespace and # a filename reference. Extract the first 64-hex-char token. local expected_hash expected_hash="$(printf '%s' "$checksum_content" | tr -d '[:space:]' | cut -c1-64)" if [ "${#expected_hash}" -ne 64 ]; then die "invalid SHA-256 checksum format in ${checksum_url}" fi local actual_hash actual_hash="$(openssl dgst -sha256 "$file_path" | awk '{print $NF}')" if [ "$expected_hash" != "$actual_hash" ]; then die "SHA-256 checksum mismatch for $(basename "$file_path"): expected: ${expected_hash} actual: ${actual_hash}" fi info "checksum verified: $(basename "$file_path")" rm -f "${file_path}.sha256" } # --------------------------------------------------------------------------- # Installation planning # --------------------------------------------------------------------------- reset_installation_plan() { BOOTSTRAP_PACKAGES=() PACKAGES=() NEED_DOCKER_REPOSITORY=0 NEED_CADDY_PACKAGE=0 } plan_base_packages() { add_bootstrap_package_if_missing ca-certificates if ! openssl_available; then add_bootstrap_package openssl fi if ! downloader_available; then add_bootstrap_package curl fi if ! python_available; then if [ -n "$BOOTSTRAP_MODE" ]; then add_bootstrap_package python3 else add_package python3 fi fi add_package_if_missing iproute2 } installed_docker_conflicts() { local conflicts=() local package for package in docker.io docker-compose docker-compose-v2 docker-doc docker-buildx \ podman-docker containerd runc; do if package_is_installed "$package"; then conflicts+=("$package") fi done if [ "${#conflicts[@]}" -gt 0 ]; then printf '%s\n' "${conflicts[*]}" fi } plan_docker() { if package_is_installed podman-docker; then die "podman-docker is installed, but Bivious requires Docker Engine and ${DOCKER_SERVICE}; remove or migrate it before retrying" fi if ! command -v docker >/dev/null 2>&1; then local conflicts conflicts="$(installed_docker_conflicts)" if [ -n "$conflicts" ]; then die "Docker is not usable, but conflicting packages are installed: ${conflicts}. Resolve or remove them before installing Docker CE; /var/lib/docker will not be modified" fi info "Docker not found — planning Docker CE and Compose" NEED_DOCKER_REPOSITORY=1 add_package_if_missing docker-ce add_package_if_missing docker-ce-cli add_package_if_missing containerd.io add_package_if_missing docker-buildx-plugin add_package_if_missing docker-compose-plugin return 0 fi local docker_version docker_version="$(docker --version 2>/dev/null)" || docker_version="Docker (version unknown)" info "detected Docker: ${docker_version}" local compose_version compose_version="$(compose_plugin_version)" if [ -z "$compose_version" ]; then info "Docker Compose plugin not found — planning installation" NEED_DOCKER_REPOSITORY=1 add_package docker-compose-plugin elif ! version_ge "$compose_version" "$COMPOSE_MIN_VERSION"; then info "detected Docker Compose ${compose_version} — planning upgrade to >= ${COMPOSE_MIN_VERSION}" NEED_DOCKER_REPOSITORY=1 add_package docker-compose-plugin else info "detected Docker Compose: ${compose_version}" fi } get_caddy_version_output() { caddy version 2>/dev/null } # Print a stable Caddy x.y.z version from `caddy version` output, or nothing # when the command reports a prerelease or an unrecognized format. parse_caddy_version() { local version_output="$1" local first_line="${version_output%%$'\n'*}" printf '%s\n' "$first_line" | \ sed -nE 's/^[[:space:]]*v?([0-9]+\.[0-9]+\.[0-9]+)(\+[0-9A-Za-z.-]+)?([[:space:]].*)?[[:space:]]*$/\1/p' } caddy_version_for_bivious() { case "$1" in # Keep every published stable release explicit. New Bivious versions must # be paired with a Caddy release before their installer can be published. 0.4.0|0.3.2|0.3.0|0.2.9|0.2.8|0.2.7|0.2.6|0.2.5|0.2.4|0.2.3) printf '%s\n' '2.11.7' ;; *) return 1 ;; esac } caddy_deb_sha512() { case "$1:$2" in 2.11.7:amd64) printf '%s\n' '47e8351c2317b427af14a103e763ca1118a3d2396a88b4c0669cdec9c4a68a957690194e2423a1633f53135741c33a41bdac2b55515b7d0f7adc8b733add50d9' ;; 2.11.7:arm64) printf '%s\n' 'ac32f03f0eea04021f2d4e5d89bfd01b84ead115f2eba9d2a0d06447e86d4af932899e56f090993a566d0925dbd1063002929b395541a8c5f5e51d7039f7375a' ;; *) return 1 ;; esac } resolve_bivious_target_version() { BIVIOUS_TARGET_VERSION="${BIVIOUS_VERSION:-}" if [ -z "$BIVIOUS_TARGET_VERSION" ]; then info "resolving latest version..." BIVIOUS_TARGET_VERSION="$(fetch_latest_version)" fi info "target version: ${BIVIOUS_TARGET_VERSION}" } plan_caddy() { local caddy_version_output="" local caddy_version="" CADDY_TARGET_VERSION="$(caddy_version_for_bivious "$BIVIOUS_TARGET_VERSION")" || \ die "no Caddy release is pinned for Bivious ${BIVIOUS_TARGET_VERSION}" CADDY_TARGET_SHA512="$(caddy_deb_sha512 "$CADDY_TARGET_VERSION" "$DEB_ARCH")" || \ die "no Caddy ${CADDY_TARGET_VERSION} package checksum is pinned for ${DEB_ARCH}" if command -v caddy >/dev/null 2>&1; then caddy_version_output="$(get_caddy_version_output)" || caddy_version_output="" caddy_version="$(parse_caddy_version "$caddy_version_output")" if [ "$caddy_version" = "$CADDY_TARGET_VERSION" ]; then info "detected pinned Caddy: ${caddy_version}" return 0 fi if [ -n "$caddy_version_output" ]; then info "detected Caddy ${caddy_version_output} — planning pin to ${CADDY_TARGET_VERSION} for Bivious ${BIVIOUS_TARGET_VERSION}" else info "could not determine installed Caddy version — planning pin to ${CADDY_TARGET_VERSION} for Bivious ${BIVIOUS_TARGET_VERSION}" fi else info "Caddy not found — planning installation of ${CADDY_TARGET_VERSION} for Bivious ${BIVIOUS_TARGET_VERSION}" fi NEED_CADDY_PACKAGE=1 } print_installation_plan() { if [ "${#BOOTSTRAP_PACKAGES[@]}" -gt 0 ]; then info "bootstrap packages: ${BOOTSTRAP_PACKAGES[*]}" else info "bootstrap packages: none" fi local repositories=() [ "$NEED_DOCKER_REPOSITORY" -eq 1 ] && repositories+=(docker) if [ "${#repositories[@]}" -gt 0 ]; then info "repositories to prepare: ${repositories[*]}" else info "repositories to prepare: none" fi if [ "${#PACKAGES[@]}" -gt 0 ]; then info "packages to install: ${PACKAGES[*]}" else info "packages to install: none" fi if [ "$NEED_CADDY_PACKAGE" -eq 1 ]; then info "Caddy package to install: ${CADDY_TARGET_VERSION} (${DEB_ARCH}, pinned for Bivious ${BIVIOUS_TARGET_VERSION})" else info "Caddy package already matches pinned version ${CADDY_TARGET_VERSION}" fi } build_installation_plan() { info "building installation plan..." reset_installation_plan plan_base_packages plan_docker } # --------------------------------------------------------------------------- # Bootstrap and repository preparation # --------------------------------------------------------------------------- install_bootstrap_packages() { if [ "${#BOOTSTRAP_PACKAGES[@]}" -eq 0 ]; then return 0 fi info "refreshing package lists for bootstrap dependencies..." apt-get update || die "apt-get update failed — bootstrap installation aborted" info "installing bootstrap dependencies..." DEBIAN_FRONTEND=noninteractive \ apt-get install -y --no-install-recommends "${BOOTSTRAP_PACKAGES[@]}" || \ die "bootstrap dependency installation failed" } validate_bootstrap_tools() { if [ -n "$BOOTSTRAP_MODE" ]; then validate_python || die "Python validation failed before bootstrap provisioning" validate_bootstrap_file fi if ! openssl_available; then die "/usr/bin/openssl not found after bootstrap installation" fi if ! downloader_available; then die "neither curl nor wget available after bootstrap installation" fi } ensure_work_dir() { if [ -z "$WORK_DIR" ]; then WORK_DIR="$(mktemp -d)" fi } install_if_changed() { local source="$1" local destination="$2" local mode="$3" if [ -f "$destination" ] && cmp -s "$source" "$destination"; then chmod "$mode" "$destination" || die "failed to set permissions on ${destination}" info "repository file already current: ${destination}" return 0 fi local staged="${destination}.bivious.$$" STAGED_FILES+=("$staged") install -m "$mode" "$source" "$staged" || die "failed to stage ${destination}" mv -f "$staged" "$destination" || die "failed to replace ${destination}" info "updated repository file: ${destination}" } prepare_docker_repository() { info "preparing Docker apt repository..." ensure_work_dir install -m 0755 -d "$(dirname "$DOCKER_APT_KEYRING")" install -m 0755 -d "$(dirname "$DOCKER_APT_LIST")" local key="${WORK_DIR}/docker.asc" local list="${WORK_DIR}/docker.list" download_file "${DOCKER_APT_KEY_URL}/${OS_ID}/gpg" "$key" if [ ! -s "$key" ] || ! grep -q '^-----BEGIN PGP PUBLIC KEY BLOCK-----$' "$key"; then die "downloaded Docker repository key is invalid" fi printf 'deb [arch=%s signed-by=%s] https://download.docker.com/linux/%s %s stable\n' \ "$DEB_ARCH" "$DOCKER_APT_KEYRING" "$OS_ID" "$OS_CODENAME" > "$list" install_if_changed "$key" "$DOCKER_APT_KEYRING" 0644 install_if_changed "$list" "$DOCKER_APT_LIST" 0644 } disable_legacy_caddy_repository() { local active_list="${1:-$CADDY_APT_LIST}" local preserved_list="${2:-$CADDY_DISABLED_APT_LIST}" [ -f "$active_list" ] || return 0 grep -Fq 'dl.cloudsmith.io/public/caddy/stable' "$active_list" || return 0 if [ -e "$preserved_list" ]; then if cmp -s "$active_list" "$preserved_list"; then rm -f "$active_list" else die "legacy Caddy Cloudsmith source is still active and its preserved copy already exists at ${preserved_list}" fi else mv "$active_list" "$preserved_list" || \ die "failed to disable legacy Caddy Cloudsmith source at ${active_list}" fi info "disabled legacy Caddy Cloudsmith source; preserved it at ${preserved_list}" } prepare_required_repositories() { if [ "$NEED_DOCKER_REPOSITORY" -eq 1 ]; then prepare_docker_repository fi } # --------------------------------------------------------------------------- # Bivious package preparation and consolidated installation # --------------------------------------------------------------------------- prepare_bivious_package() { local installed_version installed_version="$(get_installed_version)" BIVIOUS_ACTION="install" if [ -n "$installed_version" ]; then if [ "$installed_version" = "$BIVIOUS_TARGET_VERSION" ]; then if [ -n "$BOOTSTRAP_MODE" ]; then BIVIOUS_ACTION="update" BIVIOUS_FORCE_REINSTALL=1 info "reinstalling Bivious ${installed_version} to apply requested bootstrap protection" else BIVIOUS_ACTION="none" info "Bivious ${installed_version} is already installed and up to date" return 0 fi else if version_gt "$installed_version" "$BIVIOUS_TARGET_VERSION"; then die "installed version ${installed_version} is newer than available ${BIVIOUS_TARGET_VERSION} — refusing to downgrade" fi BIVIOUS_ACTION="update" info "planning upgrade from ${installed_version} to ${BIVIOUS_TARGET_VERSION}" fi else info "planning Bivious ${BIVIOUS_TARGET_VERSION} installation" fi ensure_work_dir local target_version="$BIVIOUS_TARGET_VERSION" local deb_filename="${BIVIOUS_PKG}_${target_version}_${DEB_ARCH}.deb" local deb_url="${RELEASE_BASE_URL}/${target_version}/${deb_filename}" local deb_path="${WORK_DIR}/${deb_filename}" info "downloading ${deb_filename}..." download_file "$deb_url" "$deb_path" info "verifying package integrity..." verify_checksum "$deb_path" "${deb_url}.sha256" BIVIOUS_APT_DEB="/var/cache/apt/archives/${deb_filename}" cp "$deb_path" "$BIVIOUS_APT_DEB" || die "failed to copy .deb to apt cache" } prepare_caddy_package() { [ "$NEED_CADDY_PACKAGE" -eq 1 ] || return 0 ensure_work_dir local filename="caddy_${CADDY_TARGET_VERSION}_linux_${DEB_ARCH}.deb" local url="${CADDY_RELEASE_BASE_URL}/v${CADDY_TARGET_VERSION}/${filename}" local deb_path="${WORK_DIR}/${filename}" local actual_sha512 package_name package_version package_arch info "downloading pinned Caddy ${CADDY_TARGET_VERSION} package..." download_file "$url" "$deb_path" actual_sha512="$(openssl dgst -sha512 "$deb_path" | awk '{print $NF}')" [ "$actual_sha512" = "$CADDY_TARGET_SHA512" ] || \ die "SHA-512 checksum mismatch for ${filename}" info "checksum verified: ${filename}" package_name="$(dpkg-deb -f "$deb_path" Package)" package_version="$(dpkg-deb -f "$deb_path" Version)" package_arch="$(dpkg-deb -f "$deb_path" Architecture)" [ "$package_name" = caddy ] || die "downloaded Caddy package has unexpected package name: ${package_name}" [ "$package_version" = "$CADDY_TARGET_VERSION" ] || \ die "downloaded Caddy package has version ${package_version}; expected ${CADDY_TARGET_VERSION}" [ "$package_arch" = "$DEB_ARCH" ] || \ die "downloaded Caddy package has architecture ${package_arch}; expected ${DEB_ARCH}" CADDY_APT_DEB="$deb_path" } install_planned_packages() { local install_targets=() if [ "${#PACKAGES[@]}" -gt 0 ]; then install_targets=("${PACKAGES[@]}") fi if [ -n "$CADDY_APT_DEB" ]; then install_targets+=("$CADDY_APT_DEB") fi if [ -n "$BIVIOUS_APT_DEB" ] && [ "$BIVIOUS_FORCE_REINSTALL" -eq 0 ]; then install_targets+=("$BIVIOUS_APT_DEB") fi if [ "${#install_targets[@]}" -eq 0 ] && [ "$BIVIOUS_FORCE_REINSTALL" -eq 0 ]; then info "installation plan is empty — skipping APT" return 0 fi if [ "${#PACKAGES[@]}" -gt 0 ] || [ -n "$CADDY_APT_DEB" ]; then info "refreshing package lists after repository preparation..." apt-get update || die "apt-get update failed — planned package installation aborted" fi if [ "$BIVIOUS_ACTION" = "update" ]; then info "stopping Bivious service before upgrade..." systemctl stop "$BIVIOUS_SERVICE" 2>/dev/null || true fi info "installing planned packages..." if [ "${#install_targets[@]}" -gt 0 ]; then DEBIAN_FRONTEND=noninteractive \ apt-get install -y --no-install-recommends --allow-downgrades "${install_targets[@]}" || \ die "planned package installation failed" fi if [ "$BIVIOUS_FORCE_REINSTALL" -eq 1 ]; then DEBIAN_FRONTEND=noninteractive \ apt-get install --reinstall -y --no-install-recommends --allow-downgrades "$BIVIOUS_APT_DEB" || \ die "bootstrap-capable Bivious package installation failed" fi if [ -n "$BIVIOUS_APT_DEB" ]; then rm -f "$BIVIOUS_APT_DEB" BIVIOUS_APT_DEB="" fi if [ -n "$CADDY_APT_DEB" ]; then rm -f "$CADDY_APT_DEB" CADDY_APT_DEB="" fi } validate_installed_prerequisites() { validate_python || die "Python validation failed after package installation" openssl_available || die "/usr/bin/openssl not found after package installation" command -v ip >/dev/null 2>&1 || die "ip command not found after package installation" local caddy_path caddy_path="$(command -v caddy 2>/dev/null || true)" local caddy_version_output="" caddy_version_output="$(get_caddy_version_output)" || caddy_version_output="" local caddy_version caddy_version="$(parse_caddy_version "$caddy_version_output")" if [ "$caddy_version" != "$CADDY_TARGET_VERSION" ]; then local reported_version="${caddy_version_output:-unknown or unparseable}" [ -n "$caddy_path" ] || caddy_path="not found on PATH" die "Caddy ${CADDY_TARGET_VERSION} is required for Bivious ${BIVIOUS_TARGET_VERSION}; found ${reported_version} at ${caddy_path}" fi info "Caddy matches Bivious ${BIVIOUS_TARGET_VERSION} pin — version ${caddy_version}" } # --------------------------------------------------------------------------- # Component configuration # --------------------------------------------------------------------------- configure_docker() { info "enabling and starting Docker service..." systemctl enable "$DOCKER_SERVICE" || \ die "failed to enable Docker — check: systemctl status ${DOCKER_SERVICE}" systemctl start "$DOCKER_SERVICE" || \ die "failed to start Docker — check: systemctl status ${DOCKER_SERVICE}" info "waiting for Docker service to become active (timeout: ${DOCKER_WAIT_TIMEOUT}s)..." local elapsed=0 while [ "$elapsed" -lt "$DOCKER_WAIT_TIMEOUT" ]; do if systemctl is-active --quiet "$DOCKER_SERVICE" && docker info >/dev/null 2>&1; then local docker_version docker_version="$(docker info --format '{{.ServerVersion}}' 2>/dev/null)" || \ docker_version="$(docker --version 2>/dev/null)" || docker_version="unknown" info "Docker is active — version ${docker_version}" return 0 fi sleep 1 elapsed=$((elapsed + 1)) done die "Docker service did not reach active state within ${DOCKER_WAIT_TIMEOUT}s — check: journalctl -u ${DOCKER_SERVICE}" } validate_docker_compose() { local compose_version compose_version="$(compose_plugin_version)" if [ -z "$compose_version" ] || ! version_ge "$compose_version" "$COMPOSE_MIN_VERSION"; then die "Docker Compose >= ${COMPOSE_MIN_VERSION} is required but is not usable — see https://docs.docker.com/compose/install/linux/" fi info "Docker Compose is usable — version ${compose_version}" } configure_bivious_service() { if [ "$BIVIOUS_ACTION" = "none" ] && [ "$BOOTSTRAP_RESTART_MANAGER" -eq 0 ]; then return 0 fi info "reloading systemd daemon..." systemctl daemon-reload || die "failed to reload systemd daemon" if [ "$BIVIOUS_ACTION" = "install" ] || \ { [ "$BIVIOUS_ACTION" = "none" ] && [ "$BOOTSTRAP_RESTART_MANAGER" -eq 1 ]; }; then info "enabling and starting Bivious service..." systemctl enable "$BIVIOUS_SERVICE" || die "failed to enable ${BIVIOUS_SERVICE}" systemctl start "$BIVIOUS_SERVICE" || die "failed to start ${BIVIOUS_SERVICE}" else info "restarting Bivious service..." systemctl restart "$BIVIOUS_SERVICE" || die "failed to restart ${BIVIOUS_SERVICE}" fi info "waiting for Bivious service to become active (timeout: ${SERVICE_WAIT_TIMEOUT}s)..." local elapsed=0 while [ "$elapsed" -lt "$SERVICE_WAIT_TIMEOUT" ]; do if systemctl is-active --quiet "$BIVIOUS_SERVICE"; then info "Bivious ${BIVIOUS_TARGET_VERSION} ${BIVIOUS_ACTION} successful — service is active" return 0 fi sleep 1 elapsed=$((elapsed + 1)) done die "Bivious service did not reach active state within ${SERVICE_WAIT_TIMEOUT}s — check: journalctl -u ${BIVIOUS_SERVICE}" } # --------------------------------------------------------------------------- # Caddy setup # --------------------------------------------------------------------------- setup_caddy() { local caddy_setup="/opt/bivious/appliance/caddy-setup.sh" if [ ! -x "$caddy_setup" ]; then die "caddy-setup.sh not found at ${caddy_setup}" fi # The caddy package auto-starts caddy.service on install, occupying # ports 80/443 and tripping caddy-setup.sh's port check. info "stopping Caddy before configuration..." systemctl stop "$CADDY_SERVICE" 2>/dev/null || true info "running caddy-setup.sh..." "$caddy_setup" || die "caddy-setup.sh failed" info "enabling and starting Caddy service..." systemctl enable "$CADDY_SERVICE" || \ die "failed to enable Caddy — check: systemctl status ${CADDY_SERVICE}" # The Bivious package enables caddy-setup.service only when Caddy is already # present at package-configure time. A fresh install adds Caddy afterwards, # so enable the bootstrap unit here (idempotent on re-runs) to keep boot-time # HTTPS configuration intact. systemctl enable "$CADDY_SETUP_SERVICE" || \ die "failed to enable ${CADDY_SETUP_SERVICE} — check: systemctl status ${CADDY_SETUP_SERVICE}" systemctl start "$CADDY_SERVICE" || \ die "failed to start Caddy — check: systemctl status ${CADDY_SERVICE}" info "waiting for Caddy service to become active (timeout: ${CADDY_WAIT_TIMEOUT}s)..." local elapsed=0 while [ "$elapsed" -lt "$CADDY_WAIT_TIMEOUT" ]; do if systemctl is-active --quiet "$CADDY_SERVICE"; then info "Caddy is active" return 0 fi sleep 1 elapsed=$((elapsed + 1)) done die "Caddy service did not reach active state within ${CADDY_WAIT_TIMEOUT}s — check: journalctl -u ${CADDY_SERVICE}" } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- main() { parse_installer_options "$@" trap cleanup EXIT info "Bivious installer" run_preflight prepare_bootstrap_guard build_installation_plan disable_legacy_caddy_repository install_bootstrap_packages validate_bootstrap_tools resolve_bivious_target_version plan_caddy print_installation_plan prepare_required_repositories prepare_bivious_package prepare_caddy_package install_planned_packages validate_installed_prerequisites provision_bootstrap ensure_bridge_netfilter_off configure_docker validate_docker_compose setup_caddy configure_bivious_service info "done" } if [ -z "${BASH_SOURCE[0]:-}" ] || [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@" fi