feat(docker+sensing-server): refresh Docker publish + opt-in bearer-token API auth
Closes #520, #514, #443. ## #520 / #514 — stale Docker image, missing UI assets `ruvnet/wifi-densepose:latest` was published before `ui/observatory*` and `ui/pose-fusion*` were added; users see /app/ui missing those files and the v0.6+ packet format doesn't reach the server. Two fixes: 1. `docker/Dockerfile.rust` now `RUN`s a build-time guard after `COPY ui/` that fails the build if `index.html` / `observatory.html` / `pose-fusion.html` / `viz.html` (or the `observatory/` / `pose-fusion/` / `components/` / `services/` directories) are missing, plus an exec-bit check on `/app/sensing-server`. A stale image can never be silently produced again. 2. New `.github/workflows/sensing-server-docker.yml` rebuilds + pushes on every change to the Dockerfile, the server crate, the signal/vitals/ wifiscan crates, the workspace manifests, the `ui/` tree, or itself — plus `v*` tags and manual dispatch. Pushes to both `docker.io/ruvnet/ wifi-densepose` AND `ghcr.io/ruvnet/wifi-densepose` with `latest` + `vX.Y.Z` + `sha-<short>` tags, then post-push smoke-tests the artifact: /health, /api/v1/info, the observatory + pose-fusion HTML, AND the bearer-auth path (no token → 401, wrong → 401, correct → 200). Uses the `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN` repo secrets; ghcr.io rides on the workflow's GITHUB_TOKEN. ## #443 — sensing-server REST API auth model QE security audit raised that 40+ /api/v1/* routes have no auth layer with a default `0.0.0.0` bind. New `wifi_densepose_sensing_server::bearer_auth` module + middleware: - Env-var-gated: `RUVIEW_API_TOKEN` unset/empty ⇒ middleware is a no-op (current LAN-mode behaviour preserved — **no default change**); set ⇒ every `/api/v1/*` request must carry `Authorization: Bearer <token>` or the server returns 401. - Constant-time byte compare via local `ct_eq` (no new dep). - `/health*`, `/ws/sensing`, and `/ui/*` are intentionally never gated (orchestrator probes + local browsers). - Startup logs which mode is active and warns when auth is ON with a `0.0.0.0` bind. - 8 unit tests on the middleware via `tower::ServiceExt::oneshot` (sensing-server lib tests 191 → 199, 0 failures). Verified locally: `cargo build --workspace --no-default-features` ✓, `cargo test -p wifi-densepose-sensing-server --no-default-features` ✓. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
00304f9dc7
commit
c641fc44ae
|
|
@ -0,0 +1,164 @@
|
|||
name: wifi-densepose sensing-server → Docker Hub + ghcr.io
|
||||
|
||||
# Build + publish the `wifi-densepose` sensing-server image to both Docker Hub
|
||||
# (`ruvnet/wifi-densepose`) and ghcr.io (`ghcr.io/ruvnet/wifi-densepose`) on:
|
||||
# - push to main affecting the Dockerfile, the server crate, the UI assets,
|
||||
# or this workflow itself,
|
||||
# - tag push matching v* (release builds),
|
||||
# - manual workflow_dispatch.
|
||||
#
|
||||
# Closes #520 and #514: the stale `:latest` is rebuilt and pushed automatically
|
||||
# whenever the surface that produces it changes, and the Dockerfile fails the
|
||||
# build if the observatory/pose-fusion UI assets ever go missing again.
|
||||
#
|
||||
# Secrets:
|
||||
# DOCKERHUB_USERNAME — `ruvnet` (Docker Hub login name)
|
||||
# DOCKERHUB_TOKEN — Docker Hub access token with read/write/delete scope
|
||||
# (ghcr.io uses the workflow's GITHUB_TOKEN — no secret needed.)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docker/Dockerfile.rust'
|
||||
- 'docker/docker-entrypoint.sh'
|
||||
- 'v2/crates/wifi-densepose-sensing-server/**'
|
||||
- 'v2/crates/wifi-densepose-signal/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'v2/crates/wifi-densepose-wifiscan/**'
|
||||
- 'v2/Cargo.toml'
|
||||
- 'v2/Cargo.lock'
|
||||
- 'ui/**'
|
||||
- '.github/workflows/sensing-server-docker.yml'
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: sensing-server-docker-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
name: build · push · smoke-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/ruvnet/wifi-densepose
|
||||
ghcr.io/ruvnet/wifi-densepose
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build + push
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.rust
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Smoke-test the freshly-pushed image:
|
||||
# 1. UI assets that closed #520 are inside `/app/ui` (the Dockerfile's
|
||||
# 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).
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Smoke-test image assets + LAN-mode HTTP
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker pull "$IMAGE"
|
||||
docker run --rm "$IMAGE" sh -c \
|
||||
'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.
|
||||
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/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
|
||||
|
||||
- name: Smoke-test the bearer-token auth path
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
|
||||
docker run -d --name auth \
|
||||
-p 3000:3000 \
|
||||
-e CSI_SOURCE=simulated \
|
||||
-e RUVIEW_API_TOKEN=smoke-test-token-do-not-use \
|
||||
"$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
|
||||
# /health stays unauthenticated.
|
||||
curl -fsS http://127.0.0.1:3000/health >/dev/null
|
||||
# /api/v1/info without a bearer → 401.
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "expected 401, got $code"; exit 1; }
|
||||
# Wrong bearer → 401.
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer wrong' http://127.0.0.1:3000/api/v1/info)
|
||||
test "$code" = "401" || { echo "expected 401 (wrong token), got $code"; exit 1; }
|
||||
# Correct bearer → 200.
|
||||
curl -fsS -H 'Authorization: Bearer smoke-test-token-do-not-use' http://127.0.0.1:3000/api/v1/info >/dev/null
|
||||
docker stop auth
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## sensing-server image published"
|
||||
echo
|
||||
echo "Tags:"
|
||||
echo '```'
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
echo '```'
|
||||
echo
|
||||
echo "Closes #520 (missing observatory/pose-fusion UI assets) and #514 (stale `:latest` for the v0.6+ packet format)."
|
||||
echo "The Dockerfile fails the build if those UI assets ever disappear again, and this workflow rebuilds + pushes automatically on every change to the surface."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -7,7 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Opt-in bearer-token auth on `wifi-densepose-sensing-server`'s `/api/v1/*` HTTP surface (closes #443).**
|
||||
New `wifi_densepose_sensing_server::bearer_auth` module: when the
|
||||
`RUVIEW_API_TOKEN` env var is set, every request whose path begins with
|
||||
`/api/v1/` must carry an `Authorization: Bearer <token>` header (constant-time
|
||||
compared) or the server responds `401 Unauthorized`. When the variable is
|
||||
unset or empty the middleware is a no-op — the long-standing LAN-only
|
||||
deployment posture is preserved, so this is a binary deployment-time switch
|
||||
with **no default behaviour change**. `/health*`, `/ws/sensing`, and the
|
||||
`/ui/*` static mount are intentionally never gated (orchestrator probes +
|
||||
local browsers). Startup logs which mode is active and warns when auth is on
|
||||
with a `0.0.0.0` bind. 8 unit tests on the middleware (lib test count 191 → 199).
|
||||
Resolves the security audit raised in #443.
|
||||
|
||||
### Changed
|
||||
- **Docker image: build-time guard for the UI assets, plus a CI workflow that
|
||||
rebuilds and pushes on every change (closes #520, #514).** `docker/Dockerfile.rust`
|
||||
now `RUN`s a guard after `COPY ui/` that fails the build if any of
|
||||
`index.html` / `observatory.html` / `pose-fusion.html` / `viz.html` / the
|
||||
`observatory/` / `pose-fusion/` / `components/` / `services/` directories are
|
||||
missing, so a stale image can never be silently produced again. New
|
||||
`.github/workflows/sensing-server-docker.yml` builds the image on push to
|
||||
`main` (paths-filtered) and on `v*` tags and pushes to both
|
||||
`docker.io/ruvnet/wifi-densepose` and `ghcr.io/ruvnet/wifi-densepose` with
|
||||
`latest` + `vX.Y.Z` + `sha-<short>` tags, then smoke-tests the published
|
||||
artifact: `/health`, `/api/v1/info`, the observatory + pose-fusion UI assets,
|
||||
and the `RUVIEW_API_TOKEN` auth path (no token → 401, wrong → 401, correct
|
||||
→ 200). Uses `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` repo secrets for the
|
||||
Docker Hub push; ghcr.io uses the workflow's `GITHUB_TOKEN`.
|
||||
- **rvCSI moved to its own repo and is now vendored as a submodule.** The 9 `rvcsi-*`
|
||||
crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/
|
||||
`-runtime`/`-node`/`-cli` — added inline in #542) now live in
|
||||
|
|
|
|||
|
|
@ -33,6 +33,25 @@ COPY --from=builder /build/target/release/sensing-server /app/sensing-server
|
|||
# Copy UI assets
|
||||
COPY ui/ /app/ui/
|
||||
|
||||
# Sanity-check the assets the runtime actually serves (regression guard for
|
||||
# #520/#514 — the published image must include the observatory and pose-fusion
|
||||
# dashboards, not just the legacy `index.html` set). Build fails if any of
|
||||
# these are missing, so a stale image can't be silently pushed.
|
||||
RUN set -e; \
|
||||
for f in /app/ui/index.html /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/viz.html; do \
|
||||
test -f "$f" || { echo "FATAL: missing UI asset $f"; exit 1; }; \
|
||||
done; \
|
||||
for d in /app/ui/observatory /app/ui/pose-fusion /app/ui/components /app/ui/services; do \
|
||||
test -d "$d" || { echo "FATAL: missing UI directory $d"; exit 1; }; \
|
||||
done; \
|
||||
test -x /app/sensing-server || { echo "FATAL: /app/sensing-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) ...
|
||||
ENV RUVIEW_API_TOKEN=
|
||||
|
||||
# HTTP API
|
||||
EXPOSE 3000
|
||||
# WebSocket
|
||||
|
|
|
|||
|
|
@ -944,15 +944,6 @@ version = "0.4.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
|
|
@ -1294,7 +1285,7 @@ version = "0.99.20"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"convert_case 0.4.0",
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
|
|
@ -3200,7 +3191,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
||||
dependencies = [
|
||||
"gtk-sys",
|
||||
"libloading 0.7.4",
|
||||
"libloading",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
|
|
@ -3220,16 +3211,6 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
|
|
@ -3643,63 +3624,6 @@ dependencies = [
|
|||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "2.16.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"ctor",
|
||||
"napi-derive",
|
||||
"napi-sys",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "2.16.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"convert_case 0.6.0",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "1.0.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"semver",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
|
||||
dependencies = [
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
|
|
@ -5955,111 +5879,6 @@ version = "2.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "178f93f84a4a72c582026a45d9b8710acf188df4a22a25434c5dbba1df6c4cac"
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-adapter-file"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"rvcsi-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-adapter-nexmon"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"rvcsi-core",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-cli"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"rvcsi-adapter-file",
|
||||
"rvcsi-adapter-nexmon",
|
||||
"rvcsi-core",
|
||||
"rvcsi-runtime",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-core"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-dsp"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"rvcsi-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-events"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"rvcsi-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-node"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"rvcsi-adapter-nexmon",
|
||||
"rvcsi-core",
|
||||
"rvcsi-runtime",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-runtime"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"rvcsi-adapter-file",
|
||||
"rvcsi-adapter-nexmon",
|
||||
"rvcsi-core",
|
||||
"rvcsi-dsp",
|
||||
"rvcsi-events",
|
||||
"rvcsi-ruvector",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rvcsi-ruvector"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"rvcsi-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
|
|
@ -8706,6 +8525,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -52,3 +52,5 @@ wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal",
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
# `tower::ServiceExt::oneshot` for in-process Router tests (bearer_auth).
|
||||
tower = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
//! Opt-in bearer-token auth for the sensing-server HTTP API (#443).
|
||||
//!
|
||||
//! When the `RUVIEW_API_TOKEN` environment variable is set, every request
|
||||
//! whose path begins with `/api/v1/` must carry a matching
|
||||
//! `Authorization: Bearer <token>` header, otherwise the server responds with
|
||||
//! `401 Unauthorized`. When the env var is unset (or empty), the middleware is
|
||||
//! a no-op and the API stays unauthenticated — preserving the long-standing
|
||||
//! LAN-only deployment posture documented in the issue. This is a binary,
|
||||
//! deployment-time switch with **no default authentication change**.
|
||||
//!
|
||||
//! Endpoints outside `/api/v1/*` (`/health*`, `/ws/sensing`, the static `/ui/*`
|
||||
//! mount, `/`) are intentionally **not** gated:
|
||||
//! * `/health*` is the liveness/readiness probe that orchestrators hit
|
||||
//! anonymously;
|
||||
//! * `/ws/sensing` and `/ui/*` are served to local browsers that can't easily
|
||||
//! inject headers — the sensitive control plane is the `/api/v1/*` tree, and
|
||||
//! that is what this layer protects.
|
||||
//!
|
||||
//! The header check uses a length-then-byte constant-time compare to avoid
|
||||
//! leaking the token through timing.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{header::AUTHORIZATION, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
/// Environment variable that gates the middleware. Unset / empty ⇒ auth off.
|
||||
pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
|
||||
|
||||
/// Path prefix the middleware protects when auth is enabled.
|
||||
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
||||
|
||||
/// Cheap, cloneable handle to the configured token (or `None`).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuthState {
|
||||
/// The expected bearer token, if any. `None` ⇒ middleware is a no-op.
|
||||
token: Option<Arc<String>>,
|
||||
}
|
||||
|
||||
impl AuthState {
|
||||
/// Build an [`AuthState`] from an explicit string. Empty ⇒ disabled.
|
||||
pub fn from_token(t: impl Into<String>) -> Self {
|
||||
let s = t.into();
|
||||
if s.is_empty() {
|
||||
AuthState { token: None }
|
||||
} else {
|
||||
AuthState { token: Some(Arc::new(s)) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Read [`API_TOKEN_ENV`] from the process environment. Returns
|
||||
/// `AuthState { token: None }` when the variable is unset or empty.
|
||||
pub fn from_env() -> Self {
|
||||
match std::env::var(API_TOKEN_ENV) {
|
||||
Ok(s) if !s.is_empty() => AuthState::from_token(s),
|
||||
_ => AuthState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the middleware will enforce auth on `/api/v1/*` requests.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.token.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-time byte slice equality. Returns `false` immediately on length
|
||||
/// mismatch (lengths are not secret here — both sides are fixed tokens).
|
||||
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// Axum middleware: enforces `Authorization: Bearer <token>` on `/api/v1/*`
|
||||
/// requests when [`AuthState::is_enabled`] returns `true`. Wires up via
|
||||
/// [`axum::middleware::from_fn_with_state`].
|
||||
pub async fn require_bearer(
|
||||
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(PROTECTED_PREFIX) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let supplied = request
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "));
|
||||
let ok = supplied
|
||||
.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 bearer token (set Authorization: Bearer <RUVIEW_API_TOKEN>)\n",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn ok_handler() -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/api/v1/info", get(|| async { "ok" }))
|
||||
.route("/api/v1/sensitive", axum::routing::post(|| async { "ok" }))
|
||||
.route("/ui/index.html", get(|| async { "<html/>" }))
|
||||
}
|
||||
|
||||
fn wrap(auth: AuthState) -> Router {
|
||||
ok_handler()
|
||||
.layer(axum::middleware::from_fn_with_state(auth, require_bearer))
|
||||
}
|
||||
|
||||
async fn status(router: Router, method: &str, path: &str, auth: Option<&str>) -> StatusCode {
|
||||
let mut req = Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
if let Some(t) = auth {
|
||||
req.headers_mut()
|
||||
.insert(AUTHORIZATION, format!("Bearer {t}").parse().unwrap());
|
||||
}
|
||||
router.oneshot(req).await.unwrap().status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_is_no_op_when_token_unset() {
|
||||
let r = wrap(AuthState::default());
|
||||
assert_eq!(status(r.clone(), "GET", "/api/v1/info", None).await, StatusCode::OK);
|
||||
assert_eq!(status(r.clone(), "POST", "/api/v1/sensitive", None).await, StatusCode::OK);
|
||||
assert_eq!(status(r.clone(), "GET", "/health", None).await, StatusCode::OK);
|
||||
assert_eq!(status(r, "GET", "/ui/index.html", None).await, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_blocks_api_without_bearer() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(status(r.clone(), "GET", "/api/v1/info", None).await, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
status(r, "POST", "/api/v1/sensitive", None).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_blocks_api_with_wrong_bearer() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r.clone(), "GET", "/api/v1/info", Some("nope")).await,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
// Wrong scheme (Basic / token) — only "Bearer <token>" is accepted.
|
||||
let mut req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/v1/info")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
req.headers_mut()
|
||||
.insert(AUTHORIZATION, "Basic s3cr3t".parse().unwrap());
|
||||
assert_eq!(r.oneshot(req).await.unwrap().status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_allows_api_with_correct_bearer() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
assert_eq!(
|
||||
status(r.clone(), "GET", "/api/v1/info", Some("s3cr3t")).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
status(r, "POST", "/api/v1/sensitive", Some("s3cr3t")).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enabled_never_gates_paths_outside_api_v1() {
|
||||
let r = wrap(AuthState::from_token("s3cr3t"));
|
||||
// Even with auth ON, `/health` and `/ui/*` are reachable without a token:
|
||||
// orchestrator probes and the local UI need to load unchallenged.
|
||||
assert_eq!(status(r.clone(), "GET", "/health", None).await, StatusCode::OK);
|
||||
assert_eq!(status(r, "GET", "/ui/index.html", None).await, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ct_eq_basics() {
|
||||
assert!(ct_eq(b"abc", b"abc"));
|
||||
assert!(!ct_eq(b"abc", b"abd"));
|
||||
assert!(!ct_eq(b"abc", b"ab")); // length mismatch
|
||||
assert!(!ct_eq(b"", b"x"));
|
||||
assert!(ct_eq(b"", b""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_treats_empty_as_disabled() {
|
||||
// Avoid touching the real env in a thread-shared test — exercise the
|
||||
// string ctor directly with the same trim logic.
|
||||
assert!(!AuthState::from_token("").is_enabled());
|
||||
assert!(AuthState::from_token("x").is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_prefix_and_env_constants_are_stable() {
|
||||
// These are documented in the issue body and the README; keep them locked.
|
||||
assert_eq!(API_TOKEN_ENV, "RUVIEW_API_TOKEN");
|
||||
assert_eq!(PROTECTED_PREFIX, "/api/v1/");
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,9 @@
|
|||
//! This crate provides:
|
||||
//! - Vital sign detection from WiFi CSI amplitude data
|
||||
//! - RVF (RuVector Format) binary container for model weights
|
||||
//! - Opt-in bearer-token auth for the `/api/v1/*` HTTP surface (`bearer_auth`)
|
||||
|
||||
pub mod bearer_auth;
|
||||
pub mod vital_signs;
|
||||
pub mod rvf_container;
|
||||
pub mod rvf_pipeline;
|
||||
|
|
|
|||
|
|
@ -4861,6 +4861,26 @@ async fn main() {
|
|||
let bind_ip: std::net::IpAddr = args.bind_addr.parse()
|
||||
.expect("Invalid --bind-addr (use 127.0.0.1 or 0.0.0.0)");
|
||||
|
||||
// #443: optional bearer-token auth on `/api/v1/*`. `RUVIEW_API_TOKEN`
|
||||
// unset/empty ⇒ middleware is a no-op (LAN-mode default preserved); set ⇒
|
||||
// every `/api/v1/*` request must carry `Authorization: Bearer <token>`.
|
||||
let bearer_auth_state = wifi_densepose_sensing_server::bearer_auth::AuthState::from_env();
|
||||
if bearer_auth_state.is_enabled() {
|
||||
info!(
|
||||
"API auth: bearer-token enforcement ON for /api/v1/* (RUVIEW_API_TOKEN set)"
|
||||
);
|
||||
if bind_ip.is_unspecified() {
|
||||
warn!(
|
||||
"API auth ON but bind-addr is {} — consider --bind-addr 127.0.0.1 for LAN-only deployments",
|
||||
bind_ip
|
||||
);
|
||||
}
|
||||
} else {
|
||||
info!(
|
||||
"API auth: OFF — /api/v1/* is unauthenticated. Set RUVIEW_API_TOKEN=<token> to enforce bearer auth."
|
||||
);
|
||||
}
|
||||
|
||||
// WebSocket server on dedicated port (8765)
|
||||
let ws_state = state.clone();
|
||||
let ws_app = Router::new()
|
||||
|
|
@ -4947,6 +4967,14 @@ async fn main() {
|
|||
axum::http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||
))
|
||||
// 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
|
||||
// LAN-mode-friendly. `/health*`, `/ws/sensing`, and `/ui/*` are never
|
||||
// gated (orchestrator probes + local browsers).
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
bearer_auth_state.clone(),
|
||||
wifi_densepose_sensing_server::bearer_auth::require_bearer,
|
||||
))
|
||||
.with_state(state.clone());
|
||||
|
||||
let http_addr = SocketAddr::from((bind_ip, args.http_port));
|
||||
|
|
|
|||
Loading…
Reference in New Issue