Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions .github/workflows/sensing-server-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,12 @@ jobs:
# RUN guard catches missing ones at build time, this re-checks the
# pushed artifact post-hoc as belt-and-braces).
# 2. /health is up.
# 3. /api/v1/info returns 200 with no auth (LAN-mode default).
# 4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
# Bearer header, 200 with the correct one (the #443 auth middleware).
# 3. Secure-by-default (#864): with NO token env, the entrypoint
# generates one, so /api/v1/info returns 401 without a bearer.
# 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: |
set -euo pipefail
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
Expand All @@ -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'
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"
# Wait up to 30 s for /health.
# (a) Secure-by-default: no token env ⇒ entrypoint auto-generates one
# ⇒ /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
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
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/ui/observatory.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
run: |
Expand Down
9 changes: 6 additions & 3 deletions docker/Dockerfile.rust
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@ RUN set -e; \
test -x /app/homecore-server || { echo "FATAL: /app/homecore-server is not executable"; exit 1; }; \
echo "image assets OK"

# Optional bearer-token auth on /api/v1/*: leave unset for LAN-mode (default),
# set to enforce `Authorization: Bearer <token>` (see bearer_auth module, #443).
# docker run -e RUVIEW_API_TOKEN=$(openssl rand -hex 32) ...
# Bearer-token auth on /api/v1/* and /ws/* (#443, #864). Secure-by-default:
# when left unset the entrypoint GENERATES a random token at startup and prints
# 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=

# HTTP API
Expand Down
9 changes: 9 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
- "5005:5005/udp"
environment:
- 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:-}

Check failure

Code scanning / KICS

Passwords And Secrets - Generic Token Error

Hardcoded secret key appears in source

Check warning on line 33 in docker/docker-compose.yml

View workflow job for this annotation

GitHub Actions / Infrastructure Security Scan

[HIGH] Passwords And Secrets - Generic Token

Query to find passwords and secrets in infrastructure code.
- RUVIEW_ALLOW_UNAUTHENTICATED=${RUVIEW_ALLOW_UNAUTHENTICATED:-}
# CSI_SOURCE controls the data source for the sensing server.
# Options: auto (default) — probe for ESP32 UDP then fall back to simulation
# esp32 — receive real CSI frames from an ESP32 on UDP port 5005
Expand Down
46 changes: 46 additions & 0 deletions docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,52 @@ case "${1:-}" in
;;
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
# server binary so users can just pass flags:
# docker run <image> --source esp32 --tick-ms 500
Expand Down
157 changes: 149 additions & 8 deletions firmware/esp32-csi-node/main/csi_collector.c
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ static uint8_t s_hop_index = 0;
/** Handle for the periodic hop timer. NULL when timer is not running. */
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.
*
Expand Down Expand Up @@ -464,19 +481,37 @@ void csi_collector_init(void)
ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true));
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).
*
* History: DATA frames once crashed Core 0 in wDev_ProcessFiq (SPI-flash
* cache race in the WiFi blob) under a 100-500+ interrupt/sec storm, so the
* filter was pinned to MGMT-only. But MGMT-only starves on real networks:
* the associated AP's beacons do not reliably generate CSI on the C6, and
* 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.
*
* DATA frames cause 100-500+ WiFi HW interrupts/sec which crashes Core 0
* in wDev_ProcessFiq (SPI flash cache race in ESP-IDF WiFi blob).
* MGMT-only gives ~10 Hz (beacons). Probe request injection at 10 Hz
* adds ~10 Hz probe responses from APs → ~20 Hz total, matching the
* edge processing designed sample rate of 20 Hz. */
* 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 = {
.filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT,
.filter_mask = filter_mask,
};
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
/* Wi-Fi 6 targets (e.g. ESP32-C6): wifi_csi_config_t is wifi_csi_acquire_config_t
Expand Down Expand Up @@ -526,6 +561,12 @@ void csi_collector_init(void)

ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
(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. */
Expand Down Expand Up @@ -713,3 +754,103 @@ esp_err_t csi_inject_ndp_frame(void)

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);
}
21 changes: 21 additions & 0 deletions firmware/esp32-csi-node/main/csi_collector.h
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@ void csi_collector_start_hop_timer(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).
*
Expand Down
Loading
Loading