#!/bin/sh # Ham Lab installer. # # curl -fsSL https://git.bigham.dev/mitchell/hamlab/releases/download/latest/install.sh | sh # # With a terminal it walks you through it: who the console runs as, where it listens, # HTTPS, the firewall, the password — then downloads the build for this machine, checks # it against SHA256SUMS and hands over to `hamlab install`, which sets up the binary, # the config and a service that starts at boot. Re-running it upgrades in place. # # Without a terminal, with --yes, or with flags after `--`, it asks nothing: # # curl -fsSL …/install.sh | sh -s -- --yes # curl -fsSL …/install.sh | sh -s -- --listen 0.0.0.0:8100 --tls auto --password-stdin , SHA256SUMS and VERSION are served from — # by default the newest release on Gitea; "latest" is an alias Gitea # resolves to the most recent release, so the URL never needs changing — # any static file host, or a Gitea/Forgejo release's download URL # HAMLAB_TOKEN access token, when that place is a private Gitea/Forgejo repo # NO_COLOR plain output # # POSIX sh: runs under dash, busybox ash and bash alike. set -eu HAMLAB_URL="${HAMLAB_URL:-https://git.bigham.dev/mitchell/hamlab/releases/download/latest}" BUILT_FOR="v0.1.0-114-g1eb59fd" # filled in by `make release` # ─────────────────────────────────────────────────────────────── looks ── if [ -t 2 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then B=$(printf '\033[1m'); D=$(printf '\033[2m'); R=$(printf '\033[0m') AMBER=$(printf '\033[38;5;214m'); GREEN=$(printf '\033[32m'); RED=$(printf '\033[31m'); CYAN=$(printf '\033[36m') else B=; D=; R=; AMBER=; GREEN=; RED=; CYAN= fi case "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" in *UTF-8*|*utf8*|*UTF8*|*utf-8*) OK='✓'; NO='✗'; DOT='•'; ARROW='›'; HR='────────────────────────────────────────────────────────────' ;; *) OK='ok'; NO='x'; DOT='*'; ARROW='>'; HR='------------------------------------------------------------' ;; esac out() { printf '%s\n' "$*" >&2; } blank() { printf '\n' >&2; } good() { out " ${GREEN}${OK}${R} $*"; } note() { out " ${D}$*${R}"; } warn() { out " ${AMBER}!${R} $*"; } die() { blank; out " ${RED}${NO}${R} ${B}$1${R}"; shift; for more in "$@"; do out " $more"; done; blank; exit 1; } rule() { out "${D}${HR}${R}"; } step() { blank; out "${AMBER}${B}$1${R} ${B}$2${R}"; [ -z "${3:-}" ] || note "$3"; blank; } banner() { blank out " ${AMBER}${B}Ham Lab${R} ${D}a web console for the machines you run${R}" out " ${D}terminals ${DOT} monitors ${DOT} services ${DOT} files ${DOT} updates ${DOT} disks — in one tab${R}" rule } # ─────────────────────────────────────────────────────────── questions ── # Piped from curl, stdin is this script, so every answer comes from the terminal itself. TTY=/dev/tty have_tty() { [ -r "$TTY" ] && [ -w "$TTY" ] && (: <"$TTY") 2>/dev/null; } ask() { # ask "Question" "default" → $REPLY if [ -n "${2:-}" ]; then printf ' %s %s[%s]%s ' "$1" "$D" "$2" "$R" >&2; else printf ' %s ' "$1" >&2; fi IFS= read -r REPLY <"$TTY" || REPLY= [ -n "$REPLY" ] || REPLY="${2:-}" } confirm() { # confirm "Question" y|n if [ "$2" = y ]; then hint="Y/n"; else hint="y/N"; fi while :; do ask "$1" "$hint" case "$REPLY" in "$hint") [ "$2" = y ]; return ;; y|Y|yes|Yes|YES) return 0 ;; n|N|no|No|NO) return 1 ;; *) warn "y or n, please" ;; esac done } secret() { # secret "Prompt" → $REPLY, typed unseen printf ' %s ' "$1" >&2 saved=$(stty -g <"$TTY" 2>/dev/null || true) stty -echo <"$TTY" 2>/dev/null || true IFS= read -r REPLY <"$TTY" || REPLY= [ -z "$saved" ] || stty "$saved" <"$TTY" 2>/dev/null || true printf '\n' >&2 } choose() { # choose "default-number" "label 1" "label 2" … → $REPLY = the number picked def=$1; shift; n=0 for label in "$@"; do n=$((n + 1)); if [ "$n" = "$def" ]; then out " ${AMBER}${B}$n${R} $label"; else out " ${B}$n${R} $label"; fi; done blank while :; do ask "Choose 1-$n" "$def" case "$REPLY" in *[!0-9]*|'') ;; *) if [ "$REPLY" -ge 1 ] && [ "$REPLY" -le "$n" ]; then return; fi ;; esac warn "a number from 1 to $n, please" done } restore_tty() { [ -z "${saved:-}" ] || stty "$saved" <"$TTY" 2>/dev/null || true; } # ──────────────────────────────────────────────────────────── plumbing ── fetch() { # url dest if command -v curl >/dev/null 2>&1; then curl -fsSL ${HAMLAB_TOKEN:+-H "Authorization: token $HAMLAB_TOKEN"} -o "$2" "$1" elif command -v wget >/dev/null 2>&1; then wget -q ${HAMLAB_TOKEN:+--header="Authorization: token $HAMLAB_TOKEN"} -O "$2" "$1" else die "This needs curl or wget, and the machine has neither." fi } port_busy() { # is something already listening on TCP port $1? hex=$(printf '%04X' "$1") cat /proc/net/tcp /proc/net/tcp6 2>/dev/null | awk -v p=":$hex" '$4 == "0A" && substr($2, length($2) - 4) == p { found = 1 } END { exit !found }' } lan_addresses() { # this machine's IPv4 addresses, one per line, most useful first if command -v ip >/dev/null 2>&1; then ip -4 -o addr show scope global 2>/dev/null | awk '$2 !~ /^(docker|br-|veth|virbr|cni|flannel|tailscale|wg|tun|tap)/ { split($4, a, "/"); print a[1] " " $2 }' else hostname -I 2>/dev/null | tr ' ' '\n' | awk 'NF && $1 !~ /:/ { print $1 " -" }' fi } # ────────────────────────────────────────────────────────── arguments ── YES=0; PASS= while [ $# -gt 0 ]; do case "$1" in -y|--yes) YES=1; shift ;; -h|--help) sed -n '2,20p' "$0" 2>/dev/null | sed 's/^# \{0,1\}//' >&2; exit 0 ;; --) shift; PASS="$*"; break ;; *) PASS="$*"; break ;; # flags given straight: they belong to `hamlab install` esac done INTERACTIVE=1 { [ "$YES" = 1 ] || [ -n "$PASS" ] || ! have_tty; } && INTERACTIVE=0 # ─────────────────────────────────────────────────────────── preflight ── banner [ "$(uname -s)" = Linux ] || die "Ham Lab runs on Linux; this is $(uname -s)." case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; armv6l|armv7l|armv8l|arm) arch=arm ;; i386|i486|i586|i686) arch=386 ;; riscv64) arch=riscv64 ;; *) die "There is no build for $(uname -m). (There are: amd64, arm64, arm, 386, riscv64.)" ;; esac file="hamlab-linux-$arch" me=$(id -un 2>/dev/null || echo root) if [ "$(id -u)" = 0 ]; then how=root elif command -v sudo >/dev/null 2>&1; then how=sudo elif command -v doas >/dev/null 2>&1; then how=doas else die "Installing a service needs root, and there is no sudo or doas here. Run this as root." fi if [ -d /run/systemd/system ]; then init=systemd elif command -v rc-service >/dev/null 2>&1; then init=OpenRC else init=none fi os=$(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-}"); [ -n "$os" ] || os=Linux existing=$(command -v hamlab 2>/dev/null || true); [ -n "$existing" ] || { [ -x /usr/local/bin/hamlab ] && existing=/usr/local/bin/hamlab; } || true have=; [ -z "$existing" ] || have=$("$existing" version 2>/dev/null | awk '{ print $2 }') || true good "$os ${D}${DOT}${R} $(uname -m) ${D}${DOT}${R} $init" case "$how" in root) good "running as root" ;; *) good "running as ${B}$me${R}; $how is asked for root when it is needed" ;; esac [ "$init" != none ] || warn "no systemd or OpenRC: it will be installed, but nothing starts it at boot" # ───────────────────────────────────────────────────── which version ── tmp=$(mktemp -d 2>/dev/null || mktemp -d -t hamlab) alt= cleanup() { restore_tty; rm -rf "$tmp"; [ -z "$alt" ] || rm -f "$alt"; } trap cleanup EXIT trap 'blank; out " cancelled — nothing was changed"; exit 130' INT TERM here=$(cd "$(dirname "$0")" 2>/dev/null && pwd || echo .) offline=0; [ -f "$here/$file" ] && offline=1 if [ "$offline" = 1 ]; then ver=$BUILT_FOR; case "$ver" in @*) ver="(local files)" ;; esac else # What is published right now — which may be newer than the copy of this script being run. ver=$(fetch "$HAMLAB_URL/VERSION" "$tmp/VERSION" 2>/dev/null && head -n 1 "$tmp/VERSION" | tr -cd 'A-Za-z0-9._+-' || true) [ -n "$ver" ] || { ver=$BUILT_FOR; case "$ver" in @*) ver=latest ;; esac; } fi # v0.1.0-69-g9c4f9df → 0001000069: versions compare as numbers, builds since the tag included. vnum() { printf '%s' "$1" | sed 's/^v//; s/-g[0-9a-f]*$//' | awk -F'[.-]' '{ printf "%d%03d%03d%05d", $1, $2, $3, $4 }'; } older=0 if [ -n "$have" ] && [ "$have" != "$ver" ]; then a=$(vnum "$have" 2>/dev/null || echo 0); b=$(vnum "$ver" 2>/dev/null || echo 0) case "$a$b" in *[!0-9]*) ;; *) [ "$b" -ge "$a" ] || older=1 ;; esac fi if [ "$older" = 1 ]; then warn "Ham Lab ${B}$have${R} is installed, and what is published is ${B}older${R}: $ver" note " installing it would take this machine backwards" if [ "$INTERACTIVE" = 1 ]; then blank; confirm "Go back to $ver anyway?" n || { blank; out " nothing was changed"; exit 0; } elif [ -z "${HAMLAB_ALLOW_DOWNGRADE:-}" ]; then die "Not downgrading $have to $ver." "Set HAMLAB_ALLOW_DOWNGRADE=1 if that is really what you want." fi have_msg_done=1 fi if [ -n "${have_msg_done:-}" ]; then : elif [ -n "$have" ] && [ "$have" = "$ver" ]; then good "Ham Lab ${B}$have${R} is already installed ${D}${ARROW}${R} this reinstalls it"; note " its config, user and password are kept" elif [ -n "$have" ]; then good "Ham Lab ${B}$have${R} is installed ${D}${ARROW}${R} upgrading to ${B}$ver${R}"; note " its config, user and password are kept" else good "installing Ham Lab ${B}$ver${R}"; fi # ─────────────────────────────────────────────────────────── the wizard ── flags=; password=; listen=; url_host= if [ "$INTERACTIVE" = 1 ] && [ -n "$have" ] && [ "$older" = 1 ]; then : elif [ "$INTERACTIVE" = 1 ] && [ -n "$have" ]; then blank confirm "$([ "$have" = "$ver" ] && echo Reinstall || echo Upgrade) now?" y || { blank; out " nothing was changed"; exit 0; } elif [ "$INTERACTIVE" = 1 ]; then # 1 ─ who default_user=${SUDO_USER:-${DOAS_USER:-$me}} step "1/5" "Who should it run as?" "Its terminals are that user's shell; its files are that user's files." while :; do ask "User" "$default_user"; run_as=$REPLY id "$run_as" >/dev/null 2>&1 && break warn "there is no user called \"$run_as\" on this machine" done [ "$run_as" != root ] || warn "as root, every terminal it opens is a root shell" # 2 ─ where step "2/5" "Where should it listen?" "Who can reach the console at all. The password guards it either way." addrs=$(lan_addresses || true); first=$(printf '%s\n' "$addrs" | awk 'NR == 1 { print $1 }') if [ -n "$first" ]; then first_if=$(printf '%s\n' "$addrs" | awk 'NR == 1 { print $2 }') choose 2 "This machine only ${D}127.0.0.1 — for an SSH tunnel or a reverse proxy${R}" \ "This network ${D}$first ($first_if) — reachable from the LAN${R}" \ "Every interface ${D}0.0.0.0 — every address it has or gets${R}" \ "Another address" else choose 1 "This machine only ${D}127.0.0.1${R}" "Every interface ${D}0.0.0.0${R}" "Another address" [ "$REPLY" = 1 ] || REPLY=$((REPLY + 1)) fi case "$REPLY" in 1) host=127.0.0.1 ;; 2) host=$first ;; 3) host=0.0.0.0 ;; 4) others=$(printf '%s\n' "$addrs" | awk 'NR > 1 { printf "%s%s (%s)", sep, $1, $2; sep = ", " }'); [ -z "$others" ] || note "also on this machine: $others" while :; do ask "Address" ""; host=$REPLY; case "$host" in ''|*[!0-9a-fA-F.:]*) warn "an IPv4 or IPv6 address, please" ;; *) break ;; esac; done ;; esac while :; do ask "Port" "8100"; port=$REPLY case "$port" in ''|*[!0-9]*) warn "a number, please"; continue ;; esac [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { warn "a port is between 1 and 65535"; continue; } [ "$port" -ge 1024 ] || [ "$run_as" = root ] || { warn "ports under 1024 need root, and it will run as $run_as"; continue; } if port_busy "$port"; then warn "something is already listening on port $port"; continue; fi break done case "$host" in *:*) listen="[$host]:$port" ;; *) listen="$host:$port" ;; esac # 3 ─ https step "3/5" "HTTPS?" "With a certificate made here: browsers warn once, a hub pins it." if [ "$host" = 127.0.0.1 ]; then choose 2 "HTTPS, self-signed" "Plain HTTP ${D}— fine here: nothing leaves this machine${R}" else choose 1 "HTTPS, self-signed ${D}— recommended: the password crosses the network${R}" "Plain HTTP ${D}— only behind a reverse proxy that adds HTTPS${R}" fi if [ "$REPLY" = 1 ]; then tls=auto; scheme=https; else tls=off; scheme=http; fi # 4 ─ firewall open_fw=0; fw= if [ "$host" != 127.0.0.1 ]; then if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then fw=firewalld elif command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "^Status: active"; then fw=ufw elif [ "$how" != root ] && command -v ufw >/dev/null 2>&1 && systemctl is-active -q ufw 2>/dev/null; then fw=ufw fi fi step "4/5" "Firewall" if [ -n "$fw" ]; then note "$fw is active and will block port $port until it is opened." blank if confirm "Open $port/tcp in $fw?" y; then open_fw=1; fi elif [ "$host" = 127.0.0.1 ]; then good "nothing to open: it only listens on this machine" else good "no active firewalld or ufw found; nothing to open" fi # 5 ─ password step "5/5" "The password" "One password opens the console, and the console is a shell here." choose 1 "Choose one now" "Generate a strong one ${D}— shown once at the end${R}" if [ "$REPLY" = 1 ]; then while :; do secret "Password:"; password=$REPLY [ ${#password} -ge 12 ] || { warn "at least 12 characters, please (it guards a shell)"; continue; } secret "Once more:" [ "$REPLY" = "$password" ] && break warn "those didn't match; try again" done fi # summary if [ "$host" = 0.0.0.0 ] || [ "$host" = "::" ]; then url_host=${first:-this-machine}; else url_host=$host; fi blank; rule out " ${B}Ready to install${R}" blank out " version ${B}$ver${R} ${D}($file)${R}" out " runs as ${B}$run_as${R}" out " address ${B}$scheme://$listen${R}" if [ "$open_fw" = 1 ]; then out " firewall open $port/tcp in $fw"; fi if [ -n "$password" ]; then out " password the one you typed"; else out " password generated, shown once"; fi case "$init" in none) out " service ${AMBER}none — no systemd or OpenRC${R}" ;; *) out " service $init, starts at boot" ;; esac rule; blank confirm "Install?" y || { blank; out " nothing was changed"; exit 0; } flags="--user $run_as --listen $listen --tls $tls" [ "$open_fw" = 0 ] || flags="$flags --open-firewall" [ -z "$password" ] || flags="$flags --password-stdin" fi # ──────────────────────────────────────────────── download and verify ── blank if [ "$offline" = 1 ]; then cp "$here/$file" "$tmp/hamlab"; [ ! -f "$here/SHA256SUMS" ] || cp "$here/SHA256SUMS" "$tmp/SHA256SUMS" good "using $file from $here" else printf ' %s downloading %s …' "$DOT" "$file" >&2 fetch "$HAMLAB_URL/$file" "$tmp/hamlab" || { printf '\n' >&2; die "Download failed: $HAMLAB_URL/$file"; } size=$(wc -c <"$tmp/hamlab" | awk '{ printf "%.1f MB", $1 / 1048576 }') if [ -t 2 ]; then printf '\r\033[K' >&2; else printf '\n' >&2; fi good "downloaded $file ${D}($size)${R}" fetch "$HAMLAB_URL/SHA256SUMS" "$tmp/SHA256SUMS" 2>/dev/null || rm -f "$tmp/SHA256SUMS" fi if [ -f "$tmp/SHA256SUMS" ] && command -v sha256sum >/dev/null 2>&1; then want=$(awk -v f="$file" '$2 == f || $2 == "*" f { print $1 }' "$tmp/SHA256SUMS") got=$(sha256sum "$tmp/hamlab" | awk '{ print $1 }') [ -n "$want" ] || die "SHA256SUMS has no entry for $file." [ "$want" = "$got" ] || die "Checksum mismatch for $file." "This is not the file that was published. Nothing was installed." good "checksum matches SHA256SUMS" else warn "no SHA256SUMS (or no sha256sum here): the download was not verified" fi chmod 0755 "$tmp/hamlab"; bin="$tmp/hamlab" if ! "$bin" version >/dev/null 2>&1; then alt="${HOME:-/root}/.hamlab-install.$$" # a noexec /tmp is the usual reason: try from the home directory cp "$bin" "$alt" 2>/dev/null && chmod 0755 "$alt" || true "$alt" version >/dev/null 2>&1 || die "The $arch build doesn't run on this machine." bin="$alt" fi # ───────────────────────────────────────────────────────────── install ── blank; out " ${B}Installing${R}"; blank log="$tmp/install.log" set +e # shellcheck disable=SC2086 # $flags and $PASS are lists of words on purpose if [ -n "$password" ]; then printf '%s\n' "$password" | "$bin" install $flags >"$log" 2>&1; rc=$? password= elif [ "$INTERACTIVE" = 0 ] && [ -n "$PASS" ]; then log= case " $PASS " in *password-stdin*) "$bin" install $PASS; rc=$? ;; *) if [ ! -t 0 ] && have_tty; then "$bin" install $PASS <"$TTY"; rc=$?; else "$bin" install $PASS; rc=$?; fi ;; esac else "$bin" install $flags "$log" 2>&1; rc=$? # no typed password and no terminal on stdin: it generates one and prints it fi set -e [ -z "$log" ] || sed 's/^/ /' "$log" >&2 hamlab_bin=$(command -v hamlab 2>/dev/null || echo /usr/local/bin/hamlab) [ "$rc" = 0 ] && [ -x "$hamlab_bin" ] || die "The install did not finish. Nothing above this line is hidden: the reason is in it." # ──────────────────────────────────────────────────────────────── done ── blank; rule out " ${GREEN}${B}${OK} Ham Lab $("$hamlab_bin" version 2>/dev/null | awk '{ print $2 }') is installed${R}" blank row() { out " $(printf '%-10s' "$1") $2"; [ -z "${3:-}" ] || out " ${D}$3${R}"; } if [ "$init" = none ]; then row "start it" "${B}hamlab serve${R}" "as ${run_as:-its user}, under your own supervisor: nothing starts it at boot" fi if [ -n "$listen" ]; then row "open" "${CYAN}${B}$scheme://$(printf '%s' "$listen" | sed "s/^0\.0\.0\.0/$url_host/")${R}" [ "$tls" != auto ] || out " ${D}the browser warns about the self-signed certificate once${R}" [ "$host" != 127.0.0.1 ] || out " ${D}from elsewhere: ssh -L $port:127.0.0.1:$port $run_as@this-machine${R}" fi case "$init" in systemd) row "logs" "${B}journalctl -u hamlab -f${R}" ;; OpenRC) row "logs" "${B}tail -f /var/log/hamlab.log${R}" ;; esac row "change" "${B}hamlab install --help${R}" "a new address, HTTPS or user; everything else is kept" row "add to hub" "Machines ${ARROW} Add a machine" "on your main Ham Lab, with this address and password" row "remove" "${B}hamlab uninstall${R}" "--purge deletes its config too" rule; blank