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
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# The build context is the crate source only. `target/` is many GB of local
# build artifacts that the image builds from scratch anyway, and shipping a
# local config would bake a NetBox token into the image.
target/
.git/
*.toml.local
nbox.toml
config.toml
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
move off yanked `chacha20` 0.10.0 and `spin` 0.9.8.
- Allow `clippy::unused_async_trait_impl` (new in rustc 1.98) on the MCP
`ServerHandler` impl, whose methods are async by the rmcp trait contract.
### Added

- **Multi-user NetBox API-token pass-through.** `nbox serve --http
--netbox-token-passthrough` (or `[serve].netbox_token_passthrough = true`)
makes one nbox process serve many users: each caller presents their own NetBox
API token per request — in `X-NetBox-Token`, or in `Authorization` when that
header isn't already used by OIDC or `--http-token` — and nbox forwards it
verbatim to NetBox. NetBox's object permissions and changelog therefore apply
to the real caller, with no IdP and no `[serve.vault]` mapping. Reads are
cached per token fingerprint so one user's view is never served to another,
`nbox_cache_clear` only drops the caller's own partition, and `--allow-writes`
runs writes under the caller's token. A request without a caller token is
rejected with `401`, never silently downgraded to the server's profile token.
Tokens are redacted everywhere; the audit log records `auth=netbox-token` plus
a short SHA-256 `netbox_token_fp` and the per-caller rate-limit bucket keys on
it. Pass-through requires the HTTP transport (stdio has no per-request
headers, so asking for it there is a usage error) and allows binding a routable
address; terminate TLS in front of it.
- **Container image built from source.** A `Dockerfile` (multi-stage; the
existing `Dockerfile.release` only wraps prebuilt release binaries) and a
`docker-compose.yml` for running the multi-user pass-through server:
unprivileged uid 10001, read-only root filesystem, all capabilities dropped,
published on loopback for a TLS-terminating proxy to front, and no NetBox
credential baked into the image.

## [0.14.1] - 2026-07-31

Expand Down
36 changes: 36 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Build nbox from source and ship it as a small runtime image.
#
# This is the from-source counterpart to `Dockerfile.release` (which only wraps
# a prebuilt binary produced by the release matrix). It exists for running the
# MCP server yourself — most usefully in multi-user pass-through mode, where one
# container serves many users and each request carries its own NetBox token:
#
# docker build -t nbox:local .
# docker run --rm -p 8080:8080 -v ./nbox.toml:/etc/nbox/nbox.toml:ro nbox:local \
# --config /etc/nbox/nbox.toml serve --http 0.0.0.0:8080 --netbox-token-passthrough
#
# See docs/MCP.md for the pass-through security model and header contract.

FROM rust:1.98-bookworm AS builder
WORKDIR /src
COPY . .
RUN cargo build --release --locked --all-features

FROM debian:bookworm-slim
# CA certificates only — nbox speaks HTTPS to NetBox and nothing else.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin nbox

COPY --from=builder /src/target/release/nbox /usr/local/bin/nbox

# Unprivileged: nbox needs no root, and in pass-through mode it holds no
# long-lived NetBox credential of its own that would be worth protecting with
# one — every request is authenticated by the caller's token.
USER 10001:10001
EXPOSE 8080

# nbox reads its config from `--config <path>`; pass one (see docker-compose.yml).
ENTRYPOINT ["nbox"]
CMD ["--help"]
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,27 @@ nbox serve --http 0.0.0.0:8080 \
```

This is accountability, not per-user RBAC — the last hop to NetBox still uses the
single profile token, so scope that token read-only. An audit log
single profile token, so scope that token read-only. For **real multi-user**
service, add `--netbox-token-passthrough`: each caller sends their own NetBox API
token (`X-NetBox-Token`, or `Authorization` when it is otherwise unused) and nbox
forwards it to NetBox, so NetBox's own permissions and changelog apply to the
real user — no IdP or credential vault needed. Reads are cached per caller so no
one sees another user's view, and a request without a token is rejected rather
than silently downgraded to the server's token:

```bash
nbox serve --http 0.0.0.0:8080 --netbox-token-passthrough \
--allowed-host nbox.example.com
```

The repo ships a `Dockerfile` (from source) and a `docker-compose.yml` for this
mode — unprivileged, read-only, no baked-in credential:

```bash
NBOX_PUBLIC_HOST=nbox.example.com docker compose up -d --build
```

An audit log
(`nbox::audit`) and an optional per-caller rate limit (`--rate-limit`) round it
out. Full setup, security model, and IdP notes: [docs/MCP.md](docs/MCP.md).

Expand Down
57 changes: 57 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Run the nbox MCP server in multi-user pass-through mode.
#
# docker compose up -d --build
#
# One container serves many users: each MCP client sends its *own* NetBox API
# token on every request (`X-NetBox-Token`, or `Authorization` when that header
# isn't otherwise in use), and nbox forwards it to NetBox. NetBox's own object
# permissions and changelog therefore apply to the real caller — the container
# holds no shared credential. A request without a token is rejected with 401.
#
# Point `examples/nbox-docker.toml` at your NetBox first (or set NBOX_CONFIG_FILE
# to your own config). See docs/MCP.md for the security model.

services:
nbox:
build: .
image: nbox:local
container_name: nbox-mcp
restart: unless-stopped
ports:
# Defaults to loopback. Callers send real NetBox credentials on every
# request, so anything wider should be fronted by a TLS-terminating proxy;
# set NBOX_BIND=0.0.0.0 only on a network you trust.
- "${NBOX_BIND:-127.0.0.1}:${NBOX_PORT:-8080}:8080"
configs:
- source: nbox_config
target: /etc/nbox/nbox.toml
volumes:
# Only needed when NetBox is served by an internal CA; point `ca_bundle`
# in the config at /etc/nbox/ca.pem. Harmless when unset.
- ${NBOX_CA_BUNDLE:-/dev/null}:/etc/nbox/ca.pem:ro
command:
- --config
- /etc/nbox/nbox.toml
- serve
- --http
- 0.0.0.0:8080
- --netbox-token-passthrough
# The public hostname clients use, for the Host/Origin (DNS-rebinding)
# check. Add one --allowed-host per name your proxy serves.
- --allowed-host
- ${NBOX_PUBLIC_HOST:-localhost}
- --rate-limit
- "120"
# No shared NetBox token is configured on purpose: in pass-through mode the
# caller's token is the only credential, and a missing one must fail rather
# than silently fall back to a server-side identity.
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true

configs:
nbox_config:
# A real file, not inline content: a `read_only` service can only take
# file-backed configs. Override with NBOX_CONFIG_FILE=/path/to/nbox.toml.
file: ${NBOX_CONFIG_FILE:-./examples/nbox-docker.toml}
116 changes: 115 additions & 1 deletion docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,119 @@ user's NetBox token. HTTP/static-bearer transports cannot use `local_writes` in
this release. The `nbox_plan_write` / `nbox_apply_write` tools expose the same
plan → confirm-token → apply lifecycle as the CLI.

## NetBox API-token pass-through (multi-user)

The modes above all share **one** NetBox credential: whoever nbox is configured
with. `--netbox-token-passthrough` (or `[serve].netbox_token_passthrough = true`)
turns that around — each caller presents **their own NetBox API token** on every
request, and nbox forwards it verbatim to NetBox. One nbox process then serves
many users, and NetBox — not nbox — decides what each of them may see and change.

```bash
nbox serve --http 0.0.0.0:8080 --netbox-token-passthrough \
--allowed-host nbox.example.com
```

```toml
[serve]
http = "0.0.0.0:8080"
netbox_token_passthrough = true
```

This is the only mode where NetBox's own object permissions and changelog apply
to the real user. It needs no IdP, no credential vault, and no `[serve.vault]`
mapping: the token *is* the identity.

### How a client sends its token

nbox reads the caller's NetBox token from either header:

| Header | When to use it |
| --- | --- |
| `X-NetBox-Token: <token>` | Always accepted. Required when `Authorization` is already taken. |
| `Authorization: Bearer <token>` / `Authorization: Token <token>` | Accepted only when `Authorization` isn't already in use by OIDC or `--http-token`. The `Bearer ` / `Token ` prefix is stripped before forwarding. |

So a host that can only set a bearer works out of the box, and a host that needs
`Authorization` for a gateway can still pass the NetBox token beside it:

```json
{
"mcpServers": {
"nbox": {
"type": "http",
"url": "https://nbox.example.com/mcp",
"headers": { "X-NetBox-Token": "${NETBOX_TOKEN}" }
}
}
}
```

A request with no caller token is rejected with `401` before any MCP handling.
nbox **never** falls back to its own profile token for such a request — a
misconfigured client gets an error, not somebody else's privileges.

### What the caller's token controls

- **Reads** run under the caller's token, so NetBox filters results by that
user's permissions.
- **The read cache is partitioned per token**, so one user's cached view is never
served to another. This is a correctness requirement, not an optimization:
NetBox returns different results to different users for the same query.
- **`nbox_cache_clear` only clears the caller's own partition**, so no user can
evict everyone else's cached reads.
- **Writes** are still opt-in with `--allow-writes` (or `[serve].allow_writes =
true`). When enabled they run under the caller's token, which means NetBox's
object permissions gate them and its changelog attributes them to the real
user. The `nbox_plan_write` → `nbox_apply_write` confirm-token lifecycle is
unchanged.
- **The audit log** records a short, non-reversible fingerprint of the token
(`netbox_token_fp`, a SHA-256 prefix) and `auth=netbox-token`. Tokens are never
logged, printed, or included in error messages.
- The server's own profile token is unused for request handling in this mode, and
a configured `[serve.vault]` is ignored (nbox warns at startup) — the vault
exists to bridge OIDC identities to tokens, which pass-through makes redundant.

### Running it in Docker

The repo ships a from-source `Dockerfile` and a `docker-compose.yml` for exactly
this mode. Point `examples/nbox-docker.toml` at your NetBox (or set
`NBOX_CONFIG_FILE` to your own config), then:

```bash
NBOX_PUBLIC_HOST=nbox.example.com docker compose up -d --build
```

The container runs unprivileged (uid 10001), read-only, with all capabilities
dropped, and publishes `127.0.0.1:8080` — put your TLS-terminating reverse proxy
in front of that and set `NBOX_PUBLIC_HOST` to the name it serves so the
`Host`/`Origin` check matches. No NetBox credential is baked into the image or
the config: the container is only useful once callers bring their own tokens.

```bash
# Smoke test: no token must be refused, never served from a shared identity.
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8080/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"c","version":"0"}}}'
# => 401
```

### Deployment notes

- **Pass-through requires the HTTP transport.** stdio has no per-request headers,
so `nbox serve --netbox-token-passthrough` without `--http` is a usage error
rather than a silent no-op.
- **Terminate TLS in front of nbox.** Callers send real NetBox credentials on
every request; run it behind a reverse proxy with HTTPS, and set
`--allowed-host` to the public hostname so the `Host`/`Origin` checks match.
- Because pass-through servers are meant to be shared, binding a routable
address is allowed in this mode without the loopback restriction the
single-credential modes apply.
- **Rate limiting** (below) keys its per-caller bucket on the token fingerprint,
so one user cannot spend another user's budget. The coarse pre-auth bucket is
still per peer IP — behind a reverse proxy every caller shares that peer, so
size `--rate-limit` for the whole fleet or have the proxy do per-user limiting.

## Operations (HTTP transport)

Two operational features apply to the HTTP `/mcp` endpoint (not the
Expand Down Expand Up @@ -359,7 +472,8 @@ When enabled it applies on two levels, both at `N`/minute:
limiter and could hammer JWT validation unthrottled). The check is per peer IP,
so one peer flooding never throttles another.
- **Post-auth, per caller.** An authenticated request additionally honors a
per-caller bucket keyed on the OIDC `sub` (else `client_id`). This catches a
per-caller bucket keyed on the OIDC `sub` (else `client_id`, else — in
pass-through mode — the caller's NetBox-token fingerprint). This catches a
single identity spread across many source IPs.

A loopback / static-bearer caller has no token identity, so its peer-IP bucket
Expand Down
Loading