Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cloud/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ type containerStats struct {
// NewCollector builds a Collector, reading the host fingerprint (docker engine
// ID) and versions up front. Returns an error only if the engine ID can't be
// read — without it there is no stable host identity. ts reads the local
// tailscale daemon (peer liveness, node identity) and, when API credentials are
// tailscale daemon (node identity, MagicDNS name) and, when API credentials are
// configured, the Tailscale control plane behind the tailnet vantage; pass nil
// to run without any tailnet signals.
func NewCollector(ctx context.Context, cfg Config, dc *docker.Client, ts tailnetSource, logger zerolog.Logger) (*Collector, error) {
Expand Down Expand Up @@ -759,7 +759,7 @@ func (c *Collector) session(ctx context.Context, bo *backoff) (stop bool) {
go c.metricsLoop(connCtx, conn)
}
if c.tailnet != nil {
go c.tailnetLoop(connCtx, conn)
go c.selfDNSNameLoop(connCtx)
}

err = <-runDone
Expand Down
67 changes: 21 additions & 46 deletions cloud/tailnet.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import (
)

// tailnetSource is the read-only tailscale view the collector needs: the local
// daemon (peer liveness, this node's identity, the tailnet name) plus the
// daemon (this node's identity, MagicDNS name and tailnet name) plus the
// control-plane reads that answer a [proto.TailnetProbe] with the credentials
// DockTail already holds. *tailscale.Client satisfies it. Nil when DockTail has
// no tailscale client, in which case the collector reports no peer liveness and
// answers every probe as unavailable.
// no tailscale client, in which case the collector reports no tailnet identity
// and answers every probe as unavailable.
type tailnetSource interface {
// Status returns this node's stable ID, its tailnet name, and the liveness
// of the tailnet peers it can see, from `tailscale status`.
// Status returns this node's stable ID, MagicDNS name and tailnet name,
// from `tailscale status`.
Status(ctx context.Context) (*tailscale.TailnetStatus, error)
// APIEnabled reports whether Tailscale API credentials are configured.
// False ⇒ the control plane is unreadable and no probe can be answered.
Expand Down Expand Up @@ -254,10 +254,9 @@ func controlErrorText(err error) string {

// tailnetIdentity reads this node's tailscale StableNodeID and tailnet name
// (best-effort, bounded) for the hello frame, in ONE `tailscale status` call.
// The node ID is how the cloud splits THIS host's outages into agent_down vs
// host_down (empty ⇒ it falls back to host_down) and how it matches this host
// against a service's control-plane hosts; the tailnet name groups hosts that
// share a control plane, so the cloud can probe one of them on behalf of all.
// The node ID is how the cloud matches this host against a service's
// control-plane hosts; the tailnet name groups hosts that share a control
// plane, so the cloud can probe one of them on behalf of all.
func (c *Collector) tailnetIdentity(ctx context.Context) (nodeID, tailnet string) {
if c.tailnet == nil {
return "", ""
Expand All @@ -275,8 +274,8 @@ func (c *Collector) tailnetIdentity(ctx context.Context) (nodeID, tailnet string
// funnelHostname is this node's MagicDNS name as last read from the local
// daemon, or "" when there is none. It is read on the snapshot path, so it is
// cached rather than shelled out per service: the name changes about as
// often as the machine is renamed, and the heartbeat-cadence netmap read already
// refreshes it.
// often as the machine is renamed, and [Collector.selfDNSNameLoop] refreshes it
// on the heartbeat cadence.
func (c *Collector) funnelHostname() string {
c.mu.RLock()
defer c.mu.RUnlock()
Expand All @@ -295,59 +294,35 @@ func (c *Collector) setSelfDNSName(name string) {
c.mu.Unlock()
}

// tailnetLoop reports the host's local-netmap peer liveness on the heartbeat
// cadence. It is the signal the cloud uses to tell a dead agent (the device is
// still online) from a dead host (device gone) for OTHER hosts on the tailnet.
// Skipped while unmonitored (the cloud drops the frame) and silent when there is
// no tailnet.
func (c *Collector) tailnetLoop(ctx context.Context, conn *wsConn) {
// selfDNSNameLoop re-reads this node's MagicDNS name on the heartbeat cadence,
// so a node that only just got one (a late login, MagicDNS switched on) starts
// reporting its Funnel destination without a reconnect. Skipped while
// unmonitored: the cloud runs no checks for such a host.
func (c *Collector) selfDNSNameLoop(ctx context.Context) {
ticker := time.NewTicker(proto.HeartbeatInterval * time.Second)
defer ticker.Stop()
c.sampleAndSendTailnet(ctx, conn)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.sampleAndSendTailnet(ctx, conn)
c.refreshSelfDNSName(ctx)
}
}
}

func (c *Collector) sampleAndSendTailnet(ctx context.Context, conn *wsConn) {
if c.tailnet == nil {
return
}
func (c *Collector) refreshSelfDNSName(ctx context.Context) {
c.mu.RLock()
unmonitored := c.unmonitored
c.mu.RUnlock()
if unmonitored {
return
}
st, err := c.tailnet.Status(ctx)
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
st, err := c.tailnet.Status(cctx)
if err != nil || st == nil {
return // no tailnet → nothing to report
return // no tailnet → keep whatever name we last saw
}
// Same read, second use: keep the funnel destination fresh on the heartbeat
// cadence so a node that only just got its MagicDNS name starts reporting one.
c.setSelfDNSName(st.SelfDNSName)
peers := make([]proto.TailnetPeer, 0, len(st.Peers))
for _, p := range st.Peers {
if p.NodeID == "" {
continue
}
tp := proto.TailnetPeer{
NodeID: p.NodeID,
Hostname: p.Hostname,
Online: p.Online,
}
if !p.Online && !p.LastSeen.IsZero() {
tp.LastSeen = p.LastSeen.UnixMilli()
}
peers = append(peers, tp)
}
if len(peers) == 0 {
return
}
c.send(conn, proto.TypeTailnet, proto.TailnetReport{Peers: peers})
}
45 changes: 9 additions & 36 deletions tailscale/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,12 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
)

// TailnetStatus is a minimal view of `tailscale status --json`: this node's
// stable ID and MagicDNS name, the tailnet it belongs to, and the online/offline
// status of the tailnet devices (peers) it can see. DockTail Cloud uses it (read
// over the local daemon, no API key) to report peer device liveness so the cloud
// can tell a dead agent from a dead host, and to say where this node's Funnel
// exposure answers on the public internet.
// stable ID and MagicDNS name, and the tailnet it belongs to. DockTail Cloud
// reads it over the local daemon (no API key) to identify this node and to say
// where its Funnel exposure answers on the public internet.
type TailnetStatus struct {
SelfNodeID string
// SelfDNSName is this node's MagicDNS name with the trailing dot stripped
Expand All @@ -25,23 +22,13 @@ type TailnetStatus struct {
// daemons and a logged-out node report nothing, so an empty value means
// "unknown", never "no tailnet".
Tailnet string
Peers []TailnetPeerStatus
}

// TailnetPeerStatus is one tailnet device's liveness as seen in this node's netmap.
type TailnetPeerStatus struct {
NodeID string
Hostname string
Online bool
LastSeen time.Time
}

// statusJSON is the subset of `tailscale status --json` (tailscaled's
// ipnstate.Status) that we parse.
type statusJSON struct {
Self *statusNode `json:"Self"`
Peer map[string]*statusNode `json:"Peer"`
CurrentTailnet *statusTailnet `json:"CurrentTailnet"`
Self *statusNode `json:"Self"`
CurrentTailnet *statusTailnet `json:"CurrentTailnet"`
}

// statusTailnet is ipnstate.Status.CurrentTailnet. Name is the human-facing
Expand All @@ -53,15 +40,12 @@ type statusTailnet struct {
}

type statusNode struct {
ID string `json:"ID"`
DNSName string `json:"DNSName"` // FQDN with a trailing dot, e.g. "box.tail1234.ts.net."
HostName string `json:"HostName"`
Online bool `json:"Online"`
LastSeen time.Time `json:"LastSeen"`
ID string `json:"ID"`
DNSName string `json:"DNSName"` // FQDN with a trailing dot, e.g. "box.tail1234.ts.net."
}

// Status runs `tailscale status --json` and parses this node's stable ID, its
// tailnet name, and the liveness of the peers it can see. Returns an error if
// Status runs `tailscale status --json` and parses this node's stable ID,
// MagicDNS name and tailnet name. Returns an error if
// the daemon isn't reachable or the output can't be parsed — callers treat that
// as "no tailnet" and skip reporting.
func (c *Client) Status(ctx context.Context) (*TailnetStatus, error) {
Expand All @@ -85,16 +69,5 @@ func (c *Client) Status(ctx context.Context) (*TailnetStatus, error) {
out.Tailnet = st.CurrentTailnet.MagicDNSSuffix
}
}
for _, p := range st.Peer {
if p == nil || p.ID == "" {
continue
}
out.Peers = append(out.Peers, TailnetPeerStatus{
NodeID: p.ID,
Hostname: p.HostName,
Online: p.Online,
LastSeen: p.LastSeen,
})
}
return out, nil
}
Loading