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>
This commit is contained in:
2026-10-05 22:44:14 +03:00
parent c2b3eda649
commit 74c9e0f8fa
2 changed files with 114 additions and 5 deletions
+54
View File
@@ -570,6 +570,9 @@ what `--list-ports` reports.
| Option | Default | Meaning |
| --- | --- | --- |
| `--origin URL` | – | Web app origin allowed to call the API (repeatable) |
| `--host ADDR` | `127.0.0.1` | Bind address. Loopback only by default |
| `--allow-remote` | off | Permit a non-loopback `--host` |
| `--token SECRET` | – | Require this token from non-loopback callers (see below) |
| `--device PATH` | `/dev/serial0` | Serial device the service opens (UART; use `/dev/rdm-reader` for USB) |
| `--led-pin N` | `23` | BCM GPIO number of the status LED (physical pin 16) |
| `--led-mode M` | `presence` | `presence`: lit while a card is on the reader; `error`: lit whenever running |
@@ -605,6 +608,52 @@ what `--list-ports` reports.
added. `--serve` is what makes the API available; without it the app only
prints events, which a service sends to the journal.
### Reaching the page from another machine
The bridge binds **loopback only** by default, so `http://192.168.x.x:8765/`
from your laptop gets a *connection refused* — nothing is listening on that
address. Opening the port to the network is opt-in, and needs a token:
```bash
sudo ./deploy/install.sh --device /dev/serial0 \
--host 0.0.0.0 --allow-remote --token 'pick-a-long-secret'
```
The installer refuses a non-loopback `--host` without `--allow-remote`, and
warns if you skip the token. The app enforces the same rule, so this cannot be
switched on by accident.
> **What you are exposing.** Off loopback, anyone who can reach the port can
> read card taps *and* switch the status LEDs. A tap is not a password, but it
> is a username, and the endpoint reports who has touched the reader. Keep it on
> a trusted network, and set a token.
Loopback callers (the Pi itself) never need the token, so local tools and
`curl` keep working unchanged.
The token is written to `/opt/rdm-card-reader/bridge.env` (mode `0600`) and
referenced by the unit as `EnvironmentFile=`, rather than being placed on the
`ExecStart` line — a value there is readable by anything that can run
`systemctl cat` and appears in the process list.
Once exposed, open the page and paste the token into the box at the top right;
it is kept in `localStorage` and sent as `X-Auth-Token`. Until a token is
supplied the panels stay empty and the LED buttons do nothing, because the API
answers `401`. For the LED buttons specifically:
```bash
# from your laptop, if you invoked install.sh with --host 0.0.0.0 --token SECRET
curl -sS -H "X-Auth-Token: SECRET" http://192.168.0.228:8765/api/led
```
If you would rather not expose the port at all, an SSH tunnel gives you the
same page without opening anything:
```bash
ssh -N -L 8765:127.0.0.1:8765 pi@192.168.0.228
# then browse http://127.0.0.1:8765/ on your laptop
```
### Checking the reader from the command line
```bash
@@ -816,6 +865,11 @@ curl -sS -X POST -H 'Content-Type: application/json' \
- **HTTP 409 `that LED is not available`** → the LED has no usable pin; look at
`reason` in the response, which is the same text as the journal line.
**HTTP 401 `missing or invalid token`** → you are reaching the bridge from
another machine and it was installed with a token. Paste the token into the box
at the top of the status page, or send `X-Auth-Token`. See **Reaching the page
from another machine** above.
Press *hand back to card events* (or POST `{"release": true}`) when finished, or
the LEDs stay held where you left them.
+60 -5
View File
@@ -10,6 +10,12 @@
# 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'
@@ -44,12 +50,21 @@ 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 ;;
@@ -69,12 +84,31 @@ 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
# ---------------------------------------------------------------------------
@@ -290,12 +324,13 @@ 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" <<'PY'
"$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) = sys.argv[1:14]
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
@@ -305,8 +340,20 @@ 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"
led_active_args = " --led-active-low" if led_active_low == "1" else ""
# 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}"
@@ -317,11 +364,19 @@ if error_led_pin:
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}{origin_args}"
f"{led_active_args}{error_args}{host_args}{origin_args}"
)
@@ -375,7 +430,7 @@ cat <<EOF
$(log "Installed")
Bridge : http://127.0.0.1:$BRIDGE_PORT
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)" )