Probe Agents¶
A probe agent is the thing that actually makes the HTTP requests. Everything else in Tracedown decides what to run and when, records what happened, and tells you about it. The agent is the only component that touches the endpoint you are monitoring, which is why it is the only component you deploy more than once and in more than one place.
Agents belong to the per-service deployment. The monolith executes probes in-process with an embedded executor instead — it uses no agents, and nothing on this page applies to it.
The agent lives in core/tracedown-probe-agent. It is a Python FastAPI service
(Python 3.11+, shipped as python:3.13-slim) that wraps the Lace executor. Its
dependencies are deliberately small: fastapi, uvicorn[standard], httpx,
cryptography, pydantic, boto3, and the lacelang-validator /
lacelang-executor pair.
The agent is stateless¶
The scheduler sends the agent a job containing a Lace script, the fully
resolved variables for that service, and the prev result if the script needs
it. The agent runs the script and returns the raw ProbeResult JSON. That is the
entire contract.
The agent does not know the job ID, the service, the project, or the
organization the probe belongs to — the scheduler correlates the response with
the request because it made the request. It stores no monitoring state, keeps no
queue, and has no database. The only durable state on an agent is its own
identity: three PEM files, a CA-pin file, and a slug file under /certs.
Alongside the script, variables and previous result, a job carries two
platform controls: allowBodySave, with which the scheduler can forbid body
persistence for that run regardless of what the script asks, and the plaintext
secret values in play, which the agent masks out of any body it saves before
the body is written.
This is what makes agents disposable. An agent that is destroyed and rebuilt loses nothing except its certificate, and a bootstrap token re-issues that in seconds. It also means you can scale probing capacity by adding agents without coordinating anything between them.
The scheduler dials the agent¶
The agent must be reachable inbound from the scheduler
Work flows from scheduler to agent, not the other way round. The scheduler opens a mutual-TLS connection to the agent's URI and POSTs the job. The agent never polls for work; its only outbound platform calls are registration and certificate renewal, and those go to the api-gateway, not the scheduler.
An agent behind NAT, a firewall, or a private network with no inbound route from the scheduler will bootstrap successfully, appear in the dashboard, and then sit there receiving nothing. Registration passing is not evidence that dispatch will work — registration is an outbound call to the gateway, dispatch is an inbound call from the scheduler. This is the single most common deployment mistake.
The URI the scheduler dials is the one the agent reported at registration:
https://{socket.getfqdn()}:{port} — https, because dispatch is mutual TLS. If
the agent's FQDN inside its container is not a name the scheduler can resolve
and route to, that URI is wrong and nothing will reach it.
Configuration¶
Every setting is read from the environment by pydantic-settings with the
PROBE_AGENT_ prefix. The deployment environment is the one exception: it
answers to its unprefixed, platform-wide name as well.
| Variable | Purpose | Default |
|---|---|---|
PROBE_AGENT_BOOTSTRAP_TOKEN |
One-time token from --agent-bootstrap |
"" |
PROBE_AGENT_SCHEDULER_URL |
Base URL for registration and renewal | "" |
PROBE_AGENT_BOOTSTRAP_CA_BUNDLE |
PEM bundle of the CA that issued the gateway's certificate, used to authenticate it at enrolment | "" |
PROBE_AGENT_BOOTSTRAP_PIN_SHA256 |
SHA-256 fingerprint(s) of the certificate the gateway presents, pinned at enrolment; takes precedence over the bundle | "" |
PROBE_AGENT_INSECURE_SKIP_BOOTSTRAP_TLS_VERIFY |
Skips verification of the gateway's certificate at enrolment; refused in production | false |
PROBE_AGENT_DEPLOYMENT_ENV |
Deployment environment; production refuses unauthenticated enrolment. Read unprefixed as DEPLOYMENT_ENV too |
dev |
PROBE_AGENT_CA_CERT_PATH |
CA trust bundle written at bootstrap | /certs/ca.pem |
PROBE_AGENT_CERT_PATH |
Signed agent certificate | /certs/agent.pem |
PROBE_AGENT_KEY_PATH |
Agent private key | /certs/agent-key.pem |
PROBE_AGENT_CA_PINS_PATH |
CA fingerprint pin file (trust-on-first-use) | /certs/ca-pins.txt |
PROBE_AGENT_SLUG_PATH |
Persisted slug, read at renewal | /certs/agent-slug.txt |
PROBE_AGENT_SLUG |
Overrides the persisted slug | "" |
PROBE_AGENT_RENEW_BEFORE_DAYS |
Renew when the cert is within N days of expiry | 30 |
PROBE_AGENT_RENEW_CHECK_HOURS |
Renewal check interval | 24 |
PROBE_AGENT_HOST |
Listen address | 0.0.0.0 |
PROBE_AGENT_PORT |
Listen port, also used in the self-reported URI | 8443 |
PROBE_AGENT_LOG_LEVEL |
uvicorn log level | info |
PROBE_AGENT_MAX_CONCURRENCY |
Probe thread pool size | 256 |
PROBE_AGENT_STORAGE_BACKEND |
filesystem or s3 |
filesystem |
PROBE_AGENT_STORAGE_DIR |
Body directory for the filesystem backend | /data/bodies |
PROBE_AGENT_S3_ENDPOINT_URL |
S3-compatible endpoint (AWS S3, Cloudflare R2, MinIO, …), s3 backend only |
"" |
PROBE_AGENT_S3_ACCESS_KEY_ID |
Access key, s3 backend only |
"" |
PROBE_AGENT_S3_SECRET_ACCESS_KEY |
Secret key, s3 backend only |
"" |
PROBE_AGENT_S3_BUCKET |
Bucket, s3 backend only |
"" |
PROBE_AGENT_S3_PREFIX |
Key prefix within the bucket, s3 backend only |
"" |
PROBE_AGENT_S3_REGION |
Bucket region — auto suits R2 and is ignored by MinIO; AWS S3 wants the real one |
auto |
SCHEDULER_URL points at the gateway
The name is historical. Registration and renewal are served by the
api-gateway on /internal/agents/register and /internal/agents/renew,
so the value in practice is the gateway's base URL — in the Compose stack,
http://tracedown-gateway:20714. Point it at the scheduler and bootstrap
fails. The scheme matters as much as the host — see Authenticating the
gateway at enrolment.
The dashboard's connect panel and --agent-bootstrap print the value to
use: GATEWAY_PUBLIC_URL on the gateway when it is set, otherwise the
Compose address above with a warning that it only works on that network.
The gateway cannot infer its own public address — behind a reverse proxy
it sees the proxy's host — so set the variable on any deployment whose
agents live off the Docker network.
Why MAX_CONCURRENCY defaults to 256¶
Probes block on network I/O — DNS, TCP connect, TLS handshake, time-to-first-byte,
transfer — so they run in a thread pool rather than on the event loop. Python's
default asyncio executor is only min(32, cpu_count + 4) threads. When you probe
over the public internet each probe pays a full handshake and takes on the order
of half a second to a second, so 32 threads cap you at a few dozen probes per
second no matter how much CPU the box has. The pool is I/O-bound, not CPU-bound,
so it can be far larger than the core count.
Size it as peak_rps × avg_probe_seconds, with headroom. If your fleet fires 200
probes at the top of each minute and each takes 800 ms, you need roughly 160
in-flight slots; the default of 256 covers that. See
Scaling.
Bootstrap and registration¶
Registration is a single round trip that happens at most once per agent
lifetime. On startup, if bootstrap_token and scheduler_url are both set
and the certificate and key files do not already exist, the agent:
- Generates an RSA-4096 keypair.
- Writes the private key to
key_pathas unencrypted PKCS#8 PEM. The key never leaves the agent — only the CSR does. - Builds a PKCS#10 CSR with subject
O=tracedown-agent. - Determines its own URI as
https://{socket.getfqdn()}:{port}. - Authenticates the gateway, then POSTs
{bootstrapToken, csrPem, agentUri}to{scheduler_url}/internal/agents/registerwith a 30-second timeout. Overhttps://the gateway's certificate is verified against the system trust store by default; a private CA or a self-signed certificate takes one variable. Nothing is sent to a peer the agent could not authenticate — see Authenticating the gateway at enrolment. - Writes the returned
certificatePemtocert_path,caRootPemtoca_cert_path, and the assignedslugtoslug_path. - Records SHA-256 fingerprints of the received CA bundle in
ca_pins_path.
That last step is trust-on-first-use pinning, and it is what protects every later renewal. When the agent renews, the gateway hands it the current CA bundle again — but the agent refuses a bundle that shares no fingerprint with what it pinned at bootstrap, on the grounds that a wholesale swap of the trust anchor looks like a man-in-the-middle rather than a rotation. A legitimate CA rotation keeps the old CA in the bundle during the overlap, so the pins advance naturally with each renewal.
If the certificate and key already exist, bootstrap is skipped entirely and the
token is ignored. That check is what makes the container restart-safe: you can
leave PROBE_AGENT_BOOTSTRAP_TOKEN in the environment forever, restart as often
as you like, and the agent will not re-register — as long as /certs is on a
volume that survives the restart. If it is not, every restart needs a fresh
token, because tokens are single-use.
Authenticating the gateway at enrolment¶
Registration is the agent's only unauthenticated moment, and it is the one that matters most: that single POST carries the bootstrap token and receives the CA bundle the agent pins for the rest of its life. Whoever answers it owns the agent from then on — an on-path attacker reads the token and installs a CA of their own. So the agent authenticates the gateway before the token leaves the process, and a configuration that cannot authenticate it fails registration rather than sending the token anyway.
Over https:// there are three ways to do that, in order of preference:
| Situation | What to set |
|---|---|
| Gateway behind a publicly trusted certificate (certbot, a managed edge) | Nothing — the system trust store is the default |
| Gateway behind a private or internal CA | PROBE_AGENT_BOOTSTRAP_CA_BUNDLE=/path/to/ca.pem |
| Gateway certificate chains to nothing (self-signed) | PROBE_AGENT_BOOTSTRAP_PIN_SHA256=<fingerprint> |
| Local development only | PROBE_AGENT_INSECURE_SKIP_BOOTSTRAP_TLS_VERIFY=true |
Take the fingerprint from whoever runs the gateway, the same way you take the bootstrap token — both have to reach you out of band anyway:
openssl s_client -connect tracedown.example.com:443 </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256
Colons, a sha256: prefix, and several comma- or space-separated values are all
accepted. The agent opens one throwaway handshake to read the certificate the
gateway presents, refuses to go any further unless it matches a pin, and then
makes the registration request trusting that certificate and nothing else — so
there is no gap between checking and using. A malformed pin fails before a
connection is opened at all, and a pin wins over a CA bundle when both are set.
PROBE_AGENT_INSECURE_SKIP_BOOTSTRAP_TLS_VERIFY is the only way to reach an
unverified connection — nothing else in the agent can — and it logs a warning
every time it is used.
A plain http:// gateway URL sends the token in the clear
There is no transport to authenticate: the bootstrap token crosses the wire
unencrypted, and so does the CA bundle coming back. This is what every
shipped stack does — bootstrap-agent.sh, the dashboard's connect command
and the installer all set
PROBE_AGENT_SCHEDULER_URL=http://tracedown-gateway:20714 — because there
the agent and the gateway share a private Docker network. That is the only
setting in which it is acceptable, and the agent logs a warning each time.
On any path you do not control, enrol over https://.
Both the opt-out and plain http:// are refused when
DEPLOYMENT_ENV=production: registration raises and the agent does not come
up. Only that exact value arms the guard — anything else, unset included, counts
as development, and the agent's own default is dev. It is read unprefixed as
well as as PROBE_AGENT_DEPLOYMENT_ENV, so a stack that already sets it
platform-wide needs nothing extra; note that agents run as their own containers,
so the deploy stack's .env does not reach them unless you pass it.
Reaching enrolment over https¶
/internal/agents/register and /internal/agents/renew are mounted at the
gateway root, not under /api/, so a proxy that only forwards /api/ lands
an agent's registration on the SPA fallback and answers it with the dashboard's
index.html.
The nginx.conf and apache.conf shipped in docker/deploy/ proxy them, so an
agent pointed at your public https://tracedown.example.com enrols with nothing
further to configure. Three paths are published, one rule each:
| Path | Why it is reachable from outside |
|---|---|
/internal/agents/register |
Carries a single-use bootstrap token you issued, valid for an hour. Being reachable is the whole point of an enrolment endpoint. |
/internal/agents/renew |
Gated on proof of possession of the agent's existing private key, not on a token. |
/internal/health/token/{challengeId} |
Returns a 32-byte token with a 30-second TTL, under an unguessable challenge id the scheduler minted and handed to one named agent over mTLS. The token means something only to the scheduler that issued it, against that one agent's challenge. |
What TLS adds on top is the confidentiality and the peer authentication the bootstrap token cannot provide for itself.
The third path is there because a remote agent has to fetch its health-challenge
token from the gateway, and it fetches it from whatever URL the scheduler puts in
the challenge — which is GATEWAY_URL plus that path. For an agent off the
Docker network, GATEWAY_URL therefore has to be the public https URL, not the
internal one the shipped .env.example sets.
Leave it internal and the failure is quiet and misdirected: enrolment succeeds,
the agent appears in the UI, and then every challenge fails on a URL only the
scheduler can resolve. Because the scheduler can reach it, the round is not
excused as inconclusive — it counts against the agent, and the agent is marked
down (agent_down) after two of them. See Agent
health.
Do not replace those rules with an /internal/ catch-all
The shipped configs list the three paths one at a time on purpose.
Everything else the gateway serves under /internal/ is for the internal
Docker network, and a future addition there would be published to the
internet by an over-broad rule rather than by a deliberate decision in your
vhost.
If your own reverse proxy predates these rules, or you wrote it by hand, copy them across:
location = /internal/agents/register {
proxy_pass http://127.0.0.1:20714;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /internal/agents/renew {
proxy_pass http://127.0.0.1:20714;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ^~ so this prefix wins over the SPA fallback without regex locations
# getting a look in. The trailing segment is the challenge id.
location ^~ /internal/health/token/ {
proxy_pass http://127.0.0.1:20714;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
ProxyPass /internal/agents/register http://127.0.0.1:20714/internal/agents/register
ProxyPassReverse /internal/agents/register http://127.0.0.1:20714/internal/agents/register
ProxyPass /internal/agents/renew http://127.0.0.1:20714/internal/agents/renew
ProxyPassReverse /internal/agents/renew http://127.0.0.1:20714/internal/agents/renew
ProxyPass /internal/health/token/ http://127.0.0.1:20714/internal/health/token/
ProxyPassReverse /internal/health/token/ http://127.0.0.1:20714/internal/health/token/
Creating a bootstrap token¶
The token is 32 random bytes rendered as 64 hex characters. It is stored only as a bcrypt hash (cost 12), expires 1 hour after creation, and is single use. Losing it costs nothing — generate another. Leaking it lets someone else enrol an agent under that slug within the hour.
Creating the first token also creates the CA root, which is why the Compose stack
runs a dedicated tracedown-ca-init step before the gateway. See
Certificate Authority.
Running an agent¶
For a local stack, bootstrap-agent.sh in core/tracedown-core-backend does the
whole dance:
When it needs a fresh bootstrap it refuses to run unless tracedown-gateway
reports healthy, because a token cannot be minted without the gateway and the
CA — though the fast path for an existing container (below) runs before that
check, so a plain restart succeeds even with the gateway down. For a fresh
bootstrap it generates a token via
docker compose run --rm tracedown-gateway ./bin/api-gateway --agent-bootstrap,
rebuilds the agent image with --no-cache, and docker runs the container on
network tracedown_tracedown-net with the token, PROBE_AGENT_SCHEDULER_URL
set to http://tracedown-gateway:20714, and the shared bodies volume mounted at
/data/bodies.
If the container tracedown-agent-<slug> already exists the script takes a fast
path instead: it compares the network ID the container is attached to against the
live network of that name, and either just docker starts it, or reconnects it
to the regenerated network first (which is what you want after a
docker compose down/up cycle) — no new token, no rebuild. If the backend
network is gone entirely it errors out and tells you to start the backend.
The agent will show as healthy after the first health challenge lands, roughly a minute later.
The certificate is the authorization¶
Once a certificate is present, the agent serves mutual TLS on 8443: the
listener requires a client certificate signed by the internal CA and rejects
any connection that does not present one (ssl.CERT_REQUIRED). There is no API
key or bearer token — holding the right CA-signed certificate is the
authorization to call /probe, and "right" is narrower than mere CA
membership. Certificates are role-pinned by extended key usage: agent
certificates are issued for server authentication only and the scheduler's for
client authentication only, so one agent's certificate can never be replayed as
a client to call another agent's /probe. The scheduler goes further on its
side of the handshake — beyond validating the chain it requires the agent
certificate's SAN to match the expected agent slug and checks it against
revocation, failing closed. The certificate's SAN carries the slug rather than
a network address, which has a practical consequence: the scheduler must dial
the agent at a hostname equal to its slug, or TLS hostname verification fails.
This is why the bootstrap script runs the container with --hostname and
--network-alias set to the slug, and why an agent reached through DNS needs
a record matching its slug.
The agent enters this mode on its first boot: the image runs python src/main.py,
which registers and obtains its certificate before binding the socket, then
starts the server with the mTLS context. If no bootstrap token is configured and
no certificate exists — a local development scenario — the agent falls back to
plain HTTP and logs that it did so. A failed bootstrap is fatal rather than a
silent fallback, so a production agent never comes up unauthenticated.
Certificate renewal is applied to the running listener without a restart: when the renewal loop obtains a fresh certificate it reloads it onto the live TLS context, so new connections serve the new certificate immediately (existing connections finish on the old one). A self-hosted agent left running for years rotates its certificate on its own — there is no annual restart to remember.
Encrypting the payload in flight¶
A dispatch carries the Lace script and every resolved variable for that service, secrets included. Mutual TLS already closes that to the network. What it does not close is a hop that terminates TLS on the way — an ingress, a managed edge, a tunnel — because such a hop decrypts, reads and re-encrypts.
Turning Encrypt payload in flight on for an agent (Settings → Agents, expand the agent) seals the job body to that agent's own certificate before it reaches the TLS layer: a random AES-256-GCM key encrypts the body, and that key is wrapped with RSA-OAEP-256 to the public key in the certificate the gateway issued the agent. The private key never left the agent, so only that agent can unwrap it, and a terminating hop sees an opaque envelope. The reply comes back the same way, sealed to the scheduler's certificate.
It is a per-agent setting because the exposure is a property of the path, not of the platform. An agent on the same private network as the scheduler gains nothing, and sealing is not free — each run costs an RSA operation at both ends. Turn it on for the agents you reach through something that terminates TLS, and leave the rest alone. It is off by default.
Both ends must be current. The agent reports whether it can open a sealed dispatch as part of its health challenge, and the dashboard will not arm the toggle for an agent that says it cannot — upgrade the agent and the setting becomes available after its next challenge. If an agent is downgraded after the toggle was armed, the scheduler logs a warning and dispatches unsealed rather than failing the probe: monitoring keeps running, and the warning is the signal to fix it.
What this does not buy you
It is not protection against a compromised agent. The agent has to decrypt the payload to run the probe at all, so whoever owns the agent owns the script and the variables however they arrived. Nor is it a data-residency control — it changes how the payload travels, not where it is executed or where the bodies it saves are stored.
The whole mechanism can be switched off from the scheduler's environment with
PROBE_PAYLOAD_ENCRYPTION_ENABLED=false; see
Configuration. That is a kill switch and nothing more — it
cannot turn sealing on for an agent that has not been set to it.
Health challenges¶
The agent exposes two health endpoints:
| Endpoint | Returns |
|---|---|
GET /health |
{status, version, executor_version} |
POST /health/challenge |
{challenge_id, token, elapsed_ms, success, error} |
GET /health is liveness for a load balancer or orchestrator: it proves the
process is up and answering.
POST /health/challenge proves something much stronger. The scheduler generates
a challenge ID and a one-time token, stores the token in Redis with a 30-second
TTL, and POSTs {challenge_id, token_url} to the agent. The agent then runs a
real Lace script to fetch the token from the gateway and returns whatever it
got back. The scheduler compares it against what it stored and records pass,
fail, wrong_token, timeout (10-second budget), or inconclusive — the
last when the round could not be completed for reasons that are the platform's
rather than the agent's.
That round trip exercises the entire path an actual probe uses: the executor loads and runs, DNS and TCP and HTTP work outbound, the response body is parsed without corruption, and the result comes back over the same channel a probe result would. A ping proves only that the process answers sockets — an agent whose executor is broken, whose DNS is dead, or whose egress is blackholed answers a ping perfectly and fails every probe silently. The challenge is therefore a real test of probing capability, not of liveness.
Quartz drives it on cron 30 * * * * ? — every minute at second :30. The offset
is deliberate. Probe cron schedules all fire at second 0, so a challenge sent at
the same instant queues behind the fleet-wide burst and reports a latency that
reflects the burst rather than the agent. Measuring at :30 keeps the signal
clean.
Two consecutive non-passing rounds mark the agent failed and raise an agent-down alert. One is treated as a blip and changes nothing, and a single pass puts a failed agent straight back into rotation. A round trip over 1200 ms on an otherwise passing challenge marks the agent degraded and raises an alert of its own. Results are also written to the agent health history and pushed live to the dashboard. What each result means, and when a round is discounted as inconclusive, is covered in Monitoring Tracedown.
Removing an agent¶
The command takes no arguments and is interactive. It lists every active agent
with its slug, label, last status, and last health check time in an indexed
table, then prompts for a number. q cancels; anything that is not a valid
index exits non-zero without touching the database.
Confirming does three things in one transaction: sets probe_agents.is_active
to false, marks every non-revoked certificate for that agent revoked with the
reason "Agent removed via CLI", and deletes its service_allowed_agents
bindings so no service is left pointing at an agent that will never answer.
Probe history is kept. Removing an agent retires the worker, not the data it produced — your graphs and incident history stay intact. The dashboard offers the same action.
Which agents run a service¶
A service can restrict itself to a subset of agents — useful when a probe must originate from a particular network or region. If a service names no agents, every active, healthy agent is eligible for it. Selection strategy (consecutive, simultaneous, or random) is a per-service setting, as is what happens when the chosen agent will not take the job; see Services.
Body storage¶
When a probe saves a response body, the agent writes it through the configured
backend. filesystem writes under storage_dir (/data/bodies by default) and
is the right choice when agents share a volume with the stack or when bodies are
not worth keeping beyond the retention window. Set storage_backend=s3 and the
PROBE_AGENT_S3_* variables to push bodies to any S3-compatible object
storage instead,
which is what you want once agents are geographically spread and no shared
filesystem exists.
Body saving is off unless a script asks for it, and the scheduler can forbid it per job regardless of what the script requests. See Configuration.
The agent's storage environment¶
An agent assigned a body store writes its bodies
there instead of into the platform's own storage, and it does so through the
ordinary PROBE_AGENT_* variables above. The store row tells the platform where
to look; these tell the agent where to write, and the two have to agree.
The dashboard's connect form prints the exact set for the store you pick, with the agent's slug already appended:
The directory must be the same one the gateway and the ingestor see, so the agent needs the store's volume mounted at the store's root path. See Filesystem stores.
The trailing paris-1 in both is the agent's slug, appended to the store's
prefix or root. Every agent sharing a store writes under its own
<prefix>/<agent-slug>/, and a body written anywhere else in the store is
refused at ingest with the reason outsideAssignedStore. The connect form
appends it for you; if you are writing the environment by hand, do not leave it
off.
The credentials here are the agent's own, and they need write access and nothing more. The store's credentials — the ones the gateway and the result-ingestor read with — are configured on the store and are not these.
Changing an agent's store means changing these variables and restarting the agent. The assignment on its own moves nothing; see Assigning an agent.