Documentation
¶
Overview ¶
Package client is the ONE place that talks to the Whisper control plane. It backs BOTH the scriptable Cobra subcommands and the Bubble Tea TUI (DRY): the Cypher query builder, the {ok,status,result} envelope decoder, the SSE reader, the key ladder, an embedded Mozilla CA bundle, and the RDAP client.
Robustness Principle (RFC 761): conservative in what we EMIT - every Cypher literal is escaped so a value can never break out of the map; liberal in what we ACCEPT - the envelope decoder handles both wire shapes the control plane may return.
Index ¶
- Constants
- Variables
- func BuildAgentsQuery(op string, args map[string]any) string
- func CypherMap(m map[string]any) string
- func DefaultAgentFile() string
- func DefaultBoundFile() string
- func DefaultKeyFile() string
- func DeviceHTTPClient() *http.Client
- func EmbeddedRootCAs() *x509.CertPool
- func EscapeCypherString(s string) string
- func LeafHashFromDisclosure(salt, canonicalEvent []byte) []byte
- func Lit(v any) string
- func ParseWitnessKeyPin(s string) ([]byte, error)
- func PollDeviceToken(ctx context.Context, hc *http.Client, consoleURL, deviceCode string, ...) (string, error)
- func QuoteCypherString(s string) string
- func ReachURL() string
- func ReachURLHost() string
- func ReadAgentFile(path string) string
- func ReadSSE(ctx context.Context, r io.Reader, emit func(MonitorEvent)) error
- func RootCAs() *x509.CertPool
- func SaveAgent(path, agent string) error
- func SaveKey(path, key string) error
- func TLSConfig() *tls.Config
- func VerifyInclusion(leafHash []byte, index, treeSize uint64, path [][]byte, root []byte) error
- func WriteBoundFile(path string, mode string, addr netip.Addr, name, agent string) error
- type BoundIdentity
- type Client
- func (c *Client) Agents(ctx context.Context, op string, args map[string]any) (*Envelope, error)
- func (c *Client) Credential() Credential
- func (c *Client) DirectEgressIP(ctx context.Context) (string, error)
- func (c *Client) FetchCheckpoint(ctx context.Context) (*LedgerCheckpoint, error)
- func (c *Client) FetchDoc(ctx context.Context, docURL string) (string, error)
- func (c *Client) FetchInclusion(ctx context.Context, addr string) ([]LedgerInclusion, *LedgerCheckpoint, error)
- func (c *Client) FetchLedgerKey(ctx context.Context) (*LedgerKey, error)
- func (c *Client) FetchWitnessKeys(ctx context.Context) (*WitnessPolicy, error)
- func (c *Client) GraphQuery(ctx context.Context, query string, params map[string]any) (*GraphResult, error)
- func (c *Client) GraphQueryPublic(ctx context.Context, query string, params map[string]any) (*GraphResult, error)
- func (c *Client) GraphQueryRows(ctx context.Context, cypher string, params map[string]any) ([]map[string]any, GraphStats, error)
- func (c *Client) GraphStats(ctx context.Context) (json.RawMessage, error)
- func (c *Client) MonitorURL() string
- func (c *Client) ObservedEgressIP(ctx context.Context, proxyEndpoint string) (string, error)
- func (c *Client) Query(ctx context.Context, query string) (*Envelope, error)
- func (c *Client) RDAP(ctx context.Context, kind RDAPKind, target, query string) (json.RawMessage, int, error)
- func (c *Client) RunFlow(ctx context.Context, consoleURL, slug, value string, ...) error
- func (c *Client) StreamMonitor(ctx context.Context, agentAddr string, emit func(MonitorEvent)) error
- func (c *Client) Text2Cypher(ctx context.Context, t2cURL string, r Text2CypherRequest) (json.RawMessage, error)
- func (c *Client) VerifyIdentity(ctx context.Context, addr string) (*VerifyVerdict, json.RawMessage, int, error)
- type Config
- type Credential
- type DeviceAuth
- type DeviceToken
- type DeviceTokenStatus
- type EchoResult
- type Envelope
- type FlowEvent
- type GraphResult
- type GraphStats
- type KeyLadderOptions
- type KeySource
- type LedgerCheckpoint
- type LedgerCosignature
- type LedgerInclusion
- type LedgerKey
- type MonitorEvent
- type OutsideReach
- type ProblemError
- type RDAPKind
- type Result
- type Text2CypherRequest
- type VerifyVerdict
- type WitnessEntry
- type WitnessPolicy
Constants ¶
const ( DefaultControlURL = "https://graph.whisper.online/api/query" DefaultMonitorURL = "https://ns1.whisper.online/monitor/stream" DefaultRDAPURL = "https://rdap.whisper.online" // DefaultConsoleURL is the user-facing console that hosts the device-authorization // (RFC 8628) login flow: POST /api/device/authorize and POST /api/device/token. It // is the default sign-in surface for `whisper login` (overridable with --console-url // for pre-prod - Postel: liberal in, sane zero-config default). DefaultConsoleURL = "https://console.whisper.security" // DefaultVerifyURL is the public, KEYLESS one-call identity-verification surface // GET /verify-identity?ip=<addr> runs the full agent-trust chain // server-side (reverse-DNS + FCrDNS + DANE-TLSA pin + the JWS identity doc) and // returns one signed-where-possible verdict. It is served on ANY gateway host; rdap // is the natural public default. Overridable with --verify-url for pre-prod. DefaultVerifyURL = "https://rdap.whisper.online" )
Canonical endpoints. graph.whisper.online is the ONE control endpoint - the own-infra front door that serves whisper.agents directly; the legacy graph.whisper.security proxy hop is being decommissioned (agents are moving off it), so it is NOT a default and there is NO fallback to it. The live monitor SSE is served directly by the active/active ns nodes (the gateway does not proxy /monitor/stream); rdap.whisper.online is the public RDAP service. Each is overridable with its --*-url flag for a self-hosted or pre-prod endpoint (Postel: liberal in, sane zero-config default - the common case needs no flag).
const ( KindDNS = "dns" KindConn = "conn" KindAlloc = "alloc" KindHB = "hb" // a heartbeat comment; surfaced so the TUI can show "connected" while idle )
Event kinds on the monitor stream (and in op:logs).
const AllowNetworkEnv = "WHISPER_TEST_ALLOW_NETWORK"
AllowNetworkEnv opts one test run back into real network access, for the deliberately-live suites (a release e2e, a live resolver probe). It is explicit, per-run, and named in the refusal so nobody has to go looking.
const DefaultEchoURL = "https://rdap.whisper.online/egress-ip"
DefaultEchoURL is the Whisper-owned, KEYLESS source-IP echo: a GET returns the OBSERVED source IP of the request as {"ip":"<addr>"}. `whisper ip` fetches it THROUGH the local egress proxy, so the IP it sees is the agent's /128. This replaces any dependency on api*.ipify.org (no chatty external dependency on the hot path). Served on any gateway host; rdap is the natural public default. Overridable with --echo-url for pre-prod.
const DefaultReachURL = "https://example.com/"
DefaultReachURL is that destination. example.com is dual-stack (an IPv6-only egress can reach it, so a v6-only session is never mistaken for a blocked one), it is IANA-operated for exactly this kind of use, it is a static page rather than somebody's API, and it is not a host a tenant would think to put on an allow list - which is the point, because an allow-listed probe is a rubber stamp.
const DefaultText2CypherURL = "https://mcp.whisper.security/api/v1/text2cypher/generate"
DefaultText2CypherURL is the public natural-language-to-Cypher endpoint: it translates an English question into a validated Cypher query (and can execute it). Keyed (X-API-Key). Overridable via Config.Text2CypherURL for pre-prod.
const DeviceClientTimeout = 20 * time.Second
DeviceClientTimeout bounds a single device HTTP call (authorize / one token poll). The overall flow lifetime is governed by the server-supplied expires_in deadline, not this.
const ReachURLEnv = "WHISPER_REACH_URL"
ReachURLEnv overrides that destination, the way --echo-url overrides the echo: for a pre-prod run, for an operator whose network cannot see example.com, or for one who would rather sample a destination representative of their own traffic.
Variables ¶
var DefaultMonitorURLs = []string{
"https://ns1.whisper.online/monitor/stream",
"https://ns2.whisper.online/monitor/stream",
}
DefaultMonitorURLs are the monitor SSE endpoints tried in rotation when no explicit --monitor-url override is given: both ns nodes are active/active and answer identically, so a reconnect after a node failure lands on the healthy one.
var DefaultReportFallbackBases = []string{
"https://ns1.whisper.online",
"https://ns2.whisper.online",
}
DefaultReportFallbackBases are the public Whisper hosts a client falls back to, in order. They are the active/active pair the monitor stream rides: both answer identically, so the first reachable one wins and there is nothing to choose between them.
var ErrTestNetworkRefused = errors.New("refused: a test tried to reach the network")
ErrTestNetworkRefused is the refusal, exported so a live suite can recognise it rather than string-matching the message.
Functions ¶
func BuildAgentsQuery ¶
BuildAgentsQuery builds the one control-plane verb:
CALL whisper.agents({op:'<op>', args:{...}})
args may be nil/empty (rendered as {}). Both op and every arg value are escaped, so the produced Cypher is always well-formed and injection-proof.
func CypherMap ¶
CypherMap renders a map as a Cypher map literal: {k1:v1,k2:v2}. Keys are emitted in sorted order so the produced query is DETERMINISTIC (stable for tests, caches, and logs) regardless of Go map iteration order. An empty map renders as {}.
func DefaultAgentFile ¶
func DefaultAgentFile() string
DefaultAgentFile is the on-disk location of the CHOSEN agent id, mirroring DefaultKeyFile: $HOME/.config/whisper/agent (mode 600). install.sh writes the agent the user picked/created here so `connect` binds egress to THAT identity with zero extra config; absent ⇒ the server's reuse-most-recent default applies (still zero-config).
func DefaultBoundFile ¶ added in v0.210.0
func DefaultBoundFile() string
DefaultBoundFile is the autobind marker the installer writes: $HOME/.config/whisper/bound (key=value lines: mode/address/name/agent/bound_at, no secrets). It records the /128 identity this endpoint was bound to at deploy.
func DefaultKeyFile ¶
func DefaultKeyFile() string
DefaultKeyFile is the on-disk key location, mirroring the shell CLI + installer: $HOME/.config/whisper/key (mode 600).
func DeviceHTTPClient ¶
DeviceHTTPClient builds an HTTP client wired with the embedded-CA TLS config and a per-call timeout suitable for the device flow. It is used when a caller does not supply its own client (e.g. the real `whisper login`); tests inject httptest's client.
func EmbeddedRootCAs ¶
EmbeddedRootCAs returns a pool of ONLY the embedded Mozilla bundle (no system pool). Exposed for tests that must assert the embedded bundle is non-empty and parses.
func EscapeCypherString ¶
EscapeCypherString renders s safe to embed inside a single-quoted Cypher literal.
Cypher escapes with a BACKSLASH, the way C and JSON do: \' for a quote, \\ for a backslash, \n \r \t \b \f for the control characters that would otherwise ride the wire raw. SQL is the language that doubles a quote, and this function used to do that, which is a real difference and not a stylistic one: the control plane's own map-literal reader returns on the second quote character, so 'Tim O”Reilly' terminated the string early and every value carrying an apostrophe came back as
400 bad_request "could not read the whisper.agents(...) map literal:
expected , or } after a map entry, found '''"
measured against graph.whisper.online and against ns1 directly. The docstring here promised that "a legitimate apostrophe in a label (Tim O'Reilly) just works" while the code guaranteed the opposite.
A breakout attempt ("'}}) RETURN 1 //") stays trapped inside the literal either way: the quote is escaped rather than closed.
Conservative in what we emit: the returned string is the INNER text only (no surrounding quotes) - callers wrap it in '...'. Escaping the newline matters for the same reason as the quote: a WhaleACL document is a multi-line HuJSON file sent as one argument, and a query that carries raw newlines through a JSON body and a proxy is one rewrite away from arriving mangled.
func LeafHashFromDisclosure ¶
LeafHashFromDisclosure recomputes the RFC 6962 leaf hash from a DISCLOSED (salt, event): commitment = SHA-256(salt || canonicalEvent); leafHash = SHA-256(0x00 || commitment). This is the selective-disclosure recompute: only a holder of the salt can do it.
func Lit ¶
Lit renders an arbitrary Go value as a Cypher literal: - string -> a quoted, escaped string literal - bool -> true / false - int / int64 -> the decimal form - float64 -> the shortest exact decimal form - []T / []any -> a bracketed list of literals - map[string]any -> a brace map literal (keys sorted for determinism) - nil -> null
Conservative-emit: every leaf string flows through QuoteCypherString, so no value - however hostile - can break out of the surrounding map/list.
func ParseWitnessKeyPin ¶ added in v0.130.0
ParseWitnessKeyPin decodes an OUT-OF-BAND pinned witness public key: the raw 32-byte Ed25519 key as base64 (the /witness/keys public_key form), the 44-byte X.509 SubjectPublicKeyInfo as base64 (the public_key_spki form), or 64 hex chars, whichever the operator was handed (Postel: liberal accept). Returns the raw 32-byte key.
func PollDeviceToken ¶
func PollDeviceToken(ctx context.Context, hc *http.Client, consoleURL, deviceCode string, interval, deadline time.Duration) (string, error)
PollDeviceToken polls POST <consoleURL>/api/device/token with {device_code} every interval until the console returns approved (-> the api_key), expired, the deadline passes, or ctx is cancelled. It honours the RFC 8628 minimum cadence (a 0/negative interval falls back to 5s). On approval it returns the api_key; on expiry/deadline it returns a clear *ProblemError. The api_key and device_code are NEVER logged.
hc may be nil (a default embedded-CA client is built). deviceCode is the secret from DeviceAuthorize. A transient pending-poll transport error is tolerated (we keep polling until the deadline) so a momentary blip never aborts an otherwise-fine login.
func QuoteCypherString ¶
QuoteCypherString returns s as a complete single-quoted, escaped Cypher string literal.
func ReachURL ¶ added in v0.212.1
func ReachURL() string
ReachURL is the destination the pair is measured against.
func ReachURLHost ¶ added in v0.212.1
func ReachURLHost() string
ReachURLHost is the host ReachOutside will name, for a caller that has to decide whether an answer it remembered earlier is still about the same destination.
func ReadAgentFile ¶
ReadAgentFile returns the persisted CHOSEN agent id (trimmed), or "" when the file is absent/empty/unreadable. Liberal + fail-soft: a missing agent file is NOT an error - it simply means "no pinned agent", and the caller falls back to the most-recent default. When path is empty, DefaultAgentFile() is used.
func ReadSSE ¶
ReadSSE reads an SSE byte stream to completion, emitting each decoded MonitorEvent on emit(). It parses the line-based SSE framing (`event:` + `data:`, blank line ends an event; a `:`-prefixed comment is a heartbeat) and is LIBERAL: a malformed data line is skipped, a heartbeat surfaces as a KindHB event, multi-line `data:` is concatenated per the SSE spec.
It returns when r hits EOF, ctx is cancelled, or a read error occurs. The caller owns r's lifecycle (typically an *http.Response.Body closed on ctx-cancel).
func RootCAs ¶
RootCAs returns the trust pool the CLI uses for every HTTPS call. It UNIONS the system pool (when present) with the embedded Mozilla bundle, so: - a host with a trust store gets it plus our pinned bundle (max reliability), and - a host with NO trust store still verifies via the embedded bundle (zero config).
Conservative-emit: we never disable verification; liberal-accept: either trust source suffices. A nil return is impossible - the embedded bundle is always present.
func SaveAgent ¶
SaveAgent writes the chosen agent id to an owner-only file in an owner-only directory, creating parents as needed - mirrors SaveKey. An empty id removes the pin (so a later reuse-most-recent default applies) rather than persisting a blank.
The agent id is not a secret the way the key is, but it names the /128 this host answers as, and a file that tells every account on the box which identity to impersonate is a reconnaissance gift. It rides the same writer for the same reason.
func SaveKey ¶
SaveKey writes key to the key file as an owner-only file in an owner-only directory, creating parents as needed - used by `whisper login`.
This is the one file on the machine that holds a live API key, so the write goes through secfile rather than through os.WriteFile at 0o600: the mode argument is the whole control on unix and controls nothing at all on Windows, where the file would otherwise inherit the containing directory's DACL and, under %ProgramData%, be readable by every interactive account.
func TLSConfig ¶
TLSConfig is the standard client TLS config: verified against RootCAs, TLS 1.2 floor.
func VerifyInclusion ¶
VerifyInclusion folds leafHash with the RFC 6962 audit path and returns nil iff it reconstructs the checkpoint root for (index, treeSize). The exact reference index-walk a stock CT verifier uses, interoperable with the server's RFC 6962 inclusion proofs.
func WriteBoundFile ¶ added in v0.211.0
WriteBoundFile records a completed bind at path ("" => DefaultBoundFile()), in the same key=value shape the installer writes, so two producers cannot drift on a format ReadBoundFile above has to parse.
Nothing in Go wrote this marker until now: the installers wrote it in shell, and `whisper create` did not write it at all. A host bound by hand therefore had no marker, and everything that reads one found no /128 to act as.
Secrets never appear here. A register envelope carries the minted agent's own api key; the caller parses what it needs and drops it. This writes mode, address, name, agent and a timestamp, which is exactly what a re-run needs to be idempotent and what an uninstall needs to revoke.
Owner-only, through secfile rather than through a mode argument: the marker names an endpoint's identity, and a file that leaks which /128 a host answers as is a reconnaissance gift even though it holds no key. secfile carries the umask-independent chmod this used to do by hand, and on Windows it sets the protected DACL that a mode argument there would not have set at all.
Types ¶
type BoundIdentity ¶ added in v0.210.0
type BoundIdentity struct {
Addr netip.Addr // the endpoint's /128 (zero when absent/unparseable)
Agent string // the agent handle recorded at bind time
Name string // the FCrDNS name recorded at bind time
}
BoundIdentity is the parsed autobind marker: the endpoint's own /128, its agent handle, and its FCrDNS name. Zero fields simply mean the marker did not carry them.
func ReadBoundFile ¶ added in v0.210.0
func ReadBoundFile(path string) (BoundIdentity, bool)
ReadBoundFile parses the autobind marker at path ("" => DefaultBoundFile()). Liberal + fail-soft, mirroring ReadAgentFile: a missing/unreadable/garbled file is NOT an error - it returns ok=false and the caller treats the endpoint as unbound. Unknown lines are ignored; only a parseable IPv6 address= line makes the identity usable (ok=true).
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is the single control-plane + RDAP + SSE client backing both surfaces.
func New ¶
New builds a Client from cfg, applying canonical defaults and wiring the embedded-CA TLS into a fresh transport when no HTTPClient was supplied.
func (*Client) Agents ¶
Agents runs CALL whisper.agents({op, args}) and returns the normalised envelope. It POSTs the JSON body the control plane documents; a transport error is wrapped with a helpful message (never an opaque failure).
func (*Client) Credential ¶
func (c *Client) Credential() Credential
Credential returns the resolved principal this client authenticates with.
func (*Client) DirectEgressIP ¶ added in v0.210.0
DirectEgressIP fetches the same keyless echo WITHOUT the local egress proxy, so the observed source is the host's OWN (direct, un-tunnelled) public IP. verifyEgress uses it as the reference for the v4-egress case: when the egress reaches a v4 destination via NAT64 the observed source is Whisper's shared v4 SNAT (NOT the agent's v6 /128, which cannot source a v4 packet), so a range check alone cannot confirm the tunnel. Proving the proxied source DIFFERS from this direct source proves the traffic is genuinely leaving through Whisper and not leaking straight out of the host. Never logs the URL or body.
func (*Client) FetchCheckpoint ¶
func (c *Client) FetchCheckpoint(ctx context.Context) (*LedgerCheckpoint, error)
FetchCheckpoint downloads + parses the latest checkpoint from <gateway>/checkpoint.
func (*Client) FetchDoc ¶ added in v0.130.0
FetchDoc GETs one whisper.security documentation page as markdown. It is KEYLESS (the docs are public) and takes a fully-formed https URL the caller built from a constant docs base + an index-relative path (never arbitrary caller input), so it cannot be pointed at an unexpected host. Returns the markdown text.
func (*Client) FetchInclusion ¶
func (c *Client) FetchInclusion(ctx context.Context, addr string) ([]LedgerInclusion, *LedgerCheckpoint, error)
FetchInclusion downloads the inclusion data for addr from /ip/<addr>/transparency and returns the leaf(s) for that address (index + opaque leaf hash + inclusion proof) plus the proving checkpoint note the server embedded.
func (*Client) FetchLedgerKey ¶
FetchLedgerKey downloads the published verification key from <gateway>/checkpoint/key.
func (*Client) FetchWitnessKeys ¶ added in v0.130.0
func (c *Client) FetchWitnessKeys(ctx context.Context) (*WitnessPolicy, error)
FetchWitnessKeys downloads the published witness policy from <gateway>/witness/keys . A 404 means witnessing is simply not enabled on the node, so callers treat that as "no witness policy" (the honest tamper-evident posture), never an error to surface.
func (*Client) GraphQuery ¶ added in v0.130.0
func (c *Client) GraphQuery(ctx context.Context, query string, params map[string]any) (*GraphResult, error)
GraphQuery runs one parameterised Cypher statement against the public graph endpoint (the same URL the control plane rides: POST {"query","parameters"}). params may be nil (sent as {}). A failure comes back as a *ProblemError carrying the server's own detail, never an opaque error.
It requires a credential. The keyless public procedures (whisper.assess / .identify / .explain) go through GraphQueryPublic instead.
func (*Client) GraphQueryPublic ¶ added in v0.211.0
func (c *Client) GraphQueryPublic(ctx context.Context, query string, params map[string]any) (*GraphResult, error)
GraphQueryPublic runs a graph statement that does NOT need a tenant: the public procedures anyone may call. It is the keyless half of the two tiers, so it omits GraphQuery's credential precondition. A key is still SENT when one resolves, because the same statement answers with a higher cap for a key-holder; it is simply never demanded.
func (*Client) GraphQueryRows ¶ added in v0.130.0
func (c *Client) GraphQueryRows(ctx context.Context, cypher string, params map[string]any) ([]map[string]any, GraphStats, error)
GraphQueryRows is the row-and-stats convenience over GraphQuery for interactive callers (the TUI explorer): it returns the decoded rows plus a parsed GraphStats, discarding the ordered columns and the verbatim Raw the CLI/MCP passthrough needs. A failure surfaces the same *ProblemError GraphQuery would.
func (*Client) GraphStats ¶ added in v0.130.0
GraphStats fetches the live database statistics (physical/virtual/total node + edge counts, object count, threat-intel summary) from the graph endpoint's companion /stats path (the same numbers the reference `whisper://stats` resource serves). Keyed. Returns the verbatim JSON body.
func (*Client) MonitorURL ¶ added in v0.128.0
MonitorURL returns the monitor SSE endpoint the next stream attempt will use.
func (*Client) ObservedEgressIP ¶
ObservedEgressIP performs a KEYLESS GET of the echo endpoint THROUGH the supplied local SOCKS5/HTTP proxy endpoint (socks5h://127.0.0.1:<port>) and returns the source IP the server observed - i.e. the egress /128 the traffic was sourced from.
It builds a throwaway http.Client whose transport routes via proxyEndpoint, so the request rides the local forward proxy → the Whisper egress → out from the /128. No key is sent (the echo is public). Liberal-accept: a JSON body OR a bare text/plain IP line is parsed (Postel). Never logs the proxy URL or any body.
func (*Client) Query ¶
Query runs an arbitrary control-plane Cypher query (whisper.agents OR a cognition verb) and returns the normalised envelope. The query is sent in the POST body the service documents: {"query": "..."}.
func (*Client) RDAP ¶
func (c *Client) RDAP(ctx context.Context, kind RDAPKind, target, query string) (json.RawMessage, int, error)
RDAP fetches a public, unauthenticated RDAP object (RFC 9083) for a /128 address or a forward name. It returns the verbatim JSON body (RDAP is already a stable, public schema - scripts and the TUI parse it themselves) and the HTTP status.
query is optional and appended verbatim (e.g. "history" or "time=<instant>") so a caller can ask for "?history" or "?time=...". RDAP carries NO key - it is public.
func (*Client) RunFlow ¶ added in v0.130.0
func (c *Client) RunFlow(ctx context.Context, consoleURL, slug, value string, paramValues map[string]any, emit func(FlowEvent)) error
RunFlow executes a catalog FLOW recipe by slug via the console gallery/run endpoint and emits every streamed event on emit() until the stream ends or ctx is cancelled. consoleURL "" uses the default console.
The console contract is {"slug","value","paramValues"}: value is the ONE primary entity (e.g. a domain / IP / ASN), paramValues carries every secondary input + tuning knob. (A workflow that omits value runs against its documented default.) This is the exact shape the reference MCP runner sends: sending an "inputs" map instead is silently ignored by the console and the flow falls back to its default.
func (*Client) StreamMonitor ¶
func (c *Client) StreamMonitor(ctx context.Context, agentAddr string, emit func(MonitorEvent)) error
StreamMonitor opens the live SSE monitor stream and emits each decoded event on emit() until ctx is cancelled or the stream ends. agentAddr (a /128 address, NOT an agent id - see the dev guide) optionally narrows the stream within the tenant; pass "" for the whole tenant. A non-2xx response is surfaced as a *ProblemError (e.g. 503 subscriber-cap with Retry-After) so the caller can back off.
func (*Client) Text2Cypher ¶ added in v0.130.0
func (c *Client) Text2Cypher(ctx context.Context, t2cURL string, r Text2CypherRequest) (json.RawMessage, error)
Text2Cypher translates an English question into a Cypher query via the public Text2Cypher endpoint (POST {"question",...} -> {"cypher","explanation","confidence", ...}). Keyed. Returns the verbatim JSON body so the caller can pass it through.
func (*Client) VerifyIdentity ¶
func (c *Client) VerifyIdentity(ctx context.Context, addr string) (*VerifyVerdict, json.RawMessage, int, error)
VerifyIdentity asks the public, KEYLESS verify-identity endpoint whether addr is a real Whisper agent, and returns the decoded verdict, the raw JSON body (for --json, so a script sees the server's exact bytes), and the HTTP status. It carries NO key - the answer exposes only the same public facts RDAP already does.
Liberal in what we accept: addr may be a bare or bracketed v6/v4 literal; it is sent as ?ip=<addr>. A 200 means "is a Whisper agent" (the sub-fields say how strongly it verified); a 404 is a clean "not a Whisper agent"; a 400 is a malformed address. The server never returns a 500, so a non-2xx is always a structured, decodable verdict.
type Config ¶
type Config struct {
ControlURL string
MonitorURL string
RDAPURL string
VerifyURL string
EchoURL string
Cred Credential
// Timeout bounds a single control call (not the long-lived SSE stream). 0 => 30s.
Timeout time.Duration
// HTTPClient overrides the default (mainly for tests). When nil a client with the
// embedded-CA TLS config is built.
HTTPClient *http.Client
}
Config configures a Client. Zero values fall back to the canonical defaults.
type Credential ¶
type Credential struct {
Value string
Bearer bool // true => send "Authorization: Bearer <Value>"; false => "X-API-Key: <Value>"
Source KeySource
}
Credential is a resolved principal: either an X-API-Key (owner key) or an et_ Bearer (a read-only monitor token from op:token). Exactly one auth header is sent.
func ResolveCredential ¶
func ResolveCredential(opts KeyLadderOptions) (Credential, error)
ResolveCredential walks the key ladder in strict precedence order and returns the first credential it finds:
1. --bearer flag (et_ monitor token -> Authorization: Bearer) 2. --key flag (owner key -> X-API-Key) 3. WHISPER_API_KEY env 4. WHISPER_KEY env (the alias the shell CLI also honoured) 5. ~/.config/whisper/key (mode-600 file) 6. interactive prompt (only when opts.Prompt != nil AND it yields a value)
Conservative+liberal: try every place a key could legitimately live; prompt only when one is offered, never an opaque hang. Returns a SourceNone credential (not an error) when nothing is found - the caller renders the helpful "no key" guidance.
func (Credential) IsZero ¶
func (c Credential) IsZero() bool
IsZero reports an unresolved credential.
type DeviceAuth ¶
type DeviceAuth struct {
// DeviceCode is the secret the client polls with. NEVER log it.
DeviceCode string `json:"device_code"`
// UserCode is the short code the human confirms in the browser (safe to display).
UserCode string `json:"user_code"`
// VerificationURI is where the user signs in.
VerificationURI string `json:"verification_uri"`
// VerificationURIComplete embeds the user_code so a single click/open authorizes.
VerificationURIComplete string `json:"verification_uri_complete"`
// Interval is the minimum seconds to wait between token polls.
Interval int `json:"interval"`
// ExpiresIn is the total seconds the device_code remains valid.
ExpiresIn int `json:"expires_in"`
}
DeviceAuth is the response of POST /api/device/authorize. The fields mirror RFC 8628 §3.2 plus the console's verification_uri_complete convenience field.
func DeviceAuthorize ¶
DeviceAuthorize starts the device-authorization flow: POST <consoleURL>/api/device/ authorize (no auth, empty JSON body) and decode the DeviceAuth. consoleURL is trimmed of a trailing slash; an empty consoleURL falls back to DefaultConsoleURL. hc may be nil, in which case a default embedded-CA client is built. ctx cancellation aborts the call. A non-2xx or malformed reply yields a clear *ProblemError, never a panic.
func (DeviceAuth) Lifetime ¶
func (d DeviceAuth) Lifetime() time.Duration
Lifetime returns how long the device_code is valid: the server value when positive, else a conservative 10-minute default so the flow can never poll forever.
func (DeviceAuth) OpenURL ¶
func (d DeviceAuth) OpenURL() string
OpenURL returns the best URL to send the user to: the complete (code-embedded) one when present, otherwise the plain verification URI.
func (DeviceAuth) PollInterval ¶
func (d DeviceAuth) PollInterval() time.Duration
PollInterval returns a sane poll cadence: the server value when positive, else the RFC 8628 default of 5 seconds (liberal-accept: a missing/0 interval must still work).
type DeviceToken ¶
type DeviceToken struct {
Status DeviceTokenStatus `json:"status"`
// APIKey is set ONLY when Status == approved. NEVER log it.
APIKey string `json:"api_key"`
}
DeviceToken is one POST /api/device/token reply.
type DeviceTokenStatus ¶
type DeviceTokenStatus string
DeviceTokenStatus is the discriminator of POST /api/device/token.
const ( DeviceStatusPending DeviceTokenStatus = "pending" // keep polling DeviceStatusApproved DeviceTokenStatus = "approved" // APIKey is set DeviceStatusExpired DeviceTokenStatus = "expired" // give up, restart the flow )
type EchoResult ¶
type EchoResult struct {
IP string `json:"ip"`
}
EchoResult is the decoded keyless echo verdict: the observed source IP the server saw. A caller asserts this equals the selected agent's /128 to prove the egress is bound to the right identity.
type Envelope ¶
type Envelope struct {
Ok bool
Status int
Result *Result
Err *ProblemError
// Raw is the verbatim JSON body - the scriptable `--json` path echoes this so a
// script sees EXACTLY what the server sent (no re-encoding, no field loss).
Raw json.RawMessage
}
Envelope is the decoded, NORMALISED control-plane reply. Whichever wire shape the server sent, after DecodeEnvelope you always read Ok / Status / Result / Err here.
func DecodeEnvelope ¶
DecodeEnvelope parses a control-plane reply body into a normalised Envelope. It is LIBERAL in what it accepts:
1. The dev-guide shape: {ok,status,result,error}. ok:false -> Err is set. If the top level omits result (the payload lives in the outer YIELD row instead - see 2), it is recovered from there rather than treated as absent. 2. The live outer whisper.agents YIELD-table wrapper: {columns:[op,ok,status,result, error,retry_after], rows:[...]} -> the first row is read for ok/result/error, whether that row arrived as a column-keyed object OR a positional array matched against columns BY NAME (never a fixed index, so a column reorder never breaks extraction - see decodeOuterRow). A row with ok:false is a real failure, surfaced as Err, never silently downgraded to an empty/absent result. 3. A bare problem object {type,title,status,detail} with NO ok/result/rows -> Err.
httpStatus is the transport status code; it seeds Status when the body omits it and lets us treat a >=400 transport status with no usable body as an error.
type FlowEvent ¶ added in v0.130.0
type FlowEvent struct {
Event string `json:"event"`
Data json.RawMessage `json:"data"`
}
FlowEvent is one raw Server-Sent Event from a gallery/run flow: the event name (step, graph, ... - whatever the run emits) and its verbatim data payload.
type GraphResult ¶ added in v0.130.0
type GraphResult struct {
Columns []string
Rows []map[string]any
Statistics map[string]any
Raw json.RawMessage
}
GraphResult is the decoded {columns,rows,statistics} envelope the public graph endpoint returns. Rows are normalised to column-keyed maps whichever wire form they arrived in (objects keyed by column, or positional arrays zipped against columns). Raw preserves the verbatim reply for --json / MCP passthrough.
func DecodeGraphResult ¶ added in v0.130.0
func DecodeGraphResult(body []byte, httpStatus int) (*GraphResult, error)
DecodeGraphResult parses a graph /api/query reply. Liberal-accept: rows may be column-keyed objects (the documented shape) OR positional arrays zipped against columns; an error reply may be an RFC-7807 problem object or a bare {"error":...} string. httpStatus >= 400, or an error field with no result, is surfaced as a *ProblemError with the server's own words.
func (*GraphResult) Stats ¶ added in v0.130.0
func (r *GraphResult) Stats() GraphStats
Stats derives a GraphStats from the result's Statistics map, falling back to the decoded row count when the server omits an explicit rowCount. Liberal-accept: the documented and the shorthand spellings both count.
type GraphStats ¶ added in v0.130.0
type GraphStats struct {
Rows int // statistics.rowCount (or rows); falls back to the decoded row count
MS int // statistics.executionTimeMs (or ms)
DBHits int // statistics.dbHits when present
}
GraphStats is the parsed statistics block, surfaced so an interactive surface (the TUI explorer) can show an honest latency + rowCount badge on each pane without re-parsing the raw Statistics map itself.
type KeyLadderOptions ¶
type KeyLadderOptions struct {
FlagKey string // --key (an owner key, sent as X-API-Key)
FlagBearer string // --bearer (an et_ monitor token, sent as Authorization: Bearer)
KeyFile string // override the on-disk key path; empty => DefaultKeyFile()
AllowEnv bool // consult WHISPER_API_KEY / WHISPER_KEY (default true)
AllowFile bool // consult the key file (default true)
// Prompt, when non-nil and a usable terminal exists, is called as the LAST resort.
// It must return the entered key (or "" if none). Leave nil for non-interactive runs.
Prompt func() (string, error)
}
KeyLadderOptions carries the explicit, highest-precedence inputs (the flags) plus a switch to disable the interactive prompt (for non-TTY / scriptable runs).
type KeySource ¶
type KeySource string
KeySource describes where a resolved credential came from - surfaced in `config`/ diagnostics so an operator can see WHICH key the CLI will use (zero-config clarity).
const ( SourceFlag KeySource = "flag" // --key SourceBearer KeySource = "bearer-flag" // --bearer (an et_ monitor token) SourceEnvKey KeySource = "WHISPER_API_KEY" SourceEnvAlt KeySource = "WHISPER_KEY" SourceFile KeySource = "key-file" SourcePrompt KeySource = "prompt" SourceNone KeySource = "none" )
type LedgerCheckpoint ¶
type LedgerCheckpoint struct {
Note string // the verbatim C2SP note (body + signature line)
Origin string
TreeSize uint64
Root []byte // 32-byte Merkle root
KeyID uint32 // the signed-note key-id from the signature line (0 if unsigned)
Sig []byte // the 64-byte Ed25519 signature (nil if unsigned)
// Cosigs are the C2SP cosignature/v1 witness lines appended to the note: each is a
// "\u2014 <name> <b64(keyId[4]||time[8]||sig[64])>" line, 76-byte blobs, distinct from the
// 68-byte log-signature blob, so the two can never be confused.
Cosigs []LedgerCosignature
// contains filtered or unexported fields
}
LedgerCheckpoint is the decoded C2SP signed-note checkpoint served at GET /checkpoint A checkpoint is the log origin, the tree size, the 32-byte Merkle root, and, when signed, the embedded key-id + Ed25519 signature over the note body. It is the trust anchor an inclusion/consistency proof is checked against.
func ParseCheckpointNote ¶
func ParseCheckpointNote(note string) (*LedgerCheckpoint, error)
ParseCheckpointNote parses a C2SP signed-note checkpoint (the 3-line body, optionally followed by a blank line + a "\u2014 <origin> <base64(keyId||sig)>" signature line). It does NOT verify the signature (use VerifySignature with the published key for that).
func (*LedgerCheckpoint) CosignatureMessage ¶ added in v0.130.0
func (cp *LedgerCheckpoint) CosignatureMessage(ts uint64) []byte
CosignatureMessage recomputes the EXACT C2SP cosignature/v1 byte string a witness signed over this checkpoint at ts: "cosignature/v1\ntime <ts>\n" + the note body (which already includes its trailing '\n'). A verifier re-derives this and checks Ed25519 over it.
func (*LedgerCheckpoint) VerifyIndependentCosignatures ¶ added in v0.130.0
func (cp *LedgerCheckpoint) VerifyIndependentCosignatures(policy *WitnessPolicy, now int64) int
VerifyIndependentCosignatures counts the DISTINCT independent witnesses from the published policy whose cosignature on THIS checkpoint cryptographically verifies AND is fresh (-maxForwardSkew <= now-ts <= maxAge). This is the cryptographic publicly-verifiable check : the CLI recomputes everything with stock crypto; the endpoint's own publicly_verifiable bool is never trusted, only cross-checked. Availability-only (independent=false) witnesses never count; a stale, far-future-dated, tampered, or wrong-key cosignature never counts.
func (*LedgerCheckpoint) VerifySignature ¶
func (cp *LedgerCheckpoint) VerifySignature(key *LedgerKey) error
VerifySignature checks the checkpoint's Ed25519 signature over the note body against the published key (raw 32-byte Ed25519 public key, base64). Returns nil on success.
type LedgerCosignature ¶ added in v0.130.0
type LedgerCosignature struct {
Name string
KeyID uint32
Timestamp uint64 // unix seconds (the "time" line of the signed message)
Sig []byte // 64-byte Ed25519 signature
}
LedgerCosignature is one parsed C2SP cosignature/v1 line: the witness name, its 4-byte cosignature key-id, the unix-seconds timestamp, and the 64-byte Ed25519 signature over "cosignature/v1\ntime <ts>\n" + note body.
type LedgerInclusion ¶
type LedgerInclusion struct {
Index uint64
LeafHash []byte // SHA-256(0x00 || commitment), the opaque value a verifier recomputes
ProofPath [][]byte // the bottom-up RFC 6962 sibling hashes
}
LedgerInclusion is one leaf's opaque inclusion data from /ip/<addr>/transparency.
type LedgerKey ¶
type LedgerKey struct {
Origin string `json:"origin"`
Alg string `json:"alg"`
KeyID string `json:"key_id"` // 8 hex chars
PublicKey string `json:"public_key"` // base64 of the raw 32-byte Ed25519 key
SPKI string `json:"public_key_spki"` // base64 of the X.509 SubjectPublicKeyInfo
}
LedgerKey is the published log verification key from GET /checkpoint/key.
type MonitorEvent ¶
type MonitorEvent struct {
Node string `json:"node,omitempty"`
Kind string `json:"kind,omitempty"`
Tenant string `json:"tenant,omitempty"`
Agent string `json:"agent,omitempty"`
Addr128 string `json:"addr128,omitempty"`
// TsMicros is epoch MICROSECONDS - the stream's native unit (preserves ordering).
TsMicros int64 `json:"ts,omitempty"`
// --- dns ---
QName string `json:"qname,omitempty"`
QType string `json:"qtype,omitempty"`
QClass string `json:"qclass,omitempty"`
RCode string `json:"rcode,omitempty"`
Decision string `json:"decision,omitempty"`
Source string `json:"source,omitempty"`
Answer string `json:"answer,omitempty"`
LatencyUS int64 `json:"latency_us,omitempty"`
ClientSubnet string `json:"client_subnet,omitempty"`
EDNS bool `json:"edns,omitempty"`
DO bool `json:"do,omitempty"`
// --- conn ---
Proto string `json:"proto,omitempty"`
Direction string `json:"direction,omitempty"`
PeerHost string `json:"peer_host,omitempty"`
PeerPort int `json:"peer_port,omitempty"`
BytesUp int64 `json:"bytes_up,omitempty"`
BytesDown int64 `json:"bytes_down,omitempty"`
PacketsUp int64 `json:"packets_up,omitempty"`
PacketsDown int64 `json:"packets_down,omitempty"`
DurationUS int64 `json:"duration_us,omitempty"`
Reason string `json:"reason,omitempty"`
TokenMasked string `json:"token_masked,omitempty"`
ClientSrc string `json:"client_src,omitempty"`
// --- alloc ---
Action string `json:"action,omitempty"`
AllocatedAt int64 `json:"allocated_at,omitempty"` // epoch MILLISECONDS (per the contract)
// Extra is the raw event JSON, preserved verbatim for the NDJSON tail.
Extra json.RawMessage `json:"-"`
}
MonitorEvent is one decoded stream event, normalised so the TUI/scripts never have to reason about the µs-vs-ms split: TsMicros is always epoch microseconds, and the derived latency/duration are exposed in BOTH units.
Null fields are OMITTED on the wire (compact); an absent field decodes to its zero value here. Extra is the verbatim event JSON for the scriptable NDJSON tail.
func ParseEvent ¶
func ParseEvent(eventName string, data []byte) (MonitorEvent, bool)
ParseEvent decodes one SSE `data:` JSON object into a MonitorEvent, preserving the raw bytes in Extra. A heartbeat (eventName "hb") with no/empty data yields a Kind==KindHB event. Returns ok=false for an empty/blank payload.
func (MonitorEvent) DurationMillis ¶
func (e MonitorEvent) DurationMillis() int64
DurationMillis returns the conn duration in milliseconds (rounded from µs).
func (MonitorEvent) LatencyMillis ¶
func (e MonitorEvent) LatencyMillis() int64
LatencyMillis returns the dns latency in milliseconds (rounded from µs).
func (MonitorEvent) TsMillis ¶
func (e MonitorEvent) TsMillis() int64
TsMillis returns the event timestamp in epoch milliseconds (µs/1000).
type OutsideReach ¶ added in v0.212.1
type OutsideReach struct {
// Host is the destination that was tried, for a sentence to name. Never the proxy.
Host string
// Tried is false when no measurement was made (no usable local proxy, no usable
// destination). It is NOT the same as a failed measurement, and it never means "blocked".
Tried bool
// Proxied and Direct are the two outcomes. nil means the destination answered.
Proxied error
Direct error
// ProxiedTimedOut separates a refusal from a black hole. A refusal is a decision taken by
// something - a policy, a ruleset - and can be named as one; a timeout is the ABSENCE of a
// decision, and naming it a policy would be a guess. They must not collapse.
ProxiedTimedOut bool
}
OutsideReach is one pair of observations of the SAME destination, made at the SAME moment: once through the local egress proxy and once straight from this machine.
The control is the whole point. A fetch that fails through the proxy can fail because the egress would not carry it or because this machine has no working network, and those two have opposite remedies. Only "failed through the proxy AND succeeded directly" is evidence about the egress; both failing is evidence about the machine or the destination, and it is not grounds to say anything about the egress at all.
func ReachOutside ¶ added in v0.212.1
func ReachOutside(ctx context.Context, proxyEndpoint string) OutsideReach
ReachOutside makes both observations concurrently and reports them. It never returns an error of its own: every outcome, including "nothing was measured", is a verdict the caller has to be able to render.
Keyless, like the echo, and one cheap HEAD per leg: any HTTP status counts as reached, because the question is whether the round trip completed and not what the far end thinks of us.
type ProblemError ¶
type ProblemError struct {
Type string `json:"type,omitempty"`
Title string `json:"title,omitempty"`
Status int `json:"status,omitempty"`
Detail string `json:"detail,omitempty"`
Suggestions []string `json:"suggestions,omitempty"`
}
ProblemError is the RFC-7807 problem object the control plane returns on failure (ok:false). Its Detail is written to be helpful and secret-free - surface it verbatim to the operator (Postel: a clear, helpful error, never an opaque 500).
func AsProblem ¶
func AsProblem(err error) (*ProblemError, bool)
AsProblem extracts a *ProblemError from err if it is (or wraps) one.
func (*ProblemError) Error ¶
func (e *ProblemError) Error() string
Error renders the most helpful single line we have: prefer detail, then title, then type, then a generic note - never an empty string.
type Result ¶
Result is the tabular payload every control op returns: column names plus rows of raw JSON values (one []any per row, positionally aligned with Columns).
type Text2CypherRequest ¶ added in v0.130.0
type Text2CypherRequest struct {
Question string `json:"question"`
Execute bool `json:"execute,omitempty"`
Provider string `json:"provider,omitempty"`
Fast bool `json:"fast,omitempty"`
}
Text2CypherRequest is one natural-language translation request. Question is required; the rest are optional and default server-side (provider auto-selected, non-fast, no execution).
type VerifyVerdict ¶
type VerifyVerdict struct {
IsWhisperAgent bool `json:"is_whisper_agent"`
FQDN string `json:"fqdn"`
Operator string `json:"operator"`
Tenant string `json:"tenant"`
DaneOK bool `json:"dane_ok"`
JwsOK bool `json:"jws_ok"`
VerifiedAt int64 `json:"verified_at"`
Detail string `json:"detail"`
// Evidence is the verbatim evidence object the server returned (address, ptr, the
// dane sub-object, rdap/identity_doc URLs, …). Kept raw so no field is ever lost and
// the JSON form is byte-faithful (Postel: we surface exactly what the server said).
Evidence json.RawMessage `json:"evidence"`
}
VerifyVerdict is the decoded server-side answer from GET /verify-identity: the FULL Whisper-agent trust chain run server-side from the local authoritative zone (reverse-DNS PTR + forward-confirm AAAA + the DANE-EE TLSA pin + the JWS identity doc), folded into ONE verdict so a caller need not stitch four protocols together itself.
DANE (the DNSSEC-anchored TLSA) is THE trust anchor for an agent cert - not a public CA - so DaneOK is the load-bearing field: it is true only when a strong DANE-EE pin is published AND (where the server could cross-check) the served leaf satisfies it.
type WitnessEntry ¶ added in v0.130.0
type WitnessEntry struct {
Name string `json:"name"`
KeyID string `json:"key_id"` // 8 hex chars (the 0x04 cosignature flavour)
PublicKey string `json:"public_key"` // base64 of the raw 32-byte Ed25519 key
SPKI string `json:"public_key_spki"`
Independent bool `json:"independent"`
Role string `json:"role"`
}
WitnessEntry is one published witness: name, cosignature key-id (hex), raw Ed25519 public key (base64), and whether it is genuinely independent (vs the availability cross-check).
type WitnessPolicy ¶ added in v0.130.0
type WitnessPolicy struct {
Object string `json:"object"`
Threshold int `json:"threshold"`
Claim string `json:"claim"`
PubliclyVerifiable bool `json:"publicly_verifiable"` // server's view, never trusted blindly
MaxAgeSeconds int64 `json:"publicly_verifiable_max_age_seconds"`
Witnesses []WitnessEntry `json:"witnesses"`
}
WitnessPolicy is the published witness-key set from GET /witness/keys: the verifier-pinnable trusted witnesses, the k-of-n threshold, the freshness bound, and the server's own claim (a CROSS-CHECK only; the CLI always recomputes the verification).
func PinnedWitnessPolicy ¶ added in v0.130.0
func PinnedWitnessPolicy(pins [][]byte, maxAgeSeconds int64) *WitnessPolicy
PinnedWitnessPolicy builds a WitnessPolicy from OUT-OF-BAND pinned witness keys: each pin is independent BY THE VERIFIER'S OWN DECISION; the server-published policy is not consulted at all, so the "publicly verifiable" verdict no longer trusts the origin for the key set. maxAgeSeconds <= 0 falls back to the 24h default.