Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion MANIFESTO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 :)
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`. 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 <token>`. The webhook port
(8787) only publishes events onto the bus.

## License

Expand Down
44 changes: 43 additions & 1 deletion core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,22 @@ fn approval_token() -> Option<String> {

/// The optional write credential. When set (and non-empty), the mutating surface
/// requires `Authorization: Bearer <it>`; 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<String> {
env::var("VEJAS_TOKEN").ok().filter(|t| !t.is_empty())
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
7 changes: 4 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`; 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 <token>`; 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)
Expand Down
8 changes: 6 additions & 2 deletions docs/book/src/getting-started/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<https://github.com/cpoder/vejas/releases/latest>.
<https://github.com/cpoder/vejas/releases/latest>. It listens on
`127.0.0.1:8686` until `VEJAS_HTTP_ADDR` says otherwise.

## Docker compose, to try it in one command

Expand Down
2 changes: 1 addition & 1 deletion docs/book/src/reference/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/book/src/reference/http-api.md
Original file line number Diff line number Diff line change
@@ -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 <token>`. In governed mode (ADR-0024) mutating routes
answer with a didactic refusal pointing at the proposal queue.
Expand Down
8 changes: 5 additions & 3 deletions docs/security-audit-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 19 additions & 0 deletions e2e/api-write-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading