#!/usr/bin/env bash # # Ubuntu server setup script (run as your normal user, NOT root) # Interactive picker to choose what to install: # - System packages (X11, VNC, build tools, Qt/XCB libs) # - uv (installed for you, in ~/.local/bin) # - Hikrobot MvCamCtrlSDK runtime (download + install, auto-confirms /opt) # - Authorize SSH public key (alcanex) # - Disable boot-time network wait # - Kernel lock: disable all automatic apt updates/upgrades # Asks for your sudo password once at the start, only if a selected step needs it. # # Usage: # curl -fsSL https://server.mutlu.dev/install.sh | bash # curl -fsSL https://server.mutlu.dev/install.sh | bash -s -- --all # curl -fsSL https://server.mutlu.dev/install.sh | DEB_URL="https://.../sdk.zip" bash # set -Eeuo pipefail # ───────────────────────── Configuration ───────────────────────── INSTALL_URL="https://server.mutlu.dev/install.sh" # Direct link to the SDK: a .deb, or a .zip / .tar.gz that contains it. DEB_URL="${DEB_URL:-https://CHANGE-ME/MvCamCtrlSDK_Runtime-4.8.0_x86_64_20260512.deb}" DEB_NAME_PATTERN="MvCamCtrlSDK_Runtime*x86_64*.deb" DEB_FILE="${DEB_FILE:-}" # Public SSH keys to authorize (one per line) SSH_KEYS=( "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE/cQAULfzXXMrmLPxoAt7cvDO++2FDvQjGAoBseZQKa alcanex" ) APT_PACKAGES=( curl ca-certificates unzip file xorg xinit make unclutter build-essential brightnessctl tigervnc-standalone-server tigervnc-scraping-server fonts-noto-mono libxcb-cursor0 libxcb-xinerama0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xkb1 libxkbcommon-x11-0 libx11-xcb1 libglu1-mesa ) # When piped (curl | bash) there is no script file: look for a local .deb in $PWD if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" ]]; then SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" else SCRIPT_DIR="$PWD" fi # Everything lives in the user's home, no root-owned files WORK_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/server-setup" LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/server-setup" LOG_FILE="$LOG_DIR/install-$(date +%Y%m%d-%H%M%S).log" DL_FILE="$WORK_DIR/sdk-download" DEB_PATH_FILE="$WORK_DIR/deb_path" # ───────────────────────── UI helpers ───────────────────────── GREEN=$'\e[32m'; RED=$'\e[31m'; YELLOW=$'\e[33m'; CYAN=$'\e[36m' BOLD=$'\e[1m'; DIM=$'\e[2m'; RESET=$'\e[0m' SPINNER=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏) BAR_WIDTH=30 TOTAL_STEPS=0 CURRENT_STEP=0 STEP_STATUS="" # optional function printing extra live info for a step DL_TOTAL=0 SUDO_KEEPALIVE_PID="" cleanup() { [[ -n "$SUDO_KEEPALIVE_PID" ]] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true tput cnorm 2>/dev/null || true } trap cleanup EXIT trap 'echo; echo "${RED}Interrupted.${RESET}"; exit 130' INT TERM fmt_time() { printf '%d:%02d' $(($1 / 60)) $(($1 % 60)); } fmt_mb() { awk -v b="$1" 'BEGIN { printf "%.1f MB", b / 1048576 }'; } # ───────────────────────── Root helpers ───────────────────────── # -n: never prompt (credentials are cached at the start), fail instead of hanging as_root() { sudo -n "$@"; } apt_root() { sudo -n env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get "$@"; } ask_sudo() { echo "${BOLD}Some selected steps need administrator rights.${RESET}" if ! sudo -v -p "[sudo] password for $USER: " /dev/null; do sudo -n true 2>/dev/null; sleep 50; done ) & SUDO_KEEPALIVE_PID=$! } # Installs helper tools only if missing (for when "System packages" is unchecked) ensure_tools() { local missing=() t for t in "$@"; do command -v "$t" >/dev/null 2>&1 || missing+=("$t"); done if (( ${#missing[@]} )); then echo "Installing missing tools: ${missing[*]}" apt_root update apt_root install -y "${missing[@]}" ca-certificates fi } # ───────────────────────── Checkbox picker ───────────────────────── # Arrow keys / j k: move Space: toggle a: toggle all Enter: confirm q: quit picker() { local n=${#PICK_LABELS[@]} cur=0 key rest i first=1 all tput civis 2>/dev/null || true echo "${BOLD}? Select what to install${RESET}" echo "${DIM} ↑/↓ move · space toggle · a toggle all · enter confirm · q quit${RESET}" while true; do (( first )) || printf '\e[%dA' "$n" first=0 for (( i = 0; i < n; i++ )); do local pointer=" " box="${DIM}◯${RESET}" label="${PICK_LABELS[i]}" (( PICK_SELECTED[i] )) && box="${GREEN}◉${RESET}" if (( i == cur )); then pointer="${CYAN}❯${RESET} " label="${CYAN}${label}${RESET}" fi printf '\r\e[K%s%s %-30s %s\n' "$pointer" "$box" "$label" "${DIM}${PICK_DESCS[i]}${RESET}" done IFS= read -rsn1 key /dev/null || true } # ───────────────────────── Progress bar ───────────────────────── draw_bar() { # $1 completed steps, $2 spinner, $3 description, $4 elapsed s, $5 extra info local done=$1 pct filled empty pct=$(( done * 100 / TOTAL_STEPS )) filled=$(( done * BAR_WIDTH / TOTAL_STEPS )) empty=$(( BAR_WIDTH - filled )) printf '\r\e[K%s[%s%s]%s %3d%% (%d/%d) %s %s %s %s' \ "$CYAN" "$(printf '%*s' "$filled" '' | tr ' ' '#')" \ "$(printf '%*s' "$empty" '' | tr ' ' '-')" "$RESET" \ "$pct" "$CURRENT_STEP" "$TOTAL_STEPS" "$2" "$3" "${YELLOW}$(fmt_time "$4")${RESET}" "${5:-}" } run_step() { # $1 = description, $2 = function to run local desc=$1 fn=$2 start pid i=0 rc=0 extra CURRENT_STEP=$((CURRENT_STEP + 1)) start=$SECONDS echo "===== [$CURRENT_STEP/$TOTAL_STEPS] $desc =====" >>"$LOG_FILE" "$fn" >>"$LOG_FILE" 2>&1 /dev/null; do extra="" [[ -n "$STEP_STATUS" ]] && extra="$($STEP_STATUS)" draw_bar $((CURRENT_STEP - 1)) "${SPINNER[i++ % ${#SPINNER[@]}]}" "$desc" $((SECONDS - start)) "$extra" sleep 0.1 done wait "$pid" || rc=$? STEP_STATUS="" printf '\r\e[K' if [[ $rc -eq 0 ]]; then printf ' %s✔%s %s %s(%s)%s\n' "$GREEN" "$RESET" "$desc" "$YELLOW" "$(fmt_time $((SECONDS - start)))" "$RESET" else printf ' %s✘ %s failed (exit %d)%s\n\n' "$RED" "$desc" "$rc" "$RESET" echo "${BOLD}Last 25 lines of $LOG_FILE:${RESET}" tail -n 25 "$LOG_FILE" exit "$rc" fi } download_status() { local got got=$(stat -c %s "$DL_FILE" 2>/dev/null || echo 0) if (( DL_TOTAL > 0 )); then printf '%s / %s (%d%%)' "$(fmt_mb "$got")" "$(fmt_mb "$DL_TOTAL")" $(( got * 100 / DL_TOTAL )) else printf '%s' "$(fmt_mb "$got")" fi } # ───────────────────────── Steps ───────────────────────── step_apt_update() { # root apt_root update } step_apt_packages() { # root apt_root install -y "${APT_PACKAGES[@]}" } step_install_uv() { # user (root only if curl is missing) ensure_tools curl curl -LsSf https://astral.sh/uv/install.sh | sh } step_download_sdk() { # user (root only if tools are missing) ensure_tools curl unzip file rm -rf "$WORK_DIR/extract" "$DL_FILE" mkdir -p "$WORK_DIR/extract" echo "Downloading: $DEB_URL" curl -fL --retry 3 --retry-delay 3 --connect-timeout 20 -sS -o "$DL_FILE" "$DEB_URL" local mime deb="" mime=$(file -b --mime-type "$DL_FILE") echo "Downloaded file type: $mime" case "$mime" in application/vnd.debian.binary-package|application/x-debian-package) deb="$WORK_DIR/$(basename "${DEB_URL%%\?*}")" [[ "$deb" == *.deb ]] || deb="$WORK_DIR/MvCamCtrlSDK_Runtime.deb" mv "$DL_FILE" "$deb" ;; application/zip) unzip -o -q "$DL_FILE" -d "$WORK_DIR/extract" ;; application/gzip|application/x-gzip|application/x-tar|application/x-xz|application/x-bzip2) tar -xf "$DL_FILE" -C "$WORK_DIR/extract" ;; text/html|text/plain) echo "ERROR: Got a web page instead of a file. The link probably needs a login" echo "or is not a direct download link." return 1 ;; *) echo "ERROR: Unsupported download type: $mime" return 1 ;; esac # Archives sometimes contain nested archives: unpack those too if [[ -z "$deb" ]]; then find "$WORK_DIR/extract" -type f \( -name '*.tar.gz' -o -name '*.tgz' -o -name '*.tar' \) \ -exec tar -xf {} -C "$WORK_DIR/extract" \; find "$WORK_DIR/extract" -type f -name '*.zip' -exec unzip -o -q {} -d "$WORK_DIR/extract" \; deb=$(find "$WORK_DIR/extract" -type f -name "$DEB_NAME_PATTERN" | head -n1) [[ -n "$deb" ]] || deb=$(find "$WORK_DIR/extract" -type f -name '*Runtime*.deb' | head -n1) [[ -n "$deb" ]] || deb=$(find "$WORK_DIR/extract" -type f -name '*.deb' | head -n1) fi if [[ -z "$deb" || ! -f "$deb" ]]; then echo "ERROR: No .deb file found in the download. Contents:" find "$WORK_DIR/extract" -type f return 1 fi dpkg-deb --info "$deb" >/dev/null # verify it is a valid package echo "Using package: $deb" echo "$deb" >"$DEB_PATH_FILE" } step_camera_sdk() { # root local deb deb=$(cat "$DEB_PATH_FILE") # The package's install script asks to confirm installation to /opt. # Feed it "y" answers automatically. "|| true" stops yes's SIGPIPE # from failing the pipeline under pipefail. { yes y || true; } | apt_root install -y "$deb" } step_ssh_keys() { # user (root only for openssh-server) local ssh_dir="$HOME/.ssh" auth="$HOME/.ssh/authorized_keys" key if ! dpkg -s openssh-server >/dev/null 2>&1; then apt_root update apt_root install -y openssh-server fi systemctl is-enabled --quiet ssh 2>/dev/null && systemctl is-active --quiet ssh 2>/dev/null \ || as_root systemctl enable --now ssh mkdir -p "$ssh_dir" touch "$auth" for key in "${SSH_KEYS[@]}"; do # Compare on the key body only, so a different comment doesn't duplicate it if grep -qF "$(awk '{print $2}' <<<"$key")" "$auth"; then echo "Already authorized: ${key##* }" else echo "$key" >>"$auth" echo "Added: ${key##* }" fi done chmod 700 "$ssh_dir" chmod 600 "$auth" echo "Keys in $auth:" cat "$auth" } step_disable_wait_online() { # root as_root systemctl disable systemd-networkd-wait-online.service || true as_root systemctl mask systemd-networkd-wait-online.service } step_disable_unattended() { # root as_root systemctl disable --now unattended-upgrades || true as_root systemctl mask unattended-upgrades.service } step_disable_apt_timers() { # root as_root systemctl disable --now apt-daily.timer apt-daily-upgrade.timer || true as_root systemctl mask apt-daily.service apt-daily-upgrade.service } step_apt_config() { # root as_root tee /etc/apt/apt.conf.d/99-no-auto-upgrades >/dev/null <<'EOF' APT::Periodic::Update-Package-Lists "0"; APT::Periodic::Download-Upgradeable-Packages "0"; APT::Periodic::Unattended-Upgrade "0"; EOF cat /etc/apt/apt.conf.d/99-no-auto-upgrades } usage() { cat </dev/null || { echo "${RED}sudo is not installed.${RESET}"; exit 1; } mkdir -p "$WORK_DIR" "$LOG_DIR" rm -f "$DEB_PATH_FILE" # Look for a local .deb next to the script / in the current directory if [[ -z "$DEB_FILE" ]]; then DEB_FILE=$(find "$SCRIPT_DIR" -maxdepth 1 -type f -name "$DEB_NAME_PATTERN" | head -n1 || true) fi local SDK_DESC if [[ -n "$DEB_FILE" ]]; then SDK_DESC="local file: $(basename "$DEB_FILE")" elif [[ "$DEB_URL" == *CHANGE-ME* ]]; then SDK_DESC="needs DEB_URL" else SDK_DESC="download from DEB_URL" fi # ── Picker ── key | label | description | needs root local COMPONENTS=( "packages|System packages|xorg, VNC, build tools, XCB libs|1" "uv|uv|Python package manager (for $USER)|0" "sdk|MvCamCtrlSDK runtime|$SDK_DESC|1" "ssh|Authorize SSH key|alcanex → $USER|1" "waitonline|Disable networkd-wait-online|faster boot|1" "kernellock|Kernel lock|no unattended-upgrades, apt-daily timers or APT::Periodic|1" ) PICK_KEYS=(); PICK_LABELS=(); PICK_DESCS=(); PICK_SELECTED=(); local PICK_ROOT=() for c in "${COMPONENTS[@]}"; do IFS='|' read -r k l d r <<<"$c" PICK_KEYS+=("$k"); PICK_LABELS+=("$l"); PICK_DESCS+=("$d"); PICK_SELECTED+=(1); PICK_ROOT+=("$r") done echo echo "${BOLD}Ubuntu server setup${RESET} (running as ${CYAN}$USER${RESET})" echo if (( SELECT_ALL )); then echo "Installing everything (--all)." elif ! { true /dev/null; then echo "${YELLOW}No interactive terminal, installing everything.${RESET}" else picker fi declare -A WANT=() local NEED_ROOT=0 for i in "${!PICK_KEYS[@]}"; do if (( PICK_SELECTED[i] )); then WANT[${PICK_KEYS[i]}]=1 (( PICK_ROOT[i] )) && NEED_ROOT=1 fi done # uv only needs root if curl has to be installed first [[ -n "${WANT[uv]:-}" ]] && ! command -v curl >/dev/null && NEED_ROOT=1 if (( ${#WANT[@]} == 0 )); then echo "Nothing selected. Exiting." exit 0 fi # ── SDK source check ── local DOWNLOAD=0 if [[ -n "${WANT[sdk]:-}" ]]; then if [[ -n "$DEB_FILE" ]]; then [[ -f "$DEB_FILE" ]] || { echo "${RED}DEB_FILE not found:${RESET} $DEB_FILE"; exit 1; } realpath "$DEB_FILE" >"$DEB_PATH_FILE" elif [[ "$DEB_URL" == *CHANGE-ME* ]]; then echo "${RED}MvCamCtrlSDK selected but no download link set.${RESET}" echo "Edit DEB_URL at the top of this script, or run:" echo " curl -fsSL $INSTALL_URL | DEB_URL=\"https://your-link/sdk.zip\" bash" exit 1 else DOWNLOAD=1 fi fi # ── Ask for sudo once, up front ── echo (( NEED_ROOT )) && ask_sudo # ── Build step list ── local STEPS=() [[ -n "${WANT[packages]:-}" ]] && STEPS+=("Updating package lists|step_apt_update" \ "Installing system packages|step_apt_packages") [[ -n "${WANT[uv]:-}" ]] && STEPS+=("Installing uv|step_install_uv") if [[ -n "${WANT[sdk]:-}" ]]; then (( DOWNLOAD )) && STEPS+=("Downloading MvCamCtrlSDK|step_download_sdk") STEPS+=("Installing MvCamCtrlSDK runtime|step_camera_sdk") fi [[ -n "${WANT[ssh]:-}" ]] && STEPS+=("Authorizing SSH key|step_ssh_keys") [[ -n "${WANT[waitonline]:-}" ]] && STEPS+=("Disabling networkd-wait-online|step_disable_wait_online") [[ -n "${WANT[kernellock]:-}" ]] && STEPS+=("Kernel lock: disabling unattended-upgrades|step_disable_unattended" \ "Kernel lock: disabling apt-daily timers|step_disable_apt_timers" \ "Kernel lock: writing 99-no-auto-upgrades|step_apt_config") TOTAL_STEPS=${#STEPS[@]} touch "$LOG_FILE" echo "Log: $LOG_FILE" echo tput civis 2>/dev/null || true # ── Run ── local OVERALL_START=$SECONDS for entry in "${STEPS[@]}"; do fn="${entry##*|}" if [[ "$fn" == "step_download_sdk" ]]; then # Get file size up front so the download can show a percentage DL_TOTAL=$(curl -sIL --connect-timeout 10 "$DEB_URL" 2>/dev/null \ | awk 'tolower($1)=="content-length:" {v=$2} END {gsub(/\r/,"",v); print v+0}' || true) DL_TOTAL=${DL_TOTAL:-0} STEP_STATUS=download_status fi run_step "${entry%%|*}" "$fn" done draw_bar "$TOTAL_STEPS" "✔" "Done" $((SECONDS - OVERALL_START)) echo echo echo "${GREEN}${BOLD}Setup completed in $(fmt_time $((SECONDS - OVERALL_START))).${RESET}" echo "Log file: $LOG_FILE" [[ -n "${WANT[uv]:-}" ]] && echo "To use uv now, log out and back in, or run: source \$HOME/.local/bin/env" echo "A reboot is recommended." } main "$@"