Files
rdm-card-reader/deploy/install.sh
T
ske087 74c9e0f8fa Make LAN access installable, with a token
The status page could not be reached at all from another machine:
the unit runs --serve with no --host, and the app defaults to
127.0.0.1, so nothing listens on the Pi's LAN address. Confirmed by
binding both ways - from the LAN, loopback-bound gives connection
refused. Even once bound, a non-loopback caller needs a token and
install.sh had no way to set one, so the page would load with every
panel empty and every button doing nothing (401).

Adds --host, --allow-remote and --token. The token is written to
bridge.env at mode 0600 and referenced via EnvironmentFile rather
than sitting on the ExecStart line, where 'systemctl cat' would
reveal it and the process list would show it.

Exposing the bridge stays opt-in and deliberate: the installer
refuses a non-loopback --host without --allow-remote, and warns when
no token is given, because off loopback anyone reaching the port can
read taps and switch the LEDs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-05 22:44:14 +03:00

448 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Install the RDM card reader as a service that starts at boot and keeps the
# card on the reader available over HTTP for the web page to claim.
#
# Run as root on the Raspberry Pi:
#
# sudo ./deploy/install.sh --origin https://your.web.app
#
# Options:
# --origin URL web app origin allowed to read the API (repeatable)
# --port PORT HTTP port for the bridge (default 8765)
# --host ADDR bind address (default 127.0.0.1, loopback only)
# --allow-remote permit binding a non-loopback address. Anyone who can
# reach the port can then read taps and switch the LEDs;
# pair it with --token
# --token SECRET pre-shared token required from non-loopback callers
# (or set RDM_BRIDGE_TOKEN in the environment)
# --device PATH serial device the service opens (default /dev/serial0)
# --led-pin N BCM GPIO number of the status LED (default 23)
# --led-mode M 'presence' (default) or 'error'
# --led-active-low LED wired active-low (cathode to the pin, anode to
# 3.3 V, or an inverting driver); or the light will be on
# with no card and off with one
# --error-led-pin N BCM GPIO of a second LED for bad reads (default 24)
# --no-error-led do not use an error LED; blink the status LED instead
# --error-pulse S seconds to hold the error LED on a bad read (default 1)
# --heartbeat S seconds before a silent reader counts as card removed
# (default 2.0; this is also the presence/LED window)
# --dir PATH install location (default /opt/rdm-card-reader)
# --user NAME service account (default rdmcard)
# --no-service install and configure, but do not enable the service
#
# The default device is /dev/serial0, the Raspberry Pi's on-board UART, because
# that is how the reader is normally wired (header pins 8/10). For a
# USB-to-serial adapter pass --device /dev/rdm-reader, the udev alias.
#
set -euo pipefail
ORIGINS=()
BRIDGE_PORT=8765
INSTALL_DIR=/opt/rdm-card-reader
SERVICE_USER=rdmcard
INSTALL_SERVICE=1
SERIAL_DEVICE=/dev/serial0
LED_PIN=23
LED_MODE=presence
LED_ACTIVE_LOW=0
ERROR_LED_PIN=24
ERROR_LED_ACTIVE_LOW=0
ERROR_PULSE=1.0
HEARTBEAT=2.0
# Loopback by default: an open card endpoint on a LAN lets anyone read who is
# tapping, and now also toggle the LEDs. Reaching it from another machine needs
# --allow-remote, and a token once it is off loopback.
BRIDGE_HOST=127.0.0.1
ALLOW_REMOTE=0
BRIDGE_TOKEN=""
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
while [[ $# -gt 0 ]]; do
case "$1" in
--origin) ORIGINS+=("$2"); shift 2 ;;
--port) BRIDGE_PORT="$2"; shift 2 ;;
--host) BRIDGE_HOST="$2"; shift 2 ;;
--allow-remote) ALLOW_REMOTE=1; shift ;;
--token) BRIDGE_TOKEN="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--device) SERIAL_DEVICE="$2"; shift 2 ;;
--led-pin) LED_PIN="$2"; shift 2 ;;
--led-mode) LED_MODE="$2"; shift 2 ;;
--led-active-low) LED_ACTIVE_LOW=1; shift ;;
--error-led-pin) ERROR_LED_PIN="$2"; shift 2 ;;
--error-led-active-low) ERROR_LED_ACTIVE_LOW=1; shift ;;
--error-pulse) ERROR_PULSE="$2"; shift 2 ;;
--no-error-led) ERROR_LED_PIN=""; shift ;;
--heartbeat) HEARTBEAT="$2"; shift 2 ;;
--no-service) INSTALL_SERVICE=0; shift ;;
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[33mwarning: %s\033[0m\n' "$*" >&2; }
note() { printf ' %s\n' "$*"; }
if [[ $EUID -ne 0 ]]; then
echo "Please run as root: sudo $0 $*" >&2
exit 1
fi
# A non-loopback bind is opt-in, and the app refuses it without --allow-remote
# too. Mirror that here so the failure happens at install time with an
# explanation, rather than as a crash-looping unit.
if [[ "$BRIDGE_HOST" != "127.0.0.1" && "$BRIDGE_HOST" != "::1" \
&& "$BRIDGE_HOST" != "localhost" ]]; then
if [[ "$ALLOW_REMOTE" -eq 0 ]]; then
warn "--host $BRIDGE_HOST binds beyond loopback, which is not the default."
warn "Re-run with --allow-remote to confirm, e.g."
warn " sudo $0 --host $BRIDGE_HOST --allow-remote --token SECRET"
exit 1
fi
if [[ -z "$BRIDGE_TOKEN" ]]; then
warn "--host $BRIDGE_HOST with no --token: anyone who can reach port"
warn "$BRIDGE_PORT can read card taps AND switch the status LEDs."
warn "Add --token SECRET, and open the page with that token."
fi
fi
# ---------------------------------------------------------------------------
# 1. System packages
# ---------------------------------------------------------------------------
log "Checking system packages"
# Both of these need the network, so neither may abort the install: a device
# with no internet usually already has python3, and we can work without them.
apt-get update -qq 2>/dev/null || warn "apt-get update failed (no network?) - continuing"
apt-get install -y --no-install-recommends python3 python3-venv python3-pip \
|| warn "could not install system packages - continuing with what is present"
# ---------------------------------------------------------------------------
# 2. Install the application
# ---------------------------------------------------------------------------
log "Installing to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# Copy the source tree (excluding local build/test artifacts).
for item in src pyproject.toml README.md LAUNCH.md LICENSE deploy; do
if [[ -e "$SOURCE_DIR/$item" ]]; then
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
fi
done
log "Building the virtual environment"
# --system-site-packages is what makes the status LED work. gpiozero is pure
# Python, but the pin backend it needs (lgpio on Bookworm, RPi.GPIO on older
# releases) is an apt package installed into the *system* Python. A plain venv
# cannot see it, so gpiozero finds no usable pin factory, raises BadPinFactory
# and the app falls back to a no-op LED: the reader works and the light stays
# dark, with nothing failing. Re-running this on an existing venv updates
# pyvenv.cfg in place, so an upgrade picks the flag up too.
if ! python3 -m venv --system-site-packages "$INSTALL_DIR/.venv"; then
warn "could not create the virtualenv."
warn "On Debian/Raspberry Pi OS this usually means python3-venv is missing:"
warn " sudo apt-get install python3-venv"
exit 1
fi
VENV_PY="$INSTALL_DIR/.venv/bin/python"
WHEEL_DIR="$INSTALL_DIR/src/rdm_card_reader/wheels"
# Upgrading pip is optional and needs the network.
"$INSTALL_DIR/.venv/bin/pip" install --quiet --upgrade pip 2>/dev/null \
|| echo " (kept the bundled pip - no network)"
# Install the runtime dependencies from the wheels shipped in the source tree.
#
# Deliberately NOT 'pip install .': that builds the project, which requires
# downloading setuptools into an isolated build environment. With no internet
# it fails with
# Could not find a version that satisfies the requirement setuptools>=68
# even though everything needed to *run* is sitting in the wheel directory.
# Installing the runtime dependencies directly, and putting 'src' on the
# path, achieves the same thing and works offline.
INSTALLED_PACKAGE=0
if [[ -d "$WHEEL_DIR" ]] && "$VENV_PY" -m pip install --quiet --no-index \
--find-links "$WHEEL_DIR" rdm6300 pyserial 2>/dev/null; then
echo " installed rdm6300 and pyserial from the bundled wheels (offline)"
else
echo " bundled wheels unavailable - falling back to PyPI"
if "$VENV_PY" -m pip install --quiet rdm6300 pyserial 2>/dev/null; then
echo " installed rdm6300 and pyserial from PyPI"
else
warn "could not install rdm6300 / pyserial."
warn "The app will try again on first start (it can install from its own"
warn "bundled wheels), but check network access if that also fails."
fi
fi
# Status LED support. Best-effort: the reader works fine without a LED, so a
# failure here must not stop the install.
#
# --no-deps is deliberate: gpiozero declares colorzero, which in turn declares
# setuptools. setuptools is only a packaging requirement and is never imported
# at runtime, so it is not vendored (it would add 818 KB for nothing).
if [[ -d "$WHEEL_DIR" ]] && "$VENV_PY" -m pip install --quiet --no-index \
--no-deps --find-links "$WHEEL_DIR" gpiozero colorzero 2>/dev/null; then
echo " installed gpiozero + colorzero for the status LED (offline)"
else
if "$VENV_PY" -m pip install --quiet gpiozero colorzero 2>/dev/null; then
echo " installed gpiozero + colorzero from PyPI"
else
warn "could not install LED support - the reader still works, the LED"
warn "will be inactive. The app can install it later on first use."
fi
fi
# Make the package importable. The .pth file adds src/ to sys.path, so
# 'python -m rdm_card_reader' resolves from any working directory.
SITE_PACKAGES="$("$VENV_PY" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')"
echo "$INSTALL_DIR/src" > "$SITE_PACKAGES/rdm_card_reader_src.pth"
# Console-script equivalent, so the unit and the docs work as written.
# The shebang must be the venv's interpreter by absolute path: '#!/usr/bin/env
# python3' would pick up the *system* python when the venv is not activated,
# and the package would not be importable.
cat > "$INSTALL_DIR/.venv/bin/rdm-card-reader" <<EOF
#!$VENV_PY
import sys
from rdm_card_reader.cli import main
if __name__ == "__main__":
sys.exit(main())
EOF
chmod +x "$INSTALL_DIR/.venv/bin/rdm-card-reader"
# Standalone launcher for running without activating anything.
cat > "$INSTALL_DIR/run.sh" <<EOF
#!/bin/sh
# Convenience launcher: ./run.sh [args...]
exec "$INSTALL_DIR/.venv/bin/rdm-card-reader" "\$@"
EOF
chmod +x "$INSTALL_DIR/run.sh"
# Sanity check: prove it imports and can run before we enable a service.
if "$INSTALL_DIR/.venv/bin/rdm-card-reader" --version >/dev/null 2>&1; then
echo " verified: $("$INSTALL_DIR/.venv/bin/rdm-card-reader" --version)"
else
warn "the reader does not run yet. Check with:"
warn " $INSTALL_DIR/.venv/bin/python -m rdm_card_reader --list-ports"
fi
# ---------------------------------------------------------------------------
# 3. Service account
# ---------------------------------------------------------------------------
log "Creating service account '$SERVICE_USER'"
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
# No login shell, no home directory: this account only reads a serial port.
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
fi
# Serial access requires the dialout group.
usermod -aG dialout "$SERVICE_USER"
echo " $SERVICE_USER is in group dialout"
# The status LED drives /dev/gpiochip*, which is root:gpio on modern Raspberry
# Pi OS (Bookworm onward, gpiozero via lgpio). Without this group the service
# cannot open the chip, gpiozero falls back to a no-op LED, and the reader
# works with no light - the failure that is easiest to miss. Older releases
# (Bullseye, RPi.GPIO) use /dev/gpiomem and need no extra group, hence the
# group check rather than an unconditional usermod.
if getent group gpio >/dev/null 2>&1; then
usermod -aG gpio "$SERVICE_USER"
echo " $SERVICE_USER is in group gpio (status LED)"
fi
# ---------------------------------------------------------------------------
# 4. Stable device name
# ---------------------------------------------------------------------------
log "Installing the udev rule (/dev/rdm-reader)"
install -m 0644 "$SOURCE_DIR/deploy/99-rdm-reader.rules" \
/etc/udev/rules.d/99-rdm-reader.rules
udevadm control --reload-rules
udevadm trigger --subsystem-match=tty || true
sleep 1
if [[ "$SERIAL_DEVICE" == "/dev/rdm-reader" && ! -e /dev/rdm-reader ]]; then
warn "/dev/rdm-reader does not exist yet."
warn "The USB adapter may not be plugged in, or it uses a different chip."
warn "Run '$INSTALL_DIR/.venv/bin/rdm-card-reader --list-ports --verbose-ports'"
warn "then edit /etc/udev/rules.d/99-rdm-reader.rules to match your adapter."
warn "Reader wired to the Pi's UART pins instead? That is the default device:"
warn "re-run without --device (or with --device /dev/serial0)."
fi
# Refuse to enable a service that can only exit 1. A unit that fails on every
# start burns its StartLimitBurst within seconds and then stays dead until
# someone runs 'systemctl reset-failed' by hand - a confusing failure to debug
# remotely, and one that silently returns "installed successfully".
if [[ ! -e "$SERIAL_DEVICE" ]]; then
warn "$SERIAL_DEVICE does not exist, so the service could never start."
warn "Nothing has been enabled. Fix the device, then re-run this installer."
exit 1
fi
# Prove the port can actually be opened, not merely that it exists. Existence
# alone is not enough: a node with no hardware behind it still fails to open.
if ! "$INSTALL_DIR/.venv/bin/python" - "$SERIAL_DEVICE" <<'PY'
import sys
import serial
try:
serial.Serial(sys.argv[1], 9600, timeout=1).close()
except Exception as exc:
print(" x {}: {}".format(sys.argv[1], exc))
raise SystemExit(1)
PY
then
warn "could not open $SERIAL_DEVICE at 9600 baud."
warn "For the on-board UART check that it is enabled:"
warn " sudo raspi-config # Interface Options -> Serial Port"
warn " 'login shell over serial?' -> No"
warn " 'serial port hardware?' -> Yes"
warn "Nothing has been enabled. Fix the above, then re-run this installer."
exit 1
fi
echo " $SERIAL_DEVICE opened at 9600 baud"
# ---------------------------------------------------------------------------
# 5. Service
# ---------------------------------------------------------------------------
log "Installing the systemd unit"
UNIT=/etc/systemd/system/rdm-card-reader.service
install -m 0644 "$SOURCE_DIR/deploy/rdm-card-reader.service" "$UNIT"
# Rewrite the ExecStart line with the real install dir, port and origins.
ORIGIN_ARGS=""
for origin in "${ORIGINS[@]:-}"; do
[[ -n "$origin" ]] && ORIGIN_ARGS+=" --allow-origin $origin"
done
python3 - "$UNIT" "$INSTALL_DIR" "$BRIDGE_PORT" "$ORIGIN_ARGS" "$SERVICE_USER" \
"$SERIAL_DEVICE" "$LED_PIN" "$LED_MODE" "$LED_ACTIVE_LOW" "$HEARTBEAT" \
"$ERROR_LED_PIN" "$ERROR_LED_ACTIVE_LOW" "$ERROR_PULSE" \
"$BRIDGE_HOST" "$ALLOW_REMOTE" "$BRIDGE_TOKEN" <<'PY'
import os, re, sys
(unit, install_dir, port, origin_args, user, device, led_pin, led_mode,
led_active_low, heartbeat, error_led_pin, error_led_active_low,
error_pulse, bridge_host, allow_remote, bridge_token) = sys.argv[1:17]
text = open(unit).read()
# The console script is created by the installer, but the .pth file is what
# makes the package importable, so fall back to 'python -m' if it is ever
# absent. systemd's ExecStart has no shell, so no pipes or conditionals.
wrapper = f"{install_dir}/.venv/bin/rdm-card-reader"
if not os.path.exists(wrapper):
wrapper = f"{install_dir}/.venv/bin/python -m rdm_card_reader"
# The token goes into an environment file, not the unit: a value on the
# ExecStart line is readable by anything that can run 'systemctl cat', and
# shows up in the process list. The '-' prefix makes the file optional.
env_file = os.path.join(install_dir, "bridge.env")
text = re.sub(r"^EnvironmentFile=.*\n?", "", text, count=1, flags=re.M)
if bridge_token:
with open(env_file, "w") as handle:
handle.write(f"RDM_BRIDGE_TOKEN={bridge_token}\n")
os.chmod(env_file, 0o600)
text = re.sub(r"^ExecStart=", f"EnvironmentFile=-{env_file}\\nExecStart=",
text, count=1, flags=re.M)
print(f" token stored in {env_file} (mode 0600)")
led_active_args = " --led-active-low" if led_active_low == "1" else ""
# An empty pin means --no-error-led: the status LED blinks instead.
if error_led_pin:
error_args = f" --error-led-pin {error_led_pin}"
if error_led_active_low == "1":
error_args += " --error-led-active-low"
if error_pulse:
error_args += f" --error-pulse {error_pulse}"
else:
error_args = " --no-error-led"
# Exposing the bridge beyond loopback is explicit: --allow-remote is required,
# and the token protects the API once the caller is not on this machine.
host_args = ""
if bridge_host != "127.0.0.1":
host_args = f" --host {bridge_host}"
if allow_remote == "1":
host_args += " --allow-remote"
command = (
f"{wrapper} --serve "
f"--port {device} --bridge-port {port} --mode login "
f"--led-pin {led_pin} --led-mode {led_mode} --heartbeat {heartbeat}"
f"{led_active_args}{error_args}{host_args}{origin_args}"
)
def device_unit(path):
"""systemd device unit for *path*, e.g. /dev/rdm-reader -> dev-rdm\\x2dreader.device."""
name = path[len("/dev/"):] if path.startswith("/dev/") else path
name = name.replace("-", "\\x2d").replace("/", "-")
return f"dev-{name}.device"
# Replace the whole (possibly line-continued) ExecStart definition.
text = re.sub(r"ExecStart=[\s\S]*?(?=\n\n|\nRestart=)", f"ExecStart={command}", text, count=1)
text = re.sub(r"^User=.*$", f"User={user}", text, count=1, flags=re.M)
# Order after the device we actually open. The shipped unit names a device
# that is not the one the service reads, so systemd never really waited for it.
text = re.sub(r"^After=.*$", f"After={device_unit(device)} network-online.target",
text, count=1, flags=re.M)
# DevicePolicy=closed means only the devices listed here are usable, so the
# list has to cover everything the service touches: the serial port, and the
# GPIO chip the status LED drives. Omitting the chip is a silent failure - the
# reader keeps working and only the LED stays dark - so it is easy to miss.
# gpiochip0 is the header on Pi 1-4; Pi 5 uses gpiochip4.
device_allow = "\n".join([
f"DeviceAllow={device} rw",
"DeviceAllow=/dev/gpiochip0 rw",
"DeviceAllow=/dev/gpiochip4 rw",
])
text = re.sub(r"^DeviceAllow=.*(?:\nDeviceAllow=.*)*", device_allow, text,
count=1, flags=re.M)
open(unit, "w").write(text)
PY
# Make the venv readable by the service account.
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
if [[ "$INSTALL_SERVICE" -eq 1 ]]; then
log "Enabling and starting the service"
systemctl daemon-reload
systemctl enable rdm-card-reader.service
systemctl restart rdm-card-reader.service
sleep 2
systemctl --no-pager --lines=15 status rdm-card-reader.service || true
else
log "Skipping service start (--no-service)"
fi
# ---------------------------------------------------------------------------
# Done
# ---------------------------------------------------------------------------
cat <<EOF
$(log "Installed")
Bridge : http://127.0.0.1:$BRIDGE_PORT$( [[ "$BRIDGE_HOST" != "127.0.0.1" ]] && echo " (also on http://$BRIDGE_HOST:$BRIDGE_PORT)" )
Device : $SERIAL_DEVICE
LED : GPIO $LED_PIN, $LED_MODE mode$( [[ "$LED_ACTIVE_LOW" -eq 1 ]] && echo ", active-low" )
Error LED : $( [[ -n "$ERROR_LED_PIN" ]] && echo "GPIO $ERROR_LED_PIN, ${ERROR_PULSE}s on a bad read" || echo "none (status LED blinks)" )
Heartbeat : ${HEARTBEAT}s (presence/LED window)
Card state : curl http://127.0.0.1:$BRIDGE_PORT/api/card
Health : curl http://127.0.0.1:$BRIDGE_PORT/api/health
Logs : journalctl -u rdm-card-reader -f
Test a tap : place a card and run the curl above
The service starts automatically on boot. The card stays readable at
/api/card while it is on the reader; POST /api/card/claim hands it over
once, so a page reload cannot replay the same tap into a second login.
EOF