#864 — Docker no longer exposes the sensing API/stream unauthenticated: - Add `require_ws_token` middleware gating `/ws/*` (sensing + introspection) with the API token via `?token=` (browser) or `Authorization: Bearer` (programmatic). Previously /ws/sensing was ungated even with a token set. - docker-entrypoint.sh now fails closed: auto-generates a strong RUVIEW_API_TOKEN when none is supplied and prints it; explicit RUVIEW_ALLOW_UNAUTHENTICATED=1 restores the open LAN posture. - compose/Dockerfile wire the env vars; startup logs + CI smoke test updated to assert secure-by-default (401 with no token) and the opt-out path. - 7 new bearer_auth unit tests (15 total pass). #866 — CSI callbacks were starving (~3 in 70s, 0pps) under the MGMT-only promiscuous filter: - The documented "10 Hz probe injection" never existed — implement it for real (csi_inject_probe_request + 10 Hz timer). Validated on ESP32-C6 (COM9): probe TX succeeds at 10 Hz, but management-frame CSI stays sparse. - Re-admit DATA frames (MGMT+DATA) now that the original wDev_ProcessFiq SPI-cache crash is mitigated by WiFi RX/TX IRAM opts + the existing 50 Hz rate gate. Kconfig CSI_PROMISC_MGMT_ONLY falls back if needed. - Hardware-validated on COM9: yield 0 -> ~9pps avg (peak 19), presence/motion sensing restored, 0 panics over 35s. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
9ad550d95f
commit
f02b431b59
|
|
@ -115,11 +115,12 @@ jobs:
|
||||||
# RUN guard catches missing ones at build time, this re-checks the
|
# RUN guard catches missing ones at build time, this re-checks the
|
||||||
# pushed artifact post-hoc as belt-and-braces).
|
# pushed artifact post-hoc as belt-and-braces).
|
||||||
# 2. /health is up.
|
# 2. /health is up.
|
||||||
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
|
# 3. Secure-by-default (#864): with NO token env, the entrypoint
|
||||||
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
|
# generates one, so /api/v1/info returns 401 without a bearer.
|
||||||
# Bearer header, 200 with the correct one (the #443 auth middleware).
|
# 4. Explicit LAN opt-out (RUVIEW_ALLOW_UNAUTHENTICATED=1) restores the
|
||||||
|
# old unauthenticated 200 + serves the UI.
|
||||||
# ---------------------------------------------------------------------
|
# ---------------------------------------------------------------------
|
||||||
- name: Smoke-test image assets + LAN-mode HTTP
|
- name: Smoke-test image assets + secure-by-default HTTP
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||||
|
|
@ -128,17 +129,33 @@ jobs:
|
||||||
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
|
||||||
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'
|
||||||
|
|
||||||
docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
# (a) Secure-by-default: no token env ⇒ entrypoint auto-generates one
|
||||||
# Wait up to 30 s for /health.
|
# ⇒ /api/v1/info is gated (401) even though we passed no token.
|
||||||
|
docker run -d --name secdef -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
curl -fsS http://127.0.0.1:3000/health
|
curl -fsS http://127.0.0.1:3000/health >/dev/null
|
||||||
|
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info)
|
||||||
|
test "$code" = "401" || { echo "secure-by-default broken: expected 401, got $code (#864)"; exit 1; }
|
||||||
|
# The auto-generated token is printed to the logs for operators.
|
||||||
|
docker logs secdef 2>&1 | grep -q "generated one for you" || \
|
||||||
|
{ echo "expected generated-token banner in logs (#864)"; exit 1; }
|
||||||
|
docker stop secdef
|
||||||
|
|
||||||
|
# (b) Explicit opt-out for trusted LAN ⇒ unauthenticated 200 + UI.
|
||||||
|
docker run -d --name lan -p 3000:3000 -e CSI_SOURCE=simulated \
|
||||||
|
-e RUVIEW_ALLOW_UNAUTHENTICATED=1 "$IMAGE"
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl -fsS http://127.0.0.1:3000/health >/dev/null
|
||||||
curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null
|
curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||||
curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null
|
curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null
|
||||||
curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null
|
curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null
|
||||||
docker stop sm
|
docker stop lan
|
||||||
|
|
||||||
- name: Smoke-test the bearer-token auth path
|
- name: Smoke-test the bearer-token auth path
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,12 @@ RUN set -e; \
|
||||||
test -x /app/homecore-server || { echo "FATAL: /app/homecore-server is not executable"; exit 1; }; \
|
test -x /app/homecore-server || { echo "FATAL: /app/homecore-server is not executable"; exit 1; }; \
|
||||||
echo "image assets OK"
|
echo "image assets OK"
|
||||||
|
|
||||||
# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default),
|
# Bearer-token auth on /api/v1/* and /ws/* (#443, #864). Secure-by-default:
|
||||||
# set to enforce `Authorization: Bearer <token>` (see bearer_auth module, #443).
|
# when left unset the entrypoint GENERATES a random token at startup and prints
|
||||||
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ...
|
# it to the logs, so the sensing API + WebSocket stream are never anonymous out
|
||||||
|
# of the box. Pin a known token, or opt into the open LAN posture explicitly:
|
||||||
|
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ... # pin a token
|
||||||
|
# docker run -e RUVIEW_ALLOW_UNAUTHENTICATED=1 ... # trusted LAN only
|
||||||
ENV RUVIEW_API_TOKEN=
|
ENV RUVIEW_API_TOKEN=
|
||||||
|
|
||||||
# HTTP API
|
# HTTP API
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,15 @@ services:
|
||||||
- "5005:5005/udp"
|
- "5005:5005/udp"
|
||||||
environment:
|
environment:
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
|
# Bearer-token auth (#864). Secure-by-default: if RUVIEW_API_TOKEN is
|
||||||
|
# unset the container generates a random token at startup — retrieve it
|
||||||
|
# with `docker compose logs sensing-server`. Pin a known token by exporting
|
||||||
|
# RUVIEW_API_TOKEN in your shell / .env, or run open on a trusted, isolated
|
||||||
|
# LAN with RUVIEW_ALLOW_UNAUTHENTICATED=1.
|
||||||
|
# REST: Authorization: Bearer <token>
|
||||||
|
# WS: ws://<host>:3001/ws/sensing?token=<token>
|
||||||
|
- RUVIEW_API_TOKEN=${RUVIEW_API_TOKEN:-}
|
||||||
|
- RUVIEW_ALLOW_UNAUTHENTICATED=${RUVIEW_ALLOW_UNAUTHENTICATED:-}
|
||||||
# CSI_SOURCE controls the data source for the sensing server.
|
# CSI_SOURCE controls the data source for the sensing server.
|
||||||
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
|
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
|
||||||
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
|
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,52 @@ case "${1:-}" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# ── #864: secure-by-default API auth for the sensing server ──────────────────
|
||||||
|
#
|
||||||
|
# The sensing server publishes a live RF-sensing REST API and WebSocket stream.
|
||||||
|
# Historically the Docker image shipped with RUVIEW_API_TOKEN empty, which makes
|
||||||
|
# bearer auth a no-op and exposes `/api/v1/*` and `/ws/sensing` to anyone who can
|
||||||
|
# reach the published ports. We now fail closed: if no token is supplied we
|
||||||
|
# generate a strong random one and print it, so the stream is never anonymous by
|
||||||
|
# default. Operators on a trusted, isolated LAN can opt back into the open
|
||||||
|
# posture explicitly with RUVIEW_ALLOW_UNAUTHENTICATED=1.
|
||||||
|
generate_token() {
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
openssl rand -hex 32
|
||||||
|
elif [ -r /proc/sys/kernel/random/uuid ]; then
|
||||||
|
# Two UUIDs (dashes stripped) → 64 hex chars of kernel randomness.
|
||||||
|
printf '%s%s' \
|
||||||
|
"$(cat /proc/sys/kernel/random/uuid)" \
|
||||||
|
"$(cat /proc/sys/kernel/random/uuid)" | tr -d '-'
|
||||||
|
else
|
||||||
|
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "${RUVIEW_API_TOKEN:-}" ]; then
|
||||||
|
case "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" in
|
||||||
|
1|true|TRUE|yes|YES)
|
||||||
|
echo "WARNING: RUVIEW_ALLOW_UNAUTHENTICATED is set — the sensing API and" >&2
|
||||||
|
echo " /ws/sensing stream will run UNAUTHENTICATED. Only do this on a" >&2
|
||||||
|
echo " trusted, isolated network (issue #864)." >&2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
RUVIEW_API_TOKEN="$(generate_token)"
|
||||||
|
export RUVIEW_API_TOKEN
|
||||||
|
echo "============================================================" >&2
|
||||||
|
echo " RuView: no RUVIEW_API_TOKEN supplied — generated one for you:" >&2
|
||||||
|
echo " RUVIEW_API_TOKEN=${RUVIEW_API_TOKEN}" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " REST: Authorization: Bearer <token>" >&2
|
||||||
|
echo " WS: ws://<host>:3001/ws/sensing?token=<token>" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " Pin your own with -e RUVIEW_API_TOKEN=..., or run open on a" >&2
|
||||||
|
echo " trusted LAN with -e RUVIEW_ALLOW_UNAUTHENTICATED=1 (issue #864)." >&2
|
||||||
|
echo "============================================================" >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
# If the first argument looks like a flag (starts with -), prepend the
|
# If the first argument looks like a flag (starts with -), prepend the
|
||||||
# server binary so users can just pass flags:
|
# server binary so users can just pass flags:
|
||||||
# docker run <image> --source esp32 --tick-ms 500
|
# docker run <image> --source esp32 --tick-ms 500
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,23 @@ static uint8_t s_hop_index = 0;
|
||||||
/** Handle for the periodic hop timer. NULL when timer is not running. */
|
/** Handle for the periodic hop timer. NULL when timer is not running. */
|
||||||
static esp_timer_handle_t s_hop_timer = NULL;
|
static esp_timer_handle_t s_hop_timer = NULL;
|
||||||
|
|
||||||
|
/** Handle for the periodic probe-request injection timer (RuView#866).
|
||||||
|
* NULL when not running. */
|
||||||
|
static esp_timer_handle_t s_probe_timer = NULL;
|
||||||
|
|
||||||
|
/* Probe-request injection cadence (RuView#866). The MGMT-only promiscuous
|
||||||
|
* filter (RuView#396) only surfaces management frames, so on a network with no
|
||||||
|
* nearby beaconing APs — or one saturated with DATA traffic that the filter
|
||||||
|
* drops — the CSI callback can starve (3 callbacks in 70 s was observed in
|
||||||
|
* #866). Injecting a broadcast probe request elicits probe *responses* (which
|
||||||
|
* ARE management frames) from every AP in range, giving a controlled, traffic-
|
||||||
|
* independent CSI rate without re-enabling the DATA-frame interrupt storm that
|
||||||
|
* MGMT-only exists to avoid. 100 ms ⇒ ~10 Hz, matching the 20 Hz edge sample
|
||||||
|
* rate once ambient beacons are added. Override at build time via Kconfig. */
|
||||||
|
#ifndef CONFIG_CSI_PROBE_INJECT_INTERVAL_MS
|
||||||
|
#define CONFIG_CSI_PROBE_INJECT_INTERVAL_MS 100
|
||||||
|
#endif
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize CSI data into ADR-018 binary frame format.
|
* Serialize CSI data into ADR-018 binary frame format.
|
||||||
*
|
*
|
||||||
|
|
@ -464,19 +481,37 @@ void csi_collector_init(void)
|
||||||
ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true));
|
ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true));
|
||||||
ESP_ERROR_CHECK(esp_wifi_set_promiscuous_rx_cb(wifi_promiscuous_cb));
|
ESP_ERROR_CHECK(esp_wifi_set_promiscuous_rx_cb(wifi_promiscuous_cb));
|
||||||
|
|
||||||
/* MGMT-only promiscuous filter + active probe injection (RuView#396).
|
/* Promiscuous CSI filter (RuView#396 / RuView#866).
|
||||||
*
|
*
|
||||||
* DATA frames cause 100-500+ WiFi HW interrupts/sec which crashes Core 0
|
* History: DATA frames once crashed Core 0 in wDev_ProcessFiq (SPI-flash
|
||||||
* in wDev_ProcessFiq (SPI flash cache race in ESP-IDF WiFi blob).
|
* cache race in the WiFi blob) under a 100-500+ interrupt/sec storm, so the
|
||||||
* MGMT-only gives ~10 Hz (beacons). Probe request injection at 10 Hz
|
* filter was pinned to MGMT-only. But MGMT-only starves on real networks:
|
||||||
* adds ~10 Hz probe responses from APs → ~20 Hz total, matching the
|
* the associated AP's beacons do not reliably generate CSI on the C6, and
|
||||||
* edge processing designed sample rate of 20 Hz. */
|
* broadcast probe injection (below) transmits fine yet elicits almost no
|
||||||
|
* capturable responses — #866 measured ~3 CSI callbacks in 70 s with 0 pps
|
||||||
|
* yield. Two mitigations for the original crash are now in place and active
|
||||||
|
* (confirmed in the boot log): WiFi RX/TX IRAM optimisations keep the ISR
|
||||||
|
* out of cacheable flash, and wifi_csi_callback() applies a 50 Hz early
|
||||||
|
* rate gate (CSI_MIN_PROCESS_INTERVAL_US) that caps ISR work regardless of
|
||||||
|
* arrival rate. With those guards we re-admit DATA frames so ambient/own
|
||||||
|
* traffic produces a dense, traffic-driven CSI stream. Operators who hit
|
||||||
|
* instability can fall back to MGMT-only via Kconfig.
|
||||||
|
*
|
||||||
|
* Probe injection (csi_collector_start_probe_timer) is retained: it keeps a
|
||||||
|
* ~10 Hz floor of management-frame CSI when the link is otherwise idle. */
|
||||||
|
#ifdef CONFIG_CSI_PROMISC_MGMT_ONLY
|
||||||
|
uint32_t filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT;
|
||||||
|
const char *filter_desc = "MGMT-only (Kconfig override)";
|
||||||
|
#else
|
||||||
|
uint32_t filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT | WIFI_PROMIS_FILTER_MASK_DATA;
|
||||||
|
const char *filter_desc = "MGMT+DATA (50 Hz-gated, RuView#866)";
|
||||||
|
#endif
|
||||||
wifi_promiscuous_filter_t filt = {
|
wifi_promiscuous_filter_t filt = {
|
||||||
.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT,
|
.filter_mask = filter_mask,
|
||||||
};
|
};
|
||||||
ESP_ERROR_CHECK(esp_wifi_set_promiscuous_filter(&filt));
|
ESP_ERROR_CHECK(esp_wifi_set_promiscuous_filter(&filt));
|
||||||
|
|
||||||
ESP_LOGI(TAG, "Promiscuous mode enabled (MGMT-only, RuView#396)");
|
ESP_LOGI(TAG, "Promiscuous mode enabled (%s)", filter_desc);
|
||||||
|
|
||||||
#if CONFIG_SOC_WIFI_HE_SUPPORT
|
#if CONFIG_SOC_WIFI_HE_SUPPORT
|
||||||
/* Wi-Fi 6 targets (e.g. ESP32-C6): wifi_csi_config_t is wifi_csi_acquire_config_t
|
/* Wi-Fi 6 targets (e.g. ESP32-C6): wifi_csi_config_t is wifi_csi_acquire_config_t
|
||||||
|
|
@ -526,6 +561,12 @@ void csi_collector_init(void)
|
||||||
|
|
||||||
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
|
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
|
||||||
(unsigned)s_node_id, (unsigned)csi_channel);
|
(unsigned)s_node_id, (unsigned)csi_channel);
|
||||||
|
|
||||||
|
/* RuView#866: start active probe injection so CSI keeps flowing even when
|
||||||
|
* the MGMT-only filter would otherwise starve under heavy DATA traffic or
|
||||||
|
* a beacon-sparse environment. Safe to call here — WiFi is started and the
|
||||||
|
* CSI rx callback is registered above. */
|
||||||
|
csi_collector_start_probe_timer();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Accessor for other modules that need the authoritative runtime node_id. */
|
/* Accessor for other modules that need the authoritative runtime node_id. */
|
||||||
|
|
@ -713,3 +754,103 @@ esp_err_t csi_inject_ndp_frame(void)
|
||||||
|
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- RuView#866: active probe-request injection for traffic-independent CSI ---- */
|
||||||
|
|
||||||
|
esp_err_t csi_inject_probe_request(void)
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
* Broadcast 802.11 probe request (wildcard SSID). Every AP in range answers
|
||||||
|
* with a probe response — a *management* frame that passes the MGMT-only
|
||||||
|
* promiscuous filter (RuView#396) and fires the CSI callback. This gives a
|
||||||
|
* controlled CSI rate that does not depend on ambient beacon/data traffic.
|
||||||
|
*
|
||||||
|
* Layout: 24-byte MAC header + tagged params (wildcard SSID + basic rates).
|
||||||
|
* FC(2) Dur(2) A1/DA(6) A2/SA(6) A3/BSSID(6) SeqCtl(2) | SSID tag | Rates tag
|
||||||
|
*/
|
||||||
|
uint8_t frame[] = {
|
||||||
|
0x40, 0x00, /* FC: Type=Mgmt(0), Subtype=ProbeReq(4) */
|
||||||
|
0x00, 0x00, /* Duration */
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, /* A1 DA: broadcast */
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* A2 SA: own MAC (filled below) */
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, /* A3 BSSID: broadcast */
|
||||||
|
0x00, 0x00, /* Sequence control (HW overwrites) */
|
||||||
|
0x00, 0x00, /* Tag: SSID, len 0 (wildcard) */
|
||||||
|
0x01, 0x04, 0x02, 0x04, 0x0b, 0x16, /* Tag: Supported Rates 1/2/5.5/11 Mbps */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The Wi-Fi driver requires A2 (source) to be this interface's own MAC for
|
||||||
|
* a self-originated management frame, otherwise esp_wifi_80211_tx rejects
|
||||||
|
* it with ESP_ERR_INVALID_ARG. */
|
||||||
|
uint8_t mac[6];
|
||||||
|
if (esp_wifi_get_mac(WIFI_IF_STA, mac) == ESP_OK) {
|
||||||
|
memcpy(&frame[10], mac, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* en_sys_seq=true: let the MAC assign the sequence number. */
|
||||||
|
esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, frame, sizeof(frame), true);
|
||||||
|
|
||||||
|
/* Observability (RuView#866): track TX outcome so the per-second yield can
|
||||||
|
* be correlated with whether injection is actually reaching the air. The
|
||||||
|
* first few results are logged verbatim; thereafter a periodic summary. */
|
||||||
|
static uint32_t s_probe_ok = 0;
|
||||||
|
static uint32_t s_probe_fail = 0;
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
s_probe_ok++;
|
||||||
|
if (s_probe_ok <= 3 || (s_probe_ok % 100) == 0) {
|
||||||
|
ESP_LOGI(TAG, "probe inject ok #%lu (fail=%lu)",
|
||||||
|
(unsigned long)s_probe_ok, (unsigned long)s_probe_fail);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s_probe_fail++;
|
||||||
|
if (s_probe_fail <= 3 || (s_probe_fail % 50) == 0) {
|
||||||
|
ESP_LOGW(TAG, "probe inject failed: %s (fail #%lu, ok=%lu)",
|
||||||
|
esp_err_to_name(err), (unsigned long)s_probe_fail,
|
||||||
|
(unsigned long)s_probe_ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timer callback: inject one probe request every CONFIG_CSI_PROBE_INJECT_INTERVAL_MS. */
|
||||||
|
static void probe_timer_cb(void *arg)
|
||||||
|
{
|
||||||
|
(void)arg;
|
||||||
|
csi_inject_probe_request();
|
||||||
|
}
|
||||||
|
|
||||||
|
void csi_collector_start_probe_timer(void)
|
||||||
|
{
|
||||||
|
if (CONFIG_CSI_PROBE_INJECT_INTERVAL_MS == 0) {
|
||||||
|
ESP_LOGI(TAG, "Probe injection disabled (interval=0)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s_probe_timer != NULL) {
|
||||||
|
ESP_LOGW(TAG, "Probe timer already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
esp_timer_create_args_t timer_args = {
|
||||||
|
.callback = probe_timer_cb,
|
||||||
|
.arg = NULL,
|
||||||
|
.name = "csi_probe",
|
||||||
|
};
|
||||||
|
esp_err_t err = esp_timer_create(&timer_args, &s_probe_timer);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Failed to create probe timer: %s", esp_err_to_name(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t period_us = (uint64_t)CONFIG_CSI_PROBE_INJECT_INTERVAL_MS * 1000;
|
||||||
|
err = esp_timer_start_periodic(s_probe_timer, period_us);
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGE(TAG, "Failed to start probe timer: %s", esp_err_to_name(err));
|
||||||
|
esp_timer_delete(s_probe_timer);
|
||||||
|
s_probe_timer = NULL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Probe injection started: %d ms (~%d Hz) to keep CSI alive under MGMT-only filter",
|
||||||
|
CONFIG_CSI_PROBE_INJECT_INTERVAL_MS,
|
||||||
|
1000 / CONFIG_CSI_PROBE_INJECT_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,27 @@ void csi_collector_start_hop_timer(void);
|
||||||
*/
|
*/
|
||||||
esp_err_t csi_inject_ndp_frame(void);
|
esp_err_t csi_inject_ndp_frame(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject a broadcast 802.11 probe request (wildcard SSID) (RuView#866).
|
||||||
|
*
|
||||||
|
* Nearby APs answer with probe responses — management frames that pass the
|
||||||
|
* MGMT-only promiscuous filter and fire the CSI rx callback. This produces a
|
||||||
|
* controlled CSI rate independent of ambient beacon/data traffic, without
|
||||||
|
* re-enabling the DATA-frame interrupt storm that the MGMT-only filter avoids.
|
||||||
|
*
|
||||||
|
* @return ESP_OK on success, or an error code from esp_wifi_80211_tx().
|
||||||
|
*/
|
||||||
|
esp_err_t csi_inject_probe_request(void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the periodic probe-request injection timer (RuView#866).
|
||||||
|
*
|
||||||
|
* Fires every CONFIG_CSI_PROBE_INJECT_INTERVAL_MS (default 100 ms ⇒ ~10 Hz),
|
||||||
|
* calling csi_inject_probe_request(). Called automatically from
|
||||||
|
* csi_collector_init(); no-op if already running or if the interval is 0.
|
||||||
|
*/
|
||||||
|
void csi_collector_start_probe_timer(void);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the recent CSI callback rate (per second).
|
* Get the recent CSI callback rate (per second).
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
|
||||||
/// Path prefix the middleware protects when auth is enabled.
|
/// Path prefix the middleware protects when auth is enabled.
|
||||||
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
||||||
|
|
||||||
|
/// Path prefix for the WebSocket sensing/introspection topics that
|
||||||
|
/// [`require_ws_token`] protects when auth is enabled (#864).
|
||||||
|
pub const WS_PREFIX: &str = "/ws/";
|
||||||
|
|
||||||
/// Cheap, cloneable handle to the configured token (or `None`).
|
/// Cheap, cloneable handle to the configured token (or `None`).
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct AuthState {
|
pub struct AuthState {
|
||||||
|
|
@ -115,6 +119,71 @@ pub async fn require_bearer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract a bearer token from a WebSocket-upgrade request. Browsers cannot set
|
||||||
|
/// arbitrary headers on a WS handshake, so the token is accepted via the
|
||||||
|
/// `?token=<t>` query parameter in addition to the `Authorization: Bearer`
|
||||||
|
/// header that programmatic clients (wscat, curl) can send.
|
||||||
|
///
|
||||||
|
/// No percent-decoding is applied: generated tokens are URL-safe (hex from
|
||||||
|
/// `openssl rand` / UUID concatenation). Operators who pin a custom token
|
||||||
|
/// should keep it URL-safe.
|
||||||
|
fn ws_supplied_token(request: &Request) -> Option<String> {
|
||||||
|
// 1. Authorization: Bearer <token> — for programmatic clients.
|
||||||
|
if let Some(t) = request
|
||||||
|
.headers()
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.strip_prefix("Bearer "))
|
||||||
|
{
|
||||||
|
return Some(t.to_string());
|
||||||
|
}
|
||||||
|
// 2. ?token=<token> query parameter — the only option browsers have on a
|
||||||
|
// WebSocket handshake.
|
||||||
|
request.uri().query().and_then(token_from_query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the `token` value in a `&`-separated `key=value` query string.
|
||||||
|
fn token_from_query(query: &str) -> Option<String> {
|
||||||
|
query.split('&').find_map(|pair| {
|
||||||
|
let mut it = pair.splitn(2, '=');
|
||||||
|
match (it.next(), it.next()) {
|
||||||
|
(Some("token"), Some(v)) => Some(v.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axum middleware: enforces a valid token on `/ws/*` upgrade requests when
|
||||||
|
/// [`AuthState::is_enabled`] returns `true` (#864). Mirrors [`require_bearer`]
|
||||||
|
/// but reads the token from `?token=` (browser-friendly) or `Authorization`.
|
||||||
|
/// When auth is disabled the middleware is a no-op, preserving the LAN-only
|
||||||
|
/// default for non-Docker local runs.
|
||||||
|
pub async fn require_ws_token(
|
||||||
|
State(auth): State<AuthState>,
|
||||||
|
request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let Some(expected) = auth.token.clone() else {
|
||||||
|
return next.run(request).await;
|
||||||
|
};
|
||||||
|
if !request.uri().path().starts_with(WS_PREFIX) {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
let ok = ws_supplied_token(&request)
|
||||||
|
.map(|s| ct_eq(s.as_bytes(), expected.as_bytes()))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ok {
|
||||||
|
next.run(request).await
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"missing or invalid token (append ?token=<RUVIEW_API_TOKEN> to the ws URL, \
|
||||||
|
or send Authorization: Bearer <token>)\n",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -256,5 +325,82 @@ mod tests {
|
||||||
// These are documented in the issue body and the README; keep them locked.
|
// These are documented in the issue body and the README; keep them locked.
|
||||||
assert_eq!(API_TOKEN_ENV, "RUVIEW_API_TOKEN");
|
assert_eq!(API_TOKEN_ENV, "RUVIEW_API_TOKEN");
|
||||||
assert_eq!(PROTECTED_PREFIX, "/api/v1/");
|
assert_eq!(PROTECTED_PREFIX, "/api/v1/");
|
||||||
|
assert_eq!(WS_PREFIX, "/ws/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── #864: WebSocket token enforcement ────────────────────────────────────
|
||||||
|
|
||||||
|
fn ws_router(auth: AuthState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/ws/sensing", get(|| async { "stream" }))
|
||||||
|
.route("/ws/introspection", get(|| async { "stream" }))
|
||||||
|
.route("/health", get(|| async { "ok" }))
|
||||||
|
.layer(axum::middleware::from_fn_with_state(auth, require_ws_token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_from_query_parses_first_match() {
|
||||||
|
assert_eq!(token_from_query("token=abc").as_deref(), Some("abc"));
|
||||||
|
assert_eq!(token_from_query("a=1&token=abc&b=2").as_deref(), Some("abc"));
|
||||||
|
assert_eq!(token_from_query("a=1&b=2").as_deref(), None);
|
||||||
|
assert_eq!(token_from_query("").as_deref(), None);
|
||||||
|
// bare key with no value is not a token
|
||||||
|
assert_eq!(token_from_query("token").as_deref(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_unprotected_when_token_unset() {
|
||||||
|
let r = ws_router(AuthState::default());
|
||||||
|
assert_eq!(
|
||||||
|
status(r, "GET", "/ws/sensing", None).await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_blocks_without_token() {
|
||||||
|
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||||
|
assert_eq!(
|
||||||
|
status(r.clone(), "GET", "/ws/sensing", None).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status(r, "GET", "/ws/introspection", None).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_allows_with_query_token() {
|
||||||
|
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||||
|
assert_eq!(
|
||||||
|
status(r, "GET", "/ws/sensing?token=s3cr3t", None).await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_allows_with_bearer_header() {
|
||||||
|
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||||
|
assert_eq!(
|
||||||
|
status(r, "GET", "/ws/sensing", Some("s3cr3t")).await,
|
||||||
|
StatusCode::OK
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_blocks_with_wrong_query_token() {
|
||||||
|
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||||
|
assert_eq!(
|
||||||
|
status(r, "GET", "/ws/sensing?token=nope", None).await,
|
||||||
|
StatusCode::UNAUTHORIZED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ws_middleware_never_gates_non_ws_paths() {
|
||||||
|
// /health rides on the same router (dedicated WS port) and must stay open.
|
||||||
|
let r = ws_router(AuthState::from_token("s3cr3t"));
|
||||||
|
assert_eq!(status(r, "GET", "/health", None).await, StatusCode::OK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6103,7 +6103,10 @@ async fn main() {
|
||||||
// every `/api/v1/*` request must carry `Authorization: Bearer <token>`.
|
// every `/api/v1/*` request must carry `Authorization: Bearer <token>`.
|
||||||
let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env();
|
let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env();
|
||||||
if bearer_auth_state.is_enabled() {
|
if bearer_auth_state.is_enabled() {
|
||||||
info!("API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)");
|
info!(
|
||||||
|
"API auth: bearer-token enforcement ON for /api/v1/* and /ws/* (RUVIEW_API_TOKEN set). \
|
||||||
|
WebSocket clients pass it as ?token=<token>."
|
||||||
|
);
|
||||||
if bind_ip.is_unspecified() {
|
if bind_ip.is_unspecified() {
|
||||||
warn!(
|
warn!(
|
||||||
"API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments",
|
"API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments",
|
||||||
|
|
@ -6111,8 +6114,9 @@ async fn main() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
info!(
|
warn!(
|
||||||
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> to enforce bearer auth."
|
"API auth: OFF — /api/v1/* and /ws/* sensing streams are UNAUTHENTICATED. \
|
||||||
|
Set RUVIEW_API_TOKEN=<token> to enforce bearer auth (the Docker image does this by default)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6145,6 +6149,13 @@ async fn main() {
|
||||||
let ws_app = Router::new()
|
let ws_app = Router::new()
|
||||||
.route("/ws/sensing", get(ws_sensing_handler))
|
.route("/ws/sensing", get(ws_sensing_handler))
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
|
// #864: gate the live sensing stream with the API token when set. Reads
|
||||||
|
// `?token=` (browser-friendly) or `Authorization: Bearer`. No-op when
|
||||||
|
// RUVIEW_API_TOKEN is unset (LAN-mode default for local non-Docker runs).
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
bearer_auth_state.clone(),
|
||||||
|
wifi_densepose_sensing_server::bearer_auth::require_ws_token,
|
||||||
|
))
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
host_allowlist.clone(),
|
host_allowlist.clone(),
|
||||||
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
wifi_densepose_sensing_server::host_validation::require_allowed_host,
|
||||||
|
|
@ -6257,12 +6268,19 @@ async fn main() {
|
||||||
))
|
))
|
||||||
// Opt-in bearer-token auth on `/api/v1/*` (#443). When `RUVIEW_API_TOKEN`
|
// Opt-in bearer-token auth on `/api/v1/*` (#443). When `RUVIEW_API_TOKEN`
|
||||||
// is unset/empty the middleware is a no-op — the default stays
|
// is unset/empty the middleware is a no-op — the default stays
|
||||||
// LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never
|
// LAN-mode-friendly. `/health*` and `/ui/*` are never gated
|
||||||
// gated (orchestrator probes + local browsers).
|
// (orchestrator probes + local browsers loading the static UI).
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
bearer_auth_state.clone(),
|
bearer_auth_state.clone(),
|
||||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||||
))
|
))
|
||||||
|
// #864: gate the live `/ws/*` sensing + introspection streams with the
|
||||||
|
// same token. Browsers pass it as `?token=`; programmatic clients use
|
||||||
|
// `Authorization: Bearer`. No-op when RUVIEW_API_TOKEN is unset.
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
bearer_auth_state.clone(),
|
||||||
|
wifi_densepose_sensing_server::bearer_auth::require_ws_token,
|
||||||
|
))
|
||||||
// DNS-rebinding defense: applied last so it runs first on the request
|
// DNS-rebinding defense: applied last so it runs first on the request
|
||||||
// path (axum layers run outermost-in). Rejects requests whose `Host`
|
// path (axum layers run outermost-in). Rejects requests whose `Host`
|
||||||
// header is not in the allowlist before any handler — including
|
// header is not in the allowlist before any handler — including
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue