Serving the REST API on a network#
SimulationServer puts a graph behind an HTTP and WebSocket API. Bound
to 127.0.0.1 it needs no configuration: a direct connection from this
machine is served without a credential. This page is for the other case,
a server another machine reaches over the network. Such a server asks
every request for a bearer token. The steps below choose the bind
address, set the token, confine checkpoints to one directory, call the
server with the token, and test it in-process.
Note
Prefer an SSH tunnel when you can. The API has no TLS: on a network
bind the token and every state snapshot cross the wire in cleartext. A
server that keeps its loopback bind and is reached through
ssh -L 8000:127.0.0.1:8000 user@host needs no token at all, and nothing
it sends leaves the tunnel. Put a network bind on a private network or
behind a TLS-terminating proxy.
Install the server’s dependencies with pip install "maddening[api]".
How the server decides#
The server applies one of two rules, chosen by the address you tell it it is bound to:
A loopback bind (
127.0.0.1,localhost,::1) serves a direct connection from a loopback address without a token. A request from any other peer, or one carryingX-Forwarded-FororForwarded, must present the token, and a request without a valid token is refused (403) unless itsHostnames this machine or one ofallowed_hosts.Any other bind (
0.0.0.0, a LAN or public address) asks every request forAuthorization: Bearer <token>, except/healthzand the bundled/viz/*pages, which hold no information about the graph.
The application cannot see the socket uvicorn opens, so you tell it:
SimulationServer(..., bind_host=host), or set MADDENING_HOST. Left
unset, it assumes 127.0.0.1. A server that was told loopback but is
reached from a routable address still asks that peer for the token, so
forgetting bind_host costs a 401, not an open API.
The token#
The token is MADDENING_API_TOKEN when that variable is set. Otherwise
the server generates one at start-up (32 random bytes, 43 URL-safe
characters), and on a network bind server.auth.announce(port) logs it
once. A token that
is set but blank is refused, as is one with whitespace around it or a
character outside printable ASCII: no client could present it.
from maddening.api.auth import APIAuth
# MADDENING_API_TOKEN set (read from the mapping given; os.environ by default)
fake_token = "-".join(("example", "token", "not", "a", "secret"))
auth = APIAuth(bind_host="0.0.0.0", environ={"MADDENING_API_TOKEN": fake_token})
assert auth.enforced and not auth.generated and auth.token == fake_token
# Not set: generated at start-up
auth = APIAuth(bind_host="0.0.0.0", environ={})
assert auth.generated and len(auth.token) == 43
# Set but blank: a configuration error, not "authentication off"
try:
APIAuth(bind_host="0.0.0.0", environ={"MADDENING_API_TOKEN": ""})
except ValueError as err:
print(str(err).split(".")[0])
MADDENING_API_TOKEN is set but blank
If nothing will read the server’s log (a detached container, a batch
job), set MADDENING_API_TOKEN yourself, or set MADDENING_API_TOKEN_FILE
to a path: the generated token is written there, readable by its owner
only (mode 0600), when it is announced.
import logging
import os
import stat
logging.getLogger("maddening.api.auth").setLevel(logging.ERROR) # keep it out of this page
auth = APIAuth(bind_host="0.0.0.0", environ={"MADDENING_API_TOKEN_FILE": "api-token"})
assert auth.announce(8000)
with open("api-token") as fh:
assert fh.read().strip() == auth.token
assert stat.S_IMODE(os.stat("api-token").st_mode) == 0o600
A server script#
This script serves a graph on every interface. It tells the server its bind address, keeps checkpoints under one directory, logs a generated token once, and turns off uvicorn’s proxy headers, so that no forwarded header can stand in for the peer address:
# serve.py
import os
import uvicorn
from maddening.api.server import SimulationServer, warn_if_publicly_bound
from maddening.core.graph_manager import GraphManager
from maddening.nodes import BallNode, TableNode
HOST = os.environ.get("MADDENING_HOST", "0.0.0.0")
PORT = 8000
gm = GraphManager()
gm.add_node(TableNode("table", 0.01, position=0.0))
gm.add_node(BallNode("ball", 0.01, initial_position=2.0))
gm.add_edge("table", "ball", source_field="position", target_field="table_position")
gm.compile()
server = SimulationServer(
node_registry={"BallNode": BallNode, "TableNode": TableNode},
graph_manager=gm,
bind_host=HOST, # switches the token on
checkpoint_root="/srv/maddening/checkpoints", # every checkpoint path is under it
)
server.auth.announce(PORT) # logs a generated token once; says nothing of a set one
warn_if_publicly_bound(HOST, PORT) # logs what the bind exposes
uvicorn.run(server.create_app(), host=HOST, port=PORT, proxy_headers=False)
Start it with a token you chose, so that you know it without reading the log:
export MADDENING_API_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
python serve.py
python -m maddening.examples.servers.serve_with_token runs the same
steps end to end without publishing anything: uvicorn in its own process,
on a loopback port, with the server told bind_host="0.0.0.0". It shows a
request refused without the token and served with it, a checkpoint path
refused outside the root, and a generated token logged once.
Calling it#
Every route but /healthz and /viz/* takes the token in an
Authorization header:
curl http://sim-host:8000/healthz # no token needed
curl -H "Authorization: Bearer $MADDENING_API_TOKEN" http://sim-host:8000/graph
curl -X POST -H "Authorization: Bearer $MADDENING_API_TOKEN" \
"http://sim-host:8000/sim/run?n_steps=100"
From Python, send the same header with any HTTP client. The block below
runs the server in-process with FastAPI’s TestClient, which needs no
port; against a real server, give the same headers= to httpx.Client
or requests:
from fastapi.testclient import TestClient
from maddening.api.server import SimulationServer
from maddening.core.graph_manager import GraphManager
from maddening.nodes import BallNode, TableNode
gm = GraphManager()
gm.add_node(TableNode("table", 0.01, position=0.0))
gm.add_node(BallNode("ball", 0.01, initial_position=2.0))
gm.add_edge("table", "ball", source_field="position", target_field="table_position")
gm.compile()
fake_token = "-".join(("example", "token", "not", "a", "secret"))
server = SimulationServer(node_registry={"BallNode": BallNode, "TableNode": TableNode},
graph_manager=gm, bind_host="0.0.0.0", api_token=fake_token,
checkpoint_root="checkpoints")
app = server.create_app()
anonymous = TestClient(app)
assert anonymous.get("/healthz").status_code == 200
reply = anonymous.get("/graph")
print(reply.status_code, reply.json()["detail"])
client = TestClient(app, headers={"Authorization": f"Bearer {fake_token}"})
assert client.get("/graph").status_code == 200
assert client.post("/sim/run?n_steps=10").status_code == 200
401 This server is bound to a non-loopback address, so every route requires 'Authorization: Bearer <token>'. The token is $MADDENING_API_TOKEN, or was logged once at start-up.
api_token= overrides MADDENING_API_TOKEN; a deployment usually sets
the variable instead. The other clients:
Client |
How it presents the token |
|---|---|
WebSocket client that can set headers |
|
Browser WebSocket |
the subprotocols |
Bundled pages |
open |
No API route accepts the token in a query string. /docs and
/openapi.json are not served while the token is enforced; read them
from a loopback-bound instance.
Checkpoints stay under checkpoint_root#
POST /checkpoint/save?path=... and /checkpoint/load take a path
relative to checkpoint_root, and refuse one that leaves it, whether or
not the token is enforced. The root defaults to checkpoints under the
working directory the server was started in; set it explicitly in a
deployment.
from pathlib import Path
assert client.post("/checkpoint/save?path=run1.npz").status_code == 200
assert (Path("checkpoints") / "run1.npz").is_file()
reply = client.post("/checkpoint/save?path=../run1.npz")
print(reply.status_code, reply.json()["detail"])
assert not Path("run1.npz").exists()
400 checkpoint path must stay under the checkpoint root, naming a file inside it (got '../run1.npz')
Testing a server in-process#
An in-process TestClient is not a loopback connection: it reports its
peer as "testclient". So even a loopback-bound server asks it for the
token, and TestClient(app) alone gets a 401. Give it the token, or
build it as a direct loopback client:
local = SimulationServer(node_registry={"BallNode": BallNode, "TableNode": TableNode},
checkpoint_root="checkpoints") # loopback by default
local_app = local.create_app()
assert TestClient(local_app).get("/graph").status_code == 401
with_token = TestClient(local_app,
headers={"Authorization": f"Bearer {local.auth.token}"})
as_loopback = TestClient(local_app, base_url="http://127.0.0.1",
client=("127.0.0.1", 50000))
assert with_token.get("/graph").status_code == 200
assert as_loopback.get("/graph").status_code == 200
On a cloud instance#
A cloud job opens no API port by default (JobConfig.ports is []), so
reach its API through a tunnel, ssh -L 8000:127.0.0.1:8000 root@<vm-ip> -p <ssh-port>, and talk to http://localhost:8000. To publish the port
instead, set ports=[8000] and MADDENING_API_TOKEN in the job’s
environment. The quickstart says more.
What the token does not do#
It does not encrypt anything; see the note at the top.
It does not limit what its holder can do. Whoever holds the token can build nodes, run steps and write files under the checkpoint root, as someone at the keyboard could. Do not hand it to a party you would not let run code on the host.
Request sizes are bounded, but that is a backstop against mistakes, not a defence against a token holder who sets out to exhaust the host.
The API’s own reference, src/maddening/api/README.md in the
repository,
lists every route, the limits, and what the server does and does not
defend against. The ZeroMQ state and command streams have a token of
their own: see ZeroMQ transports.