RDM card reader: print card events, select the right port, serve taps to a web app
Card reading (rdm6300 + pyserial) printing card inserted/removed with the full card data, plus a --serve mode that publishes taps over HTTP so a web page can authenticate a tap. The bridge reports card ids only - mapping a card to a user stays on the server. The tap is latched for --tap-ttl seconds, including after the card is lifted, and is single use, so a page reload cannot replay one tap into two logins. Port selection identifies the reader by serial number or by-id path instead of /dev/ttyUSBn, and refuses to guess when a selector matches several adapters. deploy/ has the systemd unit, a udev rule for a stable /dev/rdm-reader, and install.sh. Covered by unit tests plus PTY end-to-end tests.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Give the RFID reader a stable name: /dev/rdm-reader
|
||||
#
|
||||
# Without this, the adapter is /dev/ttyUSB0 or /dev/ttyUSB1 depending on the
|
||||
# order the kernel enumerated USB devices, which changes between reboots and
|
||||
# whenever anything else is plugged in. Authenticating against the wrong reader
|
||||
# is a real failure, so pin the identity here instead of hoping.
|
||||
#
|
||||
# Match on the USB-serial bridge chip. Add serial numbers once you know them -
|
||||
# see README "Identifying the right ttyUSB" for how to read them off:
|
||||
#
|
||||
# SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", \
|
||||
# ATTRS{serial}=="REPLACE_WITH_YOUR_SERIAL", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
|
||||
#
|
||||
# Current rule: match by vendor:product (CH340 is 1a86:7523).
|
||||
# Commented-out alternatives for other common bridge chips are listed below.
|
||||
# Only ONE rule should be active - remove or comment the others.
|
||||
|
||||
# CH340 / CH341 (most common on cheap USB-TTL adapters)
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
|
||||
|
||||
# FTDI FT232R
|
||||
# SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", ATTRS{serial}=="REPLACE_ME", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
|
||||
|
||||
# Silicon Labs CP2102
|
||||
# SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", ATTRS{serial}=="REPLACE_ME", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
|
||||
|
||||
# Prolific PL2303
|
||||
# SUBSYSTEM=="tty", ATTRS{idVendor}=="067b", ATTRS{idProduct}=="2303", ATTRS{serial}=="REPLACE_ME", SYMLINK+="rdm-reader", MODE="0660", GROUP="dialout"
|
||||
Executable
+162
@@ -0,0 +1,162 @@
|
||||
#!/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)
|
||||
# --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
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ORIGINS=()
|
||||
BRIDGE_PORT=8765
|
||||
INSTALL_DIR=/opt/rdm-card-reader
|
||||
SERVICE_USER=rdmcard
|
||||
INSTALL_SERVICE=1
|
||||
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 ;;
|
||||
--dir) INSTALL_DIR="$2"; shift 2 ;;
|
||||
--user) SERVICE_USER="$2"; shift 2 ;;
|
||||
--no-service) INSTALL_SERVICE=0; shift ;;
|
||||
-h|--help) sed -n '2,20p' "$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; }
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Please run as root: sudo $0 $*" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. System packages
|
||||
# ---------------------------------------------------------------------------
|
||||
log "Checking system packages"
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends python3 python3-venv python3-pip
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 tests pyproject.toml README.md LICENSE deploy; do
|
||||
if [[ -e "$SOURCE_DIR/$item" ]]; then
|
||||
cp -a "$SOURCE_DIR/$item" "$INSTALL_DIR/"
|
||||
fi
|
||||
done
|
||||
|
||||
log "Building the virtual environment"
|
||||
python3 -m venv "$INSTALL_DIR/.venv"
|
||||
"$INSTALL_DIR/.venv/bin/pip" install --quiet --upgrade pip
|
||||
"$INSTALL_DIR/.venv/bin/pip" install --quiet "$INSTALL_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 [[ -e /dev/rdm-reader ]]; then
|
||||
echo " /dev/rdm-reader -> $(readlink -f /dev/rdm-reader)"
|
||||
else
|
||||
warn "/dev/rdm-reader does not exist yet."
|
||||
warn "The reader 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."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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" <<'PY'
|
||||
import re, sys
|
||||
unit, install_dir, port, origin_args, user = sys.argv[1:6]
|
||||
text = open(unit).read()
|
||||
|
||||
command = (
|
||||
f"{install_dir}/.venv/bin/rdm-card-reader --serve "
|
||||
f"--port /dev/rdm-reader --bridge-port {port} --mode login{origin_args}"
|
||||
)
|
||||
# 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)
|
||||
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
|
||||
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
|
||||
@@ -0,0 +1,53 @@
|
||||
[Unit]
|
||||
Description=RDM card reader HTTP bridge (serves card taps for web login)
|
||||
Documentation=file:/opt/rdm-card-reader/README.md
|
||||
# The serial device must exist before we start, otherwise the reader opens the
|
||||
# wrong tty or none at all.
|
||||
After=dev-serial.device network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
# Do not restart forever if the reader is genuinely absent. These are [Unit]
|
||||
# options, not [Service] - systemd ignores them in the wrong section.
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rdmcard
|
||||
Group=dialout
|
||||
WorkingDirectory=/opt/rdm-card-reader
|
||||
|
||||
# --serve exposes the card on http://127.0.0.1:8765/api/card
|
||||
# Autodetect refuses to guess if more than one USB adapter is present, which is
|
||||
# the safe behaviour: a wrong guess means authenticating the wrong person.
|
||||
ExecStart=/opt/rdm-card-reader/.venv/bin/rdm-card-reader \
|
||||
--serve \
|
||||
--port /dev/rdm-reader \
|
||||
--mode login \
|
||||
--allow-origin https://your.web.app
|
||||
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
# The bridge only needs to read the serial port and listen on loopback.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
DeviceAllow=/dev/rdm-reader rw
|
||||
DevicePolicy=closed
|
||||
|
||||
# stdout/stderr go here; watch with: journalctl -u rdm-card-reader -f
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=rdm-card-reader
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user