Configuration
This is the full reference for every knob probectl reads at startup. The short
version: the control plane and every server-side feature read environment
variables (all prefixed PROBECTL_); the agents read a YAML file (with the
same env vars as overrides). An environment variable is a named string the
operating system hands a process at launch — and because the control plane is
stateless, these variables are the only place its behavior comes from. This
page lists each variable, its default, and what it does — and it is the
contract, so every row here is checked against the code.
How to read this page:
- A variable's default is what you get if you set nothing. The defaults are
chosen so a fresh install boots in a safe, sovereign posture (no outbound calls,
fail-closed on missing TLS/secrets) — probectl's standing security guardrails
(see
security/threat-model.md). - A default of
(none)means the value is empty/unset; the feature usually stays off until you give it one. - Where it's read: the control plane resolves its config in
internal/config/config.go(oneLoadfunction that reports every bad value at once and exits non-zero — a preflight checklist that reads all the gauges before refusing takeoff, so you never chase config errors one at a time). Each agent has its own loader (internal/agent,internal/ebpf,internal/flow,internal/device,internal/endpoint).
Conventions
- Control plane (
probectl-control): environment variables,PROBECTL_prefix. Listed in the next section. - Agents: a YAML config file is the source of truth; the matching
PROBECTL_*env vars override individual fields (handy for containers). Each agent's keys are in its own section below. - Agent YAML schema: every agent config file starts with a top-level
apiVersion(probectl.io/agent/v1,probectl.io/ebpf-agent/v1,probectl.io/flow-agent/v1,probectl.io/device-agent/v1, orprobectl.io/endpoint/v1).schema_version: 1is accepted as a compatibility alias. Unknown YAML keys fail startup, so a misspelled or retired safety knob is loud instead of silently ignored. - Secrets are never hardcoded, logged, or placed in URLs/query strings.
Sensitive values at rest are sealed with envelope encryption — each value is
encrypted with its own data key, and all those data keys are encrypted by one
master key (the envelope key): individual lockboxes whose keys live in a
single safe. Any credential in this document may be a secret reference (e.g.
vault:…) instead of the raw value — see Secrets integration.
Control plane (probectl-control)
The control plane is the brain: it serves the API/UI, accepts agent connections, runs the alerting/incident/correlation engines, and talks to the datastores. It is stateless — all durable state lives in Postgres/ClickHouse/the TSDB — so every behavioral choice it makes comes from these environment variables, read once at boot. The table below is the base set every deployment uses; the feature-specific sections that follow add more.
Subcommands: probectl-control [serve] (default), probectl-control migrate (apply
database migrations and exit), probectl-control version, and
probectl-control gen-cert [--if-missing] [dir] — a convenience that writes a
self-signed tls.crt/tls.key/ca.crt for an HTTPS quickstart
(PROBECTL_CERT_HOSTS, default localhost,127.0.0.1, sets the certificate's host
names; --if-missing preserves a complete existing bundle and rejects a partial
one rather than rotating it; production brings its own CA-issued cert).
probectl-control stage-binary <destination> is the internal, shell-free
distroless init-container helper used by the Helm backup/restore Jobs. The other
subcommands are covered with their features:
agent-ca init|renew|export, enroll-token, and revoke-agent (agent transport and
enrollment — agent/enrollment.md), scim-token
(SCIM, below), mcp-stdio and mcp-token (MCP server, below), preflight
(the storage-encryption preflight — hardening.md),
support-bundle (supportability, below), backup-seal / backup-open
(sealed backups — Tenant lifecycle, below), backup-rewrap and
envelope-rewrap (deployment-envelope key rotation — hardening.md),
bgp-analyzer (the optional Python analyzer bridge — BGP routing
intelligence, below), and replay-deadletter
(re-ingest dead-lettered records — ops/dead-letter-replay.md).
A note on the defaults: the listen address is :8080, but the database URL —
the one postgres:// or postgresql:// connection string carrying host, user,
password, database, and TLS mode — is required and has no default credential.
PostgreSQL keyword/value strings are rejected so credentials can never bypass
config-log and support-bundle redaction. Production URLs should verify the
server certificate with sslmode=verify-full and an sslrootcert trust
anchor; the multi-tenant/regulated profiles require it. HSTS — the response header
that tells browsers to only ever reach this host over HTTPS — is on. Shipped
Helm and Compose deployments set the TLS cert/key pair below so the process
serves HTTPS directly, including behind an ingress.
| Variable | Default | Description |
|---|---|---|
PROBECTL_HOP_GEO_FILE |
(empty) | Optional operator-supplied JSON table ([{"cidr","lat","lon","city","country"}]) locating path responders for the geographic path view (maximum 1 MiB). Longest prefix wins; private ranges are the operator's to map. Nothing is ever fetched from a geolocation service; a malformed or oversized file disables enrichment (fail closed) with a warning. |
PROBECTL_HTTP_ADDR |
:8080 |
API listen address |
PROBECTL_HTTP_READ_TIMEOUT |
15s |
HTTP read timeout |
PROBECTL_HTTP_WRITE_TIMEOUT |
15s |
HTTP write timeout |
PROBECTL_HTTP_IDLE_TIMEOUT |
60s |
HTTP idle (keep-alive) timeout |
PROBECTL_SHUTDOWN_TIMEOUT |
15s |
graceful-shutdown drain timeout |
PROBECTL_DRAIN_GRACE |
5s |
drain window at shutdown: readiness answers 503 while the listener stays open, so probes and load balancers stop routing before connections are refused; capped at half PROBECTL_SHUTDOWN_TIMEOUT; 0 disables (DPR-103) |
PROBECTL_VIEW_GROUP_SWEEP |
15m |
how often this instance deletes the per-replica view consumer groups left behind by control-plane processes that are gone (every restart and rolling upgrade abandons a set); shared durable groups are never touched; 0 disables (DPR-109) |
PROBECTL_CLUSTER_PROBE_TIMEOUT |
5s |
bound on each multi-region role probe, so one unreachable endpoint cannot stall the refresh loop that drives the write fence (DPR-095) |
PROBECTL_COMPLIANCE_REALERT |
24h |
how long a segmentation violation stays claimed before the (policy, rule) pair re-arms: inside the window every replica and replay collapses to one incident/SIEM export, the next window reports it again (DPR-110) |
PROBECTL_DATABASE_URL |
(required; no default) | PostgreSQL URL (postgres:// or postgresql://; keyword/value DSNs are rejected). Direct binary startup fails closed when it is absent; Compose and Helm supply it explicitly. multi-tenant/regulated profiles require server-certificate verification on writer and read-replica URLs: sslmode=verify-ca or verify-full with sslrootcert set (a trust anchor); server-unverified or plaintext modes (require, prefer, allow, disable, or omitted) are rejected because require encrypts but trusts any presented certificate (CRY-05). These profiles also reject query-string host= overrides so pgx cannot replace the TLS-checked authority. Explicit dev-only sslmode=require/disable remains accepted under the single profile |
PROBECTL_MIGRATE_DATABASE_URL |
(empty = use PROBECTL_DATABASE_URL) |
TEN-01: separate PRIVILEGED DSN used ONLY to apply schema migrations (which CREATE SCHEMA/ROLE/EXTENSION). The serving control plane connects with the least-privilege runtime login in PROBECTL_DATABASE_URL (NOSUPERUSER, NOBYPASSRLS) and never serves a request through this one, so a superuser serve login cannot silently disable tenant RLS. With siloed isolation licensed it also runs the silo schemas' DDL (provisioning and the startup catch-up), which the serve login cannot; without it a siloed tenant is refused. Empty keeps the single-login behaviour (migrations use PROBECTL_DATABASE_URL) |
PROBECTL_DANGEROUS_ALLOW_SUPERUSER_DB |
false |
TEN-01 escape hatch: by default the control plane REFUSES to serve as a SUPERUSER/BYPASSRLS Postgres login, because such a login silently disables the storage-layer tenant isolation (guardrail 7.1). Set true ONLY to override that fail-closed posture check (dangerous; never in production) |
PROBECTL_DATABASE_MAX_CONNS |
25 |
max pool connections (2–1000). One session is reserved by the singleton advisory-lock lease, leaving at least one for work; per-tier sizing (SCALE-009): small/single-node 25; medium 50; large/multi-tenant 100+ — size to instances × max_conns ≤ Postgres max_connections with headroom for migrations/admin |
PROBECTL_DATABASE_MIN_CONNS |
2 |
min (warm) pool connections — keeps a couple of conns open so the first request after idle skips the connect+TLS cold start. Production profiles may raise this |
PROBECTL_DATABASE_CONNECT_TIMEOUT |
5s |
per-connection connect timeout |
PROBECTL_MIGRATE_ON_BOOT |
false |
apply migrations during serve startup |
PROBECTL_LOG_LEVEL |
info |
debug | info | warn | error |
PROBECTL_LOG_FORMAT |
json |
json | text |
PROBECTL_THEME_OVERRIDES |
(none) | JSON object of deployment-wide design-token overrides, for example {"--radius-panel":"10px"}. Only shipped color tokens plus --radius-control|panel|pill and --font-sans|mono|display are accepted. A color value must be the bare HSL triplet the stylesheet consumes ("28 85% 30%", optionally "28 85% 30% / 0.12") — a hex or rgb() value is refused because the stylesheet reads the token as hsl(var(--token) / <alpha>). Unsafe values, unknown token names, or any set that breaks WCAG contrast in either shipped theme fails startup. Applies to every tenant while product identity remains probectl |
PROBECTL_HSTS_ENABLED |
true |
send Strict-Transport-Security |
PROBECTL_HSTS_MAX_AGE |
8760h |
HSTS max-age |
PROBECTL_TLS_CERT_FILE |
(none) | PEM server certificate; the process serves HTTPS directly when set together with the key |
PROBECTL_TLS_KEY_FILE |
(none) | PEM server private key (set together with the cert) |
PROBECTL_PUBLIC_TLS |
false |
tells the app its public origin is TLS so browser cookies are Secure. Shipped Helm and Compose paths also serve TLS on the application listener |
PROBECTL_ALLOW_PLAINTEXT_HTTP |
false |
explicit, loud compatibility opt-in for a non-loopback plaintext control listener. Shipped Helm and Compose paths leave it false; without TLS files, plaintext + a non-loopback bind refuses to start |
PROBECTL_SECURITY_CONTACT |
(none) | your vulnerability-disclosure mailbox; published in the served /.well-known/security.txt (left as a template comment when unset) |
PROBECTL_ENVELOPE_KEY |
(none) | base64-encoded 32-byte key-encryption key (KEK) for at-rest envelope encryption. The single root secret behind sealed credentials and backups — back it up |
PROBECTL_ENVELOPE_KEY_FILE |
(none) | path to the KEK file — loaded, or GENERATED+persisted (0600) on first boot if absent; an explicit PROBECTL_ENVELOPE_KEY wins over it. Shipped compose mounts it on the controldata volume. The resolved key id is pinned in a <file>.id sidecar beside it (RTO-16) so a fresh-host restore reloads the SAME id instead of renumbering — back up the sidecar with the key, or let backup-control-state carry it |
PROBECTL_ENVELOPE_KEY_ID |
dev |
identifier recorded alongside each sealed value and backup container header. For a file-based KEK the <PROBECTL_ENVELOPE_KEY_FILE>.id sidecar is authoritative once written (it names the id that sealed the data); change the active id through envelope-rewrap, not by editing this in place |
PROBECTL_ENVELOPE_OPENER_KEYS |
(none) | comma-separated oldKeyID=base64KEK opener keyring for envelope-key rotation overlap. New values use only PROBECTL_ENVELOPE_KEY; old dv1 values and .pbk backups can still open by their stored key ID. Treat this like key material; remove a retired entry only after probectl-control envelope-rewrap --verify-retired-key-id=<oldKeyID> and backup rewrap/expiry prove it is no longer needed |
PROBECTL_SESSION_HMAC_KEY |
(none) | hex-encoded 32-byte session-token HMAC key (openssl rand -hex 32). Browser/operator session tokens are random, but this server-side pepper means a DB snapshot cannot verify guesses without the app secret. Required by shipped Helm/Compose production paths, by session auth under multi-tenant / regulated profiles, and by single-profile session auth once PROBECTL_OIDC_* is configured |
PROBECTL_REQUIRE_AT_REST_ENCRYPTION |
true |
records the desired at-rest posture. The serve path refuses to start without an envelope key by default; setting this false alone does not allow plaintext |
PROBECTL_ALLOW_KEYLESS_DEV |
false |
explicit local-dev-only escape hatch for no envelope key. When true, sensitive-value sealing falls back to plaintext passthrough; never set it in production/provider profiles |
PROBECTL_STORAGE_ENCRYPTION_ATTESTED |
false |
operator attestation that the bulk-store volumes are encrypted below the host (e.g. encrypted cloud volumes the startup preflight can't see); logged, and downgrades the preflight warning |
PROBECTL_AGENT_GRPC_ADDR |
(none) | agent gRPC listen address; enables the transport when set together with the agent mTLS files below |
PROBECTL_AGENT_TLS_CERT_FILE |
(none) | agent-transport server certificate (PEM) |
PROBECTL_AGENT_TLS_KEY_FILE |
(none) | agent-transport server private key (PEM) |
PROBECTL_AGENT_TLS_CA_FILE |
(none) | CA bundle that signs agent client certificates (PEM) |
PROBECTL_BUS_MODE |
memory |
result bus: memory (volatile lightweight — in-process, nothing survives a restart; development and tests only) | nats (durable lightweight — one NATS server with JetStream; records on disk, consumer positions survive a restart and a rolling upgrade; production-supportable for small deployments, DPR-119) | kafka (the default at scale) |
PROBECTL_BUS_BROKERS |
(none) | comma-separated host:port Kafka brokers, or nats:///tls:// server URLs for nats. Required for both networked modes |
PROBECTL_BUS_STREAM_MAX_AGE |
168h |
nats only: how long a record stays on the broker (JetStream stream MaxAge, the equivalent of Kafka's retention) |
PROBECTL_BUS_STREAM_REPLICAS |
1 |
nats only: JetStream replica count. 1 is the single-broker lightweight shape; raise it on a NATS cluster |
PROBECTL_BUS_CONSUMER_IDLE |
168h |
nats only: a durable consumer nobody has used for this long is retired by the server — the server-side half of the abandoned-view-group sweep (DPR-109) |
PROBECTL_BUS_MEMORY_BUFFER |
1024 |
in-memory bus: per-subscriber channel depth (lightweight mode) |
PROBECTL_BUS_MEMORY_OVERFLOW |
block |
in-memory bus overflow policy (RESIL-002): block (default — back-pressure the publisher so an agent is not ACKed after a known in-process drop) | drop (explicit isolation mode; drops are counted at probectl_bus_memory_dropped and Publish returns an error so upstream retries) |
PROBECTL_BUS_TLS_ENABLED |
false |
TLS to the Kafka brokers. Required in kafka mode unless the explicit dev flag below is set |
PROBECTL_BUS_TLS_CA_FILE |
(none) | private CA bundle for the brokers |
PROBECTL_BUS_TLS_CERT_FILE |
(none) | client certificate (broker mTLS; with _KEY_FILE) |
PROBECTL_BUS_TLS_KEY_FILE |
(none) | client key (broker mTLS) |
PROBECTL_BUS_SASL_MECHANISM |
(none) | plain | scram-sha-256 | scram-sha-512 |
PROBECTL_BUS_SASL_USER |
(none) | SASL username |
PROBECTL_BUS_SASL_PASSWORD |
(none) | SASL password (secret references supported; never logged) |
PROBECTL_BUS_ALLOW_PLAINTEXT |
false |
dev only: allow a plaintext broker (the dev compose stack). Production never sets this |
PROBECTL_BUS_MAX_BUFFERED |
0 (= built-in bound 65536) |
bound on the async Kafka producer's in-flight records; a full buffer SHEDS new records (counted, never blocking ingest). 0/unset keeps the built-in 65536-record bound — there is deliberately no unbounded mode |
PROBECTL_BUS_MAX_MESSAGE_BYTES |
1048576 (1 MiB) |
the broker's configured max message size — the largest single record the bus will accept (0–1 GiB). The OTLP receiver rejects a marshaled batch over this BEFORE publish with a retryable 503/UNAVAILABLE so the client retries, instead of the async producer acking the push 200 and the broker dropping the oversized record afterwards (ING-13; telemetry loss is never silent). The default matches Kafka's out-of-the-box message.max.bytes; raise it to match a broker tuned higher. 0 = unbounded (no pre-publish cap) |
PROBECTL_BUS_CREATE_TOPICS |
true |
the control plane creates the bus topics it publishes and consumes — the shared lanes at startup and a siloed tenant's namespaced lanes when that tenant appears — instead of relying on broker auto-creation (production brokers usually disable it, and before DPR-047 the client never even asked for it, so the first agent's results were refused with "publish not durable"). false = you pre-create them (list in docs/deploying-agents.md); startup then verifies and fails closed naming any missing topic |
PROBECTL_BUS_TOPIC_PARTITIONS |
3 |
partitions for a topic the plane creates (1–1024; existing topics are left as they are) |
PROBECTL_BUS_TOPIC_REPLICATION |
-1 |
replication factor for a topic the plane creates; -1 = the broker's default.replication.factor |
PROBECTL_BUS_WORKERS |
4 |
per-subscription consume parallelism — each Kafka poll batch is fanned out across this many key-sharded workers (per-key ordering preserved). 0/1 = serial |
PROBECTL_INGEST_MAX_SERIES_PER_AGENT |
0 (= built-in cap 1000) |
cap on active metric-series identities one agent may mint; a NEW identity past the cap is rejected per-series and counted (known series keep flowing), and an identity idle for 1h frees its slot. 0/unset keeps the built-in 1000 cap — the wall always exists (there is no unlimited setting) |
PROBECTL_INGEST_MAX_SERIES_PER_TENANT |
0 (= built-in cap 50000) |
tenant-wide active-series wall, so one tenant's cardinality explosion never bleeds into others. 0/unset keeps the built-in 50000 cap |
PROBECTL_INGEST_WRITE_WORKERS |
4 |
result-pipeline TSDB write-stage worker count. This is separate from PROBECTL_BUS_WORKERS: bus workers decode/verify records, write workers perform durable store writes plus retry/DLQ before offsets ACK |
PROBECTL_INGEST_WRITE_QUEUE |
0 (= workers × 16) |
bounded queue between decode/verify and TSDB writes. A full queue increments probectl_pipeline_results_write_queue_saturated_total and back-pressures the bus consumer instead of growing memory without bound |
PROBECTL_TSDB_MEMORY_RETENTION |
0 (= built-in window 1h) |
lightweight-mode (in-memory) TSDB retention window, aged by ARRIVAL time (backfilled or clock-skewed sample timestamps are never swept early). 0/unset keeps the built-in 1h window — the buffer never grows forever |
PROBECTL_TSDB_MEMORY_MAX_BYTES |
0 (= built-in wall 256 MiB) |
byte ceiling for the in-memory TSDB; oldest-first eviction once exceeded, with usage + eviction counters exposed. 0/unset keeps the built-in 256 MiB wall |
PROBECTL_DERIVED_IDENTITY_RETENTION_DAYS |
90 |
age-retention clock for derived topology and endpoint identity labels (hop IP labels, device labels, SSIDs, gateway/session targets). The daily lifecycle sweeper prunes stale labels from tenant query surfaces and appends lifecycle.retention_sweep receipts; 0 disables this derived-cache TTL. A tenant's tighter flow_retention_days shortens this clock too |
PROBECTL_AUDIT_WORM_DIR |
(none) | enable write-once audit export — the provider audit chain is exported as Ed25519-signed segments into this directory (mount an S3/MinIO object-lock bucket for true write-once-read-many) and chain-verified each cycle. Set it through the chart's audit.worm.* values, not by hand: audit.worm.enabled=true with audit.worm.existingClaim pointed at an object-lock volume, and optionally audit.worm.mountPath. WORM segments are a durability requirement of their own — they do not live in the tenant artifact store, so a deployment can keep artifacts on S3/MinIO and still write signed segments (DPR-116). WORM never renders onto emptyDir |
PROBECTL_AUDIT_WORM_INTERVAL |
1h |
bounded catch-up + chain-verify cadence. Each cycle exports up to eight 1,000-event pages, then performs a read-only lag probe. A fully drained and verified cycle advances probectl_audit_worm_last_success_unix_seconds; remaining work sets probectl_audit_worm_lagging=1 and increments probectl_audit_worm_lagged_cycles_total immediately instead of reporting a false success |
PROBECTL_AUDIT_RETENTION |
0 in single; 8760h in multi-tenant / regulated |
deployment maximum for local audit-log retention. 0 keeps provider history indefinitely in the single profile; a tenant may still set a finite audit_retention_days tightening. With a positive deployment value, tenant settings above it are rejected and NULL inherits it. The hourly runner resolves each tenant's effective window, but tenant rows prune only at or below their durable SIEM cursor; provider rows use the lower of the verified signed-WORM watermark and, when encrypted IR attribution is active, the independently signed IR companion-coverage watermark. multi-tenant / regulated reject disabled retention and missing PROBECTL_AUDIT_WORM_DIR, WORM signing key, or SIEM endpoint so raw audit PII cannot silently keep forever. The runner deletes only a contiguous eligible prefix, appends audit.retention_prune receipts carrying the effective window, atomically persists subject-erasure marker hashes before their exported rows age out, and fails closed on unexported, uncovered, tampered, or in-window evidence. /readyz.audit_retention and probectl_audit_retention_* metrics expose the deployment-level provider posture. Set per the org's SOC2 CC7 / ISO 27001 A.12.4 evidence-retention requirement |
PROBECTL_AUDIT_RETENTION_MIN |
720h (30 days) |
floor for a tenant's audit_retention_days: the API rejects a tenant setting below this many days, so no tenant can shrink its audit evidence window beneath the deployment's minimum. 0 removes the floor |
PROBECTL_WORM_SIGNING_KEY_FILE |
(none) | single-replica path to the Ed25519 audit-export signing key (PKCS#8 PEM) — loaded, or GENERATED+persisted (0600) on first boot, so the key is stable across restarts (an ephemeral per-boot key would break cross-restart chain verification). Keep it on the persistent WORM claim. Helm rejects this setting when replicaCount > 1 or HPA can scale above one. Back it up like the envelope key |
PROBECTL_WORM_SIGNING_KEY |
(none) | base64-encoded Ed25519 private-key PEM (KMS/secret-manager injection) — wins over PROBECTL_WORM_SIGNING_KEY_FILE. Enabling audit export with neither set fails closed (no silent ephemeral key). Multi-replica Helm installs require one shared value in secrets.existingSecret |
PROBECTL_IR_PUBLIC_KEY_DIR |
(none) | absolute path to the operator-owned, local-only IR wrapping-key directory. Each tenant has <tenant-uuid>.pem, an RSA-3072 public key: mint the pair with probectl audit ir-keygen <tenant-id> and install the public half with probectl-control ir-key-install <tenant-id> (stdin). The real provider break-glass transaction envelope-seals attribution with that public key and fails closed (409 ir_key_unavailable) if it is missing; the routine runtime cannot unseal because no private key is loaded. This is a distinct key domain from PROBECTL_ENVELOPE_KEY, makes no network calls, and remains air-gap capable. The same persisted WORM signing key signs the opaque sidecar chain; licensed provider-plane admission therefore also requires PROBECTL_AUDIT_WORM_DIR so deleting all stage rows can never look like an unused sidecar. |
PROBECTL_IR_PRIVATE_KEY_DIR |
(none) | investigation-only absolute path to an owner-only local directory of envelope-encrypted <tenant-uuid>.<sha256-of-key-id>.pem.enc RSA private-key artifacts. Create them offline with probectl audit seal-private-key; no custom code or network access is required. Retain old versioned artifacts after public-key rotation so historical records remain revealable. The directory must not be a symlink and must deny group/other access; each artifact must be a real mode-0600 file. Configuring it also requires PROBECTL_IR_PUBLIC_KEY_DIR and both unlock settings below. Leave it unmounted during routine operation; mount it for an authorized reveal or verifiable tenant deletion. Deletion first commits a signed, WORM-covered exact-artifact plan, then removes only that tenant's planned local files after all other stores succeed; missing capability or incomplete coverage fails before deletion. Reveal stays unavailable while the directory is absent. Operator backups/snapshots/escrow or external KMS handles remain the operator's destruction responsibility; the signed tombstone prevents a restored local artifact from reactivating probectl access. |
PROBECTL_IR_UNLOCK_KEY_ID |
(none) | non-secret identity for the distinct operator-owned KEK that encrypts the IR private-key artifacts. Required only with PROBECTL_IR_PRIVATE_KEY_DIR. It is separate from the steady-state IR wrapping key and deployment envelope domain. |
PROBECTL_IR_UNLOCK_KEY |
(none) | base64-encoded 32-byte investigation-artifact KEK supplied through the local secret-injection mechanism. Required only with PROBECTL_IR_PRIVATE_KEY_DIR; never bake it into an image, values file, or repository. It is consumed through internal/crypto, is omitted from logs/support bundles, and makes no external call. Destroying or withholding it keeps the encrypted private artifacts—and therefore attribution—unavailable. |
PROBECTL_OBJECTSTORE_MODE |
filesystem |
filesystem local/dev backend or durable s3/MinIO-compatible backend. No backend is contacted unless its mode and required fields are explicitly configured. |
PROBECTL_OBJECTSTORE_DIR |
(none) | filesystem-mode tenant artifact directory. Tenant lifecycle export/erase inventories/deletes the same namespace. Use this as the local/single-node fallback. |
PROBECTL_OBJECTSTORE_S3_ENDPOINT |
(none) | S3/MinIO base endpoint for s3 mode; remote endpoints must use verified HTTPS (loopback HTTP is test-only). |
PROBECTL_OBJECTSTORE_S3_BUCKET |
(none) | operator-owned artifact bucket. |
PROBECTL_OBJECTSTORE_S3_REGION |
us-east-1 |
SigV4 region. |
PROBECTL_OBJECTSTORE_S3_ACCESS_KEY |
(none) | SigV4 access-key id; required with s3 mode. |
PROBECTL_OBJECTSTORE_S3_SECRET_KEY |
(none) | SigV4 secret; required with s3 mode and secret-reference capable. Never place it in a ConfigMap. |
PROBECTL_OBJECTSTORE_S3_SESSION_TOKEN |
(none) | optional temporary-credential token; secret-reference capable. |
PROBECTL_OBJECTSTORE_S3_PREFIX |
probectl |
traversal-free bucket prefix shared by control plane and artifact-writing agents. |
PROBECTL_TESTSYNC_SIGNING_KEY_FILE |
(none) | path to the Ed25519 test-bundle signing key (PKCS#8 PEM) — loaded, or GENERATED+persisted (0600) on first boot. When set, GET /v1/tests/bundle serves signed tenant-scoped test definitions for agents to verify; when unset, central test distribution is off and that route returns 503 |
PROBECTL_EVIDENCE_SIGNING_KEY_FILE |
(none) | single-replica path to the stable Ed25519 incident-evidence signing key (PKCS#8 PEM). The file is generated once at mode 0600 and reused across restarts. If no evidence key resolves, POST /v1/incidents/{id}/exports fails closed with 503; it never mints an ephemeral per-export identity |
PROBECTL_EVIDENCE_SIGNING_KEY |
(none) | base64-encoded PKCS#8 PEM Ed25519 private key for shared secret-manager injection. Use the same value on every HA replica. The package embeds the public half and fingerprint for offline integrity verification; publish/pin that fingerprint out of band to establish operator identity. This secret is omitted from support bundles and logs |
PROBECTL_TSDB_MODE |
memory |
time-series writer: memory (in-process) | prometheus |
PROBECTL_TSDB_URL |
(none) | Prometheus/VictoriaMetrics base URL for remote-write (required for prometheus) |
PROBECTL_TSDB_BASIC_AUTH_FILE |
(none) | optional owner-only (0600), regular non-symlink JSON credential file for the configured Prometheus URL namespace: {"username":"...","password":"..."}. The strict parser rejects unknown/duplicate/empty fields; the client never places the secret in URL userinfo, env values, arguments, or logs, refuses to forward it across an origin or configured path-prefix change, and requires HTTPS except for loopback development |
PROBECTL_REMOTE_WRITE_BATCH_ENABLED |
true in prometheus mode (else false) |
SCALE-001: coalesce concurrent results into one remote-write POST instead of one POST per result. Defaults ON for prometheus so the production ingest path is batched by default; set explicitly to override |
PROBECTL_REMOTE_WRITE_BATCH_SERIES |
500 |
flush when this many series have accumulated |
PROBECTL_REMOTE_WRITE_BATCH_WAIT |
50ms |
max time a batch waits before flushing |
PROBECTL_ALERT_EVAL_INTERVAL |
30s |
how often the alerting engine evaluates rules over the TSDB |
PROBECTL_ALERT_SMTP_ADDR |
(none) | SMTP endpoint (host:port) for the rule-level email channel. Unset keeps email off: rules with an email channel are skipped with a logged warning and the channel-test endpoint reports the failure |
PROBECTL_ALERT_SMTP_FROM |
(none) | envelope sender address; required with _ADDR |
PROBECTL_ALERT_SMTP_TLS_MODE |
starttls |
starttls (server MUST advertise STARTTLS — a plaintext-only server is refused) | implicit (SMTPS from the first byte, usually :465). There is no plaintext mode (§7 guardrail 12); certificates are always validated |
PROBECTL_ALERT_SMTP_USERNAME |
(none) | SMTP AUTH username; set together with _PASSWORD. Authentication happens only after the session is TLS-secured |
PROBECTL_ALERT_SMTP_PASSWORD |
(none) | SMTP AUTH password; a secret reference (secret://…) is resolved through the secrets backend at boot, and an unresolvable configured credential refuses to start. Never logged |
PROBECTL_INCIDENT_WINDOW |
10m |
time window within which related signals correlate into one incident |
PROBECTL_PROVISION_REAP_INTERVAL |
15m |
how often one elected replica sweeps stranded tenant-provisioning attempts. An attempt is a staging row created before the external silo legs run: it holds no routable tenant and no licensed band slot, and provisioning is now compensating (a failed leg drops the databases the earlier legs created), so a stranded row means the attempt was abandoned, not that data is orphaned |
PROBECTL_PROVISION_REAP_AFTER |
24h |
how old an attempt's LAST try must be before the sweep abandons it. Abandoning tears the external silo down first, then removes the staging row |
PROBECTL_PROVISION_REAP_MAX |
25 |
how many attempts one sweep may abandon — the sweep is destructive, so it is bounded per run |
PROBECTL_TOPOLOGY_WHATIF_MAX_AGENTS |
2000 |
how many agents one what-if simulation expands routes for. What-if runs a breadth-first search per agent plus two whole-graph reachability passes on a synchronous handler, so it is bounded: exceeding a bound returns an honest partial result (partial: true plus a note naming the bound), never an unbounded run |
PROBECTL_TOPOLOGY_WHATIF_MAX_VISITS |
2000000 |
total nodes one simulation may visit across every traversal — the bound that tracks real cost on a dense graph |
PROBECTL_TOPOLOGY_WHATIF_TIMEOUT |
5s |
wall-clock bound on one simulation, applied on both POST /v1/topology/whatif and the export GET |
PROBECTL_AUTH_MODE |
session |
identity mode: session (real OIDC SSO + session cookies) | dev (LOCAL EVALUATION ONLY — exists only in -tags devauth builds; release binaries refuse it at boot) |
PROBECTL_DEV_AUTH_ACK |
(none) | must be i-understand to start in dev auth mode (tagged builds only, loopback bind required) |
PROBECTL_SESSION_TTL |
12h |
server-side absolute session lifetime and the minimum authenticated-callback replay/detail-retention horizon. The daily tenant lifecycle leader deletes expired session identity detail and predecessor detail older than this horizon; the hash-only locator tombstone remains so cleanup cannot reopen a multiple-successor race |
PROBECTL_SESSION_IDLE_TIMEOUT |
30m |
default-on inactivity limit for tenant and provider sessions; 0 keeps the safe 30m default rather than disabling it |
PROBECTL_AUTH_RATE_MAX_FAILURES |
5 |
auth brute-force guard: failures per window before lockout |
PROBECTL_AUTH_RATE_WINDOW |
1m |
failure-counting window for the auth throttle |
PROBECTL_AUTH_RATE_LOCKOUT |
1m |
base lockout; doubles per consecutive lockout, capped at 1h; lockouts are audited |
PROBECTL_TRUSTED_PROXIES |
(none) | comma-separated CIDRs or addresses of the proxies in front of the control plane (ingress controller, load balancer). When the TCP peer is one of them, the tenant SSO and provider login limiters key on the nearest untrusted X-Forwarded-For hop (or X-Real-IP) instead of the peer; forwarded headers from any other peer stay ignored. Without it, every user behind an ingress shares the ingress pod's address, so five failed logins by anyone lock SSO for the whole deployment (DPR-039). The Helm chart therefore refuses to render when the bundled ingress is enabled (the default) and control.trustedProxies is empty — set it to the ingress controller's pod CIDR, or disable the bundled ingress if you front probectl yourself (AUTHZ-04). Helm: control.trustedProxies. |
PROBECTL_OIDC_ISSUER |
(none) | OIDC issuer URL; SSO discovery is performed against it |
PROBECTL_OIDC_CLIENT_ID |
(none) | OIDC client ID registered with the IdP |
PROBECTL_OIDC_CLIENT_SECRET |
(none) | OIDC client secret (kept out of logs/URLs) |
PROBECTL_OIDC_REDIRECT_URL |
(none) | the control plane's /auth/callback URL registered with the IdP |
PROBECTL_OIDC_CA_FILE |
(none) | PEM trust bundle for the deployment IdP (PROBECTL_OIDC_ISSUER) behind a private CA — a self-hosted IdP on the operator's network. It replaces the system roots for that connection; certificate validation is never disabled, and a missing or certificate-less bundle refuses startup. Unset means the system trust store. Tenant-configured IdPs never use it |
PROBECTL_REQUIRE_MFA |
false |
require multi-factor auth. The session's MFA state comes from the ID token's amr/acr claims (a second factor like otp/hwk/mfa, or acr aal2+/loa2+). When true, every authenticated /v1 request from a single-factor session gets 403 (enforced at request time). Off by default |
PROBECTL_METRICS_SCRAPE_TOKEN |
(none) | AUTHZ-25: scrape credential for the control-plane GET /metrics exposition. The Prometheus text carries build provenance (probectl_build_info{version,commit}) and pipeline counters — a fingerprinting surface — so on the public API listener an anonymous scrape is refused 401. Set this to a high-entropy secret and have the ServiceMonitor present it as Authorization: Bearer <token>; a mismatched or absent credential gets 401. Empty on a non-dev deployment means /metrics fails closed (no anonymous exposition). Kept out of logs and the support bundle. A local dev-auth build (loopback-only) serves /metrics without it |
Invalid values fail fast: probectl-control reports all configuration problems
at once and exits non-zero. The database password is redacted from logs.
Tenant-owned tables are protected by Postgres Row-Level Security (RLS): the
database itself filters every row by the requesting tenant, so even a buggy or
hostile SQL query cannot read another tenant's rows — the isolation does not
depend on application code getting a WHERE clause right. The
PROBECTL_DATABASE_URL role must be able to assume the least-privilege probectl_app
role (a superuser always can; otherwise run GRANT probectl_app TO <login_role>),
which internal/tenancy assumes per transaction so isolation holds regardless of
how the control plane authenticated. It must also be able to assume — never
inherit — probectl_provider, the role the provider plane, the audit and WORM
streams, agent enrollment and tenant lifecycle run as:
GRANT probectl_provider TO <login_role> WITH INHERIT FALSE, SET TRUE. Boot
refuses a login that cannot assume it, or that inherits it (its provider-only
policies would then apply outside a provider transaction). See
architecture.md.
HTTP endpoints
| Method & path | Purpose |
|---|---|
GET /healthz |
Liveness — 200 while the process is serving |
GET /readyz |
Readiness — 200 when the database is reachable, else 503. An anonymous probe (the load balancer / uptime check) gets status only; an authenticated caller additionally gets the aggregate audit_retention + alerting posture, the multi-region cluster view, and any volatile_stores (AUTHZ-25 — the detailed posture is operator reconnaissance, so it is not exposed without credentials, mirroring /version) |
GET /version |
Build and runtime metadata — full detail only for an authenticated caller; an anonymous caller gets the service name (SEC-008) |
GET /metrics |
Prometheus self-metrics (process/aggregate health; no tenant data). Gated by PROBECTL_METRICS_SCRAPE_TOKEN as a bearer on the public listener — an anonymous scrape gets 401 (AUTHZ-25) |
GET /openapi.json |
The OpenAPI 3.1 document |
Every response carries an X-Request-Id (honoring an inbound one) and the
security headers Strict-Transport-Security (when enabled) and
X-Content-Type-Options: nosniff. The versioned resource routes under /v1 are
documented in Resource API & CLI below.
Error envelope
All errors share one JSON shape and a stable domain-error → HTTP mapping:
{ "error": { "code": "not_found", "message": "…", "request_id": "…" } }
error.code is the client branching contract: it is enumerated as ErrorCode
in /openapi.json. message is for humans and may be localized. request_id
is optional, but when present the CLI prints it next to the code so operators can
join a user-facing failure to server logs.
| Domain kind | Code | HTTP |
|---|---|---|
| BadRequest | bad_request |
400 |
| Unauthorized | unauthorized |
401 |
| Forbidden | forbidden |
403 |
| NotFound | not_found |
404 |
| Conflict | conflict |
409 |
| Validation | validation |
422 |
| RateLimited | rate_limited |
429 |
| TooLarge | too_large |
413 |
| Internal | internal |
500 |
| Unavailable | unavailable |
503 |
Specialized stable codes include writer_unavailable, quota_exceeded,
tenant_suspended, tenant_offboarded, license_read_only, approvals_disabled,
blast_radius_exceeded, blast_radius_unknown, and not_proposed; the
OpenAPI ErrorCode enum is the authoritative list.
Transport security
probectl never wants a plaintext channel exposed to the network. The shipped deployment path uses app-terminated TLS:
- App-terminated TLS — set
PROBECTL_TLS_CERT_FILE+PROBECTL_TLS_KEY_FILE, and the control plane serves HTTPS only (TLS 1.2+, prefer 1.3; plaintext is refused). - Ingress compatibility mode — a legacy/operator-owned deployment can leave
them unset only with the explicit
PROBECTL_ALLOW_PLAINTEXT_HTTP=trueacknowledgment behind its TLS ingress. Shipped Helm and Compose do not use this mode because it leaves the ingress-to-process hop unencrypted.
All TLS and crypto policy lives in internal/crypto; a CI guard
(scripts/check_crypto_imports.sh) forbids crypto-primitive imports elsewhere so
a FIPS 140-3 validated module can be swapped in. At-rest secrets use the envelope
helper (a per-record data key wrapped by a KMS/HSM-pluggable KEK; the dev
StaticKeyProvider reads PROBECTL_ENVELOPE_KEY).
Agent transport
This is how agents talk to the control plane, and it is locked down by design.
The transport is gRPC — a binary remote-procedure-call protocol over HTTP/2,
carrying Protobuf (compact, typed) messages — and it runs only when
PROBECTL_AGENT_GRPC_ADDR and all three PROBECTL_AGENT_TLS_* files are set
(address + server cert + server key + the CA that signs client certs). The
service is probectl.agent.v1.AgentService, and it is mutual-TLS only
(RequireAndVerifyClientCert): in mutual TLS both sides prove their identity
with certificates — the server to the agent, and the agent back to the server —
so the agent must present a client certificate, and its tenant and id are read
out of that certificate's identity
(spiffe://probectl/tenant/<t>/agent/<a>), never from the request body. So even a
misbehaving or malicious agent can only ever write to its own tenant — the identity
is cryptographic, not self-asserted. Populate PROBECTL_AGENT_TLS_CA_FILE (the
client-cert CA pool) with probectl-control agent-ca export <file>, which writes the
public agent-CA bundle (root + intermediate, no key). Generate dev mTLS material with the
internal/crypto CA helpers. The .proto lives under proto/probectl/agent/v1/;
regenerate Go with make proto (tools via make proto-tools).
Version-skew policy. At registration the control plane rejects agents outside
the supported version window, so a rolling upgrade never admits an incompatible
agent. See lifecycle.md.
| Variable | Default | Description |
|---|---|---|
PROBECTL_AGENT_SKEW_WINDOW |
1 |
allowed minor-version skew on either side (N/N-1); the control plane at minor N accepts agents at N-1…N+1. 0 requires an exact minor match |
PROBECTL_AGENT_MIN_VERSION |
(none) | an explicit floor — agents older than this are rejected regardless of the window (force-retire a known-bad version) |
A rejected agent gets a gRPC FailedPrecondition ("upgrade required"); a dev/unpinned
build (0.0.0-dev) on either side skips the check.
Agent self-metrics
Every shipped collector process exposes Prometheus text at GET /metrics:
probectl-agent, probectl-flow-agent, probectl-device-agent,
probectl-ebpf-agent, probectl-endpoint, and probectl-bmp-listener. These are
process-wide RED/USE signals only—counts, errors, bounded queue depth, and
publish time. They never contain tenant ids, probe targets, device addresses,
or telemetry payload fields.
Each process uses a distinct loopback-only default so the collectors can share
one host. Replace <PREFIX> below with PROBECTL_AGENT, PROBECTL_FLOW,
PROBECTL_DEVICE, PROBECTL_EBPF, PROBECTL_ENDPOINT, or PROBECTL_BMP:
| Variable | Default | Meaning |
|---|---|---|
<PREFIX>_METRICS_ADDR |
127.0.0.1:9464 through 127.0.0.1:9469, in the binary order above |
metrics listen address. Plain HTTP is accepted only for a loopback address; a wildcard or remote bind fails startup unless both TLS files are configured |
<PREFIX>_METRICS_TLS_CERT_FILE |
(none) | HTTPS server certificate. Set together with the key; probectl's TLS 1.3 listener policy is applied through internal/crypto |
<PREFIX>_METRICS_TLS_KEY_FILE |
(none) | HTTPS server private key. A one-sided certificate/key configuration fails startup |
Core series are probectl_agent_collections_total (probe or collector batches),
probectl_agent_published_total (results/batches accepted by the output transport),
probectl_agent_errors_total, probectl_agent_buffer_depth, and
probectl_agent_publish_latency_seconds. Build, uptime, Go-runtime, and process
series come from the same dependency-free registry.
published_total is acceptance, not delivery. An asynchronous producer
returns from Publish long before the broker answers, so on the agents that
publish onto a bus that counter means "the transport took it". Where the
transport can report what it then failed to deliver, two more series appear and
an operator should alert on them:
| Series | Meaning |
|---|---|
probectl_agent_bus_publish_failed_total |
records the transport accepted that never reached the broker, after its own retries |
probectl_agent_bus_publish_shed_total |
records dropped at a full in-flight buffer under broker-degraded backpressure |
probectl_agent_bus_no_subscriber_total |
records dropped because the volatile in-process memory bus had no subscriber at publish time — in a standalone collector that is every batch (ING-19), since memory mode only delivers to a consumer in the same process |
All three describe records that published_total has already counted, which
is why a climbing published_total alongside a climbing failed_total or
no_subscriber_total is data loss rather than throughput. A transport that cannot report undelivered records
exposes neither series, rather than a zero that would read as healthy. The
canary agent needs neither: it removes a result from its buffer only after the
control plane's accepted count, so its published_total is verified delivery.
The eBPF agent additionally ties readiness to it (probectl_ebpf_publish_degraded). Prometheus's job and
instance target labels identify the binary/host; tenant identity is
intentionally absent.
probectl-agent
The canary agent is the worker that actually runs the probes (ping, TCP, DNS,
HTTP, …). Unlike the control plane, its primary config is a YAML file
(-config, or the path in PROBECTL_AGENT_CONFIG), bounded to 1 MiB before
YAML decoding — see
deploy/agent/probectl-agent.example.yml.
Crucially, the agent does not configure its own tenant or id: those come from
its mTLS client certificate (above), so you can't accidentally point an agent at
the wrong tenant by editing a file.
agent.labels is a bounded map of operator-declared local placement metadata.
Set at least region and site when you want the native Targets coverage
cockpit to group the vantage. Labels travel only on the authenticated mTLS
registration RPC and are stored inside the agent's forced-RLS tenant row; they
never select a tenant. Keys are normalized to lower case (maximum 32 labels,
63-byte keys, 128-byte values). probectl never infers these values from an IP
address and never calls a geolocation service.
agent:
capabilities: ["icmp", "tcp", "dns", "http"]
labels:
region: eu-west
site: dub-1
A handful of env vars override individual YAML fields — useful in containers where mounting a full file is awkward:
| Variable | Overrides (YAML) | Meaning |
|---|---|---|
PROBECTL_AGENT_CONFIG |
— | path to the YAML config (the -config flag wins over it) |
PROBECTL_AGENT_GRPC_ADDR |
control_plane.grpc_addr |
the control plane's agent-gRPC endpoint to dial |
PROBECTL_AGENT_TLS_CERT_FILE |
tls.cert_file |
the agent's mTLS client certificate (PEM) |
PROBECTL_AGENT_TLS_KEY_FILE |
tls.key_file |
the agent's mTLS client key (PEM) |
PROBECTL_AGENT_TLS_CA_FILE |
tls.ca_file |
the CA that signed the control plane's server cert (PEM) |
PROBECTL_AGENT_BUFFER_DIR |
buffer.dir |
on-disk store-and-forward directory (see below) |
PROBECTL_AGENT_OBJECTSTORE_MODE |
artifact_store.mode (filesystem) |
filesystem fallback or durable s3/MinIO mode. |
PROBECTL_AGENT_OBJECTSTORE_DIR |
artifact_store.dir |
optional filesystem-mode directory for browser artifacts. |
PROBECTL_AGENT_OBJECTSTORE_S3_ENDPOINT |
artifact_store.endpoint |
verified-HTTPS S3/MinIO endpoint. |
PROBECTL_AGENT_OBJECTSTORE_S3_BUCKET |
artifact_store.bucket |
artifact bucket; match the control-plane configuration. |
PROBECTL_AGENT_OBJECTSTORE_S3_REGION |
artifact_store.region (us-east-1) |
SigV4 region. |
PROBECTL_AGENT_OBJECTSTORE_S3_ACCESS_KEY |
artifact_store.access_key |
SigV4 access-key id. |
PROBECTL_AGENT_OBJECTSTORE_S3_SECRET_KEY |
artifact_store.secret_key |
SigV4 secret; inject through a runtime Secret, never commit it. |
PROBECTL_AGENT_OBJECTSTORE_S3_SESSION_TOKEN |
artifact_store.session_token |
optional temporary-credential token. |
PROBECTL_AGENT_OBJECTSTORE_S3_PREFIX |
artifact_store.prefix (probectl) |
bucket prefix; match the control-plane configuration so lifecycle export/erase sees agent artifacts. |
PROBECTL_AGENT_OBJECTSTORE_ISOLATION |
artifact_store.isolation (pooled) |
the agent's tenant isolation model as provisioned: pooled, hybrid or siloed. A hybrid or siloed tenant's agents write artifacts under silo/<tenant-id>/, the namespace the tenant's GET /v1/isolation/status names; the agent cannot learn the model from the control plane (no config push). Any other value refuses startup. |
PROBECTL_AGENT_BROWSER_DRIVER |
browser.driver |
http for a non-rendering HTTP transaction or browser for rendered Playwright; rendered mode fails startup unless its worker is usable |
PROBECTL_AGENT_BROWSER_WORKER_COMMAND |
browser.worker.command |
executable used for the listener-free rendered worker (the shipped browser-agent image uses node) |
PROBECTL_AGENT_BROWSER_WORKER_PATH |
browser.worker.path |
worker program passed to the command (the shipped browser-agent image uses /worker/worker.mjs) |
PROBECTL_AGENT_BROWSER_WORKER_SOCKET |
browser.worker.socket |
UNIX socket of a browser-worker running in a SEPARATE sidecar container (SUP-02/D-36). When set, the agent drives the renderer over this socket instead of spawning it as a child, so the agent's mTLS identity need not share a container with Chromium; mutually exclusive with browser.worker.command/path |
PROBECTL_AGENT_IDENTITY_SERVER |
identity.server |
control-plane HTTPS base URL enabling automatic certificate rotation — the agent rotates its mTLS identity at ~2/3 of its lifetime via /enroll/agent/rotate. See agent/enrollment.md |
PROBECTL_AGENT_JOIN_TOKEN |
— | a one-time join token for first-boot enrollment: with no identity present yet, the agent redeems it, writes its identity, then runs. Idempotent (a present identity is never overwritten) and fail-closed. See agent/enrollment.md |
PROBECTL_AGENT_ENROLL_TOKEN_FILE |
enroll.token_file |
a file holding the join token (a mounted secret, read once, maximum 64 KiB); oversized files fail closed without exposing token bytes; PROBECTL_AGENT_JOIN_TOKEN takes precedence |
PROBECTL_AGENT_ENROLL_SERVER |
enroll.server |
enrollment target for first-boot enrollment; defaults to identity.server |
PROBECTL_AGENT_ENROLL_CA_PIN |
enroll.ca_pin |
optional hex sha256 pin of the server cert for first contact; otherwise tls.ca_file verifies the server |
| — | enroll.allow_plaintext_loopback |
dev/test-only escape hatch for http://localhost enrollment. Default false; non-loopback plaintext is always refused |
PROBECTL_AGENT_CANARY_CA_DIR |
tls.canary_ca_dir |
the one directory that probe ca_file: parameters may reference (a trust-anchor allowlist for HTTP/DNS-over-TLS probes); empty = the ca_file parameter is refused |
PROBECTL_AGENT_LOG_LEVEL |
— | debug | info (default) | warn | error |
PROBECTL_AGENT_LOG_FORMAT |
— | json (default) | text |
Results buffer to disk (buffer.dir, bounded by max_records, default 10000)
while the control plane is unreachable and drain on reconnect (at-least-once
delivery). Probing keeps running regardless of connectivity, so a control-plane
outage never blocks measurement — the agent just queues and catches up.
The buffer is also bounded by on-disk bytes (buffer.max_bytes, RESIL-009) to
pre-empt ENOSPC: 0/unset uses the built-in default of 256 MiB; a negative value
disables the byte bound (records-only). Each frame costs 4 header bytes plus the
payload, so the footprint of a full record-capped buffer is roughly
max_records × (4 + avg_payload_bytes). Enqueue fails closed (sheds the newest
result, counted as dropped) when either the record or byte cap would be
exceeded, and the agent logs a WARN when the buffer crosses 90% of either bound.
Reconnect drains are also chunked (buffer.drain_max_records, default 500;
buffer.drain_max_bytes, default 8388608; buffer.drain_pace, default
150ms). In plain English: after an outage, the agent does not pick up a full
10,000-record / 256 MiB queue and shove it through one long stream. It lifts a
bounded FIFO prefix, streams that prefix, removes only the accepted prefix, logs
backlog records/bytes plus oldest result age, then jitters the next catch-up
chunk so a fleet does not stampede the control plane in lockstep.
Scheduled canary identity and cadence evidence
Every entry under canaries: accepts these common fields:
| Field | Required | Meaning |
|---|---|---|
type |
yes | Compiled-in probe family such as icmp, dns, or http |
target |
by probe | Address, name, or URL the probe measures |
interval |
no | Local execution interval; defaults to 30s |
timeout |
no | Per-execution timeout |
params |
no | Probe-specific string parameters |
test_id |
no | Stable server definition ID copied from probectl test list; 1–128 characters from letters, digits, ., _, :, and -, unique inside this agent file |
test_id is evidence attribution, not remote control. When present, the agent
stamps probectl.test.id and its effective
probectl.test.interval_seconds onto every result after the probe returns. The
control plane can then compare exact observed rounds with the server definition
on Targets → Owned-vantage coverage. Duplicate or malformed IDs fail agent
startup; an absent ID remains valid for older/local-only configurations but its
cadence receipt stays unknown rather than guessing from a shared type/target.
The control plane does not read, push, rebalance, or verify the current local
YAML. Even after an exact result, the receipt therefore says current assignment
is unverified. Server-driven config remains deliberately unimplemented under
adr/config-push.md.
Result pipeline
This is the path every measurement takes from an agent to a queryable metric, and
two env vars decide how heavy that pipeline is: PROBECTL_BUS_MODE (the message
bus — the queue that decouples ingest from storage, so a slow consumer never
stalls an agent) and PROBECTL_TSDB_MODE (the time-series writer; a TSDB is a
database specialized for timestamped measurements and the range queries
dashboards make over them). The memory defaults make a single binary work with
zero external dependencies; switch them to kafka / prometheus when you
outgrow that.
A streamed result flows agent → gRPC StreamResults → control-plane ingest →
result bus (probectl.network.results, Protobuf) → consumer → time-series writer.
The agent sends the canonical OTel-aligned result (proto/probectl/result/v1); the
control plane re-stamps the tenant and agent id from the verified mTLS
certificate before publishing, so a result is always attributed to the sending
agent's tenant regardless of payload contents — the tenant boundary is
cryptographic, never self-asserted. The bus key is the tenant_id.
PROBECTL_BUS_MODE selects the bus, and there are three of them. Two of
them used to be described by one word, "lightweight", which is how a deployment
could end up on a transport that loses everything it holds when the process
exits (DPR-119). They are named separately now:
| Mode | What it is | Survives a restart? | Use it for |
|---|---|---|---|
memory |
volatile lightweight — in-process pub/sub, no broker | No. Everything in flight is gone | development, tests, single-binary runs |
nats |
durable lightweight — one NATS server with JetStream | Yes — records are on disk and each consumer's position lives on the server | small production deployments that do not want Kafka |
kafka |
the default at scale | Yes | everything larger |
In memory mode, Flush waits for current subscriber handlers to finish before
the agent stream is ACKed. That makes the volatile path at-least-once with
respect to the in-process store/DLQ handlers, but it is still not a broker log:
a crash loses what it held, which is why it is not a production transport.
The memory bus is also a live, in-process-only pub/sub: it delivers only to
a consumer running in the same process and keeps no backlog. The standalone
collector binaries — probectl-flow-agent, probectl-device-agent,
probectl-ebpf-agent, probectl-endpoint and probectl-bmp-listener — have no
such consumer, so a collector left on memory mode (the shipped default) would
discard every batch it publishes. Each therefore logs a loud WARN at boot
when its PROBECTL_*_BUS_MODE is memory/unset, and any batch dropped for want
of a subscriber is counted at probectl_agent_bus_no_subscriber_total (and, on
the control plane's own in-process bus, probectl_bus_memory_no_subscriber)
rather than reported as a silent publish success (ING-19). Memory mode stays
valid for the embedded single-process/test case; point a standalone collector at
a durable nats/kafka bus (with PROBECTL_*_BUS_BROKERS).
In nats mode each topic is a JetStream stream of its own (so a siloed tenant's
lane stays its own lane), each consumer group is a durable consumer whose
position survives restarts and rolling upgrades, a message is acknowledged only
after its handler returns nil, and Flush waits for the server's
acknowledgment — the same delivery contract Kafka carries. Set
PROBECTL_BUS_BROKERS to one or more nats:///tls:// URLs. The transport
policy is identical to Kafka's: TLS and credentials are required, and a bus
without TLS is refused unless the explicit dev-only
PROBECTL_BUS_ALLOW_PLAINTEXT=true flag is set. "Lightweight" never meant
"plaintext".
PROBECTL_TSDB_MODE selects the writer: memory (default; in-process) or
prometheus remote-write to PROBECTL_TSDB_URL (Prometheus with
--web.enable-remote-write-receiver, or VictoriaMetrics; use an https:// URL
for TLS in transit). Each probe emits probectl_probe_success,
probectl_probe_duration_seconds, and one probectl_probe_<metric> per custom
metric, labeled tenant_id, agent_id, canary_type, server_address and,
when the canary carries a server definition id, test_id (DPR-066: the
address is the host, so two definitions against one host need the id to stay
separate series). The
canonical signal→OTel mapping is in otel-mapping.md.
Volatile stores lose all telemetry on restart (PLAT-01/RTO-04)
The memory modes above — the shipped defaults for the bus, the TSDB and every
telemetry store (PROBECTL_PATHSTORE_MODE, PROBECTL_FLOWSTORE_MODE,
PROBECTL_OTELSTORE_MODE, PROBECTL_EBPFSTORE_MODE,
PROBECTL_ENDPOINTSTORE_MODE) — keep their data in process memory only. Every
synthetic result, metric series, path, flow, OTLP trace/log, eBPF edge and
endpoint event is erased on any restart, upgrade, OOM or node drain, and
alerts then evaluate only data since the last restart. The default single-binary
install runs this way so a cold start needs zero external dependencies; it is a
development/test posture, not a durable one.
The control plane never runs volatile silently:
- it logs a
WARNat boot naming every memory-backed plane; /readyzlists them undervolatile_storesfor an authenticated caller (the node stays ready — the request path is up; an anonymous probe getsstatusonly, AUTHZ-25);/v1/diagnosticsreports degraded with the named findingreadiness.volatile_stores;probectl-control preflight --strictexits non-zero.
For any deployment whose history must survive, configure durable modes: a nats
(JetStream) or kafka bus, prometheus TSDB, and ClickHouse-backed stores.
Production-like profiles (PROBECTL_DEPLOYMENT_PROFILE other than single)
refuse memory modes outright. To run volatile deliberately on a dev/test
single-binary deployment and mark the data-loss behavior as understood — which
downgrades the diagnostics finding and lets preflight --strict pass — set:
| Variable | Default | Meaning |
|---|---|---|
PROBECTL_ALLOW_VOLATILE |
(none) | Set to i-understand-data-is-not-durable to acknowledge that this deployment keeps telemetry in volatile memory and loses it on restart. Acknowledgment never makes storage durable and the boot WARN still fires; it only records that the tradeoff was a deliberate dev/test choice. Ignored by production-like profiles, which refuse memory modes. |
ICMP test
The icmp canary measures echo loss, latency, and jitter to a target
(IPv4 or IPv6). Configure it per-canary under canaries: (see
probectl-agent.example.yml). The
schedule interval and reply timeout are canary fields; the rest are params:
| Param | Default | Meaning |
|---|---|---|
count |
5 |
echo requests per probe (continuous mode defaults to the interval in s) |
payload_bytes |
56 |
ICMP data bytes (minimum 8) |
dscp |
0 |
DSCP marking 0–63 on outgoing packets (best-effort by platform) |
mode |
batch |
batch (back-to-back) or continuous (1 packet/sec) |
privileged |
false |
true prefers raw sockets; default is unprivileged datagram ICMP |
dscp (here and on every canary that takes it) sets the Differentiated Services
Code Point — the priority field in the IP header that asks routers to treat a
packet as a given traffic class — so you can measure the same path your real
traffic class rides, e.g. probing with the voice marking to see what voice sees.
It emits probectl_probe_loss_ratio, probectl_probe_rtt_{min,avg,max,stddev}_ms,
probectl_probe_jitter_ms, and probectl_probe_packets_{sent,received}. A probe with
100% loss reports success=false (target unreachable); partial loss is a success
with a non-zero loss ratio. Continuous mode records a per-second drop-timing
record as result attributes (icmp.dropped_seqs, icmp.drop_send_offsets_ms) —
carried as OTel attributes, not TSDB labels, so they don't widen cardinality.
Privileges. By default the agent uses unprivileged datagram ICMP
(IPPROTO_ICMP), which on Linux requires the agent's group to be within
net.ipv4.ping_group_range (e.g. sysctl -w net.ipv4.ping_group_range="0 2147483647"). Alternatively grant raw-socket capability
(setcap cap_net_raw+ep /usr/bin/probectl-agent, or run with CAP_NET_RAW) and set
privileged: "true". The canary tries the preferred socket and falls back to the
other; if neither can be opened it returns an internal error (the probe is not
silently reported as loss).
TCP & UDP tests
The tcp and udp canaries are agent-to-server probes. Configure a target of
host:port (or a host with params.port). Both accept count and dscp.
The tcp canary measures connect latency + reachability (a connect-based,
unprivileged equivalent of a TCP-SYN test): it establishes a connection and times
the handshake, emitting probectl_probe_connect_{min,avg,max,stddev}_ms,
probectl_probe_jitter_ms, and probectl_probe_loss_ratio (failed connects = loss;
all-failed = success=false).
The udp canary is an echo round-trip probe: it sends token-tagged
datagrams and matches the echoes, emitting probectl_probe_rtt_* + loss. It needs a
target that echoes (a UDP echo service, or a probectl agent-to-agent responder); a
non-echoing target reports as 100% loss. params.payload_bytes (≥10) sets the
datagram size.
Voice/RTP tests
The voice canary streams real RTP packets (RTP is the protocol that carries
live audio in VoIP calls) at codec cadence to an echoing target and scores the
path: MOS + R-factor (simplified ITU-T G.107 E-model), RFC 3550 jitter, loss,
and a one-way delay estimate. MOS is the 1–5 "how did the call sound" scale;
the R-factor is the 0–100 engineering score it is derived from — both computed
from measured loss/delay/jitter by the E-model, not from a human listener.
target is host:port. Parameters: codec (g711 default, g729),
duration_seconds (1–10, default 3), dscp (default 46/EF). The model
variant and the one-way-estimate method ride the result attributes — a
computed MOS is never presented as a measured listening score. See
docs/voice.md.
DNS tests
The dns canary queries DNS and reports resolution time, the answer, and an
optional DNSSEC verdict. The target is the query name. Parameters:
| Param | Values | Default | Meaning |
|---|---|---|---|
type |
A, AAAA, MX, TXT, NS, … |
A |
record type to query |
transport |
udp | tcp | dot | doh |
udp |
how the query is sent |
server |
host[:port] or a DoH URL |
per-transport | resolver to query. Omitted = the host's default resolver from /etc/resolv.conf (1.1.1.1:53 when none is configured). A resolver you name here is request input and is SSRF-guarded like any target (a loopback or private resolver needs allow_private_targets); the host's default resolver is operator infrastructure and is deliberately exempt, so Docker's 127.0.0.11 and systemd-resolved's 127.0.0.53 stubs work out of the box |
mode |
resolver | trace |
resolver |
single query vs. delegation walk |
dnssec |
true | false |
false |
validate the zone signature |
trust_anchor |
one or more DS records (presentation format, ;-separated) |
baked IANA root anchors | DNSSEC chain-of-trust entry point(s). Pin your own (e.g. a sovereign/air-gapped internal root) in place of the baked IANA root DS. A keyset that does not chain to a configured anchor is never reported secure |
root_hints |
one or more root-server addresses host[:port] (,-separated; port defaults to 53) |
baked IANA roots | trace mode only. The root servers the delegation walk starts from, in place of the baked IANA roots — point it at your own root for an isolated/air-gapped network or a private root. Each hint is SSRF-guarded at dial time like every trace hop, so a loopback/private root also needs allow_private_targets |
server defaults by transport: the first nameserver in /etc/resolv.conf (or
1.1.1.1:53) for udp/tcp, 1.1.1.1:853 for DoT (DNS over TLS — the
classic query inside an encrypted TLS session), and
https://cloudflare-dns.com/dns-query for DoH (DNS over HTTPS — the query
as an HTTPS request). DoT verifies the resolver's TLS certificate (TLS 1.2+);
DoH posts an RFC 8484 application/dns-message query over HTTPS.
In resolver mode the canary emits probectl_probe_dns_query_ms (round-trip) and
probectl_probe_dns_answers (answer count), with dns.rcode and a compact
dns.answer summary as attributes. The probe is success=false on a non-NOERROR
rcode or an empty answer.
DNSSEC adds cryptographic signatures to DNS, so an answer can be proven to
come from the zone's owner, untampered. With dnssec: "true" the canary
requests DNSSEC records (the DO bit) and validates the zone's RRSIG over the
answer against the zone DNSKEY, then chains that DNSKEY to a trust anchor
through a DS — it does not trust the resolver's AD bit (the resolver's own
"I checked" flag, which a misbehaving resolver could simply set), and it never
trusts a DNSKEY carried in the response's own Answer/Additional section
(attacker-controlled: a forged answer would ship a matching self-made key beside
it). The DNSKEY is fetched with a separate query to the signer zone. The
verdict lands in the dns.dnssec attribute:
secure— every answer RRset carries a valid, in-window signature and the signingDNSKEYRRset chains to a configured trust anchor: a key in the set is pinned by an anchorDSand self-signs the keyset (RFC 4035). The anchor is the baked IANA rootDSby default, or an operator-pinnedDS(trust_anchor).indeterminate— every answer RRset carries a valid, in-window signature from the zone'sDNSKEY, but that keyset does not chain to a trust anchor. This is an honest "signed, but not provably authentic" signal: it does not claimsecure, because a forged answer signed by an attacker's own key (served by a malicious resolver) would verify identically. The probe still succeeds.insecure— the zone is unsigned (noRRSIG).bogus—RRSIGs are present but some answer RRset does not verify (tampered, expired, or wrong/absent key). A bogus result fails the probe.
probectl_probe_dns_dnssec_secure (1/0) is 1 only for secure; indeterminate
reports 0. Every answer RRset must verify — a single valid RRSIG over one RRset
does not vouch for the rest of the answer.
In trace mode the canary performs an iterative delegation walk from the
root hints, following NS/glue referrals down to the authoritative server (UDP,
capped iterations, with a recursive fallback when a referral ships no glue). The
walk starts from the baked IANA roots unless root_hints names your own — point
it at a private or internal root so the trace runs on an isolated or air-gapped
network with no route to the public roots. It emits
probectl_probe_dns_query_ms (total walk time) and
probectl_probe_dns_trace_hops, with the delegation chain in the dns.trace
attribute. DNS-exfiltration detection and open-data baselines are out of scope for
this probe (they live in the NDR and open-data features).
HTTP server tests
The http canary measures HTTP(S) availability with a full response-time
breakdown and captures TLS handshake details for the TLS-posture plane (see
TLS / certificate observability below). The target is the URL. Parameters:
| Param | Values | Default | Meaning |
|---|---|---|---|
method |
GET, HEAD, POST, … |
GET |
request method |
expect_status |
codes / classes / ranges | 2xx,3xx |
which statuses count as available |
follow_redirects |
true | false |
true |
follow 3xx redirects |
insecure_skip_verify |
false only |
false |
legacy compatibility key. true is always rejected: outbound certificate verification cannot be disabled. Use ca_file for a private trust anchor |
ca_file |
path to a PEM bundle | — | extra trust anchor (private/internal CA); must live under PROBECTL_AGENT_CANARY_CA_DIR |
body |
string | — | request body (e.g. for POST) |
max_body_bytes |
integer | 10485760 |
cap bytes read per probe (10 MiB) |
allow_private_targets |
true | false |
false |
SSRF-guard override. Every canary (http/tcp/udp/icmp/dns/voice/browser) denies loopback, RFC1918/ULA, link-local (incl. 169.254.169.254 cloud metadata), CGNAT, multicast and numeric-encoding bypasses by default, enforcing the check on the resolved address at dial time (rebind-proof). Setting true lifts the guard for that one test — requires the admin-only test.allow_private permission, remains subject to tenant ABAC deny policies, and is written to the tenant audit trail |
A word on the last row: SSRF (server-side request forgery) is the attack where someone defines a "test" that makes your agent fetch an internal-only address — the cloud metadata service, a loopback admin port — and report back what it found. That is why every canary ships the private-target deny-list, why it checks the resolved IP at dial time (a DNS name that later re-resolves to something internal is caught too), and why lifting it is a per-test, admin-only, audited act rather than a global switch.
HTTP canaries ignore ambient HTTP_PROXY, HTTPS_PROXY, and NO_PROXY
environment settings. A proxy is another network target that could resolve or
fetch private space on the agent's behalf, so the guarded HTTP transport goes
direct unless probectl later adds an explicit tenant-scoped proxy setting that is
checked by the same SSRF guard.
expect_status is a comma list of exact codes (200), classes (2xx), and
inclusive ranges (200-204); a response outside the set is success=false (the
status is still reported). The probe emits the timing breakdown as metrics —
probectl_probe_http_dns_ms (resolution), probectl_probe_http_connect_ms (TCP
connect), probectl_probe_http_tls_ms (TLS handshake), probectl_probe_http_ttfb_ms
(time to first byte), and probectl_probe_http_total_ms — plus
probectl_probe_http_status, probectl_probe_http_content_bytes, and
probectl_probe_http_throughput_kbps. A phase that does not occur (no DNS for an IP
target, no TLS for http://) is omitted rather than reported as zero. The resolved
server IP is captured as the network.peer.address attribute, which correlates
the result to path/traceroute data for the same destination.
TLS capture. On HTTPS the canary records the negotiated
tls.protocol.version and tls.cipher, the leaf certificate's
tls.server.{subject,issuer,not_before,not_after,san}, the chain shape
(tls.server.chain), and a probectl_probe_http_tls_cert_expiry_days metric
(negative once expired). It verifies the chain itself (hostname + trust, honoring
ca_file) after capturing the certificate, so the handshake details are
recorded even when the certificate is invalid or expired — an invalid cert
fails the probe but its details are still attached. This means operators do not
need an insecure mode to observe certificate failures. The historical
insecure_skip_verify=true probe parameter is always rejected, including for
callers holding the legacy test.insecure_tls permission; likewise,
security.allow_insecure_skip_verify: true is rejected when an agent
configuration loads. Use ca_file when the endpoint is valid under a private
CA. probectl performs no TLS posture analysis here (issuer trust,
weak-cipher/expiry policy, CT) — that is the TLS / certificate observability
feature below, which consumes these captured fields.
Browser / transaction tests
The browser canary has two deliberately different meanings. An ordinary
probectl-agent defaults to browser.driver: http: the script runs as real HTTP
requests with a cookie jar, status/text assertions, per-step timings, and a
request waterfall, but no DOM is rendered. The probectl-browser-agent image
sets browser.driver: browser and executes the packaged Playwright worker for
DOM/paint timings and visual screenshots. A test states which semantics it
requires; an agent configured for the other driver rejects it, so a rendered
test can never quietly become a cheaper HTTP check.
The target is the default start_url. Parameters:
| Param | Values | Default | Meaning |
|---|---|---|---|
browser_driver |
http | browser |
http |
required execution semantics: non-rendering HTTP transaction or rendered Playwright browser |
script |
JSON browser.Script |
generated | transaction script. If omitted, the agent runs goto target then assert_status 200 |
allow_private_targets |
true | false |
false |
the same audited SSRF-guard override as HTTP. It covers start_url, every step url, and every resolved dial address |
The matching agent YAML is:
# ordinary probectl-agent
browser:
driver: http
# dedicated probectl-browser-agent image
browser:
driver: browser
worker:
command: node
path: /worker/worker.mjs
step_timeout: 15s
Rendered mode is fail-closed: configuration loading verifies the command and
worker file before the agent connects. The worker opens no listener; the Go
agent sends one script over the child process's stdin and reads one result from
stdout. Its navigation, redirects, and page subresources pass through the same
resolved-address private-target policy as the HTTP driver. The browser image is
shipped in releases and air-gap bundles, with an opt-in Compose profile and an
opt-in Helm DaemonSet described in browser-synthetic.md.
The spawned worker receives two internal protocol variables. Operators do not set these on the agent process: the Go driver derives and overwrites them for each child from the validated YAML/test definition above. They are listed because this page inventories every startup variable, including private agent-to-worker contracts:
| Variable | Internal default | Source of truth |
|---|---|---|
PROBECTL_BROWSER_STEP_TIMEOUT_MS |
15000 |
browser.worker.step_timeout, converted to milliseconds by the Go driver |
PROBECTL_BROWSER_ALLOW_PRIVATE_TARGETS |
false |
the current test's audited allow_private_targets parameter |
PROBECTL_BROWSER_WORKER_SOCKET |
(none) | set on the browser-worker PROCESS to run it as a sidecar socket server (SUP-02/D-36): it listens on this UNIX socket and the agent dials it once per transaction, instead of the default stdin/stdout one-shot. The per-transaction step timeout and allow-private flag arrive in-band on each request |
Example CLI creation:
probectl test create \
--name login-browser \
--type browser \
--target https://app.example/login \
--interval 60 \
--param browser_driver=browser \
--param 'script={"name":"login","start_url":"https://app.example/login","steps":[{"action":"goto"},{"action":"assert_status","status":200}]}'
Browser results emit probectl_probe_transaction_total_ms,
probectl_probe_transaction_steps, probectl_probe_transaction_resources,
probectl_probe_transaction_failed_steps, and one
probectl_probe_transaction_step_<n>_duration_ms metric per step. Step names,
actions, success flags, and failure details ride as attributes
(browser.step.<n>.*) so they are visible in /v1/results/latest and the web
result modal without becoming high-cardinality TSDB labels. See
browser-synthetic.md.
Agent-to-agent tests
An agent-to-agent (A2A) test measures between two registered agents, brokered
by the control plane. The control plane assigns roles (one agent responds,
opening a short-lived listener; the other initiates), rendezvouses the
responder's endpoint to the initiator, and hands each agent its task when it
polls (PollCoordination / ReportEndpoint). The measurement is TWAMP-lite —
TWAMP is the standard two-way active measurement protocol; "lite" here means its
four-timestamp echo idea without the full session protocol: the
initiator timestamps each probe (T1), the responder stamps receive/send (T2/T3)
and echoes, and the initiator stamps receive (T4), yielding round-trip
(probectl_probe_rtt_*) plus forward and reverse one-way delay
(probectl_probe_forward_avg_ms, probectl_probe_reverse_avg_ms). The responder also
reports forward-direction delivery (probectl_probe_packets_received,
probectl_probe_loss_ratio), so both agents and both directions are observed.
Enable participation in the agent's a2a block: enabled: true,
advertise_host (the address peers use to reach this agent's responder),
poll_interval (default 2s), responder_ttl (default 15s), and
allow_private_targets (default false; when false the initiator refuses a
responder endpoint on a private/loopback/metadata address so a compromised peer
cannot aim it at internal hosts — set true only when agents legitimately
measure each other across a private network).
The default is enabled: false: an agent opens no A2A UDP/TCP listener unless
the operator explicitly opts it into this deployment policy. For each brokered
session, the control plane gives both agents a random session id over the
existing agent mTLS channel; A2A probe and reply frames are HMAC-SHA256 signed
through internal/crypto with that per-session key. A responder ignores short,
unsigned, tampered, or wrong-session frames and only counts authenticated probes.
a2a.enabled is permission to participate, not a schedule. Do not add
type: a2a under canaries:: A2A is not registered in the single-agent
interval scheduler. Start a pair or mesh through the tenant-scoped A2A API/CLI;
the control-plane broker then sequences responder → endpoint report → initiator.
See the A2A coordination ADR for why this is a
separate execution path.
Caveats (document for production):
- NAT/firewall. The responder advertises
advertise_host; behind NAT this must be a reachable address and the responder's ephemeral port must be reachable from the initiator. Auto-detection picks a non-loopback IPv4 — setadvertise_hostexplicitly when that is wrong. - Clocks. Forward/reverse one-way delays assume the two agents' clocks are synchronized (exact within one host; use NTP across hosts). Round-trip is clock-independent.
Pair sessions and full site meshes are brokered in-memory and started through
the tenant/RBAC-scoped A2A APIs (POST /v1/a2a/sessions and
POST /v1/a2a/mesh). The mesh API accepts site-labeled agents, binds tenant
identity from the authenticated caller, schedules every directed site pair, and
contributes a site-to-site topology overlay.
Path discovery
The path engine (internal/path) is the traceroute brain — it runs Paris-style
traceroutes (ICMP and TCP) and merges per-flow traces into one multi-path
picture; see architecture.md. "Paris-style" matters because
of ECMP — routers spreading traffic across several equal-cost paths by
hashing packet header fields. A naive traceroute varies those very fields probe
to probe, so its hops come from different paths stitched into a nonsense
route; Paris holds the hashed fields constant so each trace coherently follows
one path, and varies them deliberately to enumerate the others. It also
captures MPLS labels (the tags carriers use to switch packets through
label-tunnels) where routers expose them. A full per-hop trace needs raw sockets:
grant CAP_NET_RAW (setcap cap_net_raw+ep, or run privileged) to capture the
intermediate hops + MPLS labels. Without it, only the destination is discovered.
CI exercises the full raw-socket path in the path-raw-live lane with a
multi-hop Linux namespace fixture, so this path is not merely documented as a
privileged local option.
Where the discovered hops/links are stored is a control-plane choice:
| Variable | Default | Description |
|---|---|---|
PROBECTL_PATHSTORE_MODE |
memory |
memory (in-process, for the lightweight/single-binary case and tests) | clickhouse (durable hop/link rows) |
PROBECTL_PATHSTORE_URL |
(none) | ClickHouse HTTP(S) endpoint (e.g. http://localhost:8123 for single-profile dev, https://clickhouse.example:8443 for production), partitioned by tenant; required when mode is clickhouse. multi-tenant/regulated profiles require https:// |
PROBECTL_CLICKHOUSE_BASIC_AUTH_FILE |
(none) | optional owner-only (0600), regular non-symlink JSON credential file shared by pooled ClickHouse-backed planes: {"username":"...","password":"..."}. A separate certificate-verifying client pins that credential to each configured store URL's exact origin and path prefix; redirects/routing outside that namespace fail closed, and remote endpoints must use HTTPS. It stays pinned to the pooled store origins; routed silo/residency origins get their own credentials through PROBECTL_DATAPLANE_BASIC_AUTH_FILES (DPR-044) |
PROBECTL_DATAPLANE_BASIC_AUTH_FILES |
(none) | one owner-only (0600) {"username":"...","password":"..."} credential file per named data plane — name=/abs/file[;name=/abs/file...], names from PROBECTL_DATAPLANES. Each file is pinned to exactly that plane's ClickHouse origin by the same certificate-verifying, origin-bound client the pooled stores use, so a siloed or residency-pinned tenant's database is reached with its plane's credential and never with the pooled one. Every plane must have one (fail closed at startup) unless it is a plaintext loopback development target; a plane that shares the pooled ClickHouse origin must name the same file as PROBECTL_CLICKHOUSE_BASIC_AUTH_FILE, which is required whenever this is set. Helm: stage the files through control.credentialFiles (DPR-044) |
PROBECTL_PATH_RETENTION_DAYS |
90 |
delete-after-N-days TTL on the path/traceroute ClickHouse tables (applied at boot); 0 disables the TTL |
Each stored discovery is an immutable history round. The Path UI reads a
maximum of 50 rounds by default (the API hard ceiling is 100) and can request up
to two exact opaque round_id values for a stable comparison link. Those IDs
never select a tenant: the authenticated session's tenant_id and the selected
test's target are mandatory storage predicates, so a copied cross-tenant or
cross-target ID returns an empty history.
Each new round stores a closed measurement_fidelity receipt with the actual
socket acquisition mode and hop-visibility capability. Today its
timing_source is application_monotonic, with kernel and hardware
timestamping explicitly false. A raw ICMP path reports full-hop acquisition;
an unprivileged datagram fallback reports destination-only. The same receipt is
returned by latest/history API calls and generic CLI JSON:
probectl test path <test-id>
probectl test path-history <test-id>
Rounds written before this receipt existed omit it. The native Path surface
labels those rounds Unknown · legacy snapshot and does not infer precision.
No external service, packet-capture process, or hardware database participates
in the receipt.
BGP routing intelligence
BGP is the protocol networks use to tell each other which IP blocks (prefixes)
they can reach — the internet's routing gossip — so a wrong announcement
elsewhere can silently hijack or blackhole your traffic without touching your
equipment. The BGP plane watches for exactly that in two ways: a Python analyzer
(analyzer/) plus a Go bridge (internal/bgp) for public collectors, and a
direct BMP listener for routers that can stream their own BGP view; see
architecture.md. The analyzer ingests public collector
data — MRT files (the archive format for routing-table dumps, e.g.
RouteViews/RIPE RIS) and RIS Live (RIPE's websocket firehose of route updates as
they happen) — and emits probectl.bgp.events:
python -m probectl_analyzer --config config.json --mrt rib.mrt # RouteViews/RIS dump
python -m probectl_analyzer --config config.json --replay cap.jsonl # recorded RIS Live
python -m probectl_analyzer --config config.json --ris-live # live RIS Live websocket
The JSON config is per tenant (tenant_id is required — every event carries
it, and the bridge rejects any event without one):
| Key | Meaning |
|---|---|
tenant_id |
the owning tenant (outermost scope) |
monitored_prefixes[].prefix |
a prefix to watch (a more-specific announcement is matched too) |
monitored_prefixes[].expected_origins |
allowed origin ASNs — an origin outside this set raises possible_hijack |
monitored_prefixes[].no_transit |
ASNs that must not transit this prefix — mid-path appearance raises possible_leak |
collector |
collector label recorded on events (e.g. rrc00) |
rpki_vrp_file / rpki_vrp_url |
a rpki-client/Routinator VRP JSON export for RFC 6811 validation (absent → unknown) |
RPKI is the cryptographic registry recording which network is allowed to
originate which prefix; a validator (rpki-client, Routinator) distills its
signed objects into VRPs — validated prefix→origin records — and RFC 6811
validation compares each seen announcement against them, labeling it valid,
invalid, or not_found. With no VRP source configured, everything is
honestly unknown.
The shipped probectl-control bgp-analyzer sidecar starts that Python command,
tails its JSON Lines, checks every payload tenant against the trusted
tenant_id in the mounted config, and republishes each as the canonical
probectl.bgp.v1.BGPEvent protobuf onto the bus (topic probectl.bgp.events, keyed by
tenant). Event types: origin_change (old/new origin + AS path), possible_hijack,
possible_leak, rpki_invalid; each carries an RPKI status (valid / invalid /
not_found / unknown), a severity, and a confidence — they are signals, not
actions — probectl never acts on routing. MRT dumps are stream-processed (no
full RIB in memory); a down RPKI/collector source degrades gracefully rather
than breaking the plane.
RouteViews/RIS are open data — their AUP/provenance matters for MSP/commercial
resale, not for private development or single-tenant OSS use.
The sidecar is off by default and requires Kafka because a separate process cannot share the control plane's in-memory bus. It has no listener. Live mode restarts after crashes with exponential backoff capped at 30 seconds; finite MRT/replay jobs exit after one successful run. The child Python process does not receive Kafka/DB credentials — the Go bridge alone owns the bus connection.
| Variable | Default | Description |
|---|---|---|
PROBECTL_BGP_ANALYZER_CONFIG |
(none) | analyzer JSON path (maximum 1 MiB); required; its tenant_id becomes the trusted output binding |
PROBECTL_BGP_ANALYZER_SOURCE |
(none) | required: ris-live | mrt | replay; the stock image supports all three (its hash-locked runtime pins the websockets package live mode needs); a source install outside the image adds it with the analyzer's rislive extra |
PROBECTL_BGP_ANALYZER_SOURCE_FILE |
(none) | required input path for mrt and replay |
PROBECTL_BGP_ANALYZER_BUS_NAMESPACE |
(none) | publish on the config tenant's own lane, probectl.<ns>.bgp.events (t-<slug>, the value collector registration prints for every plane). Required wherever PROBECTL_INGEST_STRICT_TENANT_LANES is on — the multi-tenant and regulated profiles refuse BGP events on the shared lane; a malformed value refuses start. Empty = route through the isolation router (the shared lane for a pooled tenant), which suits only the single profile |
PROBECTL_BGP_ANALYZER_RESTART |
true for live; false for files |
restart the subprocess after it exits; backoff is always bounded |
PROBECTL_BGP_ANALYZER_PYTHON |
python3 |
Python executable path |
PROBECTL_BGP_ANALYZER_MODULE |
probectl_analyzer |
Python module passed to python -m |
PROBECTL_BGP_ANALYZER_WORKDIR |
(none; image uses /opt/probectl/analyzer) |
child working directory |
PROBECTL_BUS_* |
see result-bus table | must select Kafka; the same TLS/mTLS/SASL fail-closed policy applies |
Helm renders this with no Service and an explicit egress NetworkPolicy: a
one-replica Deployment for ris-live, and — because an MRT dump or a recorded
replay is a finite artifact that must not be re-processed on every container
restart — a one-shot Job per analyzer configuration for mrt/replay — its name
carries a digest of the image, source, config and bus settings, so an unrelated
upgrade does not re-run the artifact and a new one gets a new Job
(bgpAnalyzer.job.*, DPR-059). Compose's bgp-analyzer profile replays a
recorded RIS fixture end to end for evaluation.
For operators who run routers that export BMP (BGP Monitoring Protocol), run
probectl-bmp-listener next to the routing fabric. It serves mTLS only:
every router/collector peer presents a SPIFFE-style client certificate
(spiffe://probectl/tenant/<tenant>/bmp/<router-id>) issued through the existing
collector/agent registry. The listener derives the tenant from that verified
certificate and checks its exact tenant/router/SPIFFE/serial tuple against the
local registry instead of trusting any BMP payload field. A CA-valid
self-issued or agent-plane certificate is refused. Route-monitoring updates are decoded, the peer is recorded in an
in-process tenant-scoped peer inventory, and each observed prefix is published to
the same probectl.bgp.events topic as the public-collector analyzer.
The setup on-ramp is the same tenant-bound collector registration used by the
other bus publishers: Admin & Settings > Agents > Register collector > BGP
or probectl bgp setup --body '{"token":"pjt_...","plane":"bgp","hostname":"rrc00"}'.
The response includes source_type: bmp, PROBECTL_BMP_COLLECTOR, and the
startup command probectl-bmp-listener.
Each router is registered through the same /v1/collectors/register endpoint
with plane=bmp and a router-owned csr_pem. The returned SVID is stored in the
same identity tables and uses the existing rotation and agent-revocation API.
This is entirely operator-local: no external identity service or phone-home
path is introduced.
| Variable | Default | Description |
|---|---|---|
PROBECTL_BMP_LISTEN_ADDR |
(none) | BMP TCP listen address, for example :1179; required |
PROBECTL_BMP_AGENT_ID |
(none) | this listener's registered collector id (register-collector -plane bmp output). With PROBECTL_BMP_TENANT_ID the listener heartbeats its own fleet entry once a minute through its registry connection, so the fleet view says online/offline about it instead of freezing at registration time (DPR-084). Empty = no heartbeat |
PROBECTL_BMP_TENANT_ID |
(none) | the tenant that registered the listener as a collector; required together with PROBECTL_BMP_AGENT_ID. The registry login must be able to update agents.last_seen_at under that tenant's row-level security (the probectl_app membership the registry role already needs) |
PROBECTL_BMP_TLS_CERT_FILE |
(none) | server certificate PEM; required |
PROBECTL_BMP_TLS_KEY_FILE |
(none) | server private key PEM; required |
PROBECTL_BMP_TLS_CA_FILE |
(none) | CA bundle that signs router/client certificates; required |
PROBECTL_BMP_DATABASE_URL |
(none) | local probectl PostgreSQL URL used only to verify registry-issued BMP identities; required and must use sslmode=verify-full. Startup rejects plaintext and server-unverified modes before opening the identity registry |
PROBECTL_BMP_REVOCATION_DATABASE_URL |
(none) | separate local PostgreSQL login URL for authoritative revocation snapshots; required and must use sslmode=verify-full. Grant that login only the probectl_bmp_revocation_reader NOLOGIN role |
PROBECTL_BMP_REVOCATION_REFRESH |
30s |
bounded interval between complete revocation snapshots; must be positive |
PROBECTL_BMP_REVOCATION_TIMEOUT |
5s |
deadline for initial and periodic revocation snapshot reads; must be positive |
PROBECTL_BMP_COLLECTOR |
bmp |
collector label written on published BGP events |
PROBECTL_BMP_BUS_NAMESPACE |
(none) | bind the listener to PROBECTL_BMP_TENANT_ID's lane: every event is published on probectl.<ns>.bgp.events, and a router registered to any other tenant is refused after authentication. Printed by plane=bgp collector registration; requires PROBECTL_BMP_TENANT_ID; a malformed value refuses to serve. Required wherever PROBECTL_INGEST_STRICT_TENANT_LANES is on (multi-tenant / regulated), since those profiles refuse BGP events on the shared lane — run one listener per tenant there. Empty = one listener for every tenant's routers on the shared lane (single profile) |
PROBECTL_BMP_HANDSHAKE_TIMEOUT |
10s |
maximum time for an unauthenticated mTLS handshake; must be positive |
PROBECTL_BMP_READ_TIMEOUT |
2m |
maximum for a BMP frame in progress: once its first byte arrived, the rest of the header and the payload must complete within this; must be positive. Idle time between frames is governed by PROBECTL_BMP_IDLE_TIMEOUT (DPR-060) |
PROBECTL_BMP_IDLE_TIMEOUT |
0 |
how long an authenticated router session may wait for its next frame; 0 = unbounded (BGP tables are quiet most of the time; TCP keepalive every 30 s detects a dead peer). The earlier behavior — dropping a quiet session after the read timeout, which made the router reconnect and re-dump its table — is PROBECTL_BMP_IDLE_TIMEOUT=2m (DPR-060) |
PROBECTL_BMP_EVENT_SUPPRESSION |
5m |
an unchanged route observation from the same peer of the same router is published at most once per window (a reconnecting router re-sends its whole Adj-RIB-In); 0 publishes every observation. The peer inventory still counts every announcement (DPR-060) |
PROBECTL_BMP_MAX_SESSIONS |
256 |
process-wide concurrent POST-AUTH BMP session limit; excess sockets are refused |
PROBECTL_BMP_MAX_PREAUTH_HANDSHAKES |
64 |
concurrent UNAUTHENTICATED mTLS handshakes; kept smaller than PROBECTL_BMP_MAX_SESSIONS so idle or slow-dripping sockets that never authenticate can occupy at most this many slots (for at most the handshake timeout) and can never consume a post-auth session slot (ING-11); must be positive |
PROBECTL_BMP_MAX_SESSIONS_PER_SOURCE |
32 |
concurrent connections (pre-auth + admitted) allowed from one remote IP, so one noisy source cannot monopolize the pools (ING-11); must be positive |
PROBECTL_BMP_MAX_SESSIONS_PER_IDENTITY |
4 |
concurrent admitted sessions allowed for one registered router identity, so one compromised credential cannot monopolize the post-auth pool (ING-11); must be positive |
PROBECTL_BMP_BUS_MODE |
memory |
memory (volatile, dev only) | nats (durable lightweight) | kafka |
PROBECTL_BMP_BUS_BROKERS |
(none) | comma-separated Kafka brokers (required for kafka mode) |
PROBECTL_BMP_BUS_TLS_ENABLED |
false |
TLS to Kafka brokers; required in kafka mode unless the explicit dev-only plaintext flag is set |
PROBECTL_BMP_BUS_TLS_CA_FILE |
(none) | private CA bundle for Kafka |
PROBECTL_BMP_BUS_TLS_CERT_FILE / PROBECTL_BMP_BUS_TLS_KEY_FILE |
(none) | Kafka client certificate and key |
PROBECTL_BMP_BUS_SASL_MECHANISM / PROBECTL_BMP_BUS_SASL_USER / PROBECTL_BMP_BUS_SASL_PASSWORD |
(none) | Kafka SASL authentication |
PROBECTL_BMP_BUS_ALLOW_PLAINTEXT |
false |
dev-only Kafka plaintext escape hatch |
PROBECTL_BMP_BUS_MAX_BUFFERED |
0 (= built-in bound 65536) |
async Kafka producer in-flight record bound |
PROBECTL_BMP_LOG_LEVEL |
info |
debug | info | warn | error |
PROBECTL_BMP_LOG_FORMAT |
json |
json | text |
For both BMP PostgreSQL URLs, the URL authority is the canonical database host.
A query-string host= override is rejected before either pool opens: PostgreSQL
clients may otherwise replace the checked authority, and Unix-socket overrides
do not carry TLS even when the URL also says sslmode=verify-full.
Example:
PROBECTL_BMP_LISTEN_ADDR=:1179 \
PROBECTL_BMP_TLS_CERT_FILE=/etc/probectl/bmp/tls.crt \
PROBECTL_BMP_TLS_KEY_FILE=/etc/probectl/bmp/tls.key \
PROBECTL_BMP_TLS_CA_FILE=/etc/probectl/agent-ca.crt \
PROBECTL_BMP_DATABASE_URL='postgres://bmp_registry@postgres:5432/probectl?sslmode=verify-full' \
PROBECTL_BMP_REVOCATION_DATABASE_URL='postgres://bmp_revocation@postgres:5432/probectl?sslmode=verify-full' \
PROBECTL_BMP_BUS_MODE=kafka \
PROBECTL_BMP_BUS_BROKERS=kafka-1:9093 \
PROBECTL_BMP_BUS_TLS_ENABLED=true \
probectl-bmp-listener
Create both logins in the operator's own PostgreSQL credential/key domain
(DPR-058). The registry login verifies a router's issued identity through the
same tenant-scoped lookup the control plane uses — the query runs after
SET LOCAL ROLE probectl_app under forced row-level security — so it must be a
member of the migration-created probectl_app role. The revocation login is
granted only the execute-only role:
CREATE ROLE bmp_registry LOGIN NOSUPERUSER NOBYPASSRLS;
GRANT probectl_app TO bmp_registry;
CREATE ROLE bmp_revocation LOGIN NOSUPERUSER NOBYPASSRLS;
GRANT probectl_bmp_revocation_reader TO bmp_revocation;
GRANT CONNECT ON DATABASE probectl TO bmp_registry, bmp_revocation;
Both URLs take the usual libpq TLS parameters, e.g.
?sslmode=verify-full&sslrootcert=/etc/probectl/trust/ca-bundle.crt for a
private PostgreSQL CA.
Set its password or client-certificate authentication outside probectl. The
listener assumes probectl_bmp_revocation_reader, which can execute only
provider_list_revoked_agent_identities() and cannot select
agent_identities or tenant telemetry. Startup fails closed before binding if
the initial complete snapshot is unavailable. Later bounded refresh failures
retain the last valid snapshot; they never replace it with an empty or partial
list. This is an intra-deployment PostgreSQL connection only—no Internet
service, vendor dependency, or phone-home path.
Open-data enrichment
internal/opendata annotates IPs with context from public datasets: the ASN
(the numbered autonomous system — i.e. which network organization an IP belongs
to), geolocation, IXP presence (internet exchange points, the shared
facilities where networks interconnect), and registry allocation data. A bare
address like 203.0.113.7 becomes "AS64500, ExampleNet, DE, allocated 2014" —
the difference between a number and a story. See
architecture.md and the source provenance/AUP matrix in
opendata-aup.md. The framework is a library (the flow and
test pipelines consume it where enrichment is enabled); each source is
pluggable and individually enable-able:
| Source | Kind | Enable with | Notes |
|---|---|---|---|
| Team Cymru | asn |
PROBECTL_FLOW_ENRICH_ASN=true |
IP→ASN/prefix/registry/AS-name via the Cymru IP-to-ASN DNS service (outbound DNS) |
| ASN local file | asn |
PROBECTL_FLOW_ENRICH_ASN_FILE=<path> |
IP→ASN/AS-name/prefix from an operator-supplied MaxMind GeoLite2-ASN CSV (or any compatible network,asn,organization CSV). Local file, no egress — air-gap friendly. Supersedes PROBECTL_FLOW_ENRICH_ASN: when set, the Team Cymru DNS source is not registered, so no outbound DNS is issued |
| MaxMind GeoLite2 | geo |
PROBECTL_FLOW_ENRICH_GEOIP_DB=<path> |
country/city/lat-lon from an operator-supplied .mmdb (not shipped); local file, no egress |
| RIR delegated-stats | allocation |
PROBECTL_FLOW_ENRICH_RIR_DIR=<dir> |
RIR/country/status/date from delegated-extended stats files; parsed once into a sorted index; local files, no egress |
| PeeringDB | ixp |
PROBECTL_FLOW_ENRICH_IXP=true |
IXP/facility presence via the PeeringDB REST API (outbound HTTPS); cached per ASN; requires PROBECTL_FLOW_ENRICH_ASN |
The Enricher runs every enabled source over an IP and merges the results,
caching per IP and degrading gracefully: a disabled / failing / slow /
panicking source is logged, marked degraded or disabled in Enricher.Status(),
and skipped — a partial enrichment is returned and a down dataset never breaks a
core path. A source whose configured local data cannot be loaded (a missing
.mmdb, an empty stats directory) is registered unavailable with the load
error and never invoked, so a broken path is visible in
GET /v1/threat/intel/status instead of silently absent. Sources run in
registration order — local files first (geo, then RIR), the Cymru DNS fallback
after them, PeeringDB last because it keys on the ASN Cymru resolves.
GET /v1/opendata/enrichment?ip=<addr> (CLI: probectl opendata enrich --query ip=<addr>) serves the merged context for one IP with per-source
provenance. Each contribution records Provenance (source + license + attribution
- fields); a source's AUP (license, commercial-use permission, attribution) is on
its
Descriptor— the matrix that gates MSP/commercial resale (not private or single-tenant OSS use). All fetches are over TLS with certificate validation and treated as untrusted — external content never gets implicit trust. Open data is ingested once and shared; enrichment is scoped per tenant by the consuming record.
Alerting
The alerting engine (internal/alert) evaluates rules over the TSDB and notifies
channels; see architecture.md. Rules are CRUD'd via
/v1/alerts (tenant-scoped) and the engine runs in the control plane, ticking
every PROBECTL_ALERT_EVAL_INTERVAL (default 30s).
A rule targets a metric series and is either a threshold or a baseline rule:
| Field | Applies | Meaning |
|---|---|---|
metric + match |
both | the TSDB metric (e.g. probectl_probe_loss_ratio) and label matchers |
type |
both | threshold | baseline |
comparison + threshold |
threshold | gt/lt/gte/lte/eq/neq vs a bound |
window + sensitivity |
baseline | rolling-history size and deviation (in std-devs); warms up until the window fills |
for_n |
both | consecutive breaching evals before firing (debounce) |
renotify_seconds |
both | re-notify cadence while firing (0 = notify once) |
severity |
both | info | warning | critical |
channels |
both | webhook / email destinations |
A channels entry is {"type":"webhook","url":...,"secret":...} or
{"type":"email","recipients":[...]}. The webhook secret is the HMAC key; it
is redacted (***) from API responses and never returned. SMTP for email is
configured at the deployment level (a follow-up exposes it as config). The Alerts
page exposes these rule channels, shows the redacted/signed state, and can send a
tenant-scoped test delivery through POST /v1/alerts/test-channel before a rule
is saved.
Webhook payload (probectl.alert.v1). On fire/resolve the webhook channel POSTs:
{
"version": "probectl.alert.v1",
"state": "firing",
"rule": { "id": "…", "name": "loss-high" },
"tenant_id": "…",
"severity": "critical",
"metric": "probectl_probe_loss_ratio",
"labels": { "server_address": "1.1.1.1" },
"value": 0.9,
"threshold": 0.5,
"comparison": "gt",
"reason": "probectl_probe_loss_ratio=0.9 gt 0.5",
"fired_at": "2026-01-02T15:04:05Z"
}
When the channel has a secret, the request carries
X-Probectl-Signature: sha256=<hex> — the HMAC-SHA256 of the exact body. An
HMAC is a keyed checksum: only a holder of the shared secret can compute it, so
the receiver can verify both who sent this and that nothing was altered
before acting on the alert. Each channel delivers independently: a failing
channel is logged and skipped, never blocking the others. Alerts are signals;
probectl notifies and does not act on the network (on-call/ITSM routing and
detection-as-code are their own features below).
Incidents
The incident correlator (internal/incident) groups related signals across planes
into one Incident with a unified timeline; see architecture.md.
It runs in the control plane, fed by the alert engine (network plane) and a
probectl.bgp.events consumer (BGP plane), and is exposed at /v1/incidents
(tenant-scoped):
GET /v1/incidents— the tenant's incidents, most-recently-active first.GET /v1/incidents/{id}— an incident with its time-ordered signal timeline.PATCH /v1/incidents/{id}with{"status":"resolved"}— resolve an incident.POST /v1/incidents/{id}/shares— create an audited, redacted cited snapshot (also requiresai.query). The default seven-day expiry is capped by the tenant'sobject_retention_dayspolicy.GET /v1/incident-shares/{id}— replay a live snapshot through the current authenticated tenant's RLS scope. Missing, expired, revoked, and foreign IDs deliberately return the same not-found response.
Signals correlate into one incident when they are close in time
(within PROBECTL_INCIDENT_WINDOW, default 10m) and related in target — the
same target, an IP inside the other's prefix (either direction), or overlapping
prefixes (so a network alert on 192.0.2.10 and a BGP event on 192.0.2.0/24
land together). An incident's severity is the max of its signals; a signal
without a tenant is rejected (fail closed).
The model is extensible without schema churn: a Signal carries a free-form
plane/kind and an arbitrary attributes map, so the change, threat, cost, and
SLO planes attach as additional signal types onto the same Incident/timeline
without schema changes. AI root-cause analysis runs over the timeline.
SSO & RBAC
probectl authenticates users with OIDC SSO — OIDC (OpenID Connect) is the standard login handshake built on OAuth 2.0, and SSO (single sign-on) means users sign in at their organization's identity provider (the IdP: Okta, Entra, Keycloak, …) rather than holding a probectl-local password — and authorizes them with role-based access control (RBAC): every action requires a permission, and permissions come from roles. The security order is the two-level boundary: a request resolves to exactly one tenant first, then RBAC decides whether the caller may perform the route's action within that tenant.
Login flow. GET /auth/login (optionally ?tenant=<uuid>) starts the OIDC
authorization-code flow: it sets a short-lived, HttpOnly CSRF state cookie and
separate nonce and PKCE-verifier cookies, then redirects to the tenant's identity
provider with the PKCE S256 code_challenge. The IdP redirects back to
GET /auth/callback, which verifies the state, supplies the matching
code_verifier directly to the token endpoint, verifies the ID token and nonce,
just-in-time provisions the user within the tenant (a brand-new user gets
no roles — a secure default; an admin grants access), mints a server-side
session, and sets the session cookie. Missing or mismatched transient values fail
closed. POST /auth/logout revokes the session. GET /v1/me returns the caller's
tenant, identity, and effective permissions.
Sessions. A session is a random, high-entropy opaque token. Only its
server-keyed HMAC hash is stored (table sessions), so a database read
cannot mint or cheaply test a session without PROBECTL_SESSION_HMAC_KEY.
Production session-cookie deployments fail closed without that key: all
multi-tenant / regulated session auth, and single profile session auth once
OIDC is configured. The cookie is HttpOnly + SameSite=Lax, and Secure
whenever the API serves HTTPS. PROBECTL_SESSION_TTL (default 12h) is the
absolute lifetime; activity never extends it. PROBECTL_SESSION_IDLE_TIMEOUT
(default 30m) is a second, database-enforced inactivity wall. A successful
lookup atomically advances last_activity_at; an idle or absolutely expired
token returns no session. Setting the idle value to 0 keeps the safe default
rather than creating an unlimited session.
Every successful login replaces the browser's previous session ID. Each session
also stores a deterministic digest of its effective permission keys. When a
role grant or revoke changes that digest, the next request atomically consumes
the old token, sets a newly random cookie, and only then serves the request.
Two racing requests cannot create two successors. The session table also has a
composite (tenant_id, user_id) foreign key, so the database rejects a session
that pairs one tenant with another tenant's user.
The cluster-wide daily lifecycle leader is the cleanup owner for session
identity detail. Under each tenant's storage scope it deletes absolutely
expired rows on the next sweep and deletes replaced predecessor detail after
one PROBECTL_SESSION_TTL replay horizon (there is no separate retention
setting). The global credential_locators row contains only the keyed token
hash, credential UUID, tenant UUID, and state timestamps; that hash-only
tombstone is intentionally retained after detail cleanup. It remains the
database lock for authenticated callbacks, so a cleanup/callback race still
commits at most one successor. In practice, identity detail therefore ages out
within the session TTL plus the daily sweep interval. Tenant erasure removes
the locator by its tenant key; subject erasure removes one while its detail row
still supplies the credential ID. Once detail cleanup has intentionally
unlinked that hash-only tombstone from a subject, it remains only as
replay-protection metadata until tenant erasure.
Per-tenant IdP. Providers are resolved per tenant through a provider factory.
The environment configuration (PROBECTL_OIDC_*) is the deployment fallback;
an enabled row saved through Admin & Settings → Identity administration or
PUT /v1/identity/settings overrides it for that tenant. The two are reached
differently on purpose. The deployment IdP is the operator's own
configuration, so it may live on the operator's network — loopback, RFC1918 or
in-cluster DNS — behind the CA in PROBECTL_OIDC_CA_FILE. A tenant-configured
issuer is tenant input, so its discovery, keys and code exchange go through the
SSRF-guarded client (INJ-04): it must resolve to a public address, and one that
resolves into private, loopback or link-local space is refused. GET /v1/identity/settings returns public metadata plus
client_secret_configured, never plaintext or ciphertext. Sending an empty
client_secret on an update preserves the existing secret.
The tenant_idp row is protected by forced Postgres RLS from its first
migration. Its client secret is envelope-encrypted through internal/crypto
using tenant-bound authenticated data, so even copied ciphertext cannot be
opened under another tenant. This requires PROBECTL_ENVELOPE_KEY (or the
configured enterprise key provider); writes fail closed if encryption is not
available. Missing or disabled tenant settings use the environment fallback,
but a present enabled configuration that is malformed, cannot be read, or
cannot be decrypted fails login closed instead of falling through. A login
always resolves to one tenant before provider selection. Provider/MSP operators
authenticate into the provider domain (the management plane), not into
tenant data.
RBAC. Every /v1 route declares a required permission key; the wrapped
handler returns 401 when unauthenticated and 403 when the principal lacks
the permission — checked before the handler runs. Effective permissions are
loaded per request from the user's role bindings (RLS-scoped to the tenant), so a
role grant or revoke takes effect immediately. The permission catalog:
| Permission | Granted to (seeded roles) | Guards |
|---|---|---|
test.read |
viewer, editor, admin | GET /v1/tests*, GET /v1/tests/{id}/path |
test.write |
editor, admin | POST/PUT/DELETE /v1/tests*, POST .../path |
agent.read |
viewer, editor, admin | GET /v1/agents* |
agent.write |
admin | PATCH/DELETE /v1/agents/{id} |
alert.read |
viewer, editor, admin | GET /v1/alerts* |
alert.write |
editor, admin | POST/PUT/DELETE /v1/alerts* |
incident.read |
viewer, editor, admin | GET /v1/incidents*, GET /v1/incident-shares/*, POST /v1/incidents/*/shares |
incident.write |
editor, admin | PATCH /v1/incidents/{id} |
The seeded system roles for the default tenant are admin (all permissions),
editor (read everything + manage tests/alerts/incidents), and viewer
(read-only). GET /v1/me requires only authentication (no specific permission).
Programmatic bearer tokens. Browser users authenticate with the session cookie;
scripts and the probectl CLI can instead send Authorization: Bearer <token>.
Mint that token with probectl-control mcp-token --tenant <tenant-uuid> --user <user-uuid> --name <label> after the user exists through OIDC login or SCIM. The
token is stored only as a hash in mcp_tokens, resolves to exactly that user's
tenant and effective RBAC permissions, and can be sent with
X-Probectl-Tenant: <tenant-uuid> as a tenant assertion. A mismatched tenant
assertion fails closed; the header does not let a token switch tenants. Do not use
SCIM, OTLP, or agent-enrollment tokens for normal /v1 API calls: those tokens
authenticate only their own subsystem.
Dev mode. PROBECTL_AUTH_MODE=dev bypasses SSO and synthesizes an
all-permissions principal for the default tenant, with the
X-Probectl-Tenant: <uuid> override for multi-tenant dev. It is
triple-gated: (1) the code path exists only in binaries built with
-tags devauth (make build-devauth) — a release binary refuses to start
in this mode; (2) PROBECTL_DEV_AUTH_ACK=i-understand must be set; (3) the
listener must bind loopback (PROBECTL_HTTP_ADDR=127.0.0.1:…). When active it
logs at error level and writes an auth.dev_mode_active audit event. The CI
gate no-devauth-in-release proves release binaries contain neither the
symbols nor the dev-principal literal. The test suite installs its own hook
in _test.go files, which never ship.
Resource API & CLI
The versioned resource API lives under /v1 (full schema at /openapi.json):
GET/POST /v1/tests,GET/PUT/DELETE /v1/tests/{id}— synthetic-test CRUD.GET /v1/agents,GET/PATCH/DELETE /v1/agents/{id}— agents register over mTLS; the API lists, renames, and deregisters them.GET/POST /v1/tests/{id}/path— the latest discovered network path for a test, and a trigger to discover it now. The Path & Topology UI consumes this.
API lifecycle
/v1 is the LTS compatibility line. A compatible field or route can be added at
any time, but a removal or incompatible semantic change must first be marked in
OpenAPI with deprecated: true and x-probectl-lifecycle metadata:
deprecated_at, sunset, lts_until, replacement, and policy. Deprecated
operations stay served for at least 12 months after deprecated_at.
Every deprecated operation also emits machine-readable response headers on live
requests: Deprecation (structured-field date), Sunset (HTTP date),
X-Probectl-API-Replacement, X-Probectl-API-LTS-Until, and Link entries for
the successor operation plus /openapi.json. This lets clients discover both the
retirement date and the replacement path without scraping release notes.
Current deprecated operation: DELETE /v1/agents/{id} is retained for metadata
deregistration compatibility through 2027-06-19; prefer POST /v1/agents/{id}/revoke for security offboarding because it persists identity
revocation and feeds the live mTLS deny-list.
Every /v1 route is tenant-scoped through internal/tenancy + Postgres RLS,
so a request can never read or write across tenants. Authentication and RBAC are
real (see SSO & RBAC below): the caller's tenant and effective permissions come
from an authenticated session, and each route requires a permission. The "no
undocumented routes" rule is enforced by a test that matches the route table
against openapi.json.
The probectl CLI is the web-parity client. Configure it with flags or
environment: PROBECTL_API_URL (default https://localhost:8443),
PROBECTL_CA_FILE (a PEM bundle that verifies the control plane's certificate
when it is issued by a private CA — --ca-file; Go on macOS ignores
SSL_CERT_FILE, and verification is never switched off),
PROBECTL_API_TOKEN (sent as Bearer), PROBECTL_TENANT (sent as
X-Probectl-Tenant), PROBECTL_SESSION_COOKIE_FILE (owner-only file containing
an MFA-bearing OIDC session for audit reveal and the tenant-admin break-glass
consent commands provider consent|decide-consent|revoke-consent; it takes
precedence over the bearer token for those requests), and PROBECTL_LOCALE (default en; accepts
shipped language tags such as es/es-MX for CLI help and API error messages). API
error code values stay stable and machine-readable; only the human message is
localized. In en the CLI prints the server's own message — what to fix, as the
console shows it — and in other locales the localized message for the code.
When an API error includes request_id, the CLI includes it in the
terminal error output for support/debugging.
A CLI request gives up after 15 seconds, except where the server itself runs
longer and lifts its write timeout for the route: export and other downloads
get 15 minutes, a tenant or subject erasure 15 minutes, a tenant provisioning
(provider create-tenant, tenant create) 5 minutes, a path discovery
(test path <id> --body) 150 seconds, and ai ask 90 seconds.
PROBECTL_HTTP_WRITE_TIMEOUT sets the server's ordinary write timeout; the
long routes keep their own budgets above it.
The terminal-native product surface is the CLI. There is no separate committed
TUI mode today; if one is added later it must be declared in the surface catalog
and backed by its own parity tests. For automation, use --json. The OpenAPI
gate checks both tenant /v1 and provider /provider/v1 JSON APIs; every
operation requires either a named CLI command or a documented none-by-design
exception. Provider bootstrap, enrollment, and login payloads are
credential-bearing and therefore require --body-file <0600-file|->; inline
--body is rejected before any request. Use PROBECTL_API_TOKEN rather than
--token for the resulting provider bearer token so it stays out of process
arguments.
Journey-critical parity is currently served by the CLI:
| Operator journey | CLI command family | Primary API path |
|---|---|---|
| Incident triage, cited sharing, and tenant-local journal | `probectl incident list | get |
| Alert review and response | `probectl alert active | ack |
| Topology and path investigation | `probectl topology show | whatifplusprobectl test path |
| Ask/RCA answer and local Markdown handoff | probectl ai ask --body JSON; add --handoff for the versioned point-in-time artifact |
/v1/ai/ask |
| Human-gated remediation review | `probectl remediation list | get |
| Live detection rules (defaults + overlay) | probectl threat rules |
/v1/threat/rules |
| SLO and cost posture | `probectl slo list | export, probectl cost summary` |
| Tenant lifecycle portability and erasure | probectl lifecycle export --redact, probectl lifecycle erase --body JSON |
/v1/lifecycle* |
| Tenant identity-provider settings | `probectl identity settings | set-settings` |
probectl test list
probectl test create --name edge-dns --type icmp --target 1.1.1.1 --interval 30
probectl test delete <id>
probectl agent list
probectl incident list --query status=open
probectl --json incident journal <id>
probectl incident journal-append <id> --body '{"kind":"note","body":"Operator hypothesis"}'
probectl topology show
probectl alert active
probectl ai ask --body '{"question":"Why is WAN loss high?","subject":{"incident_id":"inc_123"}}'
probectl ai ask --handoff --body '{"question":"Why is WAN loss high?","subject":{"incident_id":"inc_123"}}' > ask-handoff.md
probectl remediation list
probectl slo list
probectl lifecycle export --redact > tenant-export.tar.gz
probectl --json cost summary # machine-readable output
probectl api GET /v1/fairness # raw escape hatch for JSON APIs
eBPF host agent
The eBPF agent watches a host's network from inside the Linux kernel — it sees
which processes talk to which services without you instrumenting anything. It is
observe-only: it never blocks or modifies traffic. Like the canary agent, its
real config is a YAML file (-config / PROBECTL_EBPF_CONFIG), bounded to
1 MiB before YAML decoding; see
deploy/agent/probectl-ebpf-agent.example.yml
and ebpf-agent.md, with PROBECTL_EBPF_* env vars overriding
individual fields. The in-kernel loader is compiled in only with the ebpf build
tag; without it (or for tests), point fixture_path at a recording to replay.
The big idea in the keys below: layer-7 plaintext capture is off, and stays off
until you prove three separate intents — turn it on (L7_CAPTURE), name the
tenant that consents (L7_CONSENT_TENANT), and list the exact workloads
(L7_SCOPE). Miss any one and the kernel copies no payload. That is the
fail-closed posture for the most sensitive thing this agent can do.
| Variable | Default | Description |
|---|---|---|
PROBECTL_EBPF_CONFIG |
(none) | path to the YAML config (-config flag overrides) |
PROBECTL_EBPF_TENANT_ID |
(required) | the tenant every flow is stamped with — the agent refuses to start without it |
PROBECTL_EBPF_HOST |
OS hostname | observing host name — each record's host; also the agent_id fallback when no collector identity is set (single-node lightweight mode only) |
PROBECTL_EBPF_AGENT_ID |
(none) | the registered collector identity every record carries as agent_id (DPR-051): the id minted by register-collector -plane ebpf / POST /v1/collectors/register. The control plane verifies the (tenant, agent_id) pair against the tenant's registry before accepting a batch (TENANT-101), so an unregistered identity means every batch is rejected. Printable, no whitespace, at most 128 bytes |
PROBECTL_EBPF_BUS_MODE |
memory |
memory (volatile, dev only) | nats (durable lightweight) | kafka |
PROBECTL_EBPF_BUS_BROKERS |
(none) | comma-separated Kafka brokers (kafka mode) |
PROBECTL_EBPF_BUS_NAMESPACE |
(none) | publish on this tenant's siloed bus lane (probectl.<ns>.ebpf.flows) instead of the shared topic; for per-tenant-namespaced (siloed) deployments |
PROBECTL_EBPF_FIXTURE_PATH |
(none) | replay recorded flows instead of loading eBPF (no-kernel path); maximum 8 MiB, with oversized fixtures rejected at startup |
PROBECTL_EBPF_L7_FIXTURE_PATH |
(none) | replay recorded layer-7 events (no-kernel L7 path); maximum 8 MiB, with oversized fixtures rejected at startup |
PROBECTL_EBPF_RING_BUFFER_BYTES |
16777216 |
size of the L4 flow kernel→userspace ring buffer (16 MiB; live loader only). Bigger absorbs bigger flow bursts at the cost of memory. Rounded up to the next power of two at load; capped at 268435456 (256 MiB) — a larger value fails validation (EBPF-005) |
PROBECTL_EBPF_L7_RING_BUFFER_BYTES |
16777216 |
size of the TLS/plaintext L7 chunk ring buffer (tls_chunks, 16 MiB default; live ebpf build only). This is separate from the L4 flow ring because L7 chunk bursts and flow bursts are different queues. Rounded/capped like PROBECTL_EBPF_RING_BUFFER_BYTES; capped at 268435456 (256 MiB) |
PROBECTL_EBPF_LIBSSL |
(auto) | explicit OpenSSL/BoringSSL-compatible libssl path for TLS-plaintext (uprobe) L7 capture; when unset, the live loader auto-discovers installed libssl.so.* and libgnutls.so.* libraries (ebpf build) |
PROBECTL_EBPF_L7_CAPTURE |
false |
master switch — live TLS-plaintext capture is OFF by default. true alone is not enough; consent AND scope below are also required |
PROBECTL_EBPF_L7_CONSENT_TENANT |
(none) | the explicit per-tenant consent: must equal this agent's bound tenant id exactly, else capture stays off |
PROBECTL_EBPF_L7_SCOPE |
(none) | the explicit workload opt-in — comma-separated pid:<n>, exe:/abs/path, cgroup:/abs/cgroup-dir entries. The kernel program drops every other process BEFORE copying a byte; empty = capture refuses to start. Host-wide capture is deliberately not expressible. Container/pod scoping is the cgroup: form (a container IS a cgroup); exe: entries are re-resolved every 10s so restarts stay in scope |
PROBECTL_EBPF_L7_REDACTION |
headers |
how much of a payload may survive capture: headers zeroes bodies plus secret-bearing and identity-like header values (Authorization, cookies, api-key, token, secret, credential, x-amz-*, user, email, subject, employee, account, customer, session, person) before anything is retained (protocol metadata survives); length captures NO payload bytes (traffic shape only, no parsed calls); full (consented debugging) disables masking |
PROBECTL_EBPF_L7_KERNEL_WINDOW |
1024 |
max plaintext bytes per chunk that may cross from kernel into userspace under headers redaction (128–4095); bytes past the window never leave the kernel. length forces 0, full forces 4095. An unprogrammed kernel defaults to length-only, so it ships no plaintext |
PROBECTL_EBPF_L7_IDENTITY_HEADER_FRAGMENTS |
built-ins | comma-separated extra header-name fragments whose values are identity-like and zeroed in headers mode. Built-ins already cover user, email, subject, employee, account, customer, session, and person; use this for local names such as member, viewer, or operator |
PROBECTL_EBPF_L7_HASH_ALL_HEADER_VALUES |
false |
when true, every non-denied header value in headers mode is replaced with a length-preserving sha256: fingerprint. Secret and identity-like header values are still zeroed, so this is for correlation without raw header-value retention |
PROBECTL_EBPF_PROC_ROOT |
/proc |
procfs root for process/cgroup enrichment |
PROBECTL_EBPF_L7_HOST_ROOT |
(none) | where the NODE's filesystem is mounted inside the agent container, when TLS capture must attach a uprobe to the node's own libssl rather than the container's. Must be an absolute path (the chart mounts /host). Discovery looks under it for the library, and refuses a copy that is present but has no execute bit, because the uprobe loader will not attach to one — Debian and Ubuntu package shared libraries 0644, which is exactly that case (DPR-125). Empty means look only inside the container |
PROBECTL_EBPF_FLUSH_INTERVAL |
10s |
how often flows + the service map are emitted (also the idle-prune cadence) |
PROBECTL_EBPF_MAX_SERVICE_EDGES |
50000 |
cap on live service-map edges (EBPF-001/SCALE-003); least-recently-seen evicted past the cap. 0 = unbounded (lightweight/test only) |
PROBECTL_EBPF_MAX_BATCH_BYTES |
786432 |
per-record bound for one published FlowBatch (DPR-071). The service map is a cumulative snapshot that travels with every flush, so an unbounded record outgrows the bus's per-message limit (Kafka: 1,000,012 bytes client-side, 1 MiB broker message.max.bytes) on a busy host — after which every flush is rejected asynchronously. A flush above the bound is split into several records that together carry exactly the flush. 0 = unbounded (lightweight/test only). Undelivered records are logged at ERROR (ebpf publish FAILED, with the bus's reason), counted in publish_failures_total, and take the readiness probe down until a flush delivers cleanly |
PROBECTL_EBPF_MAX_L7_CONNS |
8192 |
cap on live L7 per-connection trackers (FUZZ-001); oldest-seen connection evicted past the cap. 0 = unbounded |
PROBECTL_EBPF_L7_CONN_IDLE_TTL |
5m |
a connection/edge idle longer than this is abandoned on the flush ticker (FUZZ-001) — connIDs have no socket-close signal yet, so this sweep bounds the maps |
PROBECTL_EBPF_HEALTH_STATE_DIR |
(none) | write live.json and ready.json health state files for exec probes. The Helm DaemonSet sets this from health.stateDir by default so probes work without opening a plaintext listener; each state file is limited to 16 KiB and oversized files fail closed before decoding |
PROBECTL_EBPF_HEALTH_ADDR |
(none) | compatibility-only liveness/readiness HTTP probe server (e.g. :9090; /healthz = process up, /readyz = flow source attached). Empty disables it. The Helm chart renders this only with health.mode=http and health.allowPlaintextHTTP=true |
PROBECTL_EBPF_LOG_LEVEL |
info |
debug | info | warn | error |
PROBECTL_EBPF_LOG_FORMAT |
json |
json | text |
Flows + service edges are published to probectl.ebpf.flows (ebpfv1.FlowBatch,
tenant-keyed). The live loader needs a BTF Linux kernel (≥5.8; BTF is the type
metadata modern kernels embed, which lets one compiled eBPF program adapt itself
to many kernel versions instead of being rebuilt per machine) and
CAP_BPF/CAP_PERFMON; see ebpf-agent.md.
Agent→bus TLS/SASL (eBPF, endpoint, flow, and device agents)
When a telemetry agent publishes straight to Kafka, its broker connection takes
the same hardening keys as the control plane's PROBECTL_BUS_* set, under the
agent's own prefix: PROBECTL_EBPF_BUS_* here, and likewise
PROBECTL_ENDPOINT_BUS_*, PROBECTL_FLOW_BUS_*, and PROBECTL_DEVICE_BUS_*
for the agents below. TLS encrypts the broker hop; SASL is Kafka's
authentication layer on top of it — a username/password proven either directly
(plain, safe only inside TLS) or via a challenge that never sends the
password (scram-sha-256/-512). The policy is the same fail-closed one: kafka
mode without TLS refuses to start unless the explicit dev-only plaintext flag is
set. (The canary agent has no bus keys — it talks gRPC/mTLS to the control
plane, which publishes on its behalf.)
| Suffix (append to the agent's prefix) | Default | Meaning |
|---|---|---|
_BUS_TLS_ENABLED |
false |
TLS to the brokers — required in kafka mode unless _BUS_ALLOW_PLAINTEXT is set |
_BUS_TLS_CA_FILE |
(none) | private CA bundle for the brokers |
_BUS_TLS_CERT_FILE / _BUS_TLS_KEY_FILE |
(none) | client certificate + key (broker mTLS) |
_BUS_SASL_MECHANISM |
(none) | plain | scram-sha-256 | scram-sha-512 |
_BUS_SASL_USER / _BUS_SASL_PASSWORD |
(none) | SASL credentials (the agents read these as literal env values — the secret-reference schemes are a control-plane feature) |
_BUS_ALLOW_PLAINTEXT |
false |
dev only: allow a plaintext broker (the dev compose stack). Production never sets this |
_BUS_MAX_BUFFERED |
0 (= built-in bound 65536) |
async-producer in-flight bound; a full buffer sheds + counts, never blocks |
Endpoint / DEM agent (probectl-endpoint)
"DEM" is digital experience monitoring: this agent runs on an end-user's laptop
(Linux/macOS/Windows), measures their actual last-mile experience, and figures out
whether a slowdown is the WiFi, the ISP, or the network. Because it sits on a
personal device, its defaults are privacy-first — it collects the WiFi name
and gateway (useful, low-risk) but not the AP MAC or public hop IPs (which can
geolocate a person), and it discloses exactly what it collects on startup. It reads
a YAML config (default path PROBECTL_ENDPOINT_CONFIG), bounded to 1 MiB before
YAML decoding; PROBECTL_ENDPOINT_* env vars override it. See
endpoint-dem.md.
| Variable | Default | Meaning |
|---|---|---|
PROBECTL_ENDPOINT_CONFIG |
(none) | path to the YAML config (-config flag overrides) |
PROBECTL_ENDPOINT_TENANT_ID |
(required) | the tenant every result is stamped with — refuses to start without it |
PROBECTL_ENDPOINT_AGENT_ID |
OS hostname | device identifier in the fleet |
PROBECTL_ENDPOINT_BUS_MODE |
memory |
memory (volatile, dev only) | nats (durable lightweight) | kafka |
PROBECTL_ENDPOINT_BUS_BROKERS |
(none) | comma-separated Kafka brokers (kafka mode) |
PROBECTL_ENDPOINT_BUS_NAMESPACE |
(none) | publish on this tenant's siloed bus lane instead of the shared topic (siloed deployments) |
PROBECTL_ENDPOINT_INTERVAL |
60s |
how often a sample is collected |
PROBECTL_ENDPOINT_TARGETS |
(none) | comma-separated targets (the first target's host drives the last-mile trace — a URL is reduced to its host (DPR-061); all = session probes). No external hosts ship by default (no-phone-home, G7-2) — an agent with no configured targets probes nothing off-device (local Wi-Fi/gateway only) until you set your own |
PROBECTL_ENDPOINT_MAX_HOPS |
20 |
last-mile trace hop cap |
PROBECTL_ENDPOINT_COLLECT_SSID |
true |
retain the WiFi network name (SSID) |
PROBECTL_ENDPOINT_COLLECT_BSSID |
false |
retain the access-point MAC (BSSID) — geolocatable PII, off by default |
PROBECTL_ENDPOINT_COLLECT_GATEWAY_IP |
true |
retain the (private) default-gateway address |
PROBECTL_ENDPOINT_COLLECT_PUBLIC_HOPS |
false |
retain PUBLIC last-mile hop IPs (which reveal ISP/geo), off by default |
PROBECTL_ENDPOINT_LOG_LEVEL |
info |
debug | info | warn | error |
PROBECTL_ENDPOINT_LOG_FORMAT |
json |
json | text |
Results (WiFi / gateway / last-mile / session signals + the attribution verdict)
are published to probectl.endpoint.results (resultv1.Result, tenant-keyed),
flowing through the same pipeline as every other canary. The agent discloses
exactly what it collects at startup and never phones home.
Flow collector (probectl-flow-agent)
A flow record is a router's summary of one conversation — who talked to
whom, on which ports/protocol, when, and how many bytes/packets — with no
payload: the phone bill, not the phone call. NetFlow, IPFIX, and sFlow are the
three export dialects devices speak; AWS VPC Flow Logs, Azure NSG Flow Logs, and
GCP VPC Flow Logs are the equivalent cloud-export path. The flow collector
listens for NetFlow v5/v9, IPFIX, and sFlow v5 datagrams from network devices,
decodes them (template + sampling handling), and publishes normalized batches to
probectl.flow.events (flowv1.FlowBatch, tenant-keyed). Cloud flow logs and
metrics are imported from local files already exported by the operator's cloud
pipeline. probectl does not poll cloud-provider APIs.
It reads a YAML config (default path PROBECTL_FLOW_CONFIG), refusing files
larger than 1 MiB before YAML decoding; PROBECTL_FLOW_* env vars override the
file. The defaults serve all three protocols on their
standard ports (NetFlow :2055, IPFIX :4739, sFlow :6343). See
flow.md for the security posture: flow export is plaintext UDP by
design, so every datagram is treated as untrusted and the collector should sit
adjacent to its exporters (not exposed to the wider network).
| Variable | Default | Meaning |
|---|---|---|
PROBECTL_FLOW_CONFIG |
(none) | path to the YAML config (-config flag overrides) |
PROBECTL_FLOW_TENANT |
(required) | the tenant every flow record is stamped with — refuses to start without it |
PROBECTL_FLOW_BUS_NAMESPACE |
(none) | publish this agent's batches on its tenant's siloed bus lane (probectl.<ns>.flow.events) instead of the shared topic; a malformed value refuses start. The same key exists for the other agents: PROBECTL_DEVICE_BUS_NAMESPACE, PROBECTL_EBPF_BUS_NAMESPACE, PROBECTL_ENDPOINT_BUS_NAMESPACE |
PROBECTL_FLOW_AGENT_ID |
OS hostname | collector identifier |
PROBECTL_FLOW_BUS_MODE |
memory |
memory (volatile, dev only) | nats (durable lightweight) | kafka |
PROBECTL_FLOW_BUS_BROKERS |
(none) | comma-separated Kafka brokers (kafka mode) |
PROBECTL_FLOW_NETFLOW_ENABLED |
true |
serve NetFlow v5 and v9 (version-sniffed) on one socket |
PROBECTL_FLOW_NETFLOW_LISTEN |
:2055 |
NetFlow UDP listen address |
PROBECTL_FLOW_NETFLOW_ALLOWED_SOURCES |
(required for non-loopback) | comma-separated exporter CIDRs/IPs admitted before decode |
PROBECTL_FLOW_IPFIX_ENABLED |
true |
serve IPFIX |
PROBECTL_FLOW_IPFIX_LISTEN |
:4739 |
IPFIX UDP listen address |
PROBECTL_FLOW_IPFIX_ALLOWED_SOURCES |
(required for non-loopback) | comma-separated exporter CIDRs/IPs admitted before decode |
PROBECTL_FLOW_SFLOW_ENABLED |
true |
serve sFlow v5 |
PROBECTL_FLOW_SFLOW_LISTEN |
:6343 |
sFlow UDP listen address |
PROBECTL_FLOW_SFLOW_ALLOWED_SOURCES |
(required for non-loopback) | comma-separated exporter CIDRs/IPs admitted before decode |
PROBECTL_FLOW_CLOUD_PROVIDER |
(none) | one-shot local cloud-flow import provider: aws_vpc_flow_logs, azure_nsg_flow_logs, or gcp_vpc_flow_logs; when set, the agent imports and exits |
PROBECTL_FLOW_CLOUD_FILE |
(none) | local file path for cloud-flow import, or - for stdin; required when PROBECTL_FLOW_CLOUD_PROVIDER is set |
PROBECTL_FLOW_BATCH_SIZE |
1000 |
records per emitted batch |
PROBECTL_FLOW_FLUSH_INTERVAL |
2s |
max time a record waits before emission |
PROBECTL_FLOW_TEMPLATE_TTL |
30m |
v9/IPFIX template expiry |
PROBECTL_FLOW_MAX_TEMPLATES |
4096 |
template-cache size cap (untrusted-input bound) |
PROBECTL_FLOW_READ_BUFFER_BYTES |
4194304 |
kernel UDP receive buffer (burst absorption) |
PROBECTL_FLOW_QUEUE_SIZE |
65536 |
decode→flush channel depth (overflow drops are counted) |
PROBECTL_FLOW_WORKERS |
2 |
reader goroutines per socket |
PROBECTL_FLOW_LOG_LEVEL |
info |
debug | info | warn | error |
PROBECTL_FLOW_LOG_FORMAT |
json |
json | text |
YAML equivalent for one-shot cloud import:
cloud_import:
provider: aws_vpc_flow_logs
path: /var/lib/probectl/cloud-flow/aws-vpc-flow.log
If all UDP listeners are disabled, cloud_import must be complete; otherwise
the flow agent refuses to start. Cloud import still publishes to the configured
bus as probectl.flow.events, tenant-keyed by tenant_id.
Cloud metrics use the live probectl-cloud-metrics importer documented below.
It reads operator-exported JSONL locally and sends tenant-bound remote write to
the self-hosted control plane. The previous cloud-pull prototype was never wired
into a command, so there is no hidden cloud-API polling path.
The control plane consumes that flow topic, optionally enriches each record
with ASN/geo, and persists to the flow store behind /v1/flows/* (top-talkers /
capacity / anomalies). These are control-plane keys (not flow-agent keys):
| Variable | Default | Meaning |
|---|---|---|
PROBECTL_FLOWSTORE_MODE |
memory |
where flow records live: memory (lightweight/single-binary) | clickhouse (durable, high-cardinality) |
PROBECTL_FLOWSTORE_URL |
(none) | ClickHouse HTTP(S) endpoint; required in clickhouse mode. multi-tenant/regulated profiles require https:// |
PROBECTL_EBPFSTORE_MODE |
memory |
where eBPF flow/L7 service-edge aggregates live: memory (lightweight/single-binary) | clickhouse (durable history) |
PROBECTL_EBPFSTORE_URL |
(none) | ClickHouse HTTP(S) endpoint; required when PROBECTL_EBPFSTORE_MODE=clickhouse. multi-tenant/regulated profiles require https:// |
PROBECTL_ENDPOINTSTORE_MODE |
memory |
where event-shaped endpoint/DEM observations live: memory (lightweight) | clickhouse (production durable history and restart recovery). Numeric endpoint metrics always use the configured TSDB |
PROBECTL_ENDPOINTSTORE_URL |
(none) | ClickHouse HTTP(S) endpoint; required when PROBECTL_ENDPOINTSTORE_MODE=clickhouse. multi-tenant/regulated profiles require https:// |
PROBECTL_CLICKHOUSE_BASIC_AUTH_FILE |
(none) | optional strict 0600 JSON Basic-auth file used by every enabled ClickHouse store; see the path-store table above. It contains secret material and belongs in a mounted secret, never a ConfigMap |
PROBECTL_DEPLOYMENT_PROFILE |
single |
isolation posture (TENANT-004): single (sovereign/single-tenant — app-layer WHERE scoping is the boundary) | multi-tenant | regulated. The latter two default DB-enforced ClickHouse tenant isolation ON for every telemetry plane (flow/otel/eBPF/path/endpoint), strict tenant bus lanes, durable stores, and TLS datastore URLs — defense-in-depth above app code (guardrails 7.1/7.12). In multi-tenant/regulated, ClickHouse-backed lanes may not downgrade this: startup requires each *_TENANT_SCOPING=true and its matching *_READER_USER |
PROBECTL_FLOWSTORE_TENANT_SCOPING |
profile | defense-in-depth: also constrain flow reads at the database by attaching a per-request tenant setting that a ClickHouse row policy enforces (needs server-side custom_settings_prefixes=SQL_ + a reader user). Defaults ON under multi-tenant/regulated, off under single |
PROBECTL_FLOWSTORE_READER_USER |
(none) | the ClickHouse reader user the setting-scoped row policy is installed on at boot (pairs with the toggle above) |
PROBECTL_OTELSTORE_TENANT_SCOPING |
profile | TENANT-003/004: DB-level reader scoping on the OTLP traces+logs plane (the PII-heaviest). Same mechanism as flow; defaults ON under multi-tenant/regulated |
PROBECTL_OTELSTORE_READER_USER |
(none) | the ClickHouse reader user the otel setting-scoped row policy is installed on at boot |
PROBECTL_EBPFSTORE_TENANT_SCOPING |
profile | TENANT-004: DB-level reader scoping on the eBPF L7 edge plane; defaults ON under multi-tenant/regulated |
PROBECTL_EBPFSTORE_READER_USER |
(none) | the ClickHouse reader user the eBPF setting-scoped row policy is installed on at boot |
PROBECTL_PATHSTORE_TENANT_SCOPING |
profile | TENANT-004: DB-level reader scoping on the path plane; defaults ON under multi-tenant/regulated |
PROBECTL_PATHSTORE_READER_USER |
(none) | the ClickHouse reader user the path setting-scoped row policy is installed on at boot |
PROBECTL_ENDPOINTSTORE_TENANT_SCOPING |
profile | DB-level reader scoping on the endpoint/DEM event plane; defaults ON under multi-tenant/regulated and fails closed when its tenant setting is absent |
PROBECTL_ENDPOINTSTORE_READER_USER |
(none) | the ClickHouse reader user the endpoint setting-scoped row policy is installed on at boot |
PROBECTL_INGEST_STRICT_TENANT_LANES |
profile | WIRE-001: refuse agent-published collector planes (flow/eBPF/device/endpoint, and BGP from the analyzer bridge and BMP listener) on the shared pooled bus lane, forcing them onto tenant-namespaced lanes (broker-ACL isolated, forgery-proof). Every active tenant — pooled or siloed — owns the lane probectl.t-<slug>.<plane>, created by the control plane and printed by collector registration as PROBECTL_<PLANE>_BUS_NAMESPACE (DPR-049). Closes the residual shared-lane forgery surface. Defaults ON under multi-tenant/regulated, OFF under single. Rejections increment probectl_pipeline_tenant_rejected_total on /metrics |
PROBECTL_FLOW_RETENTION_DAYS |
90 |
delete-after-N-days TTL for the raw probectl_flows ClickHouse table. Hourly tenant-scoped rollups remain queryable in probectl_flow_rollups_hour until tenant/subject lifecycle deletion. 0 disables the raw TTL and keeps flows indefinitely; the control plane logs a loud warning because the raw flow table can then grow without bound |
PROBECTL_EBPF_RETENTION_DAYS |
30 |
delete-after-N-days TTL for the eBPF ClickHouse tables. 0 disables the TTL and keeps eBPF history indefinitely; use a finite value for high-churn L7/service-edge deployments |
PROBECTL_ENDPOINT_RETENTION_DAYS |
90 |
delete-after-N-days TTL for raw endpoint/DEM event history. 0 disables the table TTL; tenant derived-identity policy can still enforce a tighter window |
PROBECTL_FLOW_ENRICH_ASN |
false |
opt-in Team Cymru ASN enrichment. Off by default because it makes outbound DNS lookups (the no-phone-home guardrail); AS numbers the device itself exported always pass through regardless |
PROBECTL_FLOW_ENRICH_ASN_FILE |
(unset) | path to an operator-supplied local IP→ASN file — the MaxMind GeoLite2-ASN CSV edition, or any compatible network,asn,organization CSV (ASN, AS-name, prefix). Local file — no egress, air-gap friendly. Supersedes PROBECTL_FLOW_ENRICH_ASN: when set, ASN enrichment reads from this file and the Team Cymru DNS source is not registered (no outbound DNS). An unreadable path degrades honestly (unavailable in GET /v1/threat/intel/status; the control plane keeps serving) |
PROBECTL_FLOW_ENRICH_CACHE_MAX |
65536 |
hard maximum entries in the shared open-data enrichment cache. When more distinct IPs arrive, stale entries expire first and then the least-recently-used entry is evicted; cache size/hits/misses/evictions are exposed on /metrics |
PROBECTL_FLOW_ENRICH_GEOIP_DB |
(unset) | path to an operator-supplied MaxMind GeoLite2 .mmdb (country/city/lat-lon). probectl never ships or fetches the database (see opendata-aup.md for licensing). Local file — no egress, air-gap friendly. An unreadable path degrades honestly: the source shows unavailable in GET /v1/threat/intel/status and the control plane keeps serving |
PROBECTL_FLOW_ENRICH_RIR_DIR |
(unset) | directory of RIR delegated-extended stats files (RIR, allocation status/date; drop in all five registries' files for the global view). Local files — no egress. Same honest unavailable degradation as the geo source |
PROBECTL_FLOW_ENRICH_IXP |
false |
opt-in PeeringDB IXP/facility enrichment. Off by default because it makes outbound HTTPS calls to the PeeringDB API (the no-phone-home guardrail). Requires PROBECTL_FLOW_ENRICH_ASN=true — PeeringDB keys on the ASN the Cymru source resolves, and the control plane refuses to start with an IXP source that could never contribute |
PROBECTL_PATH_RETENTION_DAYS applies the same pattern to raw path/traceroute
ClickHouse rows: raw hops/links age out, while hourly tenant-scoped hop/link
rollups stay available for lower-resolution trend and RCA context until tenant
deletion removes them.
PROBECTL_DEPLOYMENT_PROFILE=multi-tenant and regulated are production-like
profiles, so startup refuses volatile raw-ingest/serving defaults. Set
PROBECTL_BUS_MODE=kafka, PROBECTL_TSDB_MODE=prometheus, and
PROBECTL_PATHSTORE_MODE, PROBECTL_FLOWSTORE_MODE, PROBECTL_OTELSTORE_MODE,
PROBECTL_EBPFSTORE_MODE, and PROBECTL_ENDPOINTSTORE_MODE to clickhouse with their required https://
URLs before using those profiles. Postgres writer/read-replica URLs must verify the
server certificate: sslmode=verify-ca or verify-full with sslrootcert set; the
server-unverified sslmode=require is single-profile dev only (CRY-05). Each ClickHouse lane also needs the corresponding scoped
reader user (PROBECTL_PATHSTORE_READER_USER, PROBECTL_FLOWSTORE_READER_USER,
PROBECTL_OTELSTORE_READER_USER, PROBECTL_EBPFSTORE_READER_USER, and
PROBECTL_ENDPOINTSTORE_READER_USER) so boot
can install the database row policy. The default single profile may still use
memory modes for a lightweight sovereign/lab install.
Device telemetry agent (probectl-device-agent)
This agent reads metrics straight off network gear (routers, switches). It polls
the old way (SNMP v2c/v3), listens for authenticated SNMP traps, and
subscribes the modern streaming way (gNMI/OpenConfig). Polling/subscription
samples normalize into one DeviceMetric shape and publish to
probectl.device.metrics (tenant-keyed). An explicitly configured SNMP target
with neighbors: true also publishes a bounded LLDP/CDP snapshot to
probectl.device.neighbors; it never scans for devices or opens a follow-up
session. Each LLDP/CDP attempt separately publishes a stable, non-secret
readiness receipt to probectl.device.collection-outcomes, so failure,
successful-empty, unsupported, and never-observed cannot be confused. Accepted
traps become tenant-scoped event and alert rows. The full
device list and optional trap listener live in a YAML config, bounded to 1 MiB
before YAML decoding (see deploy/agent/probectl-device-agent.example.yml); the
env vars below override it and give a single-device quick start for trying
one device fast. See
device-telemetry.md.
Set top-level collection_profile to minimal, standard, or
topology-rich to expand one compiled evidence budget across the configured
targets. The vocabulary uses only the agent's existing SNMP and gNMI
collectors. minimal uses 5m SNMP / 2m gNMI cadence with no optional walks;
standard uses 1m / 30s with base interface and host evidence; and
topology-rich additionally enables SNMP temperature and bounded LLDP/CDP
walks. gNMI stays on the two supported OpenConfig interface paths because the
agent does not own a gNMI topology collector.
Profile changes belong under each target's collection_overrides block:
interval, sensors, and neighbors for SNMP, or
gnmi_sample_interval and gnmi_paths for gNMI. Mixing a profile with the old
top-level collection knobs fails closed. SNMP overrides are bounded to
15s–24h; gNMI cadence is bounded to 5s–1h; and profile-mode paths are restricted
to the compiled counters/status paths. A config without collection_profile
keeps the legacy explicit behavior. Run probectl-device-agent config-check -config <path> or probectl device config-preview --config <path> for the
credential- and tenant-free effective plan; neither command opens a network
connection.
| Variable | Default | Meaning |
|---|---|---|
PROBECTL_DEVICE_CONFIG |
(none) | path to the YAML config (-config flag overrides) |
PROBECTL_DEVICE_TENANT |
(required) | the tenant every device metric is stamped with — refuses to start without it |
PROBECTL_DEVICE_AGENT_ID |
OS hostname | agent identifier |
PROBECTL_DEVICE_BUS_MODE |
memory |
memory (volatile, dev only) | nats (durable lightweight) | kafka |
PROBECTL_DEVICE_BUS_BROKERS |
(none) | comma-separated Kafka brokers (kafka mode) |
PROBECTL_DEVICE_BUS_NAMESPACE |
(none) | publish on this tenant's siloed bus lane instead of the shared topic (siloed deployments) |
PROBECTL_DEVICE_PROFILE |
(none) | optional compiled profile: minimal | standard | topology-rich; empty preserves legacy explicit config |
PROBECTL_DEVICE_TARGET |
(none) | quick start: add one device by address |
PROBECTL_DEVICE_TRANSPORT |
snmpv2c |
quick-start transport: snmpv2c | snmpv3 | gnmi |
PROBECTL_DEVICE_CREDENTIAL |
(none) | quick start: credential NAME for the device (see below) |
PROBECTL_DEVICE_PORT |
161 (SNMP) / 9339 (gNMI) |
quick start: port override (defaults to the transport's standard port) |
PROBECTL_DEVICE_INTERVAL |
profile value (60s legacy) |
quick start: explicit poll/sample interval override |
PROBECTL_DEVICE_SENSORS |
false |
quick start: explicit temperature-sensor override; with a profile this is recorded as collection_overrides.sensors |
PROBECTL_DEVICE_NEIGHBORS |
false |
quick start: opt in to bounded, read-only LLDP/CDP MIB walks on the configured SNMP target; no subnet scan |
PROBECTL_DEVICE_CORRELATION_RETENTION |
2160h |
age-retention clock for the device agent's in-process sysName/interface correlation cache; stale device labels are no longer matchable after this window. 0 disables agent-local pruning |
PROBECTL_DEVICE_LOG_LEVEL |
info |
debug | info | warn | error |
PROBECTL_DEVICE_LOG_FORMAT |
json |
json | text |
Credentials are referenced by NAME, never inlined — no secrets in
the device list. The default credential source resolves those names from the
environment (the PROBECTL_DEVICE_CRED_<NAME>_* vars below); the secrets backends
plug Vault/CyberArk into the same seam. An unresolvable name fails closed at
startup. <NAME> is the upper-cased credential name with -/. → _:
In legacy YAML without a profile, neighbors: true is a per-device opt-in. In
profile mode, use collection_overrides.neighbors: true. Both forms are valid
only for SNMP targets. One snapshot contains at most 256 normalized neighbors. The control
plane retains at most 16,384 current/stale rows per tenant, returns at most 500
rows per read, and prunes evidence more than 24 hours old. Unsupported LLDP/CDP
MIBs remain explicit. The independent receipt store retains at most 4,096
configured target/protocol receipts per tenant for 30 days and returns at most
500 per read. Receipt payloads contain allowlisted states/reasons/actions, not
raw SNMP values, credentials, discovered targets, or free-form errors. probectl
does not infer a link from a name or management address alone.
| Variable | Used by | Meaning |
|---|---|---|
PROBECTL_DEVICE_CRED_<NAME>_COMMUNITY |
snmpv2c | community string |
PROBECTL_DEVICE_CRED_<NAME>_USERNAME |
snmpv3, gnmi | USM user / gNMI metadata user |
PROBECTL_DEVICE_CRED_<NAME>_AUTH_PROTO |
snmpv3 | sha (default) | sha256 | sha512 | md5 |
PROBECTL_DEVICE_CRED_<NAME>_AUTH_PASS |
snmpv3 | auth passphrase (empty → NoAuthNoPriv) |
PROBECTL_DEVICE_CRED_<NAME>_PRIV_PROTO |
snmpv3 | aes (default) | aes256 | des |
PROBECTL_DEVICE_CRED_<NAME>_PRIV_PASS |
snmpv3 | privacy passphrase (empty → AuthNoPriv) |
PROBECTL_DEVICE_CRED_<NAME>_PASSWORD |
gnmi | gNMI metadata password |
(USM is SNMPv3's user security model: a named user authenticates with the
AUTH_* pair and optionally encrypts with the PRIV_* pair — leaving a
passphrase empty steps the security level down, as the table notes.)
SNMP trap ingestion is opt-in YAML, not an unauthenticated open UDP port. A
trap-only agent is allowed, but traps.sources must name at least one sender and
credential:
traps:
enabled: true
listen: ":9162" # default; bind 127.0.0.1:9162 or a private interface if preferred
sources:
- name: core-switches
address: 192.0.2.10 # optional source-IP allow-list
transport: snmpv2c # snmpv2c | snmpv3
credential: core-traps # resolves PROBECTL_DEVICE_CRED_CORE_TRAPS_*
For SNMPv3 trap sources, USERNAME and AUTH_PASS are required; NoAuthNoPriv
traps are rejected. Accepted rows store the source name / v3 username and never
store the community or passphrase.
gNMI connections always use TLS with certificate verification (system roots
or a per-device ca_file); there is no skip-verify or plaintext mode. A legacy
plaintext: true value is rejected during validation before credentials or a
dial attempt can occur.
Discovery jobs
probectl-device-agent discover -job discovery.json -out review.json runs a
bounded, tenant-scoped SNMP discovery pass and writes review candidates. It
does not mutate the YAML config and does not activate monitoring; accepted
devices must be explicitly reviewed into normal devices: targets.
The job file is JSON:
| Field | Meaning |
|---|---|
id |
operator-chosen discovery job ID, included in audit events |
tenant_id |
tenant boundary for the job, every credential, and every result |
created_by |
optional actor string for audit receipts |
ranges |
private, loopback, or link-local IPv4 addresses/CIDRs only; public ranges fail before probing |
max_hosts |
maximum expanded targets, default 1024 |
credentials[] |
tenant-owned credential references: tenant_id, name, transport (snmpv2c or snmpv3), optional port |
classifier_rules[] |
optional rules using sys_name_contains, sys_descr_contains, if_name_contains, min_interfaces, role, and confidence |
The command uses the same PROBECTL_DEVICE_CRED_<NAME>_* variables and secrets
resolver described above. The optional -fixture fixture.json flag supplies
canned device inventories for offline demos/tests and avoids network probes.
Both the required job and optional fixture JSON files have an 8 MiB maximum;
larger files fail closed before JSON decoding or any discovery probe.
OTLP receiver
OTLP is the OpenTelemetry protocol — the vendor-neutral standard wire format for
metrics, traces, and logs. This receiver lets other systems push their OTLP
data into probectl: anything that already speaks OpenTelemetry can send here
without changing its exporter. It
is off by default and, when on, is locked to the same posture as everything
else: TLS-only, token-authenticated, tenant-scoped, on its own listeners
separate from the /v1 REST API. There is no anonymous-plaintext mode: setting
a listen address without a TLS cert/key pair fails config validation. Bearer
tokens can be DB-backed and hot-revoked through /v1/otlp-tokens; static
PROBECTL_OTLP_TOKENS entries are legacy/bootstrap only. Because those static
tokens never expire, each one must clear a strength floor at config load —
at least 32 characters of a real random secret (e.g. openssl rand -hex 32);
a short or low-entropy token fails validation and the process refuses to start
(AUTHZ-27, fail closed). See otlp.md.
| Variable | Default | Description |
|---|---|---|
PROBECTL_OTLP_GRPC_ADDR |
(none) | OTLP/gRPC listen address (e.g. :4317) |
PROBECTL_OTLP_HTTP_ADDR |
(none) | OTLP/HTTP listen address (e.g. :4318); accepts all three signals — POST /v1/metrics, /v1/traces, /v1/logs |
PROBECTL_OTELSTORE_MODE |
memory |
where ingested OTLP traces+logs live: memory (lightweight) | clickhouse (production; (tenant_id, day) partition) |
PROBECTL_OTELSTORE_URL |
(none) | ClickHouse HTTP(S) URL for clickhouse mode; multi-tenant/regulated profiles require https:// |
PROBECTL_OTEL_RETENTION_DAYS |
30 |
delete-TTL for stored OTLP traces+logs (0 disables) |
PROBECTL_OTLP_TLS_CERT_FILE |
(none) | PEM server certificate (required to enable) |
PROBECTL_OTLP_TLS_KEY_FILE |
(none) | PEM server private key (required to enable) |
PROBECTL_OTLP_TOKENS |
(none) | optional legacy/bootstrap bearer-token→tenant map: token1=tenant1,token2=tenant2; DB tokens from /v1/otlp-tokens can be the only token source. Each static token must be ≥ 32 characters of real randomness (AUTHZ-27 strength floor); a weak token fails config validation |
PROBECTL_OTLP_FRESHNESS_HMAC_KEY |
(none) | optional hex-encoded 32-byte HMAC key for first-party OTLP replay protection. When set, every OTLP/gRPC and OTLP/HTTP request must include a signed timestamp+nonce envelope |
PROBECTL_OTLP_FRESHNESS_WINDOW |
5m |
accepted clock-skew/replay window for the OTLP freshness envelope |
Setting an address without the TLS files fails config validation — the receiver
is never anonymous plaintext. Missing, unknown, revoked, or freshness-invalid
credentials fail per request. Ingested metrics are tenant-tagged and published
to the probectl.otlp.metrics bus topic.
Syslog receiver
An authenticated, TLS-only syslog listener for network devices and appliances
that forward RFC 3164 / RFC 5424 syslog over TLS (e.g. rsyslog omfwd with
StreamDriverMode=1). It is off by default and holds the same posture as the
rest of probectl: TLS-only, authenticated, tenant-scoped, fail closed. Each
sender is an explicit source authenticated per message by a TLS
client-certificate subject (verified against PROBECTL_SYSLOG_TLS_CA_FILE) or an
HMAC-SHA256 shared secret; the tenant is bound to the source here and never read
from the payload, so one listener can serve many tenants. A sender that matches
no source is rejected and nothing is stored. Accepted lines are normalized and
land on the GET /v1/device/syslog read path. Setting a listen address without a
server cert/key pair, a source without a credential, or a source without a tenant
fails config validation — the listener is never anonymous plaintext.
| Variable | Default | Description |
|---|---|---|
PROBECTL_SYSLOG_LISTEN_ADDR |
(none) | syslog-over-TLS listen address (e.g. :6514); enables the receiver |
PROBECTL_SYSLOG_TLS_CERT_FILE |
(none) | PEM server certificate (required to enable) |
PROBECTL_SYSLOG_TLS_KEY_FILE |
(none) | PEM server private key (required to enable) |
PROBECTL_SYSLOG_TLS_CA_FILE |
(none) | PEM client-certificate CA bundle; required when any source authenticates by tls_client_subject — the listener requests and verifies the client cert against it |
PROBECTL_SYSLOG_DEFAULT_TENANT_ID |
(none) | tenant stamped on records from a source that carries no tenant_id of its own |
PROBECTL_SYSLOG_MAX_LINE_BYTES |
65536 |
maximum accepted syslog line length (1 B–1 MiB; 0 uses the 64 KiB default) |
PROBECTL_SYSLOG_SOURCES |
(none) | JSON array of authenticated senders: [{"name":"edge-fw","tenant_id":"tenant-a","tls_client_subject":"CN=edge-fw,O=probectl","address":"192.0.2.10","hmac_secret":"","rate_limit":120}]. Each source needs a name, a credential (tls_client_subject or hmac_secret), and a tenant (tenant_id or the default); address optionally pins the source host and rate_limit bounds lines per minute |
OTLP export
probectl can export OTLP to an upstream collector. All three signals are
forwarded — metrics, traces, and logs (ARCH-003): ingested traces/logs are
re-exported to a customer's own trace/log backend, not just queryable inside
probectl. For OTLP/HTTP the per-signal paths are derived from the configured
endpoint (…/v1/metrics → …/v1/traces, …/v1/logs). This egresses
confidential customer telemetry (plus the bearer token), so a remote collector
must be encrypted — guardrail 12. Config validation fails closed otherwise:
for http a remote endpoint must be https://; for grpc the INSECURE flag is
refused for a remote endpoint. A loopback collector (a co-located sidecar) may use
plain http:// / INSECURE for development.
Before egress the re-export runs the SAME redaction the storage path applies (RTP-06): span names, log bodies and every attribute value pass through the governance telemetry masking, so an Authorization header, a URL token or a known secret shape that storage masks never leaves the operator's network in the clear.
Tenant isolation (RTP-06). PROBECTL_OTLP_EXPORT_ENDPOINT is a shared
endpoint — every tenant's telemetry is re-exported to it. That is safe only in the
single/sovereign profile (one tenant). Under the multi-tenant/regulated
profiles a shared endpoint would cross tenants (one tenant's spans reaching
another tenant's or the provider's collector) and is refused at startup;
configure a per-tenant, tenant-scoped map with PROBECTL_OTLP_EXPORT_TENANT_ENDPOINTS
instead. Each tenant's telemetry then reaches only that tenant's own collector,
and a tenant with no configured endpoint is dropped closed (never forwarded).
The export configuration is recorded on the provider audit stream at startup
(tokens excluded).
| Variable | Default | Description |
|---|---|---|
PROBECTL_OTLP_EXPORT_ENDPOINT |
(none) | shared upstream OTLP collector; enables export. Remote must be https:// (HTTP) / TLS (gRPC). Refused under multi-tenant/regulated — use the per-tenant map. |
PROBECTL_OTLP_EXPORT_PROTOCOL |
grpc |
grpc | http (shared endpoint) |
PROBECTL_OTLP_EXPORT_TOKEN |
(none) | bearer token sent to the shared collector |
PROBECTL_OTLP_EXPORT_INSECURE |
false |
disable TLS — loopback endpoints only (refused for a remote target) |
PROBECTL_OTLP_EXPORT_TENANT_ENDPOINTS |
(none) | per-tenant export, a JSON object {"<tenant>":{"endpoint":"https://…","token":"…","protocol":"grpc|http","insecure":false}}. Each tenant exports only to its own collector. Mutually exclusive with the shared endpoint; the required shape under multi-tenant/regulated. |
Ecosystem integrations
The Grafana datasource API (/v1/grafana/api/v1/*), the federation endpoint
(/v1/prometheus/federate), and the remote-write receiver
(/v1/prometheus/write) ride the existing TSDB config (PROBECTL_TSDB_MODE /
PROBECTL_TSDB_URL) and the /v1 API listener — no extra keys. Reads need
metrics.read, remote-write metrics.write (migration 0022). See
ecosystem-integrations.md.
A CMDB (configuration management database) is an organization's IT inventory — a registry of configuration items (CIs: servers, applications, services) and who owns them. The ServiceNow or NetBox CMDB correlation tags probectl incidents and agents with the CI they belong to, so an alert arrives already knowing "this is the payments edge proxy, owned by team X". It is off unless configured:
| Variable | Default | Meaning |
|---|---|---|
PROBECTL_CMDB_PROVIDER |
(none) | servicenow or netbox enables CI correlation (/v1/cmdb/*, incident/agent CIs) |
PROBECTL_CMDB_URL |
(none) | instance URL, e.g. https://acme.service-now.com or https://netbox.example.com (https; http only for loopback test doubles) |
PROBECTL_CMDB_SECRET |
(none) | ServiceNow user:password or NetBox token for a read-only integration user (env only — never in files/logs) |
PROBECTL_CMDB_TABLE |
cmdb_ci |
ServiceNow CI table queried via the Table API; ignored for NetBox |
PROBECTL_CMDB_CACHE_TTL |
10m |
CI lookup cache TTL (a down CMDB serves stale entries) |
Cloud metric import
probectl-cloud-metrics imports already-exported AWS CloudWatch, Azure Monitor,
or Google Cloud Monitoring JSONL rows into /v1/prometheus/write. It never
polls cloud APIs; the file is local input from the operator's own export
pipeline. The API URL must be HTTPS except for loopback test instances.
| Variable | Default | Meaning |
|---|---|---|
PROBECTL_CLOUD_METRICS_PROVIDER |
(none) | aws_cloudwatch_export, azure_monitor_export, or gcp_cloud_monitoring_export |
PROBECTL_CLOUD_METRICS_FILE |
- |
JSONL export path; - reads stdin |
PROBECTL_CLOUD_METRICS_BATCH_SIZE |
1000 |
samples per remote-write request |
PROBECTL_API_URL |
https://localhost:8443 |
self-hosted probectl API URL |
PROBECTL_CA_FILE |
(OS trust store) | PEM CA bundle that verifies the control plane's certificate (--ca-file); required when a private CA issued it and the OS trust store does not hold that CA (DPR-077). Verification is never disabled |
PROBECTL_TENANT / PROBECTL_API_TOKEN |
(none) | tenant header and bearer token for /v1/prometheus/write |
AI assistant
Worked per-provider setups (Ollama, vLLM, OpenAI, Anthropic, Azure) are in
ai-rca.md → Copy-paste recipes; the remote-egress enablement
chain (operator ack + per-tenant consent) is in ai-egress.md.
The assistant (root-cause analysis + natural-language query) works out of the
box with zero network access — the default builtin provider is an in-process
synthesizer that writes its answers locally. You only point it at a real language
model if you want nicer prose, and doing so is treated as data egress: a remote
endpoint must be https, and you have to explicitly acknowledge that tenant data
will leave (PROBECTL_AI_EGRESS_ACK). A loopback endpoint may be http (for a
local model on the same box). The redaction keys below mask sensitive values
before anything reaches an external model. Redaction tokens are tenant-scoped
keyed-HMAC labels; PROBECTL_SESSION_HMAC_KEY makes those labels stable across
restarts, while an unset key falls back to process-local random labels that
rotate on restart. See ai-rca.md.
| Variable | Default | Description |
|---|---|---|
PROBECTL_AI_MODEL_PROVIDER |
builtin |
builtin (air-gapped, the default) | ollama | openai | anthropic |
PROBECTL_AI_EGRESS_ACK |
(none) | required to use a REMOTE model: must equal yes-send-tenant-data-to-the-remote-model, or the server refuses to start. This is a deliberate "yes, I know data leaves" gate, on top of per-tenant consent + audit — see docs/ai-egress.md |
PROBECTL_AI_REDACT_IPS |
true |
mask IP addresses in anything sent to an external model (stable per-tenant tokens, so correlation survives without public hash dictionaries; local file paths are never redacted) |
PROBECTL_AI_REDACT_HOSTNAMES |
true |
also mask hostnames before remote egress — internal FQDNs are a service inventory; set false to deliberately opt out (secrets are masked unconditionally regardless of this) |
PROBECTL_AI_REDACT_PII |
true |
mask free-text PII — emails, phone numbers, MAC addresses — in anything sent to an external model (RCA prompts, MCP tool results, authoring prompts) |
PROBECTL_AI_REDACT_PATTERNS |
(none) | your own regexes (;;-separated), masked as [custom:<token>] — for org-specific identifiers (employee IDs, ticket refs). A bad pattern refuses start (fail closed) |
PROBECTL_AI_MODEL_ENDPOINT |
(none) | base URL of the model (required for a non-builtin provider) |
PROBECTL_AI_MODEL_NAME |
(none) | model name (e.g. llama3.1, gpt-4o-mini) |
PROBECTL_AI_MODEL_TOKEN |
(none) | API key / bearer token (optional for a local Ollama) |
PROBECTL_AI_MODEL_CA_FILE |
(none) | PEM trust bundle that anchors validation of the model endpoint's certificate instead of the system roots — for an enterprise LLM gateway behind a private/corporate CA (Helm: mount it with control.trustBundle.existingConfigMap and point this at the mounted path). Validated at startup: an unreadable or certificate-less bundle refuses to start. Certificate validation is never disabled |
PROBECTL_AI_MODEL_TIMEOUT |
60s |
per-request timeout for the model endpoint |
PROBECTL_AI_MAX_EVIDENCE |
50 |
cost guard: the most signals one answer may gather |
PROBECTL_AI_MAX_CONCURRENT |
8 |
process-wide cap on concurrent analyzes (HTTP 429 when exceeded); a backstop beneath the per-tenant fairness gate |
PROBECTL_AI_PERSIST_ANSWERS |
false |
persist privacy-minimized answer artifacts (tokenized prompt/cited JSON + model + config hash) for reproducibility/disputes |
PROBECTL_AI_ANSWER_RETENTION |
2160h (90 days) |
prune persisted answers older than this (enforced opportunistically on write) |
A non-builtin provider without an endpoint fails config validation. Whatever the
backend, every answer is tenant- and RBAC-scoped by the query layer and every claim
is citation-checked before it reaches the user — a model can never see out-of-scope
data or inject an ungrounded claim.
MCP server
MCP — the Model Context Protocol — is the standard by which AI applications
(Claude Desktop, IDE assistants, agent frameworks) call external tools. The MCP
server exposes read-only, tenant- + RBAC-scoped probectl tools to such clients,
which means an MCP caller is an external AI reading tenant data — so tool
results ride the same egress gate as the RCA model
(ai-egress.md). The
HTTP transport is off by default and is TLS-only + bearer-authenticated;
the stdio transport is local (the client launches probectl-control mcp-stdio
as a subprocess and talks over stdin/stdout — no listener at all; the
token comes from PROBECTL_MCP_TOKEN). Mint a token with
probectl-control mcp-token --user <user-uuid> [--tenant <uuid>] [--name <label>] —
the token prints once and only its hash is stored, so a database read can never
recover it. See mcp.md.
| Variable | Default | Description |
|---|---|---|
PROBECTL_MCP_HTTP_ADDR |
(none) | MCP HTTP listen address (e.g. :8090) — enables the transport |
PROBECTL_MCP_TLS_CERT_FILE |
(none) | PEM server certificate (required to enable HTTP) |
PROBECTL_MCP_TLS_KEY_FILE |
(none) | PEM server private key (required to enable HTTP) |
PROBECTL_MCP_RATE_PER_MIN |
120 |
per-tenant tool-call rate limit (0 disables) |
Setting PROBECTL_MCP_HTTP_ADDR without the TLS files fails config validation — the
MCP endpoint is never anonymous plaintext.
TLS / certificate observability
The control plane analyzes TLS/cert posture from TLS handshakes the HTTP and
eBPF-L7 probes already captured — it never re-handshakes a target itself — and
correlates the findings into threat-plane incidents. See
tls-observability.md.
| Variable | Default | Description |
|---|---|---|
PROBECTL_TRUSTCTL_URL |
(none) | trustctl base URL; enables a one-click renewal deep-link on findings |
PROBECTL_TLS_EXPIRY_WARNING |
504h (21d) |
expiring-soon window |
PROBECTL_CT_ENABLED |
false |
opt in to Certificate Transparency correlation (external fetch) |
PROBECTL_CT_ENDPOINT |
https://crt.sh |
CT log API endpoint |
CT logs are Certificate Transparency's public, append-only ledgers of every certificate publicly issued — correlation against them answers "was a cert for my name issued that I don't know about?" (a rogue or mis-issued certificate). It is off by default (an external fetch — sovereignty / AUP / rate limits) and degrades gracefully when the CT source is down. Responses are read through a bounded 1 MiB sentinel: a larger or trailing-garbage body is rejected before JSON decoding and degrades closed — never accepted as a silently truncated prefix.
Threat-intel enrichment
Threat-intel feeds are public lists of IOCs — indicators of compromise:
known-bad IPs, certificates, URLs. A JA3 is a fingerprint of how a client
performs its TLS handshake, which identifies malware families even when their
server IPs rotate. The control plane can match peer IPs / hostnames / certs /
JA3 seen in your own telemetry against these feeds, surfacing
confidence-scored, source-attributed threat-plane
signals (a signal, not an IPS — never blocks). See
threat-intel.md for the feed/AUP matrix and caveats.
| Variable | Default | Description |
|---|---|---|
PROBECTL_THREATINTEL_ENABLED |
false |
master switch (outbound feed fetches); off ⇒ no IOC code runs |
PROBECTL_THREATINTEL_REFRESH |
6h |
feed refresh cadence |
PROBECTL_THREATINTEL_FEEDS |
(all) | comma-separated feed names (spamhaus_drop, feodo_tracker, sslbl, sslbl_ja3, urlhaus, tor_exit, firehol_level1); empty ⇒ all |
PROBECTL_THREATINTEL_MIRROR |
(unset) | operator-hosted/offline mirror base for all feeds — a local directory or file:// URL (fully offline, air-gap) or an operator-hosted https:// base URL. Each feed loads from <base>/<feed-name> (e.g. <base>/spamhaus_drop) instead of its public URL, so no hardcoded internet URL is contacted. Mirrored bodies stay untrusted (parsed defensively), cached, and graceful-degrading; an https:// mirror validates TLS |
Off by default (an outbound fetch — sovereignty / no-phone-home). The refresher keeps each source's last-good indicators, so a feed outage degrades gracefully and never breaks a core path.
Enterprise identity: SCIM + ABAC
SCIM is the standard API identity providers use to push user lifecycle into
applications — a joiner appears, a leaver is deactivated, without anyone touching
probectl. ABAC (attribute-based access control) layers policy conditions on
top of RBAC roles — "editors may write tests only in project X". Both have
no environment keys — the SCIM bearer token
an IdP presents is minted in Admin & Settings → Identity administration or
with the control-plane CLI, and ABAC policies are managed from the same Admin
surface or over the API. See scim-abac.md.
# mint a per-tenant SCIM token for an IdP (shown once)
probectl-control scim-token --tenant <tenant-uuid> --name okta
The /scim/v2/* surface is gated by a valid SCIM token (no token ⇒ 401), and the
directory-admin APIs (/v1/directory/scim-tokens, /v1/abac/policies) require
directory.read/directory.write.
SCIM provisioning is bounded per tenant even though it has no environment knobs:
each tenant may hold up to 10,000 SCIM users and 2,000 SCIM groups, list calls
are SQL-paged with a maximum count of 200, and each SCIM bearer token has its
own 600-request/minute bucket. A noisy or compromised IdP token therefore burns
only its own local budget; it cannot force an unbounded directory scan or grow a
tenant directory forever.
Change intelligence
Most outages follow a change. This feature ingests change webhooks — deploys,
config pushes, route changes, IaC (infrastructure-as-code) applies, commits —
each cryptographically signed by its provider, into a change timeline, and
correlates them with incidents ("this incident started 4 minutes after that
deploy"), feeding the AI RCA. See
change-intel.md for the webhook contract + provider/signature
table.
| Variable | Default | Description |
|---|---|---|
PROBECTL_CHANGE_WEBHOOKS |
(none) | comma-separated id:tenant:provider:secret webhook credentials (provider ∈ generic/github/gitlab). The secret is the last field, so it may contain : but not , — use URL-safe (hex/base64) secrets. |
PROBECTL_CHANGE_CORRELATION_WINDOW |
24h |
how far before an incident a change is treated as a candidate cause |
Each inbound delivery is TLS + signature-verified (HMAC/token, constant-time) +
tenant-bound to the credential + replay-deduplicated by provider delivery ID;
an unsigned, stale, forged, or duplicate event is rejected before a second
mutation, and one tenant cannot inject another's changes. Generic webhooks sign
<unix-timestamp>.<body> and include X-Probectl-Delivery,
X-Probectl-Timestamp, and X-Probectl-Signature; GitHub/GitLab use their
provider delivery UUID headers. Webhook secrets are
runtime config — inject them from a secret manager, never commit them.
SIEM export
A SIEM (security information and event management system — Splunk, Microsoft
Sentinel, Elastic, Google Chronicle, …) is the SOC's central platform where an
organization's security events converge for detection and investigation. This
feature forwards the audit stream and threat-plane signals there over
hardened TLS. probectl is the forwarder, not a SIEM — events are rendered into a
standard format and pushed; nothing is auto-blocked. See siem.md for
formats, delivery guarantees, and per-SIEM setup.
| Variable | Default | Description |
|---|---|---|
PROBECTL_SIEM_ENABLED |
false |
master switch (an outbound connection to your SIEM); off ⇒ no SIEM code runs |
PROBECTL_SIEM_PRESET |
generic |
SIEM adapter: generic, splunk, sentinel, elastic, chronicle (sets the auth scheme + default format) |
PROBECTL_SIEM_FORMAT |
(preset) | wire format: syslog (RFC 5424), cef, ecs, otlp; empty ⇒ the preset's native default (Elastic⇒ecs, Chronicle⇒otlp, else cef) |
PROBECTL_SIEM_ENDPOINT |
(none) | HTTPS ingest URL (e.g. the Splunk HEC / Sentinel / Chronicle / Elasticsearch endpoint). Enabled without it ⇒ a startup warning and the export stays disabled (the control plane still runs) |
PROBECTL_SIEM_TOKEN |
(none) | ingest credential (Splunk ⇒ Splunk <tok>, Elastic ⇒ ApiKey <tok>, others ⇒ Bearer <tok>). Inject from a secret manager |
PROBECTL_SIEM_AUTH_SCHEME |
(preset) | pins the Authorization scheme when your target does not use the preset's default: apikey, bearer, basic (the token is user:password, encoded for you), splunk, or none. OpenSearch — the self-hosted Elastic-compatible target most operators run — authenticates with Basic where Elasticsearch takes ApiKey, so PROBECTL_SIEM_PRESET=elastic with _AUTH_SCHEME=basic is the OpenSearch combination (DPR-115) |
PROBECTL_SIEM_POLL_INTERVAL |
30s |
audit-stream drain cadence |
PROBECTL_SIEM_BUFFER |
1024 |
threat-signal buffer; full ⇒ producers block (backpressure, never drop) |
PROBECTL_SIEM_REDACT_KEYS |
(none) | extra audit data keys to scrub (on top of the built-in secret/PII denylist) |
PROBECTL_SIEM_AUDIT_IDENTITY |
clear |
AUD-06: identity fidelity of audit events mirrored to the operator's SIEM. clear (default) carries the real actor — the SIEM is the operator's own SOC, which must attribute who did what; pseudonymize masks actor identities in the SIEM copy |
PROBECTL_SIEM_AUDIT_PRUNE_MASKED |
false |
AUD-06: when PROBECTL_SIEM_AUDIT_IDENTITY=pseudonymize, local full-fidelity audit rows are NOT pruned past the tenant's SIEM-delivery watermark by default, because the masked SIEM copy would be the only surviving record and could not attribute actions. Set true to opt into pruning even though the exported copy is identity-masked |
The wire formats are the SIEM world's lingua francas: syslog (RFC 5424
structured syslog), cef (ArcSight's Common Event Format), ecs (the Elastic
Common Schema), and otlp (OpenTelemetry logs). Off by default (an outbound
connection — sovereignty / no-phone-home). Audit
forwarding resumes from a durable per-tenant cursor — a saved bookmark in the
audit stream, so a restart or SIEM outage picks up exactly where delivery
stopped — and delivery retries
without dropping under a SIEM outage. Exported audit events are PII/secret
redacted (built-in denylist + PROBECTL_SIEM_REDACT_KEYS, followed by the
core governance PII scanner over actor/action/target/outcome and
non-denylisted values).
Inbound syslog collection is a separate input path, not SIEM export. A deployment
profile that enables it registers tenant-bound sources with a source name,
optional address allow-list, either an HMAC secret or TLS client-certificate
subject, a per-source rate limit, and a max line size. The receiver accepts RFC
5424 and RFC 3164 over TLS only, rejects malformed or oversized lines, stamps the
tenant from the registered source instead of trusting the payload, and records
event provenance (source.name, auth.method, wire format, source address) for
incident correlation.
On-call + ITSM integration
ITSM (IT service management) tooling is where operations work is tracked —
ticketing systems like ServiceNow and Jira; on-call platforms like PagerDuty and
Opsgenie are what wake the right human. This feature mirrors incidents into that
tooling: page on-call, post
to chat (Slack/Teams), and open + bidirectionally sync tickets (ServiceNow/Jira)
— resolve it there and probectl hears about it, resolve it here and the ticket
closes. probectl is the forwarder, not the system of record — it never auto-blocks
anything.
See oncall-itsm.md for the connector matrix, mapping, and the
inbound webhook contract.
Use GET /v1/oncall/status or probectl oncall status to inspect the
tenant-scoped posture; the response is redacted and never returns connector
secrets or endpoint path/query values.
| Variable | Default | Description |
|---|---|---|
PROBECTL_NOTIFY_CONNECTORS |
(none) | outbound connectors, comma-separated, each tenant|provider|endpoint|secret (pipe-delimited because the endpoint is a URL). provider ∈ pagerduty/opsgenie/slack/teams/servicenow/jira/psa. psa is the signed vendor-neutral probectl-psa-ticket/v1 lifecycle contract and requires an HMAC secret; the first named vendor adapter remains design-partner-selected. endpoint must be https:// for remote provider URLs; plain http:// is accepted only for loopback local dev/test doubles. |
PROBECTL_NOTIFY_INBOUND |
(none) | inbound status-sync credentials, comma-separated, each id:tenant:provider:secret (the id is the URL selector for POST /ingest/itsm/{provider}/{id}; secret verifies the delivery). |
Off by default (each connector is an outbound connection to the operator's
tooling). Paging + ticket creation are idempotent (an incident opens at most
once per connector — a retry/restart never double-pages), status sync is
bidirectional with loop protection (an inbound resolve from one system is
never echoed back to it), and routing is per-tenant (a connector only fires for
its own tenant). Endpoint specifics: a Slack/Teams endpoint is the incoming-webhook
URL; a Jira endpoint carries the project (and optional resolve transition) as query
params, e.g. …/rest/api/2/issue?project=OPS&resolve_transition=31; a ServiceNow
endpoint is the …/api/now/table/incident URL. Inbound deliveries must include
X-Probectl-Signature: sha256=<hmac> or X-Probectl-Token: <secret> over TLS; an
unsigned or forged delivery is rejected (401). Secrets are runtime config —
inject them from a secret manager, never commit them.
The Alerts page renders the same tenant-scoped connector posture: provider
choices (pagerduty, opsgenie, slack, teams, servicenow, jira, psa),
sanitized endpoint host, TLS posture, credential presence, inbound webhook path,
and redaction state. POST /v1/oncall/test sends an operator-triggered test
incident through an already-configured connector id for the caller's tenant; the
browser never supplies ad-hoc endpoints or secrets.
Topology graph + what-if
The topology graph is the live map probectl assembles from everything it observes — nodes (hosts, services, devices, prefixes) and the edges between them — and what-if is simulation over that map: "if this link or node disappeared, what would be cut off?".
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_TOPOLOGY_ENGINE |
indexed |
graph engine: indexed (adjacency-indexed, for large/extra-large graphs) or memory (the simpler reference implementation). Both sit behind the same query API. Any value other than memory selects the indexed engine (the key is not validated as an enum) |
The graph feeds from eBPF/BGP/device streams + path discoveries; served at
GET /v1/topology with what-if simulation at POST /v1/topology/whatif.
See docs/topology.md.
FinOps / egress cost
Cloud providers bill for egress — bytes leaving a zone, region, or cloud — and those line items are notoriously hard to attribute. This engine prices the flow volume probectl already observes (bytes × the provider's public list rates), entirely locally: it never calls a billing API, so the sovereignty posture holds and the numbers are explicitly estimates with stated provenance.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_COST_ENABLED |
true |
cost engine over the local flow stream (volume × public pricing; no billing-API calls) |
PROBECTL_COST_ZONES |
(none) | CIDR→zone rules, e.g. 10.0.1.0/24=us-east-1a,… (locality classification) |
PROBECTL_COST_SERVICES |
(none) | CIDR→service:team attribution rules (showback) |
PROBECTL_COST_BUDGETS |
(none) | monthly USD budgets, e.g. team:payments=500 (breach = one cost-plane signal per month) |
PROBECTL_COST_PRICES_FILE |
(none) | JSON price-table override (maximum 1 MiB; oversized files fail startup closed); embedded public list rates otherwise (provenance + as-of surfaced) |
PROBECTL_COST_PRICED |
true |
false = volume-only mode (bytes attributed, dollars never invented) |
PROBECTL_COST_ZONES, PROBECTL_COST_SERVICES, and PROBECTL_COST_BUDGETS are
deployment-wide and single-tenant-only (AI-09, guardrail 7.1): one map is
applied to every tenant, so in a pooled deployment two tenants that share a
prefix would be cross-attributed (one tenant's bytes booked to the other's
service/team) and cross-alerted (a budget keyed on a shared owner fires for
every tenant). Under PROBECTL_DEPLOYMENT_PROFILE=multi-tenant/regulated these
keys are therefore refused at startup (fail closed); leave them unset and
cost runs per-tenant in volume-only mode. Pricing (PROBECTL_COST_PRICES_FILE /
PROBECTL_COST_PRICED) is list rates that are the same for every tenant and
stays allowed in all profiles.
Summary at GET /v1/cost/summary and the Cost page; deeper cross-plane and
ad-hoc views are native at /dashboards and /explore. The
Prometheus-compatible API is optional interoperability, not a dashboard
dependency. See docs/finops.md.
SLO engine
An SLO (service-level objective) is a reliability target — "99.9% of probes to this service succeed over 30 days"; the SLI is the measured indicator behind it, and the error budget is the failure allowance the target leaves (0.1% here): spend it slowly and you're fine, burn it fast and you get paged before the month is lost. OpenSLO is the vendor-neutral YAML standard for declaring these.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_SLO_ENABLED |
true |
OpenSLO SLI/SLO engine over the synthetic-result stream (error budgets + multi-window burn-rate signals) |
PROBECTL_SLO_DIR |
(none) | directory of OpenSLO v1 YAML definitions (strictly validated; malformed, duplicate, or over-1-MiB definitions fail startup) |
Statuses at GET /v1/slos, OpenSLO export at GET /v1/slos/openslo, and the
SLOs page. See docs/slo.md.
Compliance / segmentation validation
Segmentation is the declared rule set of who may talk to whom on your network ("only the app tier may reach the database tier"). Firewalls enforce such rules; this validator checks them against the traffic probectl actually observed, producing audit-grade evidence that the declared boundaries hold — or a verdict naming the flow that crossed one. It never blocks anything.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_COMPLIANCE_ENABLED |
true |
segmentation validator over observed flow/eBPF traffic (validation only — never enforcement) |
PROBECTL_COMPLIANCE_POLICY_DIR |
(none) | segmentation policy YAML directory (strictly validated; malformed or over-1-MiB files fail startup) |
A segmentation policy's zones are CIDR→zone maps applied to every tenant, so
the policy directory is deployment-wide and single-tenant-only (AI-09,
guardrail 7.1): in a pooled deployment two tenants that share a prefix would be
cross-validated and cross-alerted off one tenant's zone definitions. Under
PROBECTL_DEPLOYMENT_PROFILE=multi-tenant/regulated the directory is therefore
refused at startup (fail closed); leave it unset in those profiles.
Verdicts at GET /v1/compliance, hash-chained audit evidence at
GET /v1/compliance/evidence, and the Compliance page. See
docs/compliance.md.
Collective internet-outage view
"Is it us, or is it the internet?" This view answers that: a local engine detects outages from your own vantage points (many tests failing toward the same provider at once), and — only if you opt in — correlates them with public internet-outage feeds (IODA, Cloudflare Radar) reporting trouble in the same networks or countries.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_OUTAGE_ENABLED |
true |
the local engine: vantage detection over your own results + correlation with external events (no outbound calls) |
PROBECTL_OUTAGE_FEEDS_ENABLED |
false |
opt-in public outage feeds (IODA, Cloudflare Radar) — enabling makes outbound fetches (sovereignty / no-phone-home) |
PROBECTL_OUTAGE_FEEDS |
(all) | feeds to load: ioda, cloudflare_radar |
PROBECTL_OUTAGE_REFRESH |
10m |
feed refresh cadence (last-good kept on failure) |
PROBECTL_OUTAGE_RETENTION |
48h |
event window kept/queried |
PROBECTL_OUTAGE_RADAR_TOKEN |
(none) | Cloudflare API token the radar feed requires (a secret reference is accepted); the feed is omitted without it |
PROBECTL_OUTAGE_MIRROR |
(unset) | operator-hosted/offline mirror base for the outage feeds — a local directory or file:// URL (fully offline, air-gap) or an operator-hosted https:// base URL. Each feed loads from <base>/<feed-name> (<base>/ioda, <base>/cloudflare_radar) instead of its public API. Mirrored bodies stay untrusted, cached, and graceful-degrading; an https:// mirror validates TLS |
The collective view at GET /v1/outages (events + the caller-tenant's
affected tests + vantage detections + feed AUP/health + coverage notes) and
the Internet outages page. Scope resolution (IP→ASN/country) rides the open-data
enricher (PROBECTL_FLOW_ENRICH_ASN); without it the response reports the
degradation honestly. See docs/outage.md.
RUM convergence
RUM (real-user monitoring) is timing data real browsers report from real visits — page-load vitals sent home as small beacons — the complement to synthetic probes: synthetics tell you a path is broken before users arrive; RUM tells you what users actually experienced. Convergence is laying the two over each other for the same service.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_RUM_ENABLED |
false |
the browser-beacon ingest + synthetic↔RUM convergence engine (an inbound surface — opt-in) |
PROBECTL_RUM_APPS |
(none) | public app-key registry pk_key=tenant/app;origins=https://shop.example|https://www.shop.example;hosts=shop.example|www.shop.example,... — origins are required under multi-tenant/regulated, optional in single; the optional hosts= list refuses a beacon reporting any other host (403); each beacon binds to its KEY's tenant; enabled-but-empty fails startup |
PROBECTL_RUM_RATE_PER_MIN |
300 |
per-key beacon rate limit (429 + Retry-After above it; 0 = unlimited) |
Beacons ingest at POST /ingest/rum (public-key routed, optional origin
allow-listed, consent-gated, URL-redacted, no IP stored — privacy is enforced
server-side, fail closed);
the convergence view serves at GET /v1/rum and folds into the Endpoints
surface; rum.* vitals flow to the TSDB for dashboards. The SDK is
web/public/probectl-rum.js. See docs/rum.md.
Carbon / power observability
Moving bytes costs energy; energy has a carbon intensity. This engine multiplies observed traffic volume by published energy coefficients and your grid's intensity to produce estimates — always labeled as such, with the methodology attached to every response — entirely locally.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_CARBON_ENABLED |
true |
coefficient-based energy/carbon ESTIMATES over the local flow stream (local-only; methodology served with every response) |
PROBECTL_CARBON_GRID_GCO2E |
436 |
your grid's carbon intensity in gCO2e/kWh (defaults to the world average — set yours) |
Attribution reuses PROBECTL_COST_ZONES / PROBECTL_COST_SERVICES. The
estimate serves at GET /v1/carbon and folds into the Cost page. See
docs/carbon.md. The chaos injector and the large/extra-large scale gate are
test-harness tools — see docs/chaos.md and docs/scale-gate.md.
Editions / license
A license file is a small signed document stating what was bought: the tier, the feature list, the expiry, the customer. Ed25519 is the signature scheme — verifying needs only the public key, which is baked into the binary, which is precisely why verification works with the network cable unplugged.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_LICENSE_FILE |
(none) | path to the Ed25519-signed license file (maximum 1 MiB). Unset = Community (the full core, default-open). Set-but-missing/invalid/oversized = startup error (fail closed on configuration) |
PROBECTL_LICENSE_PUBKEYS_B64 |
(none) | build-time, not runtime: comma-separated base64 Ed25519 public keys linked into probectl-control and probectl-license as the license trust anchors (make variable / Docker LICENSE_PUBKEYS_B64 build-arg / scripts/build-release-binaries.sh). Release builds ship the production anchors; an evaluation build links the evaluation signer instead. A binary built without any anchor cannot verify a license and says so at version and probectl-license verify (DPR-001); the runtime never reads this variable from the environment. |
Verification is offline — local signature math against public keys baked
into the binary at build time (never an env var; never phone-home). Expiry
runs the 30-day-grace → read-only ladder and never breaks running
telemetry. License state + the feature→tier map serve at
GET /v1/editions and render on Admin → Editions — the one place tiers
appear when unlicensed. See docs/editions.md for the file format, the
signing CLI (probectl-license), and the gating pattern.
Provider / management plane (ee/)
The provider plane is the MSP operators' own console — tenant lifecycle, fleet
view, metering — deliberately a separate privilege domain from tenant users.
Break-glass (named for the fire-alarm cover) is its only path into tenant
data: an explicit, tenant-consented, time-bounded, separately-audited emergency
grant. Active only when the license grants provider_plane; otherwise
/provider/* is a plain 404 (hidden, not locked).
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_PROVIDER_BOOTSTRAP_TOKEN |
(none) | creates the FIRST operator via POST /provider/v1/auth/bootstrap; single-use — inert once any operator exists |
PROBECTL_PROVIDER_BREAKGLASS_MAX_TTL_MINUTES |
240 |
cap on break-glass grant lifetimes (5–1440) |
The provider plane additionally requires PROBECTL_ENVELOPE_KEY (operator
TOTP secrets are envelope-sealed at rest) and a database. Operator MFA is
mandatory; operators are a privilege domain distinct from tenant users with
no implicit access to tenant telemetry — see docs/provider-plane.md for
the model, the break-glass consent flow, and the storage-layer confinement
(probectl_provider role). That page also records the engineering eval smoke:
community builds must keep /provider/* as a plain 404, while provider evals
must attach the licensed ee/ plane with an envelope key and bootstrap token.
Suspending a tenant rejects its users at the API
(tenant_suspended) without touching data or ingestion.
Siloed / hybrid isolation (ee/)
Two ways to keep tenants apart: pooled — shared tables where every row is
tagged and filtered by tenant_id (one building, locked apartments) — and
siloed — physically separate stores per tenant (separate buildings);
hybrid mixes the two per tenant. Pooled stays the default and needs no
configuration. Siloed and
hybrid tenants (per-tenant Postgres schema / ClickHouse database / bus topic
namespace / object key namespace) require a license granting
siloed_isolation and are selected per tenant at provisioning
(isolation_model + optional residency).
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_DATAPLANES |
(none) | named residency data planes — name=clickhouseURL[;name=clickhouseURL...] (e.g. eu=https://ch-eu:8443;us=https://ch-us:8443). multi-tenant/regulated profiles require https:// ClickHouse endpoints. A tenant's residency pins its ClickHouse database to that plane |
Residency pins the tenant's ClickHouse flow data in this release;
Postgres control state, the TSDB, object storage, and bus brokers are NOT
region-pinned yet — docs/isolation.md states the exact contract, the
catch-up/migration story for silo schemas, and the offboard-teardown
boundary: Offboard is status-only, while retained-store reclamation requires a
separate tombstone-aware maintenance flow.
Deployment theme and product identity
PROBECTL_THEME_OVERRIDES configures one token map for the whole deployment.
It is a JSON object because CSS color functions and font lists can contain
commas. Startup validates token names, value grammar, the 64-entry size bound,
and WCAG contrast against both shipped themes; malformed or unsafe config fails
closed. The light and dark operator themes remain built in; light is the default.
Public GET /branding is intentionally available before authentication so the
login shell and signed-in UI receive the same theme. It always returns
product_name: "probectl", never varies by host/session/tenant, and may include
the validated deployment token_overrides. It is cacheable for 60 seconds.
There are no provider-plane or per-tenant branding APIs, custom-domain tenant
mapping, logo override, login-message override, or notification-email identity
override. The product decision and schema compatibility window are recorded in
white-label.md.
Advanced data governance (governance, ee/)
Per-tenant data classification + redaction, composed with retention, residency,
and BYOK. No new config keys: the classification + redaction MECHANISM is core (the
?redact=true export toggle works anywhere,
masking PII with a partial strategy); the governance feature adds per-tenant
POLICY (stored in tenant_governance, migration 0033) set from the provider
plane (GET/PUT /provider/v1/tenants/{id}/governance). IPs are PII by default.
Full model: docs/governance.md. Redacted export: GET /v1/lifecycle/export?redact=true.
Tenant lifecycle: export, retention, erasure (core)
Export + verifiable deletion are a compliance right — core in every edition.
GET /v1/lifecycle/export (permission lifecycle.export) streams the
portability bundle; GET/PUT /v1/lifecycle/retention + POST /v1/lifecycle/erase (permission lifecycle.erase, slug-confirmed,
irreversible) manage retention and run the attested cross-store erasure — the
attestation is the signed receipt enumerating what was deleted from which
store, the document an auditor or a departing tenant actually gets to keep. The
provider console adds the operator-side erase trigger. See
docs/runbooks/tenant-offboarding.md for the full procedure and the
per-store verification table.
The data-class and purpose matrix for those clocks is
data-retention.md.
Subject lifecycle uses the same tenant-first boundary for a narrower privacy
request: a data subject is a person or identifier named by the tenant admin,
for example an email address, user name, host-owned endpoint IP, or trace/log
attribute. Use POST /v1/lifecycle/subjects/export with
{"subject":"alice@example.com","redact":true} for a subject portability
bundle, and POST /v1/lifecycle/subjects/erase with
{"subject":"alice@example.com","confirm":"alice@example.com","reason":"dsar"}
to erase that subject. Both routes require the lifecycle permissions
(lifecycle.export and lifecycle.erase) and intentionally use POST bodies so
the subject never appears in URLs, proxy logs, or browser history. The receipt
stores only a tenant-scoped subject hash, deleted/remaining counts per plane,
and the report hash. The subject manifest is complete across privacy-relevant
surfaces: flow and OTLP planes report exported/deleted row counts; immutable
audit reports projected; TSDB metrics, topology labels, eBPF workload
aggregates, endpoint latest views, and topology device nodes report their own
export/delete receipts when the wired backend is subject-capable. RUM reports
covered_by_parent because its host/path samples live in tsdb_metrics.
The encrypted IR attribution sidecar reports retained_encrypted_evidence:
ordinary subject export and erasure cannot read, decrypt, or delete it, while
the separately authorized and audited IR investigation path remains available.
Aggregate backends that cannot locally delete one subject report not_capable
with the delete-series or retention age-out basis instead of disappearing from
the receipt.
Audit subject erasure is layered on the append-only audit chain. A
privacy.subject_erase marker and its tenant-routed
audit_subject_erasures projection row store only a tenant-scoped subject hash;
later audit reads/exports replace matching structured actor/target/data values
with an erased token while preserving the raw chain links. The projection
and every stable-alias marker commit atomically with all classified Postgres
subject deletions; any alias-marker failure rolls the whole tenant transaction
back. Audit retention also captures marker-only events from rolling old writers
before pruning their exported prefix, so a marker never becomes a permanent
retention barrier and redaction never expires with that event row. Subject
portability exports match this hash directly, and both subject and full-tenant
bundles serialize audit rows through the canonical projection while preserving
tenant, sequence, timestamp, and chain fields. WORM provider exports are
minimized before signing, so write-once audit evidence does not become a second
forever-copy of personal actor/data values.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_BACKUP_RETENTION_NOTE |
(empty → a generic fallback statement) | your backup-TTL statement, included VERBATIM in every deletion attestation — be explicit about when snapshots expire. When unset, a generic placeholder sentence is recorded instead |
PROBECTL_BACKUP_RETENTION_DAYS |
0 |
concrete backup TTL in days. When > 0, the tenant-erasure attestation quantifies a bounded backup-coverage window (backup_erasure_deadline = erased_at + this many days); 0 = note-only |
PROBECTL_ENVELOPE_KEY / PROBECTL_ENVELOPE_KEY_FILE |
(none) | the at-rest KEK (see the control-plane table) — also used by probectl-control backup-seal/backup-open to encrypt/restore backups. The chart's Postgres backup CronJob mounts it to seal dumps in the pipeline |
The daily lifecycle sweeper enforces most tenant policy fields from
GET/PUT /v1/lifecycle/retention: flow_retention_days,
otel_retention_days, ebpf_retention_days, path_retention_days,
audit_retention_days, ai_answer_retention_days,
object_retention_days, and derived_identity_retention_days. NULL means
the deployment default remains in force; a tenant value can only tighten the
clock. An audit_retention_days value above a positive
PROBECTL_AUDIT_RETENTION maximum is rejected with HTTP 422 before the policy
or its audit event is written. Stores with a safe tenant+cutoff delete hook prune and append a
lifecycle.retention_sweep receipt. Aggregate or externally governed stores
append delegated/not-capable receipts that name the owning clock (object-store
lifecycle or backend TTL) instead of over-claiming. Audit is handled by its
hourly runner, which emits the tamper-evident audit.retention_prune receipt
only with an actual exported-prefix prune and records that tenant's effective
window.
Prometheus-mode TSDB series deletion remains a documented manual step.
Per-tenant metering & quotas (ee/)
No configuration keys: metering activates with a license granting metering
(provider/MSP tier). Counters flush every minute; gauge snapshots run every
15 minutes; usage and quotas live in Postgres (migration 0026). The usage
API, the CSV/JSONL billing-export feed, per-tenant quotas (creation-gating
only — telemetry is never quota-dropped), and the console showback card are
documented in docs/metering.md.
Per-tenant key isolation / BYOK (ee/)
BYOK is bring your own key: a tenant supplies (and can revoke) the encryption
key its data is sealed under, instead of trusting the operator's. Unlocked by
the byok feature (Enterprise). The keyring wraps managed tenant KEKs under
PROBECTL_ENVELOPE_KEY (required when byok is licensed — startup fails
loudly without it) and resolves BYOK references through the secret backends.
Surfaces: GET/POST /v1/security/keys[...] (permission security.keys) + the
Admin → Encryption keys card. The full model — sealing formats, rotation, the
BYOK lockout warning, crypto-offboarding — is in docs/byok.md.
A BYOK reference submitted through POST /v1/security/keys/rotate is
untrusted tenant input that the control plane resolves with its own
secret-store credentials. PROBECTL_BYOK_REF_PREFIX fences which references a
tenant may bind so a tenant admin cannot aim the resolver at any secret it can
read (confused deputy) or store literal key material in the DB (AUTHZ-10;
docs/guardrails.md G7-1, G7-6):
| key | default | meaning |
|---|---|---|
PROBECTL_BYOK_REF_PREFIX |
(none) | the allowed secret-reference prefix for tenant BYOK references, e.g. vault:secret/probectl/byok/{tenant}/ (a vault: reference names <mount>/<path> — the KV path you write with vault kv put — and probectl adds KV v2's data/ segment itself, so the prefix never contains it). An optional {tenant} token is replaced with the tenant id so each tenant is pinned to its own namespace. env: and literal/bare values are always refused (they would read the control plane's environment, or persist key material), and so is a reference whose path has an empty, . or .. segment (once the path is cleaned it could walk out of the tenant's namespace). Empty (the default) refuses every tenant BYOK reference — fail closed; set it to the operator-owned per-tenant namespace to enable BYOK. Managed-mode rotation is unaffected. A refused reference returns one generic 422; the specific reason is logged server-side only. |
Tenant fairness (core)
These are the per-tenant bounds that protect a pooled (shared) deployment, so
one noisy tenant can't starve the others — and they are core in every edition. The
ingest-rate and query bounds are on by default with conservative numbers; you
opt out of a bound by setting it to an explicit 0 (unlimited). Unset keeps
the default, and a negative value is a startup error — config validation rejects
it. Per-tenant overrides are set from the provider console into
tenant_fairness. Full model: docs/fairness.md.
These are token-bucket rate limits: the steady rate is the value below, and the
bucket can hold a burst of rate × PROBECTL_FAIRNESS_BURST_SECONDS. Telemetry over
a bound is admission-controlled (shed + counted), never silently corrupted.
| Key | Default | Description |
|---|---|---|
PROBECTL_FAIRNESS_RESULTS_PER_SEC |
1000 |
per-tenant result-message admission rate. Explicit 0 = unlimited |
PROBECTL_FAIRNESS_FLOW_EVENTS_PER_SEC |
10000 |
per-tenant flow-record admission rate. Explicit 0 = unlimited |
PROBECTL_FAIRNESS_INGEST_BYTES_PER_SEC |
2097152 |
per-tenant ingest byte rate (2 MiB/s). Explicit 0 = unlimited |
PROBECTL_FAIRNESS_DEVICE_METRICS_PER_SEC |
2000 |
per-tenant SNMP/gNMI device-sample admission rate. Explicit 0 = unlimited |
PROBECTL_FAIRNESS_OTLP_SERIES_PER_SEC |
5000 |
per-tenant OTLP metric/trace/log series admission rate (SCALE-003). Explicit 0 = unlimited |
PROBECTL_FAIRNESS_BURST_SECONDS |
10 |
burst window: bucket capacity = rate × this. 0 falls back to 10 — an enforced bucket always has a burst |
PROBECTL_FAIRNESS_QUERY_CONCURRENCY |
4 |
per-tenant in-flight query cap (HTTP 429 over it). Explicit 0 = unlimited |
PROBECTL_FAIRNESS_QUERIES_PER_MIN |
120 |
per-tenant query budget per minute (HTTP 429 over it). Explicit 0 = unlimited |
PROBECTL_FAIRNESS_TENANT_IDLE_TTL |
24h |
evict a tenant's in-memory fairness state after it is idle this long (SCALE-002), bounding the gate's per-tenant map under tenant churn. A returning tenant's state is re-created (defaults re-enforced) on its next message. 0 falls back to 24h |
Multi-region / active-active HA (core)
Inert unless PROBECTL_REGION is set (single-region deployments need none of
these). The control plane stays stateless and active in every region; the
split-brain fence pauses API writes during a failover while reads + telemetry
keep flowing — split-brain being the failure where two regions each believe
they hold the writable database and diverge, which a write-pause makes
impossible. Two vocabulary keys below: RPO (recovery point objective) is how
much recent data a disaster may cost you, RTO (recovery time objective) how
long recovery may take — both recorded here as declared targets for humans to
sign off, not values the software can promise. Full model + the failover
runbook: docs/multi-region.md,
docs/runbooks/region-failover.md.
| Key | Default | Description |
|---|---|---|
PROBECTL_REGION |
(empty) | this replica's region; empty = single-region (fence inert) |
PROBECTL_REGIONS |
(empty) | comma list of all regions in the deployment |
PROBECTL_DATABASE_URL |
… | the writer postgres:// or postgresql:// endpoint (DNS/proxy that resolves to the current primary) |
PROBECTL_DATABASE_READ_URL |
(empty) | optional local read-replica postgres:// or postgresql:// endpoint; keyword/value DSNs are rejected and empty means reads use the writer. multi-tenant/regulated profiles require PostgreSQL TLS (sslmode=require, verify-ca, or verify-full) |
PROBECTL_REPLICATION_MODE |
async |
sync (RPO 0) or async (RPO ≈ lag) — descriptive; configure Postgres to match |
PROBECTL_RESIDENCY |
(empty) | default data-residency region (governance) |
PROBECTL_RPO_SECONDS |
0 |
provisional RPO target (human sign-off) |
PROBECTL_RTO_SECONDS |
60 |
provisional RTO target (human sign-off) |
PROBECTL_SINGLETON_LEASE_INTERVAL |
5s |
renew/retry cadence for the cluster-wide background-work advisory lock. Alert evaluation, SIEM audit polling, WORM export, and retention run only on the fenced holder; standbys retry within this interval. Minimum 250ms |
The writer must be reachable for API writes; cluster_state (migration 0032)
holds the promotion epoch the fence reads. Promotion is cluster_promote() in
the failover runbook.
Supportability (core)
Deep health + a secret-stripped support bundle for triage (CORE; the support
org/SLA is contract). No new config keys; diagnostics.read (migration 0034,
admin-seeded) gates GET /v1/diagnostics and GET /v1/diagnostics/bundle. An
offline bundle: probectl-control support-bundle [-o file]. Self-monitoring
series probectl_self_* + probectl_build_info feed the native Dashboards
surface. deploy/grafana/dashboards/probectl-self.json is an optional
operator-supplied-client example, not a bundled runtime. The bundle NEVER contains
secrets/credentials/PII (allowlist config + anonymized topology + a final
scrub). Full model: docs/supportability.md.
Guarded agentic remediation (remediation, ee/)
The assistant PROPOSES remediations; a human APPROVES; probectl NEVER executes —
there is no executor in the codebase (remediation is human-gated by design). Approve is a recorded,
audited, blast-radius-limited, human-only sign-off that an operator carries out
in their own change process; ingested data (e.g. a prompt-injection routed
through the propose_remediation MCP tool) can at most create a proposed
proposal a human must approve via the authenticated UI. The feature is hidden
(404) when the remediation Enterprise feature is unlicensed.
| Variable | Default | Notes |
|---|---|---|
PROBECTL_REMEDIATION_APPROVALS_ENABLED |
false |
advisory-only master switch — until an operator turns this on, Approve is unavailable and proposals are review-only |
PROBECTL_REMEDIATION_MAX_BLAST_RADIUS |
50 |
a proposal whose simulated (topology what-if) blast radius exceeds this cannot be approved; an unknown radius (no topology available) is also blocked — fail closed |
Permissions remediation.propose and remediation.approve (migration 0035,
admin-seeded) gate the /v1/remediation/* routes; the dry-run blast radius is a
read-only topology simulation. Full policy + architecture: docs/remediation.md.
NDR-lite detection
NDR (network detection and response) spots attacker behavior in traffic patterns rather than matching known-bad lists. The detectors here: DGA — the algorithmically-generated throwaway domains malware uses to find its command server; exfiltration — data leaving in unusual volume or via covert channels (e.g. DNS); beaconing — the metronome-regular heartbeat of an implant calling home; plus unusual egress and lateral movement (an internal host suddenly exploring its neighbors). It runs entirely over locally-collected streams — no outbound calls — and, per the guardrails, only ever signals.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_NDR_ENABLED |
true |
behavioral detection engine (DGA/exfil/beaconing/egress/lateral) over local DNS/flow/eBPF streams; signals only — never blocks |
PROBECTL_NDR_RULES_DIR |
(none) | detection-as-code overlay directory; rules merge by id over the embedded defaults, malformed directories fail startup, and each overlay file is capped at 1 MiB before decode |
Detections are confidence-scored threat-plane signals (ndr.*) exported to
incidents, the Security triage surface, and the SIEM (see SIEM export above).
See docs/ndr.md for the detector and tuning reference.
Secrets integration
This is the feature that lets you keep raw passwords out of your config entirely. Anywhere this document asks for a credential, you can instead hand it a pointer to where the real secret lives — a Vault path, a CyberArk query, an AWS/Azure/GCP secret id — and the control plane fetches it at boot (or per poll, for device creds). The settings below just tell probectl how to reach each backend; the references themselves go in the credential keys documented throughout this page.
Any credential value in this document may be a secret reference instead of
the literal material — env:NAME, vault:<mount>/<path>#<field>,
cyberark:<query>, aws:<id>[#<json-field>], azure:<vault>/<name>,
gcp:<project>/<secret>[/<version>], or literal:<value> as the escape
hatch. The control plane resolves PROBECTL_OIDC_CLIENT_SECRET,
PROBECTL_CMDB_SECRET, PROBECTL_AI_MODEL_TOKEN, PROBECTL_SIEM_TOKEN,
PROBECTL_BUS_SASL_PASSWORD, PROBECTL_OUTAGE_RADAR_TOKEN, and the secret
parts of PROBECTL_CHANGE_WEBHOOKS / PROBECTL_NOTIFY_CONNECTORS /
PROBECTL_NOTIFY_INBOUND at startup (fail closed); the device agent resolves
every PROBECTL_DEVICE_CRED_<NAME>_* value per poll cycle. Resolved values are
cached only encrypted, for a short lease (5 m). See docs/secrets.md.
Backend access settings (environment only; all remote endpoints over verified
TLS). Vault and CyberArk base URLs must use https://; plaintext http:// is
accepted only for explicit loopback dev/test endpoints such as 127.0.0.1,
::1, or localhost. Two Vault terms used below: AppRole is Vault's login
method for machines — a role id + secret id pair playing username/password for a
service — and a lease is the expiry Vault stamps on what it issues, which
probectl renews before it runs out:
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_SECRETS_VAULT_ADDR |
(none) | Vault base URL; enables vault: references; remote URLs must be https:// |
PROBECTL_SECRETS_VAULT_TOKEN |
(none) | static Vault token (alternative to AppRole) |
PROBECTL_SECRETS_VAULT_ROLE_ID / PROBECTL_SECRETS_VAULT_SECRET_ID |
(none) | AppRole login; the lease-aware client token is renewed at ⅔ TTL |
PROBECTL_SECRETS_VAULT_NAMESPACE |
(none) | X-Vault-Namespace (Vault Enterprise) |
PROBECTL_SECRETS_CYBERARK_URL |
(none) | CyberArk CCP base URL; enables cyberark:; remote URLs must be https:// |
PROBECTL_SECRETS_CYBERARK_APP_ID |
(none) | CCP AppID |
PROBECTL_SECRETS_CYBERARK_CERT_FILE / PROBECTL_SECRETS_CYBERARK_KEY_FILE / PROBECTL_SECRETS_CYBERARK_CA_FILE |
(none) | optional CCP client-certificate auth |
AWS_REGION (or AWS_DEFAULT_REGION), AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN |
(none) | enables aws: (Secrets Manager, SigV4) |
AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET |
(none) | enables azure: (Key Vault) |
GOOGLE_APPLICATION_CREDENTIALS |
(none) | service-account key file; enables gcp: (Secret Manager) |
Backend health (counters + redacted last error, never secret material) is
served at GET /v1/secrets/health and on the Admin page.
Shipped all-in-one stack (deploy/compose/probectl.yml)
The production-shaped stack reads deploy/compose/.env (copy it from
.env.example). Alongside the required secrets and the image/TLS/license keys
documented there, these optional variables control host exposure and container
resource ceilings. They are Compose-file interpolation variables (consumed by
docker compose), not control-plane config read by the binary.
| Variable | Default | Purpose |
|---|---|---|
PROBECTL_BIND_ADDR |
127.0.0.1 |
Host interface the HTTPS API (8443) is published on. The shipped stack binds loopback by default — it does not expose the API on every interface (RTO-03, docs/guardrails.md G7-12). Set a specific LAN address, or 0.0.0.0 to publish on all interfaces (only behind a firewall/load balancer). HTTPS only; no plaintext port is ever published. |
PROBECTL_CONTROL_MEM_LIMIT |
2g |
Memory ceiling (mem_limit) for the control service. Accepts Docker byte suffixes (512m, 2g). Raise for larger deployments. |
PROBECTL_CONTROL_PIDS_LIMIT |
512 |
Process/thread ceiling (pids_limit) for the control service. |
PROBECTL_POSTGRES_MEM_LIMIT |
1g |
Memory ceiling for the bundled Postgres. Raise for a large database. |
Every service in this stack ships hardened (RTO-03): the control plane and the
certgen/migrate one-shots run the distroless control image with a read-only
root filesystem (writable paths limited to an in-memory /tmp and the
controldata volume at /var/lib/probectl), all Linux capabilities dropped, and
no-new-privileges. The upstream Postgres image keeps its own root filesystem
and capabilities (its entrypoint chowns the data dir and drops to the postgres
user) but still runs with no-new-privileges. See deploy/compose/README.md.
Local dev stack (deploy/compose/dev.yml)
Started with make compose-up. Local, non-production defaults — plaintext
listeners and dev credentials for convenience. Production deploys are
TLS/HTTPS-by-default — TLS on every listener.
| Service | Compose name | Host port(s) | Purpose | Dev credentials |
|---|---|---|---|---|
| PostgreSQL | postgres |
5432 |
Durable state, tenants, RBAC, audit, SLOs | user/pass/db = probectl |
| Kafka | kafka |
9092 |
Result/event bus (KRaft, no ZooKeeper) | none (PLAINTEXT) |
| ClickHouse | clickhouse |
8123 (HTTP), 9000 (native) |
High-cardinality events/flows | user/pass/db = probectl |
| Prometheus | prometheus |
9090 |
Metrics TSDB (remote-write enabled) | none |
The disposable ClickHouse probectl user has access management enabled so
make test-isolation can create and remove temporary tenant reader users and
row policies. That is a local test/admin convenience, not the production
credential model: production uses a separate bootstrap administrator to install
policies and a least-privilege runtime service credential afterward.
Kafka listeners: host clients use localhost:9092; in-network containers use
kafka:19092; the KRaft controller uses 9093 (internal — KRaft is Kafka's
built-in consensus mode, so there is no separate ZooKeeper to run). Prometheus runs with
--web.enable-remote-write-receiver so the result pipeline can remote-write into
it.
These names and ports are a contract — the integration test harness depends on them, so don't rename them casually.
Tear-down
make compose-down removes the containers and volumes (pgdata, chdata,
promdata).