dispatch

package
v1.0.1 Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2026 License: AGPL-3.0 Imports: 30 Imported by: 0

Documentation

Overview

Package dispatch implements The Dispatch: peer-to-peer sharing between LAN Sheriff installations that the operator has explicitly paired.

The threat model and wire format are in docs/DISPATCH-PROTOCOL.md and were written and reviewed before any of this existed. Read that first, the reasoning behind several choices here is deliberately not repeated in the code, and a change that looks like a simplification may be removing a mitigation.

Nothing in this package runs unless the user enables the feature.

Index

Constants

View Source
const (
	// HandshakeTimeout bounds how long a connection may take to authenticate.
	// Against a slow-loris that opens sockets and never finishes (A1).
	HandshakeTimeout = 10 * time.Second

	// IdleTimeout closes a connection that has said nothing. Longer than the
	// keep-alive interval so a healthy quiet peer is never dropped.
	IdleTimeout = 90 * time.Second

	// FrameDeadline bounds a single read or write once started. A peer that
	// sends half a frame and stops must not hold the reader forever.
	FrameDeadline = 30 * time.Second

	// KeepAlive is how often a ping goes out on an otherwise silent connection.
	KeepAlive = 30 * time.Second

	// MaxMessagesPerSecond is the sustained rate one peer may send at.
	MaxMessagesPerSecond = 20

	// MaxBurst is how many messages may arrive at once before the rate applies.
	// A peer legitimately sends several summaries back to back after
	// reconnecting, so a burst allowance avoids punishing normal behaviour.
	MaxBurst = 60
)
View Source
const (
	TypeHello   = "hello"
	TypeSummary = "summary"
	TypeFinding = "finding"
	TypeDevice  = "device"
	TypePing    = "ping"
	TypePong    = "pong"
	TypeBye     = "bye"
)

Message types.

View Source
const (
	TypePairRequest  = "pair_request"
	TypePairResponse = "pair_response"
)

Pairing message types.

View Source
const (

	// MaxPeers bounds the whole feature's resource use.
	MaxPeers = 8

	// PeerDataTTL is how long a peer's merged data is kept once it stops being
	// refreshed. Peer data is a cache, not a record.
	PeerDataTTL = 7 * 24 * time.Hour
)
View Source
const (
	// MaxBuckets caps one summary message.
	//
	// Derived from MaxFrameSize rather than chosen: a bucket with every string
	// at its limit encodes to about 562 bytes, so 1,500 of them is roughly 74%
	// of a frame. The first draft said 5,000, which is 247% of a frame, a peer
	// sending a legitimately maximal summary would have been disconnected for
	// oversizing it. A test encodes the worst case and fails if these two
	// constants ever disagree again.
	//
	// A sender with more than this to report sends several messages. Buckets are
	// keyed and upserted, so splitting a report changes nothing downstream.
	MaxBuckets = 1500

	// MaxBucketAge is how far back a peer may report. Anything older is
	// discarded rather than clamped: a peer sending week-old buckets is
	// confused, and quietly filing them under a wrong hour would be worse than
	// dropping them.
	MaxBucketAge = 48 * time.Hour

	// MaxClockAhead is how far into the future a peer's timestamp may sit before
	// it is treated as skew. Small, because the damage from a future timestamp
	// is that it pins itself to the top of every time-ordered view.
	MaxClockAhead = 5 * time.Minute
)
View Source
const ExporterLabel = "lan-sheriff/dispatch/pair/v1"

ExporterLabel is the RFC 5705 label for the pairing binding. It is part of the wire protocol: both sides must use the identical string or no proof will ever verify.

View Source
const JoinCodeVersion = 1

JoinCodeVersion is the current code generation. A code from a different generation is rejected outright rather than interpreted generously.

View Source
const MaxFrameSize = 1 << 20

MaxFrameSize caps a single frame at 1 MiB.

Generous for the messages that exist, a full summary at the 5,000-bucket ceiling is well under this, and small enough that a peer cannot make us allocate meaningfully by asking.

View Source
const PairingWindow = 15 * time.Minute

PairingWindow is how long a displayed code stays valid.

Five minutes is long enough to walk to another room and short enough that a code left on a screen stops being a credential quickly. Fifteen minutes, raised from five.

The window is not what protects a pairing: the secret is 128 bits, the tag stops an attacker ever collecting a grindable proof, only one attempt is accepted per code, and the listener takes one connection at a time. Tripling the clock changes none of that.

What five minutes did do was punish the actual workflow. The code is forty characters, and it has to be carried to another machine, in another room, where a dashboard has to be opened and an address typed before the code is. Somebody who mistypes one character does not get a retry, because the attempt is spent, so they walk back for a fresh code with the clock already running. Fifteen minutes absorbs one such round trip; five did not.

View Source
const PeerIDLen = 25

PeerIDLen is the peer ID's length in characters.

Twenty-five, not twenty-six, so it divides into exactly five groups of five with nothing left over. This is a string a person compares across two screens, and a trailing group of one invites the eye to skip it. The cost is three bits of a digest that has 125 to spare.

View Source
const ProtocolVersion = 1

ProtocolVersion is the wire generation this build speaks.

Variables

View Source
var (
	// ErrFrameTooLarge is returned for a declared length above MaxFrameSize.
	// The connection must be closed: a peer that sends one is either broken or
	// hostile, and there is no way to resynchronize a stream framed by lengths
	// once one of them is wrong.
	ErrFrameTooLarge = errors.New("dispatch: frame exceeds the maximum size")

	// ErrFrameEmpty is returned for a zero-length frame, which carries no
	// message and would otherwise be a free way to keep a connection alive.
	ErrFrameEmpty = errors.New("dispatch: zero-length frame")
)
View Source
var (
	ErrCodeLength  = errors.New("pairing code is the wrong length")
	ErrCodeChars   = errors.New("pairing code contains characters that are not part of a code")
	ErrCodeVersion = errors.New("pairing code is from a different version of LAN Sheriff")
)

Errors a caller may want to distinguish when telling the user what went wrong.

View Source
var (
	// ErrWrongVersion is returned for a message from another protocol
	// generation. The connection should be closed: there is no downgrade path.
	ErrWrongVersion = errors.New("dispatch: message is from a different protocol version")

	// ErrUnknownType is returned for a type this build does not implement. The
	// caller logs and continues, the connection stays up, since an unknown
	// message is how a newer peer adds a feature.
	ErrUnknownType = errors.New("dispatch: unknown message type")

	// ErrMalformed covers anything that is not valid JSON or not the shape the
	// type requires.
	ErrMalformed = errors.New("dispatch: malformed message")
)
View Source
var (
	ErrNoPairingSession = errors.New("dispatch: no pairing session is open")
	ErrPairingExpired   = errors.New("dispatch: the pairing code has expired")
	ErrPairingUsed      = errors.New("dispatch: the pairing code has already been used")
	ErrWrongMachine     = errors.New("dispatch: that code belongs to a different machine")

	// ErrPeerDeclined is the far side answering the handshake and then saying
	// goodbye, which is what it does when no pairing window is open.
	//
	// **This is not a network failure and used to be reported as one.** The
	// connection succeeded, TLS completed, and the other machine replied; it
	// simply had nothing to pair with. Falling through to the generic case
	// printed "could not reach that address" over a connection that had plainly
	// been reached, and sent people to check addresses, firewalls and cables
	// that were all correct. The real cause is almost always a code that has
	// already been used, since codes are single use.
	ErrPeerDeclined = errors.New("dispatch: the other machine is not showing a pairing code")
	ErrBadProof     = errors.New("dispatch: the pairing code is wrong")
)

Errors a caller may want to tell apart when explaining a failure.

View Source
var ErrDisabled = errors.New("dispatch: not enabled")

ErrDisabled is returned when the service is asked to start without being enabled.

View Source
var ErrRateLimited = errors.New("dispatch: peer exceeded its message rate")

ErrRateLimited is returned when a peer exceeds its message allowance.

View Source
var ErrUnpinnedPeer = errors.New("dispatch: peer key is not paired")

ErrUnpinnedPeer is returned by a verifier for a key that is not paired. It surfaces during the handshake, which is the point: the connection dies before a single application byte is read from it.

Functions

func Binding

func Binding(conn *tls.Conn) ([]byte, error)

Binding derives the channel-binding value for a completed connection.

This is what makes a pairing proof worthless outside the session it was made in, see docs/DISPATCH-PROTOCOL.md §5. It must be called only after the handshake completes; on an incomplete connection it returns an error rather than a zero value that would compare equal on both sides of an attack.

func ClientTLS

func ClientTLS(id *Identity, check KeyVerifier) (*tls.Config, error)

ClientTLS builds the dialler's configuration.

func DecodeBody

func DecodeBody[T any](env Envelope) (T, error)

DecodeBody parses an envelope's body into a typed value.

func Dir

func Dir(dataDir string) string

Dir returns the directory holding Dispatch state, given the data directory.

func EncodeMessage

func EncodeMessage(msgType string, body any) ([]byte, error)

EncodeMessage marshals a message body into a frame payload.

func Fingerprint

func Fingerprint(peerID string) string

Fingerprint renders a peer ID as five groups of five characters, for a person comparing two screens.

func FingerprintFor

func FingerprintFor(peerID string) string

FingerprintFor renders a peer id for display.

func KeyTag

func KeyTag(pub ed25519.PublicKey) [8]byte

KeyTag is the 64-bit truncation of the SPKI digest carried in a join code, so the joining side can reject the wrong machine before proving anything to it.

Truncation is safe here only because the pairing proof is bound to the TLS session (see the protocol document, §5): the tag defends against an honest mistake and an online guess, not against an offline search.

func OffSubnet

func OffSubnet(target netip.Addr) (locals []netip.Prefix, off bool)

OffSubnet reports whether an address is outside every network this machine is on, and returns the local networks so the caller can say which they are.

Why this is worth checking before blaming a firewall

The Dispatch pairs machines on the same network. Nothing enforces that, because a routed network with two subnets is a legitimate setup and refusing it would be wrong. But the overwhelmingly common case for "it will not connect" is that the two machines are not on the same network at all: one on Wi-Fi and one on Ethernet behind a different router, a guest network, or an address typed from memory that belonged to a different house.

Every one of those produced the same message about firewalls and VPNs, which sends somebody to turn off protections that were never the problem. The address itself already says so, and it costs nothing to look.

func PairProof

func PairProof(secret [secretLen]byte, binding []byte, pub ed25519.PublicKey) []byte

PairProof computes the proof of knowledge of a pairing secret, bound to a specific TLS session and to the prover's own key.

binding comes from tls.ConnectionState.ExportKeyingMaterial. Including it is the entire defence against an on-path attacker: two relayed connections have two different bindings, so a proof lifted from one does not verify in the other. Including the prover's public key stops a proof being reflected back at its sender.

func PeerIDFor

func PeerIDFor(pub ed25519.PublicKey) string

PeerIDFor derives a peer ID from a public key.

func PeerKeyOf

func PeerKeyOf(conn *tls.Conn) (ed25519.PublicKey, error)

PeerKeyOf returns the public key the peer authenticated with.

func ReadFrame

func ReadFrame(r io.Reader) ([]byte, error)

ReadFrame reads one frame.

**The length is validated before anything is allocated.** A peer declaring four gigabytes gets an error, not four gigabytes of address space. This is the single most important line in the package.

func ServerTLS

func ServerTLS(id *Identity, check KeyVerifier) (*tls.Config, error)

ServerTLS builds the listener's configuration.

func TailscalePresent

func TailscalePresent() bool

TailscalePresent reports whether this machine has a Tailscale interface.

Why name one product

Because it is the one that caused this, it is extremely common, and the setting responsible is on by default for many people. Tailscale's "Block incoming connections" discards inbound traffic on **every** interface, not only the tailnet, while leaving outbound working perfectly. The result is a machine that reaches the internet, reaches its peers, and even reaches its own LAN address, while nothing on the network can open a socket to it.

That combination is almost impossible to diagnose from the outside and takes minutes to fix once named, so naming it is worth more than a generic sentence about firewalls. It is detected rather than assumed, and it is only ever mentioned alongside a timeout, never on its own.

Detection is by address range, deliberately. Shelling out to another product's binary to read its configuration would be fragile, would need that binary on PATH, and would make LAN Sheriff depend on a thing it merely coexists with. Every Tailscale node holds an address in 100.64.0.0/10, the carrier-grade NAT range Tailscale uses for its tailnet, and reading our own interface list costs nothing because Patrol Mode already does it.

func VPNPresent

func VPNPresent() (string, bool)

VPNPresent reports the name of a VPN this machine appears to be running, if one is recognisable, and whether anything was found at all.

Why this exists beside the Tailscale check

A tester spent an evening on two machines that paired and then never connected, both reporting "Never connected" at each other across one /24. The Windows machine was running NordVPN, which puts a TAP adapter in front of the default route and, with its kill switch on, discards traffic that does not go through the tunnel. Traffic to a machine on the same subnet is exactly that.

This is the same shape as the Tailscale problem: the machine is up, the dashboard works, outbound browsing works, and the one port that matters is silently dropped. It is worth naming the cause rather than leaving somebody to conclude the software is broken.

Matched on interface names because that is what a VPN reliably leaves behind and it needs no privilege to read. Deliberately a short list of distinctive names: "utun" and "tun0" are not on it, because macOS and Linux use those for things that are not VPNs and a false accusation is worse than silence.

func VerifyPairProof

func VerifyPairProof(secret [secretLen]byte, binding []byte, pub ed25519.PublicKey, proof []byte) bool

VerifyPairProof checks a proof in constant time.

func WriteFrame

func WriteFrame(w io.Writer, payload []byte) error

WriteFrame writes one length-prefixed frame.

Types

type Bye

type Bye struct {
	Reason string `json:"reason"`
}

Bye is an orderly close. Advisory only: a connection may vanish without one, and the code must handle both identically.

type Config

type Config struct {
	// Enabled must be set explicitly. There is no default-on path.
	Enabled bool
	// Listen is the address to accept peers on, host:port.
	Listen string
	// AllowPublic permits binding an address reachable from outside this
	// network. Off unless the operator insisted.
	AllowPublic bool
	// DataDir is where the identity key lives.
	DataDir string
}

Config controls the service. The zero value is disabled, which is the point: nothing here starts unless somebody asked for it.

type Conn

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

Conn is an authenticated connection to one peer.

Safe for one reader and one writer concurrently, which is how it is used: a read loop and a write pump. It is *not* safe for two concurrent writers, so all sends go through Send, which holds a mutex.

func Handshake

func Handshake(ctx context.Context, raw net.Conn, cfg *tls.Config, isServer bool) (*Conn, error)

Handshake completes the TLS handshake under HandshakeTimeout.

Separate from NewConn so the timeout applies to the handshake specifically: this is the phase an unauthenticated stranger can reach, so it is the phase that must be bounded most tightly.

func NewConn

func NewConn(tc *tls.Conn) (*Conn, error)

NewConn wraps a connection whose handshake has already completed.

func (*Conn) Close

func (c *Conn) Close() error

Close ends the connection. Safe to call more than once, and on a zero value.

The nil check is not defensive clutter: Close is the one method callers invoke from a defer without checking anything first, and one that panics turns an ordinary teardown into a crash. Nothing in the running system can produce a Conn without a transport (NewConn and Handshake both set one) but a Close that only works on well-formed values is a poor primitive.

func (*Conn) PeerID

func (c *Conn) PeerID() string

PeerID is the identity that authenticated on this connection.

Taken from the pinned key that completed the handshake, never from anything the peer said afterwards. Everything written to the store is attributed with this, which is what makes "a peer may only speak about itself" enforceable.

func (*Conn) PublicKey

func (c *Conn) PublicKey() ed25519.PublicKey

PublicKey returns the peer's authenticated key.

func (*Conn) Receive

func (c *Conn) Receive() (Envelope, error)

Receive reads one message, applying the idle timeout and the rate limit.

The rate limit is checked *after* a frame is read rather than before, because the cost being limited is the work a message causes, and a peer cannot be prevented from putting bytes on a socket. A peer over its allowance gets ErrRateLimited and the caller closes the connection.

func (*Conn) RemoteAddr

func (c *Conn) RemoteAddr() net.Addr

RemoteAddr reports where the peer is, for reconnection hints only.

func (*Conn) SayGoodbye

func (c *Conn) SayGoodbye(reason string)

SayGoodbye sends a bye and closes, best effort.

Advisory: a peer may vanish without one and the code treats both identically, so a failure here is not worth reporting.

func (*Conn) Send

func (c *Conn) Send(msgType string, body any) error

Send writes one message.

func (*Conn) Unread

func (c *Conn) Unread(env Envelope)

Unread puts one message back, so the next Receive returns it.

Why a listener needs this

Pairing and ordinary peer traffic share a port, and the listener used to decide between them by whether it recognised the key: a known key was always treated as a peer reconnecting. That is wrong whenever a machine you have unpaired still has you paired, which is the normal state after one side unpairs, because unpairing is local. The other machine then dials in to pair, is recognised, is handed to the session loop, and gets a goodbye.

The client already says which it wants in its first message: `hello` for a session, `pair_request` for pairing. So the listener reads that message and then puts it back for whichever handler it chose, rather than guessing from the key and being unable to change its mind.

type Envelope

type Envelope struct {
	V    int             `json:"v"`
	Type string          `json:"type"`
	Body json.RawMessage `json:"body,omitempty"`
}

Envelope wraps every message.

func DecodeEnvelope

func DecodeEnvelope(payload []byte) (Envelope, error)

DecodeEnvelope reads the envelope of a received frame.

It deliberately does not decode the body: the caller dispatches on the type first, so a body is only ever parsed by code that knows what shape it should be. A single decode into a union type would mean every message allocating every field.

type Hello

type Hello struct {
	PeerID string `json:"peer_id"`
	// Label is what the sender calls itself, sent on every connection rather
	// than only at pairing so that a machine renamed later is renamed for its
	// peers too, and so a pairing made before this field existed still ends up
	// with a name instead of a fingerprint. Display only: the receiver keeps
	// any name its own operator chose.
	Label string `json:"label,omitempty"`
	// Software is this build's version string, for display only. It is never
	// used to decide behaviour: a peer that lies about its version must not be
	// able to steer us into a different code path.
	Software string `json:"software"`
	// Clock is the sender's wall clock in Unix seconds, so skew can be reported
	// rather than silently corrected.
	Clock int64 `json:"clock"`
	// Capabilities names what the peer can observe, so its absence of data can
	// be explained rather than displayed as silence.
	Mode string `json:"mode"`
	// ListenPort is where the sender accepts peer connections.
	//
	// Only the port, deliberately. The address a peer can be reached at is taken
	// from the connection we are already holding; a peer that could name a *host*
	// could point us at a third party, which is a redirect primitive handed to
	// the one participant the merge rules already assume may be compromised.
	// A port is the minimum needed to dial back and cannot redirect anything.
	ListenPort int `json:"listen_port,omitempty"`
}

Hello is the first message in both directions.

type Identity

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

Identity is this instance's cryptographic identity on the Dispatch network.

The key *is* the identity. The certificate is a container TLS requires, and is regenerated freely when it expires; peers pin the public key, so a new certificate over the same key needs no re-pairing.

func LoadIdentity

func LoadIdentity(dataDir string) (*Identity, error)

LoadIdentity reads this instance's key, generating one if none exists.

**Called only when the feature is enabled**, never at startup. An install that has never turned the Dispatch on should not have a private key on disk that could be stolen and used to impersonate it later.

func LoadIdentityIfExists

func LoadIdentityIfExists(dataDir string) (*Identity, error)

LoadIdentityIfExists reads an existing identity without creating one.

For callers that need to know whether peering has ever been enabled, the settings UI, and tests asserting that nothing was written, without the act of asking bringing a private key into existence.

func (*Identity) AcceptPairing

func (id *Identity) AcceptPairing(conn *Conn, ps *PairingSession, label string, listenPort int, now time.Time) (PairedPeer, error)

AcceptPairing runs the displaying side of the exchange on an accepted connection whose handshake has completed.

func (*Identity) Certificate

func (id *Identity) Certificate() (certDER []byte, err error)

Certificate builds the self-signed certificate this identity presents.

Everything a normal certificate carries for the benefit of a verifier is absent or arbitrary, there is no name to validate, no chain to build, and no CA. Peers compare the public key against the one pinned at pairing and ignore the rest.

func (*Identity) PeerID

func (id *Identity) PeerID() string

PeerID is the stable identifier for this instance: the leading bits of SHA-256 over the SPKI-encoded public key.

Over the SPKI encoding rather than the raw key bytes so that the identifier is well defined if another key type is ever supported, and so it matches what a peer computes from the certificate it received.

This is an identifier, not a security control. Nothing is authorized by matching a peer ID: authorization is the pinned key, compared in full.

func (*Identity) Public

func (id *Identity) Public() ed25519.PublicKey

Public returns the public key peers pin.

type JoinCode

type JoinCode struct {
	Version uint8
	Tag     [tagLen]byte
	Secret  [secretLen]byte
}

JoinCode is a pairing code as displayed by one instance and typed into another.

func NewJoinCode

func NewJoinCode(pub ed25519.PublicKey) (JoinCode, error)

NewJoinCode mints a code for the instance holding pub.

The secret is fresh for every code. A code is single-use and short-lived; the caller enforces both, because expiry is a property of the pairing session rather than of the code's bytes.

func ParseJoinCode

func ParseJoinCode(s string) (JoinCode, error)

ParseJoinCode reads a code a person typed.

Deliberately forgiving about presentation and unforgiving about content: separators, spaces and case are all ignored, and the letters most often confused for digits are folded (I and L to 1, O to zero) because a person copying from a screen will make exactly those substitutions. Anything else is an error rather than a guess.

func (JoinCode) Matches

func (jc JoinCode) Matches(pub ed25519.PublicKey) bool

Matches reports whether a public key is the one this code was minted for.

Constant time, and used before the joining side discloses its proof, an attacker who fails this check learns nothing they can grind offline.

func (JoinCode) String

func (jc JoinCode) String() string

String renders the code for a human to copy: eight groups of five characters.

type KeyVerifier

type KeyVerifier func(ed25519.PublicKey) error

KeyVerifier decides whether a presented public key may proceed.

A function rather than a peer list, because the two listeners need different answers from the same machinery: the peer listener admits only pinned keys, while the pairing listener admits an unknown key precisely once and relies on the join code to establish who it belongs to.

func AcceptAnyKey

func AcceptAnyKey() KeyVerifier

AcceptAnyKey admits any well-formed key. **Only for the pairing listener**, where the join code rather than the key establishes trust, and only while a pairing session is open.

func PinnedTo

func PinnedTo(want ed25519.PublicKey) KeyVerifier

PinnedTo returns a verifier accepting exactly one key.

func PinnedToAny

func PinnedToAny(keys []ed25519.PublicKey) KeyVerifier

PinnedToAny returns a verifier accepting any key in the set. Used by the peer listener, which does not know which paired peer is dialling until it arrives.

type PairRequest

type PairRequest struct {
	PeerID string `json:"peer_id"`
	Label  string `json:"label,omitempty"`
	Proof  []byte `json:"proof"`
	// ListenPort is where the joiner accepts connections.
	//
	// Without it the displaying side records the joiner's *ephemeral source
	// port* as its address, which is what happened, and meant a freshly paired
	// pair could never connect if the displayer was the side that dials.
	ListenPort int `json:"listen_port,omitempty"`
}

PairRequest is the joiner's proof.

type PairResponse

type PairResponse struct {
	PeerID string `json:"peer_id"`
	Label  string `json:"label,omitempty"`
	Proof  []byte `json:"proof"`
	// ListenPort is where the displaying side accepts connections. The joiner
	// already dialled it, so this is confirmation rather than news, but it keeps
	// the two directions symmetrical.
	ListenPort int `json:"listen_port,omitempty"`
}

PairResponse is the displayer's counter-proof.

type PairedPeer

type PairedPeer struct {
	PeerID    string
	PublicKey ed25519.PublicKey
	Label     string
	Addr      string
}

PairedPeer is what a completed pairing produced.

func JoinWithCode

func JoinWithCode(ctx context.Context, id *Identity, addr, codeText, label string, listenPort int) (PairedPeer, error)

JoinWithCode runs the joining side: dial, verify, prove, verify the reply.

The order matters and is the reason this is not simply "connect and send a password". Step 2 (checking the key tag before sending anything) is what stops an on-path attacker from ever receiving a proof they could attack offline.

type PairingSession

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

PairingSession is one open invitation to pair.

Single-use and short-lived, and **burned on the first bad proof** rather than after some number of attempts. There is no legitimate reason to get a pairing code wrong against the machine that just displayed it, so one failure is treated as an attempt rather than a typo. The user re-displays a code, which costs them a moment and costs an attacker the whole 128-bit search.

func NewPairingSession

func NewPairingSession(id *Identity, now time.Time) (*PairingSession, error)

NewPairingSession mints a code for this identity.

func (*PairingSession) Cancel

func (ps *PairingSession) Cancel()

Cancel closes the window immediately, which is what closing the pairing screen must do. A code that outlives the screen showing it is a credential nobody is watching.

func (*PairingSession) Code

func (ps *PairingSession) Code() string

Code is the string to show the operator.

func (*PairingSession) ExpiresAt

func (ps *PairingSession) ExpiresAt() time.Time

ExpiresAt is when the code stops working.

func (*PairingSession) Result

func (ps *PairingSession) Result(ctx context.Context) (PairedPeer, error)

Result waits for a pairing to complete, or for the context to end.

type PeerRecord

type PeerRecord struct {
	PeerID    string
	PublicKey ed25519.PublicKey
	Label     string
	Suspended bool
	LastAddr  string
}

PeerRecord is what the service needs to know about a paired peer.

A local type rather than the store's, so this package can be tested against a map and does not depend on the database.

type PeerState

type PeerState struct {
	PeerID    string    `json:"peer_id"`
	Label     string    `json:"label,omitempty"`
	Connected bool      `json:"connected"`
	LastSeen  time.Time `json:"last_seen,omitempty"`
	Addr      string    `json:"addr,omitempty"`
	// Status is "connected", "grey" or "suspended", the three things a person
	// needs to tell apart. "grey" means we cannot reach it, which is a different
	// statement from "it reports nothing", and conflating them would let a
	// silenced monitor look like a quiet network.
	Status string `json:"status"`
	// DataStale marks a peer we have not heard from for long enough that what it
	// last told us should not be presented as current.
	//
	// Separate from Status on purpose: a peer can be freshly reconnected and
	// still have nothing recent to show, and a peer can be unreachable while its
	// last hour of data is perfectly good. Collapsing the two would make the map
	// claim currency it does not have.
	DataStale bool `json:"data_stale,omitempty"`
}

PeerState is a peer's live status, for display.

type Ping

type Ping struct {
	Nonce int64 `json:"nonce"`
	Clock int64 `json:"clock"`
}

Ping and Pong carry a nonce so a reply can be matched to its request, and the sender's clock so skew is measured continuously rather than only at hello.

type Pong

type Pong struct {
	Nonce int64 `json:"nonce"`
	Clock int64 `json:"clock"`
}

Pong echoes a Ping.

type Reachability

type Reachability int

Telling one failure to connect from another.

Why this exists

Every failed dial produced one message: "could not reach that address". That sentence is true of several completely different faults with completely different fixes, and it sent an afternoon down the wrong path more than once.

The two that matter most are opposites, and the operating system already distinguishes them:

  • **Refused.** The packet arrived, the far side answered, and nothing was listening on that port. The address is right and the software is not running, or is on another port. Fast, and unambiguous.
  • **Timed out.** The packet left and nothing came back at all. Somebody is dropping it in silence, which is what a host firewall does: it discards rather than replies, precisely so that a scanner learns nothing. Slow, and the most misleading of the two, because "no answer" feels like "wrong address" and is usually not.

A machine can be up, correct, listening, and reachable at layer 2, and still look exactly like a machine that is switched off. Only the timing tells you, and only if somebody writes it down.

const (
	// ReachOther is any failure this file cannot categorize.
	ReachOther Reachability = iota
	// ReachRefused means the connection was actively refused.
	ReachRefused
	// ReachDropped means nothing answered: a timeout, or no route.
	ReachDropped
)

func Classify

func Classify(err error) Reachability

Classify sorts a dial error into something worth telling a person.

type Service

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

Service runs The Dispatch.

func New

func New(cfg Config, st Store, log *slog.Logger) (*Service, error)

New prepares the service, generating this instance's identity if needed.

**Called only when the feature is enabled.** The key is created here rather than at startup so that an install which never turns peering on never has a private key on disk to steal.

func (*Service) Addr

func (s *Service) Addr() net.Addr

Addr reports where the service is listening.

func (*Service) CancelPairing

func (s *Service) CancelPairing()

CancelPairing closes the window. Called when the pairing screen closes, so a displayed code never outlives the screen showing it.

func (*Service) Identity

func (s *Service) Identity() *Identity

Identity exposes this instance's identity, for the pairing UI.

func (*Service) JoinWithCode

func (s *Service) JoinWithCode(ctx context.Context, addr, code, label string) (PairedPeer, error)

JoinWithCode pairs this instance with one displaying a code.

**The label sent is this machine's own name, not the one being joined.**

Both halves of the exchange carry the sender's name for itself: that is how each side ends up with something human to show for the other. The accepting side already sent selfLabel(); this side passed through whatever the operator typed into the join form, which is a name for the *remote* machine. So the machine displaying the code learned its new peer was called "Pi in the basement", or, when the field was left blank as it usually is, learned nothing and displayed a 29-character fingerprint as the peer's name.

The typed name still does its job: it is applied to the peer here, after the exchange, overriding the name that peer chose for itself. Naming the far end is a local preference and belongs on this machine only.

func (*Service) Start

func (s *Service) Start(ctx context.Context) error

Start begins listening and dialling. It returns once the listener is up; everything else runs in the background.

func (*Service) StartPairing

func (s *Service) StartPairing() (*PairingSession, error)

StartPairing opens a pairing window and returns the code to display.

Only one at a time: two open windows would mean two unpinned keys admitted at once, and there is no interface in which a person is pairing two machines in the same instant.

func (*Service) States

func (s *Service) States(ctx context.Context) ([]PeerState, error)

States reports every paired peer's status, for the dashboard.

Reads only in-memory state and the pairing list. Never waits on a peer: this can be called from an API handler, and a handler that could block on a network peer would make one unreachable laptop stall the whole dashboard.

func (*Service) Stop

func (s *Service) Stop()

Stop closes the listener and every live connection, and waits.

type Store

type Store interface {
	DispatchPeers(ctx context.Context) ([]PeerRecord, error)
	MergeDispatchSummaries(ctx context.Context, peerID string, buckets []SummaryBucket, now time.Time) (int, error)
	AddDispatchPeer(ctx context.Context, p PairedPeer) error
	// LocalSummaries is what this instance offers its peers.
	LocalSummaries(ctx context.Context, since time.Time, limit int) ([]SummaryBucket, error)
	// SetDispatchPeerAddr records where a peer was last reached, so a peer whose
	// DHCP lease moved can still be dialled after a restart.
	SetDispatchPeerAddr(ctx context.Context, peerID, addr string) error

	// SetDispatchPeerLabelIfEmpty gives a peer the name it calls itself, and
	// only when this machine has none for it. A name chosen here always wins.
	SetDispatchPeerLabelIfEmpty(ctx context.Context, peerID, label string) error
	// ExpireDispatchSummaries drops peer data past its time to live.
	ExpireDispatchSummaries(ctx context.Context, ttl time.Duration, now time.Time) (int64, error)
}

Store is the persistence the service needs. Narrow on purpose: the service reads pairings and writes summaries, and can do nothing else.

type SummaryBucket

type SummaryBucket struct {
	// Hour is the start of the hour, Unix seconds. Truncated by the sender and
	// re-truncated by the receiver, since a sender's truncation is not something
	// to take on trust.
	Hour int64 `json:"hour"`
	// Device is the reporting peer's own device identifier. It is namespaced
	// under that peer on arrival and is never matched against local device IDs.
	Device string `json:"device"`

	Org     string `json:"endpoint_org,omitempty"`
	Country string `json:"endpoint_country,omitempty"`
	ASN     int    `json:"asn,omitempty"`

	App   string `json:"app,omitempty"`
	Proto string `json:"proto,omitempty"`
	Port  uint16 `json:"port,omitempty"`

	Flows    int64 `json:"flows"`
	BytesOut int64 `json:"bytes_out,omitempty"`
	BytesIn  int64 `json:"bytes_in,omitempty"`
}

SummaryBucket is one hour of one device's traffic to one organization.

type SummaryMessage

type SummaryMessage struct {
	Buckets []SummaryBucket `json:"buckets"`
}

SummaryMessage is the body of a TypeSummary message.

func (SummaryMessage) Sanitize

func (m SummaryMessage) Sanitize(now time.Time) (kept []SummaryBucket, dropped int, err error)

Sanitize validates a received summary and returns the buckets worth keeping.

It **never** returns an error for an individual bad bucket: a peer with one malformed row should not have its whole report discarded, and disconnecting over it would let a single corrupt record silence a machine. Buckets that cannot be trusted are dropped and counted, so the caller can log a peer that is producing many of them.

It does return an error for a message that is structurally unreasonable, more buckets than the protocol permits, because that is not one bad row, it is a peer ignoring the protocol.

Jump to

Keyboard shortcuts

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