A ctlplne studio product

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 (one Load function 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, or probectl.io/endpoint/v1). schema_version: 1 is 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=true acknowledgment 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 WARN at boot naming every memory-backed plane;
  • /readyz lists them under volatile_stores for an authenticated caller (the node stays ready — the request path is up; an anonymous probe gets status only, AUTHZ-25);
  • /v1/diagnostics reports degraded with the named finding readiness.volatile_stores;
  • probectl-control preflight --strict exits 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 signing DNSKEY RRset chains to a configured trust anchor: a key in the set is pinned by an anchor DS and self-signs the keyset (RFC 4035). The anchor is the baked IANA root DS by default, or an operator-pinned DS (trust_anchor).
  • indeterminate — every answer RRset carries a valid, in-window signature from the zone's DNSKEY, but that keyset does not chain to a trust anchor. This is an honest "signed, but not provably authentic" signal: it does not claim secure, 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 (no RRSIG).
  • 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 — set advertise_host explicitly 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 requires ai.query). The default seven-day expiry is capped by the tenant's object_retention_days policy.
  • 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).

Rendered live from github.com/ctlplne/probectl — found a mistake? edit this page.