discovery

package
v0.14.3-dev Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 28 Imported by: 0

Documentation

Overview

mDNS advertisement: register ourselves on `_outpost._tcp.local`.

The service-instance name is the AssignedHostname when present (cloudbox-issued slug), falling back to a sanitized AgentName. Either way it must be DNS-safe: lowercase letters, digits, and hyphens. The .local resolution that OSes already do for free gives any other host on the LAN a working `<name>.local` → IP lookup with no extra protocol on the caller's side.

TXT records carry the Tier-1 metadata. The full set comes from AdvertiseOptions:

id   = PeerID (SHA256 fingerprint of the host key)
an   = AgentName
host = AssignedHostname (when different from `an`)
user = OS username the outpost runs as
email= operator OAuth2 email (when paired; "" otherwise)
cb   = cloudbox base URL (when paired; "" otherwise)
ver  = build commit
pair = "1" when paired, "0" otherwise
ssh  = plain-TCP SSH LAN listener `host:port` (when bound; PAM-gated)
sshws= WS-mounted SSH LAN listener `host:port` (when bound; peer-ticket auth)
http = HTTP discover LAN listener `host:port` (when bound)

Receivers ignore unknown keys, so this set is forward-compatible.

mDNS browse: one-shot query for `_outpost._tcp.local` returning the parsed Peer records. Watch (continuous) is a thin loop on top of Browse for the daemon's discovery cache.

Discovery cache: in-memory snapshot of currently-known peers, merged from every discovery source (mDNS browse, HTTP probes, cloudbox NAT hints, gossip). The daemon owns one instance; outpost://peers + outpost peers list read from it; the observation ticker snapshots it every 5 min.

Eviction policy: any peer not refreshed within `TTL` is dropped. Defaults are conservative — 15 minutes covers a browse miss or two without flapping. Tests can shrink it.

Concurrency: safe for concurrent reads + writes. Single mutex; the cache is small (<256 entries in normal operation) so finer-grained locking would be overkill.

HTTP client for the discovery surface. Used by `outpost discover probe <url>` and by the daemon's NAT-hint poller (Wave 3A.2) to turn a hint URL into a verified peer record.

The probe flow is intentionally one round-trip per call: each of Hello/Probe/Peers is a separate HTTP request. Wave 3B may collapse these for latency if it matters; Wave 3A.1 prefers obvious stateless wire shape.

HTTP `/api/v1/discover/*` surface for peer-to-peer discovery.

Tier-1 endpoints (open, no cert required):

POST /api/v1/discover/hello   — exchange Tier-1 metadata

Tier-2 endpoints (cert-or-signed-nonce verified, session-keyed):

POST /api/v1/discover/probe   — caller signs server's nonce;
                                 verifies cert presence + match
GET  /api/v1/discover/peers   — list known peers (verified only)

Wave 3A.2 will add /gossip and the full cloudbox-CA cert path. Wave 3A.1 keeps the surface ready for both: the Cert blob is already plumbed in PeerHello and the probe path verifies signatures without yet requiring the cert to be cloudbox-signed.

Reachability ledger: append-only JSONL of every successful peer dial. Wave 3B.1 just records; Wave 3B.2 (Memberlist gossip + the route-to hint surface) consumes the ledger to build transitive reachability hints.

Storage shape mirrors PRoPHET-style DTN routing: each entry is a fact "Self reached Peer via Endpoint at Time with Latency." The set is grow-only with size-bounded rotation — when the file exceeds LedgerMaxEntries, the oldest LedgerMaxEntries/2 entries are pruned. That gives us a rolling window of recent contacts without ever having to do a full sort.

Operator-debuggable: it's plain JSONL, one entry per line. `tail` works; `jq` works. No binary encoding to fight.

Temporal topology observations: PRoPHET-lite presence prediction.

Every ObsTickInterval (5 min default) the daemon snapshots the discovery cache as a set of (peer_id, reachable, hour_of_week) records. The Observations struct aggregates these into a rolling EWMA per (peer, hour-of-week) bucket.

EWMA half-life is 14 days (ObsHalfLife). That converges fast enough to track a "started working from home Tuesdays" shift within ~two weeks, while smoothing out a single missed observation. The math:

alpha = 1 - exp(-tick / half_life * ln(2))
prob' = prob*(1-alpha) + present*alpha

where `present` is 1 if the peer was observed in this tick, 0 if not.

Storage: a flat JSON map on disk at <cacheDir>/outpost/peers_obs.json. Small enough (~5KB per peer per week of buckets) to load wholesale. Updated atomically (write to tmp + rename).

Wave 3B.1 only RECORDS observations. The act-on-predictions surface (`outpost scan --predicted`, pre-warm SSH connections) lands in Wave 3B.2.

Default on-disk locations for the discovery package's persistent artifacts. Centralized here so the daemon, the CLI, the MCP tools, and the operator-facing `outpost peers` surfaces all agree on where to look.

All paths sit under conf.DefaultCacheDir()/outpost/ — the same XDG-aware root the upgrade ledger and shell history use. Mode 0700 on the parent dir, 0600 on the files.

In-memory session manager for the HTTP `/api/v1/discover/*` flow.

Sessions are short-lived (5 min default) and bounded (256 entries). They carry the nonce we issued to the caller for `/probe` and a `verified` flag flipped after the signed-nonce check passes.

We deliberately don't persist sessions; an outpost restart drops in-flight handshakes and callers re-handshake. That keeps the security surface small and the implementation trivial.

Package discovery exposes LAN peer-finding (mDNS browse + HTTP /discover) plus the data structures shared across advertisers, browsers, the HTTP surface, and the discovery cache.

Identity model (Wave 3A.1, TOFU; Wave 3A.2 lifts to cloudbox-signed certs):

  • PeerID = SHA256 fingerprint of the outpost's ed25519 host key. Same value `ssh.FingerprintSHA256(pubkey)` returns. Stable across the lifetime of the host key (never rotated except by re-pair).
  • Hostname identity = AgentName (operator-chosen) + AssignedHostname (cloudbox-assigned slug; equals os.Hostname() in 3A.1 before the cloudbox change lands).
  • Resource-owner identity = OAuth2Email — Tier-2 trust anchor.
  • OS user = OSUsername — informational + future SSH user-cert flow.

Trust tiers, repeated from the plan so reviewers don't have to context- switch:

  • Tier 1 (open, no cert needed): `/discover/hello`, mDNS, NAT hints, `outpost scan`, `outpost://peers`. Just metadata.
  • Tier 2 (cert-or-TOFU verified): `/discover/probe`, `/peers`, `/gossip`, plus everything that *acts on* a peer (ssh exec, jump, sftp, repair binary).

Index

Constants

View Source
const (
	DefaultActiveMax  = 16
	DefaultPassiveMax = 256
)

Default HyParView bounds. Operator can override by calling (*Cache).SetBounds.

View Source
const (
	// ObsTickInterval is the cadence at which the daemon should
	// snapshot the current cache and feed it through the EWMA
	// update. Wave 3B.1 records-only; the daemon-side ticker lands
	// with the cache wiring in Wave 3B.2.
	ObsTickInterval = 5 * time.Minute

	// ObsBuckets is the number of (peer, hour-of-week) buckets we
	// track per peer. 7 days * 24 hours = 168.
	ObsBuckets = 7 * 24
)
View Source
const DefaultCacheTTL = 15 * time.Minute

DefaultCacheTTL is how long a peer stays in the cache after its most-recent observation. 15 min absorbs a typical browse miss (browse cadence ~30s) without holding ghost entries forever.

View Source
const DefaultGossipBindPort = 7946

DefaultGossipBindPort is the SWIM UDP/TCP port memberlist binds. Operator can override via FileConfig.GossipBindAddr (host:port). 7946 is memberlist's default — picked deliberately to be the recognized "SWIM" port for firewall-allow rules.

View Source
const LedgerMaxEntries = 10000

LedgerMaxEntries is the soft cap on the ledger file. After this many entries are appended, rotation prunes the oldest half. Sized so 5000 entries kept ≈ 200 days of two-edge-per-day observations (typical home-host pattern).

View Source
const ServiceName = "_outpost._tcp"

ServiceName is the DNS-SD service type we advertise under and browse on. Matches RFC 6763 §7 conventions.

Variables

View Source
var ObsHalfLifeObservations = 24.0

ObsHalfLifeObservations is the EWMA half-life expressed in *observation count* rather than wall-clock time. That's the right unit because each (peer, hour-of-week) bucket only updates when the tick happens to land in that hour — calibrating in wall-clock time gives wildly different effective half-lives for different tick cadences. With the default ObsTickInterval (5 min) each bucket sees 12 updates per occupied hour per week, so 24 observations cover roughly 2 weeks of production data — fast enough to track a schedule shift, slow enough to absorb the occasional missed tick. Exposed as a var so tests can shrink it for fast convergence.

Functions

func AppendLedgerEntry added in v0.2.0

func AppendLedgerEntry(e ReachabilityEdge) (string, error)

AppendLedgerEntry is the cmd-side convenience: open the ledger at the default path, append one entry, close. Errors are logged-not- fatal at the call site since a missing ledger entry is far less bad than a failed dial.

Returns the path written to on success (for debug log lines) and any error encountered.

func DefaultLedgerPath added in v0.2.0

func DefaultLedgerPath() (string, error)

DefaultLedgerPath returns the canonical reachability-ledger path. Callers should pass this to OpenLedger.

func DefaultObservationsPath added in v0.2.0

func DefaultObservationsPath() (string, error)

DefaultObservationsPath returns the canonical temporal-observations model path. Callers pass this to OpenObservations.

Types

type AdvertiseOptions

type AdvertiseOptions struct {
	// InstanceName is the service-instance label. Becomes
	// `<InstanceName>._outpost._tcp.local`. Must be a single DNS
	// label (no dots).
	InstanceName string

	// Port is the primary LAN port advertised in the SRV record.
	// Pick whichever endpoint is most useful as the "default" dial
	// target — the LAN SSH listener if bound, else the HTTP
	// discover listener, else the admin/MCP listener.
	Port int

	// IPs are the addresses to advertise. When empty,
	// hashicorp/mdns auto-detects from local interfaces.
	IPs []string

	// PeerID, AgentName, AssignedHostname, OSUsername, OAuth2Email,
	// CloudboxBase, Version, Paired, SSHListenAddr, SSHWSListenAddr,
	// HTTPDiscoverListenAddr feed the TXT records.
	PeerID                 PeerID
	AgentName              string
	AssignedHostname       string
	OSUsername             string
	OAuth2Email            string
	CloudboxBase           string
	Version                string
	Paired                 bool
	SSHListenAddr          string
	SSHWSListenAddr        string
	HTTPDiscoverListenAddr string
}

AdvertiseOptions parameterises one mDNS service registration. All string fields are passed through to TXT records as-is (the caller is responsible for sanitization).

type Advertiser

type Advertiser struct {
	// contains filtered or unexported fields
}

Advertiser owns one running mDNS registration. Close stops the goroutines and removes the service from the LAN.

func Advertise(ctx context.Context, opts AdvertiseOptions) (*Advertiser, error)

Advertise starts an mDNS server announcing this outpost. The returned Advertiser must be closed; the supplied context cancellation is also honored — when ctx is done, the registration is removed.

func (*Advertiser) Close

func (a *Advertiser) Close() error

Close stops the mDNS server and removes our service registration from the LAN. Safe to call after the context-driven Shutdown.

type BrowseOptions

type BrowseOptions struct {
	// Timeout caps the query duration. The mDNS responder window
	// is intentionally short (1–2s is plenty on a working LAN).
	// Default 3s.
	Timeout time.Duration

	// SelfPeerID, when non-empty, filters out entries matching the
	// caller's own PeerID. The mDNS responder library doesn't
	// suppress one's own announcements; we do it here.
	SelfPeerID PeerID
}

BrowseOptions configures one query.

type Cache added in v0.2.0

type Cache struct {
	// contains filtered or unexported fields
}

Cache holds the live set of known peers, keyed by PeerID.

HyParView bounds (roadmap item #16): active / passive view caps set via SetBounds. Enforced by the periodic Compact goroutine — Upsert itself stays O(1) and unaware of partitions, so the hot path doesn't pay any extra cost.

func NewCache added in v0.2.0

func NewCache(ttl time.Duration) *Cache

NewCache returns an empty cache. TTL == 0 means use the default.

func (*Cache) Compact added in v0.3.0

func (c *Cache) Compact() (active int, passive int)

Compact enforces the active/passive bounds + TTL eviction. Promotes recently-seen passive peers into active when there's headroom; demotes the oldest active when overflowing; drops the oldest passive when over the passive bound.

Safe to call concurrently with Upsert. Returns the post-compact counts (active, passive) for callers that want to log.

func (*Cache) Len added in v0.2.0

func (c *Cache) Len() int

Len reports the current live entry count (after evicting stale). Cheap; for tests.

func (*Cache) SetBounds added in v0.3.0

func (c *Cache) SetBounds(activeMax, passiveMax int)

SetBounds reconfigures the active / passive view limits. Pass zero to keep the current value (or fall back to defaults if unset). Concurrent with Upsert; the next Compact pass enforces.

func (*Cache) Snapshot added in v0.2.0

func (c *Cache) Snapshot() []Peer

Snapshot returns a copy of all currently-live cache entries. Stale entries (older than TTL) are evicted as a side effect, so the returned slice reflects only fresh observations.

func (*Cache) SnapshotIDs added in v0.2.0

func (c *Cache) SnapshotIDs() []PeerID

SnapshotIDs is the cheap "just the keys" path the observation ticker uses every 5 min. Doesn't allocate the full Peer values.

func (*Cache) Upsert added in v0.2.0

func (c *Cache) Upsert(p Peer) Peer

Upsert merges `p` into the cache. When a peer with the same ID already exists, its Sources union with p.Sources, its endpoint list is replaced with p.Endpoints (the most recent observation wins — we don't keep historical endpoints), and LastSeenAt becomes the newer of the two.

Returns the merged Peer (a fresh value, not aliased to caller's argument) for callers that want to use the post-merge state.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client speaks the discovery HTTP surface against a remote outpost. Reuse one Client across calls; it pools the underlying *http.Client.

func NewClient

func NewClient() *Client

NewClient returns a Client with sane defaults (10s timeout, follow redirects = no, since /discover responses should never redirect).

func (*Client) FetchPeers

func (c *Client) FetchPeers(ctx context.Context, baseURL, sessionID string) ([]Peer, error)

FetchPeers calls GET /api/v1/discover/peers on a verified session. Returns an error when the session isn't verified (server replies 403) or expired.

func (*Client) Probe

func (c *Client) Probe(ctx context.Context, baseURL string, selfSigner ssh.Signer, selfHello PeerHello) (*ProbeResult, error)

Probe performs the full hello → probe round-trip against the remote URL. baseURL is the URL of the discovery HTTP listener, e.g. `http://192.168.1.42:17778`. The /api/v1/discover prefix is appended automatically.

`selfSigner` is the local outpost's ed25519 host signer; we sign the server's challenge with it.

`selfHello` is what we tell the remote about ourselves.

type Endpoint

type Endpoint struct {
	Kind EndpointKind `json:"kind"`
	Host string       `json:"host"` // IP literal, DNS name, or assigned_hostname.local
	Port int          `json:"port"` // 0 when not applicable (e.g. cloudbox WS path)
}

Endpoint is one reachable address for a Peer. Multiaddr-shape borrowed from libp2p: the Kind names the transport so future transports plug in without reshaping the struct.

func (Endpoint) HostPort

func (e Endpoint) HostPort() string

HostPort returns the conventional dialing form. Hides the zero-port case behind an obvious-when-broken result.

type EndpointKind

type EndpointKind string

EndpointKind names the transport + role of an Endpoint. The set is open-ended (a future Wave can add quic, webrtc, etc.) but Wave 3A.1 only emits the four below.

const (
	// EndpointLANSSH is the outpost's optional LAN TCP SSH listener
	// (FileConfig.SSHListenAddr). Reachable directly from peers on
	// the same broadcast domain. PAM-gated (no cloudbox-vouching on
	// LAN-direct) — the legacy plain-TCP path before peer-tickets.
	EndpointLANSSH EndpointKind = "lan-ssh"

	// EndpointLANSSHWS is the outpost's optional LAN WebSocket-mounted
	// SSH listener (FileConfig.SSHWSListenAddr). Speaks the same
	// /ssh route the loopback handler does, plus accepts peer-ticket
	// JWTs as the auth signal (replacing the cloudbox-stamped
	// X-Periscope-Role header that's only trustworthy on loopback).
	// Lets `outpost ssh <peer>` stay passwordless on the LAN-direct
	// path without putting cloudbox on the data plane.
	EndpointLANSSHWS EndpointKind = "lan-ssh-ws"

	// EndpointLANHTTPDiscover is the optional LAN HTTP discovery
	// listener (FileConfig.DiscoveryHTTPListenAddr). Hosts the
	// `/api/v1/discover/*` surface.
	EndpointLANHTTPDiscover EndpointKind = "lan-http-discover"

	// EndpointLANAdminMCP is the admin/MCP listener
	// (FileConfig.AdminAddr) when bound to a LAN address. Most
	// operators leave this on loopback; we report it as a LAN
	// endpoint only when it's actually LAN-reachable.
	EndpointLANAdminMCP EndpointKind = "lan-admin-mcp"

	// EndpointCloudboxSSH is the cloudbox-fronted /h/<host>/ssh
	// path. Always present for a paired outpost. Used as fallback
	// reachability when the peer is not on our LAN.
	EndpointCloudboxSSH EndpointKind = "cloudbox-ssh"
)

type Gossip added in v0.3.0

type Gossip struct {
	// contains filtered or unexported fields
}

Gossip is the lifecycle handle. Construct via NewGossip; call Run to start the SWIM listener + bootstrap loop; closes on ctx.

func NewGossip added in v0.3.0

func NewGossip(cfg GossipConfig) (*Gossip, error)

NewGossip constructs a gossip handle. Does NOT start the listener — call Run.

func (*Gossip) Members added in v0.3.0

func (g *Gossip) Members() []GossipMember

Members returns the current memberlist (excluding self) — used by `outpost_gossip_edges` to expose the live peer set.

func (*Gossip) Run added in v0.3.0

func (g *Gossip) Run(ctx context.Context) error

Run starts memberlist + the bootstrap loop. Blocks until ctx.Done().

type GossipConfig added in v0.3.0

type GossipConfig struct {
	// SelfPeerID identifies this outpost on the gossip mesh.
	SelfPeerID PeerID

	// SelfAgentName is the human-friendly name (PeerID is opaque).
	SelfAgentName string

	// BindAddr is the local listen address (host:port). Empty
	// uses 0.0.0.0:DefaultGossipBindPort.
	BindAddr string

	// AdvertiseAddr is the address gossip messages claim as the
	// source. Defaults to BindAddr's host. Override when a NAT
	// rewrites the source IP.
	AdvertiseAddr string

	// Cache is the live discovery cache. Gossip-received peers
	// get Upserted here with SourceGossip.
	Cache *Cache

	// Bootstrap is a callback returning a list of join addresses
	// (host:port). Called at boot AND on the periodic re-bootstrap
	// ticker so newly-discovered peers (via mDNS or NAT hints)
	// get pulled into the mesh. May return an empty list — gossip
	// still works as a single-node sink that accepts pushes.
	Bootstrap func() []string

	// Logger is optional; nil = slog-default.
	Logger io.Writer
}

GossipConfig is the input to NewGossip.

type GossipMember added in v0.3.0

type GossipMember struct {
	PeerID PeerID `json:"peer_id"`
	Addr   string `json:"addr"`
	Port   int    `json:"port"`
	State  string `json:"state"` // alive / suspect / dead / left
}

GossipMember is the wire shape returned by Members(). One row per known member (alive, suspect, or dead) excluding self.

type HelloRequest

type HelloRequest struct {
	SessionID string    `json:"session_id,omitempty"`
	My        PeerHello `json:"my"`
}

HelloRequest is the body of POST /api/v1/discover/hello.

type HelloResponse

type HelloResponse struct {
	SessionID string    `json:"session_id"`
	My        PeerHello `json:"my"`                  // server's own info
	You       PeerHello `json:"you"`                 // server echoes the caller's claim
	Challenge string    `json:"challenge,omitempty"` // base64 32-byte nonce
}

HelloResponse is the response shape.

type HintsClient added in v0.3.0

type HintsClient struct {
	// contains filtered or unexported fields
}

HintsClient is the polling worker. One per outpost.

func NewHintsClient added in v0.3.0

func NewHintsClient(cfg HintsConfig) *HintsClient

func (*HintsClient) Run added in v0.3.0

func (h *HintsClient) Run(ctx context.Context) error

Run drives the poll loop. Blocks until ctx.Done(). Configurations missing required pieces (cloudbox URL, access token, cache) cause Run to log + sleep on ctx without polling — useful so caller can always Go(client.Run) without thinking about a half-configured outpost.

type HintsConfig added in v0.3.0

type HintsConfig struct {
	CloudboxBase string
	AccessToken  string
	Cache        *Cache
	Interval     time.Duration // default 5m
	HTTPClient   *http.Client  // optional; default 30s timeout
}

HintsConfig is the input shape for NewHintsClient.

type HyParViewCompactor added in v0.3.0

type HyParViewCompactor struct {
	// contains filtered or unexported fields
}

HyParViewCompactor drives periodic Compact() calls. Runs as a long-lived goroutine under the daemon's errgroup; ctx-aware.

func NewCompactor added in v0.3.0

func NewCompactor(cache *Cache, interval time.Duration) *HyParViewCompactor

NewCompactor returns a new HyParView compactor. interval == 0 defaults to 2 minutes — frequent enough to catch fast-onset fleet churn, infrequent enough that the goroutine is invisible in CPU terms.

func (*HyParViewCompactor) Run added in v0.3.0

Run blocks until ctx.Done(). One Compact at boot + every interval.

type Ledger added in v0.2.0

type Ledger struct {
	// contains filtered or unexported fields
}

Ledger is the persistent append-only log of ReachabilityEdges. Safe for concurrent use; one global instance per daemon process (callers reach it via OpenLedger).

func OpenLedger added in v0.2.0

func OpenLedger(path string) (*Ledger, error)

OpenLedger constructs a Ledger at the given path. Creates the containing directory (mode 0700) on demand. Returns an empty Ledger even when the file doesn't exist yet — first Append creates it.

func (*Ledger) Append added in v0.2.0

func (l *Ledger) Append(e ReachabilityEdge) error

Append writes one edge to the ledger. Errors during write are reported but the daemon should NOT abort on them — a missing ledger entry is far less bad than a daemon crash. Callers typically log-and-continue.

func (*Ledger) Tail added in v0.2.0

func (l *Ledger) Tail(n int) ([]ReachabilityEdge, error)

Tail returns the most-recent n entries, newest-last. n == 0 means "all". Cheap for n up to a few thousand on rotated ledgers.

type Observations added in v0.2.0

type Observations struct {
	// contains filtered or unexported fields
}

Observations is the temporal-presence model. Safe for concurrent use. One global instance per daemon.

func OpenObservations added in v0.2.0

func OpenObservations(path string) (*Observations, error)

OpenObservations loads the model from disk. Missing file => empty model. Corrupted file => empty model (we log silently rather than crash).

func (*Observations) Predict added in v0.2.0

func (o *Observations) Predict(peer PeerID, hour int) float64

Predict returns the EWMA presence probability for peer at the given hour-of-week. Returns 0 for never-seen peers.

func (*Observations) PredictedAt added in v0.2.0

func (o *Observations) PredictedAt(hour int) []PredictedView

PredictedAt returns predictions for every known peer at the given hour, sorted by probability descending. Used by the CLI surface.

func (*Observations) Record added in v0.2.0

func (o *Observations) Record(now time.Time, seen []PeerID)

Record applies one EWMA update for each peer present in `seen`. Peers NOT in `seen` get a "0" observation for the current hour bucket (we observed; they weren't there).

`now` is configurable for tests; production callers pass time.Now().

func (*Observations) Save added in v0.2.0

func (o *Observations) Save() error

Save persists the model to disk atomically. Called by the daemon's observation-tick loop after each Record.

type Peer

type Peer struct {
	ID PeerID `json:"id"`

	AgentName        string `json:"agent_name"`
	AssignedHostname string `json:"assigned_hostname,omitempty"`

	// Tier-2 identity facts. Populated when the peer presented a
	// cert (Wave 3A.2) or when we already know them via cloudbox.
	OAuth2Email string `json:"oauth2_email,omitempty"`
	OSUsername  string `json:"os_username,omitempty"`

	Endpoints    []Endpoint   `json:"endpoints"`
	Addrs        []netip.Addr `json:"-"` // resolved IPs from mDNS; not serialized in MCP/JSON (use Endpoints)
	Version      string       `json:"version,omitempty"`
	CloudboxBase string       `json:"cloudbox_base,omitempty"`
	Paired       bool         `json:"paired"`

	Sources    []Source   `json:"sources"`
	Trust      TrustLevel `json:"trust"`
	LastSeenAt time.Time  `json:"last_seen_at"`

	// Active marks a peer as part of the HyParView "active view" —
	// the bounded set of peers we maintain hot connections to and
	// preferentially gossip with. Roadmap item #16. Default false
	// (= passive view); promotion happens in Cache.markActiveLocked
	// when there's headroom (activeMax) and the peer has freshly
	// been observed via a transport we trust.
	Active bool `json:"active,omitempty"`
}

Peer is one discovered outpost. Endpoints are ordered by preference (LAN-direct first, cloudbox fallback last). Sources records how we found out about it. LastSeenAt is updated on every fresh observation across any source.

func Browse

func Browse(ctx context.Context, opts BrowseOptions) ([]Peer, error)

Browse queries the LAN for outpost service instances and returns the parsed Peer records. Blocks up to Timeout.

It first queries dual-stack (IPv4 + IPv6). hashicorp/mdns aborts the whole query if either multicast send fails, and IPv6 mDNS to ff02::fb fails with "no route to host" on any host lacking an IPv6 multicast route (common on macOS and IPv4-only LANs) — even though the IPv4 query (224.0.0.251, the workhorse) already went out fine. So on a send failure we fall back to an IPv4-only query rather than surfacing the IPv6 error. IPv6 is kept when it works.

func (*Peer) AddSource

func (p *Peer) AddSource(s Source)

AddSource is the canonical "union" operation when we re-observe a peer via a new channel. Idempotent.

func (*Peer) FirstEndpoint

func (p *Peer) FirstEndpoint(k EndpointKind) Endpoint

FirstEndpoint returns the first endpoint of the given kind, or a zero Endpoint when none matches. Callers compare against the zero to distinguish "no endpoint" from "endpoint with empty host."

func (*Peer) HasEndpoint

func (p *Peer) HasEndpoint(k EndpointKind) bool

HasEndpoint reports whether the peer advertises a reachable endpoint of the given kind. Used by the dial path to decide whether to attempt a LAN-direct dial vs falling back to cloudbox.

type PeerHello

type PeerHello struct {
	PeerID           PeerID     `json:"peer_id"`
	AgentName        string     `json:"agent_name"`
	AssignedHostname string     `json:"assigned_hostname,omitempty"`
	OAuth2Email      string     `json:"oauth2_email,omitempty"`
	OSUsername       string     `json:"os_username,omitempty"`
	Endpoints        []Endpoint `json:"endpoints,omitempty"`
	Version          string     `json:"version,omitempty"`
	CloudboxBase     string     `json:"cloudbox_base,omitempty"`
	Paired           bool       `json:"paired"`
	// HostCert is the cloudbox-CA-signed `ssh.Certificate` blob
	// (ssh.MarshalAuthorizedKey output, base64'd). Empty in Wave
	// 3A.1; populated in Wave 3A.2 after the cloudbox CA endpoint
	// lands.
	HostCert string `json:"host_cert,omitempty"`
}

PeerHello is the wire shape every /hello and /probe exchanges. The Cert field is optional in Wave 3A.1 (we don't issue them yet) and strictly required for Tier-2 ops in Wave 3A.2.

type PeerID

type PeerID string

PeerID is the canonical peer identity. Format matches ssh.FingerprintSHA256 output, e.g.

"SHA256:Z6JEnskW1k5N2OFEcLmRpY+UDc/yX4tFr8r5KH8e0Dk"

We use this rather than the bare base64 because the prefix is self-describing (an operator looking at scan output knows what kind of identifier they're seeing) and it round-trips with ssh-keygen / SSH known_hosts output.

func (PeerID) IsValid

func (p PeerID) IsValid() bool

IsValid reports whether the PeerID has the SHA256 fingerprint shape. Cheap syntactic check; does not verify the underlying key.

type PeersResponse

type PeersResponse struct {
	Peers []Peer `json:"peers"`
}

PeersResponse is the body of GET /api/v1/discover/peers.

type PredictedView added in v0.2.0

type PredictedView struct {
	PeerID      PeerID    `json:"peer_id"`
	Hour        int       `json:"hour_of_week"`
	Probability float64   `json:"probability"`
	LastSeenAt  time.Time `json:"last_seen_at,omitzero"`
}

PredictedView is the wire shape returned by `outpost scan --predicted`.

type ProbeRequest

type ProbeRequest struct {
	SessionID        string `json:"session_id"`
	SignedChallenge  string `json:"signed_challenge"`             // base64 ed25519 sig over server's nonce
	YourChallenge    string `json:"your_challenge,omitempty"`     // optional: a fresh nonce for the server to sign back
	YourCallerPubkey string `json:"your_caller_pubkey,omitempty"` // base64 ed25519 pubkey; needed for sig verification when no cert
}

ProbeRequest is the body of POST /api/v1/discover/probe.

type ProbeResponse

type ProbeResponse struct {
	SessionID           string `json:"session_id"`
	OK                  bool   `json:"ok"`
	SignedYourChallenge string `json:"signed_your_challenge,omitempty"` // server's sig over the caller's optional nonce
	ServerPubkey        string `json:"server_pubkey,omitempty"`         // base64 ed25519 pubkey so caller can verify
}

ProbeResponse is the response shape.

type ProbeResult

type ProbeResult struct {
	// Peer is the parsed Peer record extracted from the remote's
	// /hello response. Trust starts at Unverified and is promoted
	// to TOFU on successful mutual signature verification.
	Peer Peer

	// ServerVerified is true when the server's /probe response
	// carried a signature over our nonce AND the signature matched
	// the server's claimed pubkey AND the pubkey fingerprints to
	// the same PeerID returned in /hello.
	ServerVerified bool

	// SessionID is the discovery session established with the
	// remote; usable for follow-up /peers calls (caller retains
	// the Client to make them on the verified session).
	SessionID string
}

ProbeResult is the structured outcome of a one-shot probe(url) call: we get the peer's hello info plus mutual signature verification when possible.

type ReachabilityEdge

type ReachabilityEdge struct {
	Self PeerID `json:"self"`
	// Peer is the destination's fingerprint when we have one (set
	// for cert-verified or TOFU-pinned peers). For Wave 3B.1 cloudbox-
	// reached hosts dialed by alias we don't yet know the fingerprint
	// at dial time; PeerName carries the alias for those rows and
	// Peer stays empty until fingerprint discovery lands in 3B.2.
	Peer      PeerID    `json:"peer,omitempty"`
	PeerName  string    `json:"peer_name,omitempty"`
	Endpoint  Endpoint  `json:"endpoint"`
	Transport string    `json:"transport"` // "ssh", "http-probe", "cloudbox-ssh", "lan-direct-ssh"
	LatencyMs int64     `json:"latency_ms"`
	At        time.Time `json:"at,omitzero"`
	Source    Source    `json:"source,omitempty"` // "" when locally observed; SourceGossip when received from a peer
}

ReachabilityEdge is one observation from the reachability ledger: "I (Self) successfully reached Peer via Endpoint at Time." Used by Component 6 (edge gossip, Wave 3B) and Component 7 (temporal observations).

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server hosts the HTTP discovery surface. Construction is dependency- injected: the daemon supplies the local PeerHello (what we advertise about ourselves) and a function that returns the current peer cache (for /peers). The HostSigner is the outpost's ed25519 host key, reused from internal/agent/hostkey.go — we sign challenges with it.

func NewServer

func NewServer(opts ServerOptions) *Server

NewServer constructs a discovery HTTP server. Self is what the server returns in /hello.My; PeersFn provides /peers content. HostSigner signs probe challenges.

func (*Server) Mount

func (s *Server) Mount(mux *http.ServeMux, prefix string)

Mount registers handlers under the given prefix (default `/api/v1/discover`) on the supplied ServeMux. Caller controls the listener.

func (*Server) Self

func (s *Server) Self() PeerHello

Self updates the local PeerHello returned by /hello.My. Called by the daemon when discovered metadata changes (e.g., pairing completes, cert renewed).

func (*Server) SetSelf

func (s *Server) SetSelf(self PeerHello)

SetSelf swaps the advertised PeerHello atomically.

type ServerOptions

type ServerOptions struct {
	Self    PeerHello
	Signer  ssh.Signer
	PeersFn func() []Peer
}

ServerOptions wires up dependencies.

type Session

type Session struct {
	ID        string
	PeerID    PeerID
	Nonce     []byte // we issued this; caller signs it in /probe
	Verified  bool   // flipped true after a successful /probe
	ExpiresAt time.Time
}

Session is one in-flight or completed discovery handshake.

type SessionStore

type SessionStore struct {
	// contains filtered or unexported fields
}

SessionStore is the in-memory cap-bounded session table. Safe for concurrent use.

func NewSessionStore

func NewSessionStore() *SessionStore

NewSessionStore returns an empty store with defaults.

func (*SessionStore) Get

func (s *SessionStore) Get(id string) (*Session, bool)

Get returns the session by ID, or (nil, false) when absent/expired. A get on an expired session evicts it as a side effect — saves a separate sweep goroutine.

func (*SessionStore) Len

func (s *SessionStore) Len() int

Len reports the current number of live sessions. For tests.

func (*SessionStore) MarkVerified

func (s *SessionStore) MarkVerified(id string)

MarkVerified flips a session's verified flag (after a successful /probe signature check). No-op when the session has already expired.

func (*SessionStore) New

func (s *SessionStore) New(peer PeerID) (*Session, error)

New mints a fresh session for the given peer with a freshly-generated 32-byte nonce. The caller should send back `{session_id, nonce}` in the /hello response.

When the store is at capacity, the oldest session is evicted to make room — small-scale outposts are unlikely to ever hit this.

type Source

type Source string

Source records how the local cache first heard about a Peer. The list is additive — when the same peer is rediscovered via multiple channels, we union the Sources rather than picking one.

const (
	SourceMDNS         Source = "mdns"
	SourceHTTPProbe    Source = "http-probe"
	SourceCloudboxHint Source = "cloudbox-nat-hint"
	SourceGossip       Source = "gossip"
	SourceHistory      Source = "history"  // we connected to them before; remembering from disk
	SourceOperator     Source = "operator" // explicit `outpost discover probe <url>`
)

type TrustLevel

type TrustLevel string

TrustLevel summarises what we know about the peer's identity. Wave 3A.1 only emits TOFU and Unverified; Wave 3A.2 adds CloudboxCert once cloudbox-side CA work lands.

const (
	TrustUnverified   TrustLevel = "unverified"    // metadata only; no cryptographic check has been done
	TrustTOFU         TrustLevel = "tofu"          // fingerprint pinned to known_hosts (first-contact)
	TrustCloudboxCert TrustLevel = "cloudbox-cert" // cloudbox-CA-signed cert verified (Wave 3A.2)
)

Jump to

Keyboard shortcuts

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