client

package
v0.213.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 29 Imported by: 0

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

View Source
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).

View Source
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).

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

View Source
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.

View Source
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.

View Source
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

func BuildAgentsQuery(op string, args map[string]any) string

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

func CypherMap(m map[string]any) string

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

func DeviceHTTPClient() *http.Client

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

func EmbeddedRootCAs() *x509.CertPool

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

func EscapeCypherString(s string) string

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

func LeafHashFromDisclosure(salt, canonicalEvent []byte) []byte

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

func Lit(v any) string

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

func ParseWitnessKeyPin(s string) ([]byte, error)

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

func QuoteCypherString(s string) string

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

func ReadAgentFile(path string) string

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

func ReadSSE(ctx context.Context, r io.Reader, emit func(MonitorEvent)) error

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

func RootCAs() *x509.CertPool

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

func SaveAgent(path, agent string) error

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

func SaveKey(path, key string) error

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

func TLSConfig() *tls.Config

TLSConfig is the standard client TLS config: verified against RootCAs, TLS 1.2 floor.

func VerifyInclusion

func VerifyInclusion(leafHash []byte, index, treeSize uint64, path [][]byte, root []byte) error

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

func WriteBoundFile(path string, mode string, addr netip.Addr, name, agent string) error

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

func New(cfg Config) *Client

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

func (c *Client) Agents(ctx context.Context, op string, args map[string]any) (*Envelope, error)

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

func (c *Client) DirectEgressIP(ctx context.Context) (string, error)

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

func (c *Client) FetchDoc(ctx context.Context, docURL string) (string, error)

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

func (c *Client) FetchLedgerKey(ctx context.Context) (*LedgerKey, error)

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

func (c *Client) GraphStats(ctx context.Context) (json.RawMessage, error)

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

func (c *Client) MonitorURL() string

MonitorURL returns the monitor SSE endpoint the next stream attempt will use.

func (*Client) ObservedEgressIP

func (c *Client) ObservedEgressIP(ctx context.Context, proxyEndpoint string) (string, error)

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

func (c *Client) Query(ctx context.Context, query string) (*Envelope, error)

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

func DeviceAuthorize(ctx context.Context, hc *http.Client, consoleURL string) (*DeviceAuth, error)

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

func DecodeEnvelope(body []byte, httpStatus int) (*Envelope, error)

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 RDAPKind

type RDAPKind string

RDAPKind selects which RDAP object to fetch.

const (
	RDAPIP     RDAPKind = "ip"     // /ip/<v6> - the /128 object
	RDAPDomain RDAPKind = "domain" // /domain/<fqdn> - the forward-name object
)

type Result

type Result struct {
	Columns []string `json:"columns"`
	Rows    [][]any  `json:"rows"`
}

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).

func (*Result) Records

func (r *Result) Records() []map[string]any

Records turns a Result into a slice of column-keyed maps - the ergonomic form the TUI and the human-readable subcommands render from. A nil Result yields nil.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL