From a46fc5a909290d63f99d25af018435624a362bf9 Mon Sep 17 00:00:00 2001 From: Cyril Poder Date: Sat, 7 Nov 2026 18:11:00 +0100 Subject: [PATCH] runtime: the binary stays on loopback unless told otherwise The default was 0.0.0.0:8686 with no token, on the port that writes flows and runs commands through exec connectors. Compose hid it by publishing on localhost, but we also publish the raw binary. The default is now 127.0.0.1:8686. The image sets VEJAS_HTTP_ADDR=0.0.0.0:8686, since loopback inside a container reaches nothing: a bare docker run -p stays as exposed as before, so the runtime warns at boot past loopback with no token, and the install page publishes on localhost. http-in keeps 0.0.0.0 on its own port by design. MANIFESTO.md: the demo is 45 seconds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018wXgxT2UoFSZfqhAE1Tb5V --- CHANGELOG.md | 7 ++++ Dockerfile | 3 ++ MANIFESTO.md | 2 +- README.md | 15 ++++---- core/src/main.rs | 44 +++++++++++++++++++++++- docs/ARCHITECTURE.md | 7 ++-- docs/book/src/getting-started/install.md | 8 +++-- docs/book/src/reference/env-vars.md | 2 +- docs/book/src/reference/http-api.md | 2 +- docs/security-audit-scope.md | 8 +++-- e2e/api-write-gate.sh | 19 ++++++++++ 11 files changed, 98 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 330c005..ab0fa7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ is `0`, minor versions may carry breaking changes — they are called out here. ## [Unreleased] +### Security +- **The binary listens on `127.0.0.1:8686` by default.** It was `0.0.0.0:8686`, with + no token, on a port that writes flows and runs commands. To reach a raw binary from + elsewhere, set `VEJAS_HTTP_ADDR`, and `VEJAS_TOKEN` with it. The image sets + `0.0.0.0:8686` itself, so containers do not change. Past loopback with no token, the + runtime now warns at boot. + ### Changed - Published numbers corrected against v0.5.1: cluster promote 20 ms, graceful handoff at most 2 s, crash failover 4–5 s, image 40 MB to pull (100 MB unpacked), and against diff --git a/Dockerfile b/Dockerfile index 2861ea3..6e681d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,5 +21,8 @@ COPY connectors/ connectors/ COPY docs/ docs/ COPY --from=build /build/target/release/vejas-runtime /usr/local/bin/vejas-runtime ENV VEJAS_ROOT=/app +# The binary alone stays on loopback. In a container that would reach nothing, and +# publishing the port is the deliberate act: docker-compose.yml keeps it on localhost. +ENV VEJAS_HTTP_ADDR=0.0.0.0:8686 EXPOSE 8686 8787 CMD ["vejas-runtime"] diff --git a/MANIFESTO.md b/MANIFESTO.md index 3b281f5..826af77 100644 --- a/MANIFESTO.md +++ b/MANIFESTO.md @@ -53,4 +53,4 @@ Buyers. Enterprises buy governance, support, and someone to blame when it breaks Timing. Maybe letting agents write production glue is two years early for most shops. Fine by me, the demo was cheap and git does not expire. My own conviction, built from watching agents write integration code all year, is that it is not early at all. -The repo is at https://github.com/cpoder/vejas: one Rust runtime, a handful of connectors, and a 53-second recorded demo — a real SAP and a real Salesforce bridged both ways by two readable flows, a domain expert correcting a business rule on screen, shadow-replayed on real events before it lands. The recording is generated by a script that is also a green end-to-end test. If you run integrations for a living and think this is wrong, I want the strongest version of your objection. If you think it is obvious, even better, come help with the connector SDK :) +The repo is at https://github.com/cpoder/vejas: one Rust runtime, a handful of connectors, and a 45-second recorded demo — a real SAP and a real Salesforce bridged both ways by two readable flows, a domain expert correcting a business rule on screen, shadow-replayed on real events before it lands. The recording is generated by a script that is also a green end-to-end test. If you run integrations for a living and think this is wrong, I want the strongest version of your objection. If you think it is obvious, even better, come help with the connector SDK :) diff --git a/README.md b/README.md index 21f308d..c3eb104 100644 --- a/README.md +++ b/README.md @@ -133,13 +133,14 @@ cargo test --manifest-path core/Cargo.toml # language unit tests core/target/release/vejas-runtime vjs-test tests/vjs # golden end-to-end cases ``` -Security defaults: the bundled compose publishes the panel/MCP port (8686) on -localhost only, because that surface can write flows and run commands (exec -connectors). The raw binary listens on `0.0.0.0:8686`, with no token: set -`VEJAS_HTTP_ADDR` and `VEJAS_TOKEN` before running it anywhere reachable. With -`VEJAS_TOKEN` set, every write (POST, `/mcp` included) requires -`Authorization: Bearer `. The webhook port (8787) only publishes events -onto the bus. +Security defaults: the panel/MCP port (8686) stays on this machine, because that +surface can write flows and run commands (exec connectors). The binary listens on +`127.0.0.1:8686` unless `VEJAS_HTTP_ADDR` says otherwise, and the bundled compose +publishes the port on localhost only. The image binds `0.0.0.0` inside the +container, so a bare `docker run -p 8686:8686` exposes it: set `VEJAS_TOKEN` first, +and the runtime warns at boot if you did not. With `VEJAS_TOKEN` set, every write +(POST, `/mcp` included) requires `Authorization: Bearer `. The webhook port +(8787) only publishes events onto the bus. ## License diff --git a/core/src/main.rs b/core/src/main.rs index 333cd48..e36394f 100644 --- a/core/src/main.rs +++ b/core/src/main.rs @@ -624,6 +624,22 @@ fn approval_token() -> Option { /// The optional write credential. When set (and non-empty), the mutating surface /// requires `Authorization: Bearer `; None = writes are open (dev default). +/// Where the panel, the API and `/mcp` listen unless `VEJAS_HTTP_ADDR` says otherwise. +/// Loopback, because this port writes flows and runs commands and has no token until +/// one is set. The image sets `0.0.0.0:8686`: inside a container, publishing the port +/// is the deliberate act. +const DEFAULT_HTTP_ADDR: &str = "127.0.0.1:8686"; + +/// Whether `addr` takes connections from anywhere but this machine. An address that +/// does not resolve answers yes: all this feeds is a warning. +fn beyond_loopback(addr: &str) -> bool { + use std::net::ToSocketAddrs; + match addr.to_socket_addrs() { + Ok(mut resolved) => resolved.any(|s| !s.ip().is_loopback()), + Err(_) => true, + } +} + fn write_token() -> Option { env::var("VEJAS_TOKEN").ok().filter(|t| !t.is_empty()) } @@ -4446,6 +4462,13 @@ fn serve_http(registry: Registry, root: PathBuf, addr: String) { return; } }; + if write_token().is_none() && beyond_loopback(&addr) { + eprintln!( + "[vejas] WARNING: {addr} with no VEJAS_TOKEN: whatever reaches this port can \ + write flows and run commands (exec connectors). Set VEJAS_TOKEN, or publish \ + the port on localhost only." + ); + } eprintln!("[vejas] panel + monitoring on http://{addr}"); // One thread per request, so a slow /flows/new never blocks monitoring, and a // bound on them, so a burst costs a 503 instead of this loop. @@ -4623,7 +4646,7 @@ fn main() { } let root = PathBuf::from(env::var("VEJAS_ROOT").unwrap_or_else(|_| ".".into())); - let addr = env::var("VEJAS_HTTP_ADDR").unwrap_or_else(|_| "0.0.0.0:8686".into()); + let addr = env::var("VEJAS_HTTP_ADDR").unwrap_or_else(|_| DEFAULT_HTTP_ADDR.into()); let registry: Registry = Arc::new(Mutex::new(HashMap::new())); // Governed-change mode (ADR-0024): refuse to boot without the DISTINCT approval @@ -4736,6 +4759,25 @@ mod supervision_tests { } } +#[cfg(test)] +mod bind_tests { + use super::{beyond_loopback, DEFAULT_HTTP_ADDR}; + + #[test] + fn the_default_address_stays_on_this_machine() { + assert!(!beyond_loopback(DEFAULT_HTTP_ADDR)); + assert!(!beyond_loopback("[::1]:8686")); + } + + #[test] + fn any_other_address_counts_as_reachable() { + assert!(beyond_loopback("0.0.0.0:8686")); + assert!(beyond_loopback("[::]:8686")); + assert!(beyond_loopback("192.0.2.1:8686")); + assert!(beyond_loopback("not an address")); + } +} + #[cfg(test)] mod tests { #[test] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c556b9f..2063ce9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -143,9 +143,10 @@ MCP tools include `vejas_drivers` (connector driver catalog), `vejas_secrets` VejasScript reference for agents) and `vejas_events` (the trace ring). When `VEJAS_TOKEN` is set, every POST (the whole mutating surface, `/mcp` -included) requires `Authorization: Bearer `; reads stay open. The -bundled compose binds 8686 (panel/MCP) to localhost only — that surface can -write flows and run commands (exec connectors) — while 8787 (webhook ingest, +included) requires `Authorization: Bearer `; reads stay open. The binary +listens on `127.0.0.1:8686` unless `VEJAS_HTTP_ADDR` says otherwise, and the +bundled compose publishes 8686 (panel/MCP) on localhost only, since that surface +can write flows and run commands (exec connectors). 8787 (webhook ingest, publish-only) stays exposed. ## MCP server (ADR-0006) diff --git a/docs/book/src/getting-started/install.md b/docs/book/src/getting-started/install.md index 84ce89c..3b9a736 100644 --- a/docs/book/src/getting-started/install.md +++ b/docs/book/src/getting-started/install.md @@ -15,12 +15,16 @@ root, point `NATS_URL` at the bus, publish the ports. ```bash docker run --rm -v "$PWD/my-root:/root" -e VEJAS_ROOT=/root \ - -e NATS_URL=nats://nats:4222 -p 8686:8686 -p 8787:8787 \ + -e NATS_URL=nats://nats:4222 -p 127.0.0.1:8686:8686 -p 8787:8787 \ ghcr.io/cpoder/vejas-runtime:latest ``` +Port 8686 writes flows and runs commands, so it is published on localhost here. +Before publishing it wider, set `VEJAS_TOKEN`. + The release also carries a stripped Linux binary and its SHA-256: -. +. It listens on +`127.0.0.1:8686` until `VEJAS_HTTP_ADDR` says otherwise. ## Docker compose, to try it in one command diff --git a/docs/book/src/reference/env-vars.md b/docs/book/src/reference/env-vars.md index 25ad3cb..84600e7 100644 --- a/docs/book/src/reference/env-vars.md +++ b/docs/book/src/reference/env-vars.md @@ -10,7 +10,7 @@ directory is a complete system. |---|---|---| | `NATS_URL` | `nats://127.0.0.1:4222` | The bus. The only infrastructure dependency (ADR-0002). | | `VEJAS_ROOT` | `.` | Root directory: `flows/`, `connectors/`, `tables/`, `tests/`. | -| `VEJAS_HTTP_ADDR` | `0.0.0.0:8686` | Panel + HTTP API + `/mcp` listen address. | +| `VEJAS_HTTP_ADDR` | `127.0.0.1:8686` (the image sets `0.0.0.0:8686`) | Panel + HTTP API + `/mcp` listen address. Past loopback with no `VEJAS_TOKEN`, the runtime warns at boot. | | `VEJAS_TOKEN` | *(unset)* | When set, the whole mutating surface (including `/mcp`) requires `Authorization: Bearer`. | | `VEJAS_SUBJECT_ROOT` | `vx` | Bus subject prefix. | | `VEJAS_STREAM` | `VEJAS` | JetStream stream name the runtime creates/uses. | diff --git a/docs/book/src/reference/http-api.md b/docs/book/src/reference/http-api.md index 3203fb9..dedf479 100644 --- a/docs/book/src/reference/http-api.md +++ b/docs/book/src/reference/http-api.md @@ -1,6 +1,6 @@ # HTTP API -One listener (`VEJAS_HTTP_ADDR`, default `:8686`) serves the panel, the API +One listener (`VEJAS_HTTP_ADDR`, default `127.0.0.1:8686`) serves the panel, the API and MCP. With `VEJAS_TOKEN` set, every mutating route requires `Authorization: Bearer `. In governed mode (ADR-0024) mutating routes answer with a didactic refusal pointing at the proposal queue. diff --git a/docs/security-audit-scope.md b/docs/security-audit-scope.md index baf2c6d..5c0b18c 100644 --- a/docs/security-audit-scope.md +++ b/docs/security-audit-scope.md @@ -20,9 +20,11 @@ invariant is wrong, that is the finding we want. | **Event source** | `http-in` on its own port, **no auth** | Publish a JSON body to one subject — nothing else | | **Bus peer** | NATS/JetStream | Governed by NATS's own auth (deployment-owned) | -Default posture: the write API binds `0.0.0.0:8686` in the raw binary but -**`127.0.0.1` in the shipped bundle**; `http-in` binds `0.0.0.0` on its own -port by design. The invariants below assume the write API is not exposed +Default posture: the write API binds **`127.0.0.1:8686` in the raw binary**. The +image binds `0.0.0.0:8686` inside the container, and the shipped bundle publishes +it on `127.0.0.1` only; the runtime warns at boot past loopback with no +`VEJAS_TOKEN`. `http-in` binds `0.0.0.0` on its own port by design. The invariants +below assume the write API is not exposed unauthenticated to a hostile network — an assumption the audit should pressure-test. diff --git a/e2e/api-write-gate.sh b/e2e/api-write-gate.sh index c0a2d2a..79a410b 100755 --- a/e2e/api-write-gate.sh +++ b/e2e/api-write-gate.sh @@ -86,5 +86,24 @@ echo "── HEAD never executes a flow → no bus write via HEAD" C=$(code -I "$H/api/audit") [ "$C" = "404" ] && ok "HEAD on GET route → 404, flow not executed (no emit)" || ko "HEAD on GET route unexpected ($C)" +echo "── the listen address: loopback unless told otherwise, and loud about no token" +kill $RT 2>/dev/null; wait $RT 2>/dev/null +boot(){ # log, then the env for this run + L=$1; shift + env "$@" NATS_URL=nats://127.0.0.1:4276 VEJAS_ROOT="$R" "$BIN" > "$L" 2>&1 & RT=$! + for _ in $(seq 1 100); do grep -q 'panel + monitoring' "$L" 2>/dev/null && break; sleep 0.1; done +} +boot "$S/rt-default.log" -u VEJAS_HTTP_ADDR -u VEJAS_TOKEN +curl -sf -o /dev/null http://127.0.0.1:8686/healthz && ok "no VEJAS_HTTP_ADDR: answers on 127.0.0.1:8686" || ko "no VEJAS_HTTP_ADDR: nothing on 127.0.0.1:8686" +BOUND=$(ss -ltnH 'sport = :8686' | awk '{print $4}') +[ "$BOUND" = "127.0.0.1:8686" ] && ok "bound to loopback only ($BOUND)" || ko "bound to '$BOUND'" +grep -q WARNING "$S/rt-default.log" && ko "warned on loopback" || ok "no warning on loopback" +kill $RT 2>/dev/null; wait $RT 2>/dev/null +boot "$S/rt-open.log" -u VEJAS_TOKEN VEJAS_HTTP_ADDR=0.0.0.0:8747 +grep -q 'WARNING: 0.0.0.0:8747 with no VEJAS_TOKEN' "$S/rt-open.log" && ok "0.0.0.0 with no token warns" || ko "0.0.0.0 with no token is silent" +kill $RT 2>/dev/null; wait $RT 2>/dev/null +boot "$S/rt-token.log" VEJAS_TOKEN="$TOK" VEJAS_HTTP_ADDR=0.0.0.0:8747 +grep -q WARNING "$S/rt-token.log" && ko "warned despite the token" || ok "0.0.0.0 with a token does not warn" + [ $fail -eq 0 ] && echo "WRITE GATE HOLDS" || echo "WRITE GATE REGRESSION — do not ship" exit $fail