mirror of
https://github.com/n0mjs710/HBlink4.git
synced 2026-11-08 04:19:26 +00:00
HBlink4 draws operators who have never run a network server before, and several things in the install path assumed otherwise. Sample configuration split in two. config_sample.json was a feature showcase, and copying it as documented produced a broken server: its example repeater pattern claimed IDs 310000-312999 with a passphrase of its own, so an operator whose repeater fell in that range was rejected with no way to diagnose it (patterns match first-wins and never fall through to "default"). It also shipped an outbound link and an OpenBridge trunk enabled against hosts that do not exist, and bound both address families to one port, which cannot work. It is now a minimal working config needing one edit -- the passphrase. The full reference moves to config_advanced_sample.json with its link examples disabled and the ID overlap called out. Ship IPv4-only. Dual-stack is fully supported and now documented as four concrete layouts, each verified to bind; it is simply not the default, because most repeaters are IPv4 and the same-port bind conflict is not something a new operator should have to learn first. The built-in default when the key is absent stays false, so existing installs are unaffected. "IPv6 is globally disabled" drops from WARNING to INFO -- it is now the recommended setting and should not read as a fault. install_services.sh: refuse paths containing a space, $ or % (systemd mis-parses them in ExecStart=, and a space previously produced a unit that passed verification and then failed at start); escape & in the path substitution; render and verify units in a temp dir so a failed substitution cannot leave a broken unit in /etc/systemd/system. Check the things that otherwise surface only in the journal: complete checkout, Python 3.9+, server and dashboard dependencies, both config files, and agreement between the two halves of the dashboard event link. readme: seven numbered steps with no diversions, adding the prerequisites (python3-venv, which Debian and Ubuntu users hit constantly) and the firewall ports, neither of which were mentioned. Also: the dashboard logged a hardcoded localhost:8080 regardless of the configured port; run_all.sh now fails clearly on missing config instead of a traceback; pyproject's comment claimed systemd should invoke the console scripts, contradicting how the units actually work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
275 lines
11 KiB
Bash
Executable File
275 lines
11 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# Install the HBlink4 systemd units, with your user, group and paths filled in.
|
|
#
|
|
# sudo ./scripts/install_services.sh
|
|
#
|
|
# Prompts for the service user and group (sensible defaults are offered),
|
|
# rewrites the shipped unit files accordingly, installs them to
|
|
# /etc/systemd/system/, and reloads systemd. Existing units are backed up first.
|
|
#
|
|
# The install location is taken from where this script lives, so the checkout
|
|
# can be named anything and live anywhere -- HBlink4, hblink4, /opt/dmr/hb4.
|
|
#
|
|
# Set DESTDIR to install somewhere else (useful for inspecting the result
|
|
# without touching the live system):
|
|
#
|
|
# sudo DESTDIR=/tmp/preview ./scripts/install_services.sh
|
|
#
|
|
|
|
set -euo pipefail
|
|
|
|
RED='\033[0;31m'; GREEN='\033[0;32m'; BLUE='\033[0;34m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
|
|
|
die() { echo -e "${RED}✗ $*${NC}" >&2; exit 1; }
|
|
note() { echo -e "${BLUE}$*${NC}"; }
|
|
ok() { echo -e "${GREEN}✓ $*${NC}"; }
|
|
warn() { echo -e "${YELLOW}⚠ $*${NC}"; }
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
VENV_PY="$REPO_ROOT/venv/bin/python"
|
|
UNIT_DIR="${DESTDIR:-/etc/systemd/system}"
|
|
UNITS=(hblink4.service hblink4-dash.service)
|
|
LIVE_INSTALL=0
|
|
[ -z "${DESTDIR:-}" ] && LIVE_INSTALL=1
|
|
|
|
if [ "$LIVE_INSTALL" = 1 ] && [ "$(id -u)" -ne 0 ]; then
|
|
die "This script must be run with sudo: sudo $0"
|
|
fi
|
|
|
|
# ---------------------------------------------------------------- sanity checks
|
|
|
|
# The installation path ends up in ExecStart=, where systemd splits on
|
|
# whitespace, expands $ as a variable and % as a specifier. A path containing
|
|
# any of those produces a unit that fails at start with a misleading error, so
|
|
# refuse up front rather than install something broken.
|
|
case "$REPO_ROOT" in
|
|
*[[:space:]]*) die "The installation path contains a space:
|
|
$REPO_ROOT
|
|
systemd splits ExecStart= on whitespace, so this cannot work.
|
|
Move the checkout to a path with no spaces and re-run." ;;
|
|
*['$%']*) die "The installation path contains a '\$' or '%':
|
|
$REPO_ROOT
|
|
systemd treats those as variable/specifier escapes in a unit file.
|
|
Move the checkout to a path without them and re-run." ;;
|
|
esac
|
|
|
|
# A wrong or partial clone (downloaded zip of the wrong branch, cloned one
|
|
# directory too deep) shows up here rather than as a mystery failure later.
|
|
for f in hblink4/hblink.py dashboard/server.py "${UNITS[@]}"; do
|
|
[ -f "$REPO_ROOT/$f" ] || die "Missing $REPO_ROOT/$f
|
|
This does not look like a complete HBlink4 checkout.
|
|
Run the script from the checkout: sudo ./scripts/install_services.sh"
|
|
done
|
|
|
|
[ -x "$VENV_PY" ] || die \
|
|
"No virtual environment at $REPO_ROOT/venv
|
|
Create it first, from $REPO_ROOT:
|
|
python3 -m venv venv
|
|
./venv/bin/pip install -r requirements.txt -r requirements-dashboard.txt"
|
|
|
|
"$VENV_PY" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)' 2>/dev/null || die \
|
|
"The virtual environment's Python is older than 3.9 (HBlink4 needs 3.9+).
|
|
Check with: $VENV_PY --version"
|
|
|
|
# Missing dependencies are the most common reason a service starts and then
|
|
# restarts forever, and the traceback only shows up in the journal. Name the
|
|
# exact command that fixes it.
|
|
"$VENV_PY" -c 'import dmr_utils3' 2>/dev/null || die \
|
|
"HBlink4's dependencies are not installed in $REPO_ROOT/venv
|
|
Install them:
|
|
./venv/bin/pip install -r requirements.txt"
|
|
|
|
DASH_READY=1
|
|
"$VENV_PY" -c 'import fastapi, uvicorn' 2>/dev/null || DASH_READY=0
|
|
if [ "$DASH_READY" = 0 ]; then
|
|
warn "The dashboard's dependencies are not installed in $REPO_ROOT/venv"
|
|
warn "hblink4-dash will restart in a loop until you run:"
|
|
warn " ./venv/bin/pip install -r requirements-dashboard.txt"
|
|
fi
|
|
|
|
echo
|
|
note "HBlink4 service installer"
|
|
echo " Install directory : $REPO_ROOT"
|
|
echo " Unit directory : $UNIT_DIR"
|
|
echo
|
|
|
|
# ----------------------------------------------------------------- config files
|
|
|
|
# Both programs need their config in place before the services will start.
|
|
# Offer to create them from the samples rather than just reporting the problem.
|
|
offer_config() {
|
|
local live="$1" sample="$2" label="$3"
|
|
[ -f "$REPO_ROOT/$live" ] && return 0
|
|
if [ ! -f "$REPO_ROOT/$sample" ]; then
|
|
warn "No $live and no $sample to copy from."
|
|
return 0
|
|
fi
|
|
warn "No $live yet."
|
|
read -r -p " Create it from $sample? [Y/n]: " REPLY_CFG
|
|
case "${REPLY_CFG:-y}" in
|
|
[nN]|[nN][oO]) warn " $label will not start until $live exists." ;;
|
|
*)
|
|
cp "$REPO_ROOT/$sample" "$REPO_ROOT/$live"
|
|
# Created under sudo, but owned by the person who has to edit it.
|
|
[ -n "${SUDO_USER:-}" ] && chown "$SUDO_USER" "$REPO_ROOT/$live" 2>/dev/null || true
|
|
ok " created $live -- edit it before going live"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
offer_config "config/config.json" "config/config_sample.json" "hblink4"
|
|
offer_config "dashboard/config.json" "dashboard/config_sample.json" "hblink4-dash"
|
|
|
|
# A mismatch between the two halves of the event link is invisible at runtime:
|
|
# the dashboard simply shows HBlink4 as disconnected, with no error either side.
|
|
if [ -f "$REPO_ROOT/config/config.json" ] && [ -f "$REPO_ROOT/dashboard/config.json" ]; then
|
|
LINK_PROBLEM="$("$VENV_PY" - "$REPO_ROOT" <<'PY' 2>/dev/null || true
|
|
import json, sys, pathlib
|
|
root = pathlib.Path(sys.argv[1])
|
|
try:
|
|
server = json.loads((root / "config" / "config.json").read_text()).get("dashboard", {})
|
|
dash = json.loads((root / "dashboard" / "config.json").read_text()).get("event_receiver", {})
|
|
except Exception as e:
|
|
print(f"could not read both config files ({e})")
|
|
raise SystemExit(0)
|
|
st, dt = server.get("transport", "unix"), dash.get("transport", "unix")
|
|
if st != dt:
|
|
print(f"transport differs: config/config.json says '{st}', dashboard/config.json says '{dt}'")
|
|
elif st == "unix":
|
|
ss, ds = server.get("unix_socket"), dash.get("unix_socket")
|
|
if ss != ds:
|
|
print(f"unix_socket differs: '{ss}' vs '{ds}'")
|
|
elif server.get("port") != dash.get("port"):
|
|
print(f"port differs: {server.get('port')} vs {dash.get('port')}")
|
|
PY
|
|
)"
|
|
if [ -n "$LINK_PROBLEM" ]; then
|
|
warn "Dashboard event link mismatch -- $LINK_PROBLEM"
|
|
warn "The dashboard will show HBlink4 as disconnected until these agree."
|
|
echo
|
|
fi
|
|
fi
|
|
|
|
# ------------------------------------------------------------------- user/group
|
|
|
|
# Default to whoever invoked sudo, falling back to the directory's owner --
|
|
# HBlink4 must run as a user that can write logs/ and dashboard/data/.
|
|
DEFAULT_USER="${SUDO_USER:-$(stat -c '%U' "$REPO_ROOT")}"
|
|
DEFAULT_GROUP="$(id -gn "$DEFAULT_USER" 2>/dev/null || echo "$DEFAULT_USER")"
|
|
|
|
read -r -p "Service user [$DEFAULT_USER]: " SVC_USER
|
|
SVC_USER="${SVC_USER:-$DEFAULT_USER}"
|
|
read -r -p "Service group [$DEFAULT_GROUP]: " SVC_GROUP
|
|
SVC_GROUP="${SVC_GROUP:-$DEFAULT_GROUP}"
|
|
|
|
id "$SVC_USER" >/dev/null 2>&1 || die "No such user: $SVC_USER"
|
|
getent group "$SVC_GROUP" >/dev/null 2>&1 || die "No such group: $SVC_GROUP"
|
|
|
|
# The service user must be able to write persistence and logs.
|
|
if [ "$(id -u)" -eq 0 ] && command -v runuser >/dev/null 2>&1; then
|
|
if ! runuser -u "$SVC_USER" -- test -w "$REPO_ROOT" 2>/dev/null; then
|
|
warn "$SVC_USER cannot write to $REPO_ROOT"
|
|
warn "The dashboard needs write access for dashboard/data/ and logs/."
|
|
fi
|
|
fi
|
|
|
|
echo
|
|
note "Will install as:"
|
|
echo " User=$SVC_USER"
|
|
echo " Group=$SVC_GROUP"
|
|
echo " WorkingDirectory=$REPO_ROOT"
|
|
echo " ExecStart=$VENV_PY $REPO_ROOT/hblink4/hblink.py $REPO_ROOT/config/config.json"
|
|
echo " ExecStart=$VENV_PY $REPO_ROOT/dashboard/server.py"
|
|
echo
|
|
read -r -p "Proceed? [y/N]: " CONFIRM
|
|
case "$CONFIRM" in [yY]|[yY][eE][sS]) ;; *) echo "Aborted."; exit 0 ;; esac
|
|
|
|
# ---------------------------------------------------------------------- install
|
|
|
|
mkdir -p "$UNIT_DIR"
|
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
|
|
|
# '&' and '\' are substitution metacharacters on the right-hand side of s|||,
|
|
# and would otherwise be pasted in as the matched text.
|
|
REPO_ROOT_SED="$(printf '%s' "$REPO_ROOT" | sed -e 's/[&\\|]/\\&/g')"
|
|
|
|
# Render and verify each unit in full before anything reaches the unit
|
|
# directory, so a failed substitution can never leave a half-rewritten unit
|
|
# behind for systemd to pick up.
|
|
TMPDIR_UNITS="$(mktemp -d)"
|
|
trap 'rm -rf "$TMPDIR_UNITS"' EXIT
|
|
|
|
for u in "${UNITS[@]}"; do
|
|
# Substitute only the shipped author values; anchored so nothing else matches.
|
|
sed -e "s|/home/cort/hblink4|$REPO_ROOT_SED|g" \
|
|
-e "s|^User=cort$|User=$SVC_USER|" \
|
|
-e "s|^Group=cort$|Group=$SVC_GROUP|" \
|
|
"$REPO_ROOT/$u" > "$TMPDIR_UNITS/$u"
|
|
|
|
# Confirm the substitution produced what we intended. Checked against the
|
|
# chosen values with fixed-string matching -- the path is data, not a
|
|
# regex, and may legitimately contain '.', '[' or '+'.
|
|
grep -Fqx "User=$SVC_USER" "$TMPDIR_UNITS/$u" || die "$u: User= was not set correctly"
|
|
grep -Fqx "Group=$SVC_GROUP" "$TMPDIR_UNITS/$u" || die "$u: Group= was not set correctly"
|
|
grep -Fqx "WorkingDirectory=$REPO_ROOT" "$TMPDIR_UNITS/$u" || die "$u: WorkingDirectory= was not set correctly"
|
|
grep -Fq "ExecStart=$VENV_PY " "$TMPDIR_UNITS/$u" || die "$u: ExecStart= was not set correctly"
|
|
done
|
|
|
|
for u in "${UNITS[@]}"; do
|
|
# Back up any existing unit into the project root rather than leaving stray
|
|
# files in /etc/systemd/system -- systemd reads every *.service there, and
|
|
# clutter in a system directory is easy to forget about. The ".bak" suffix
|
|
# is git-ignored.
|
|
if [ -f "$UNIT_DIR/$u" ]; then
|
|
BACKUP="$REPO_ROOT/$u.$STAMP.bak"
|
|
cp -p "$UNIT_DIR/$u" "$BACKUP"
|
|
[ -n "${SUDO_USER:-}" ] && chown "$SUDO_USER" "$BACKUP" 2>/dev/null || true
|
|
echo " backed up existing $u -> $BACKUP"
|
|
fi
|
|
|
|
cp "$TMPDIR_UNITS/$u" "$UNIT_DIR/$u"
|
|
chmod 644 "$UNIT_DIR/$u"
|
|
ok "installed $UNIT_DIR/$u"
|
|
done
|
|
|
|
if [ "$LIVE_INSTALL" = 0 ]; then
|
|
echo
|
|
ok "Wrote units to $UNIT_DIR (DESTDIR set -- systemd not touched)."
|
|
exit 0
|
|
fi
|
|
|
|
systemctl daemon-reload
|
|
ok "systemctl daemon-reload"
|
|
|
|
# ------------------------------------------------------------- enable and start
|
|
|
|
echo
|
|
read -r -p "Enable services at boot? [y/N]: " DO_ENABLE
|
|
case "$DO_ENABLE" in
|
|
[yY]|[yY][eE][sS]) systemctl enable "${UNITS[@]}" >/dev/null && ok "enabled at boot" ;;
|
|
esac
|
|
|
|
read -r -p "Start (or restart) the services now? [y/N]: " DO_START
|
|
case "$DO_START" in
|
|
[yY]|[yY][eE][sS])
|
|
systemctl restart "${UNITS[@]}"
|
|
sleep 2
|
|
echo
|
|
for u in "${UNITS[@]}"; do
|
|
if systemctl is-active --quiet "$u"; then
|
|
ok "$u is running"
|
|
else
|
|
warn "$u did not start -- journalctl -u ${u%.service} -n 30"
|
|
fi
|
|
done
|
|
;;
|
|
esac
|
|
|
|
echo
|
|
note "Done. Useful commands:"
|
|
echo " systemctl status hblink4 hblink4-dash"
|
|
echo " journalctl -u hblink4 -f"
|
|
echo
|
|
note "If you edit a unit later, edit $UNIT_DIR/<unit> and run 'systemctl daemon-reload'."
|