Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
362 lines
18 KiB
Bash
Executable File
362 lines
18 KiB
Bash
Executable File
#!/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 —
|
||
# 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://hamlab-demo.bigham.dev/dl}"
|
||
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}$*${R}"; 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
|
||
if [ -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" ]; 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 here 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
|