diff --git a/README.md b/README.md index 3911cdb..2fad1bc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/deploy/install.sh b/deploy/install.sh index fa799fc..49f7e9e 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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 <