all: implement PingRequest for node connectivity checking
Implement tailcfg.PingRequest support so the control server can verify whether a connected node is still reachable. This is the foundation for faster offline detection (currently ~16min due to Go HTTP/2 TCP retransmit behavior) and future C2N communication. The server sends a PingRequest via MapResponse with a unique callback URL. The Tailscale client responds with a HEAD request to that URL, proving connectivity. Round-trip latency is measured. Wire PingRequest through the Change → Batcher → MapResponse pipeline, add a ping tracker on State for correlating requests with responses, add ResolveNode for looking up nodes by ID/IP/hostname, and expose a /debug/ping page (elem-go form UI) and /machine/ping-response endpoint. Updates #2902 Updates #2129
This commit is contained in:
parent
32e1d77663
commit
b113655b71
9 changed files with 478 additions and 0 deletions
97
hscontrol/state/ping.go
Normal file
97
hscontrol/state/ping.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package state
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
)
|
||||
|
||||
const pingIDLength = 16
|
||||
|
||||
// pingTracker manages pending ping requests and their response channels.
|
||||
// It correlates outgoing PingRequests with incoming HEAD callbacks.
|
||||
type pingTracker struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]*pendingPing
|
||||
}
|
||||
|
||||
type pendingPing struct {
|
||||
nodeID types.NodeID
|
||||
startTime time.Time
|
||||
responseCh chan time.Duration
|
||||
}
|
||||
|
||||
func newPingTracker() *pingTracker {
|
||||
return &pingTracker{
|
||||
pending: make(map[string]*pendingPing),
|
||||
}
|
||||
}
|
||||
|
||||
// register creates a new pending ping and returns a unique ping ID
|
||||
// and a channel that will receive the round-trip latency when the
|
||||
// ping response arrives.
|
||||
func (pt *pingTracker) register(nodeID types.NodeID) (string, <-chan time.Duration) {
|
||||
pingID, _ := util.GenerateRandomStringDNSSafe(pingIDLength)
|
||||
ch := make(chan time.Duration, 1)
|
||||
|
||||
pt.mu.Lock()
|
||||
pt.pending[pingID] = &pendingPing{
|
||||
nodeID: nodeID,
|
||||
startTime: time.Now(),
|
||||
responseCh: ch,
|
||||
}
|
||||
pt.mu.Unlock()
|
||||
|
||||
return pingID, ch
|
||||
}
|
||||
|
||||
// complete signals that a ping response was received.
|
||||
// It sends the measured latency on the response channel and returns true.
|
||||
// Returns false if the pingID is unknown (already completed, cancelled, or expired).
|
||||
func (pt *pingTracker) complete(pingID string) bool {
|
||||
pt.mu.Lock()
|
||||
|
||||
pp, ok := pt.pending[pingID]
|
||||
if ok {
|
||||
delete(pt.pending, pingID)
|
||||
}
|
||||
pt.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
pp.responseCh <- time.Since(pp.startTime)
|
||||
|
||||
close(pp.responseCh)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cancel removes a pending ping without completing it.
|
||||
// Used for cleanup when the caller times out or disconnects.
|
||||
func (pt *pingTracker) cancel(pingID string) {
|
||||
pt.mu.Lock()
|
||||
delete(pt.pending, pingID)
|
||||
pt.mu.Unlock()
|
||||
}
|
||||
|
||||
// RegisterPing creates a pending ping for the given node and returns
|
||||
// a unique ping ID and a channel that receives the round-trip latency
|
||||
// when the response arrives.
|
||||
func (s *State) RegisterPing(nodeID types.NodeID) (string, <-chan time.Duration) {
|
||||
return s.pings.register(nodeID)
|
||||
}
|
||||
|
||||
// CompletePing signals that a ping response was received for the given ID.
|
||||
// Returns true if the ping was found and completed, false otherwise.
|
||||
func (s *State) CompletePing(pingID string) bool {
|
||||
return s.pings.complete(pingID)
|
||||
}
|
||||
|
||||
// CancelPing removes a pending ping without completing it.
|
||||
func (s *State) CancelPing(pingID string) {
|
||||
s.pings.cancel(pingID)
|
||||
}
|
||||
|
|
@ -146,6 +146,9 @@ type State struct {
|
|||
// only proceeds when the generation it carries matches the latest.
|
||||
connectGen sync.Map // types.NodeID → *atomic.Uint64
|
||||
|
||||
// pings tracks pending ping requests and their response channels.
|
||||
pings *pingTracker
|
||||
|
||||
// sshCheckAuth tracks when source nodes last completed SSH check auth.
|
||||
//
|
||||
// For rules without explicit checkPeriod (default 12h), auth covers any
|
||||
|
|
@ -256,6 +259,7 @@ func NewState(cfg *types.Config) (*State, error) {
|
|||
authCache: authCache,
|
||||
primaryRoutes: routes.New(),
|
||||
nodeStore: nodeStore,
|
||||
pings: newPingTracker(),
|
||||
|
||||
sshCheckAuth: make(map[sshCheckPair]time.Time),
|
||||
}, nil
|
||||
|
|
@ -699,6 +703,37 @@ func (s *State) GetNodeByMachineKey(machineKey key.MachinePublic, userID types.U
|
|||
return s.nodeStore.GetNodeByMachineKey(machineKey, userID)
|
||||
}
|
||||
|
||||
// ResolveNode looks up a node by numeric ID, IPv4/IPv6 address, hostname, or given name.
|
||||
// It tries ID first, then IP, then name matching.
|
||||
func (s *State) ResolveNode(query string) (types.NodeView, bool) {
|
||||
// Try numeric ID first.
|
||||
id, idErr := types.ParseNodeID(query)
|
||||
if idErr == nil {
|
||||
return s.GetNodeByID(id)
|
||||
}
|
||||
|
||||
// Try IP address.
|
||||
addr, addrErr := netip.ParseAddr(query)
|
||||
if addrErr == nil {
|
||||
for _, n := range s.ListNodes().All() {
|
||||
if slices.Contains(n.IPs(), addr) {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
|
||||
return types.NodeView{}, false
|
||||
}
|
||||
|
||||
// Try hostname / given name.
|
||||
for _, n := range s.ListNodes().All() {
|
||||
if n.Hostname() == query || n.GivenName() == query {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
|
||||
return types.NodeView{}, false
|
||||
}
|
||||
|
||||
// ListNodes retrieves specific nodes by ID, or all nodes if no IDs provided.
|
||||
func (s *State) ListNodes(nodeIDs ...types.NodeID) views.Slice[types.NodeView] {
|
||||
if len(nodeIDs) == 0 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue