Files
hamlab/install.sh
T
Mitchell-JohnsonandClaude Opus 5 b5454c1022 Public downloads: the installer and the updater come from Gitea releases, no token
REQUIRE_SIGNIN_VIEW is off and mitchell/hamlab is public, so anyone can clone it or pull
a release. The other five repos are individually private and 404 to anonymous. Gitea
resolves releases/download/latest/<asset>, so the install one-liner and HAMLAB_URL point
at the newest release and never need changing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 18:59:42 -07:00

383 lines
20 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/sh
# Ham Lab installer.
#
# curl -fsSL https://hamlab-demo.bigham.dev/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 <pw
# ./install.sh # beside the hamlab-linux-* files: installs offline
#
# HAMLAB_URL where hamlab-linux-<arch>, 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="@VERSION@" # 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 </dev/null >"$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