Skip to content

Commit 14c1e05

Browse files
committed
Daemon: multi-beacon discovery, RelayOnly mode, async IPC, pubsub retry
Multi-beacon discovery: -beacon flag accepts comma-separated list; FNV-of-pubkey-hash picks one. Foundation for autoscaling beacon MIG (per-pair tunnels pin to the same beacon for ordering). RelayOnly: -relay-only flag hides the daemon's real_addr from peers via registry-side substitution. Daemon-side hint handling skips direct hole-punch when peer is RelayOnly. Async IPC write queue: per-conn buffered sendCh + single writer goroutine, done-channel pattern, ErrIPCBackpressure on slow clients. Removes 96.7% of daemon mutex-wait in stress profiles. Pubsub retry resilience: failed publish retries once before raising; per-peer failure counter via webhook.
1 parent ab14ba5 commit 14c1e05

23 files changed

Lines changed: 2770 additions & 170 deletions

cmd/daemon/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ func main() {
4848
maxConnsTotal := flag.Int("max-conns-total", 0, "max total connections (default 4096)")
4949
timeWait := flag.Duration("time-wait", 0, "TIME_WAIT duration (default 10s)")
5050
public := flag.Bool("public", false, "make this node's endpoint publicly visible (default: private)")
51+
relayOnly := flag.Bool("relay-only", false, "hide real_addr from peers; reach this node only via beacon-relay path. Privacy stance: peers cannot enumerate this daemon's public IP. Trade-off: relay adds one beacon hop. Default false (current direct-first behavior).")
5152
hostname := flag.String("hostname", "", "hostname for discovery (lowercase alphanumeric + hyphens, max 63 chars)")
5253
noEcho := flag.Bool("no-echo", false, "disable built-in echo service (port 7)")
5354
noDataExchange := flag.Bool("no-dataexchange", false, "disable built-in data exchange service (port 1001)")
@@ -96,6 +97,7 @@ func main() {
9697
MaxTotalConnections: *maxConnsTotal,
9798
TimeWaitDuration: *timeWait,
9899
Public: *public,
100+
RelayOnly: *relayOnly,
99101
Hostname: *hostname,
100102
DisableEcho: *noEcho,
101103
DisableDataExchange: *noDataExchange,

pkg/daemon/beacon_discovery.go

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
package daemon
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
"log/slog"
9+
"math/rand"
10+
"os"
11+
"path/filepath"
12+
"sort"
13+
"sync"
14+
"time"
15+
)
16+
17+
// beaconRefreshInterval is how often the daemon re-fetches beacon_list
18+
// from the registry. At 100k daemons × 1 call/min = ~1.7k req/sec on
19+
// the registry — bounded, small responses, well within capacity.
20+
const beaconRefreshInterval = 60 * time.Second
21+
22+
// beaconRefreshJitter spreads the initial tick across a window so a
23+
// fleet-wide simultaneous restart doesn't thunder-herd the registry.
24+
// The first refresh fires at t = rand[0..beaconRefreshJitter).
25+
const beaconRefreshJitter = 10 * time.Second
26+
27+
// beaconCacheFilename is the on-disk fallback used when the registry
28+
// is unreachable at cold-start. Lives next to the identity file.
29+
const beaconCacheFilename = "beacons.json"
30+
31+
// beaconLister abstracts the registry call. Production wires this to
32+
// (*registry.Client).Send; tests inject a fake.
33+
type beaconLister interface {
34+
Send(msg map[string]interface{}) (map[string]interface{}, error)
35+
}
36+
37+
// fetchBeaconList queries the registry's beacon_list endpoint and
38+
// returns just the addresses, in the registry's response order. Empty
39+
// addrs are dropped. Returns an error if the call fails or the response
40+
// shape is wrong; the caller treats that as "keep the current list".
41+
func fetchBeaconList(client beaconLister) ([]string, error) {
42+
if client == nil {
43+
return nil, fmt.Errorf("nil registry client")
44+
}
45+
resp, err := client.Send(map[string]interface{}{"type": "beacon_list"})
46+
if err != nil {
47+
return nil, fmt.Errorf("beacon_list send: %w", err)
48+
}
49+
rawList, ok := resp["beacons"].([]interface{})
50+
if !ok {
51+
// No "beacons" key OR not an array — treat as empty list, not error.
52+
return nil, nil
53+
}
54+
out := make([]string, 0, len(rawList))
55+
for _, b := range rawList {
56+
entry, ok := b.(map[string]interface{})
57+
if !ok {
58+
continue
59+
}
60+
addr, _ := entry["addr"].(string)
61+
if addr != "" {
62+
out = append(out, addr)
63+
}
64+
}
65+
// Sort to give a deterministic order even when the registry's map
66+
// iteration produces a different order each call. mergeBeaconLists
67+
// dedupes against bootstrap, but stable order keeps the hash-pick
68+
// stable when the SET is unchanged.
69+
sort.Strings(out)
70+
return out, nil
71+
}
72+
73+
// beaconCacheEntry is the on-disk format. We keep "saved_at" so a stale
74+
// cache (older than e.g. an hour) can be sniffed out by an operator.
75+
type beaconCacheEntry struct {
76+
SavedAt time.Time `json:"saved_at"`
77+
Addrs []string `json:"addrs"`
78+
}
79+
80+
// beaconCachePath returns the path to the on-disk beacon cache derived
81+
// from the identity path. Returns "" if identityPath is empty (in-memory
82+
// daemons skip caching).
83+
func beaconCachePath(identityPath string) string {
84+
if identityPath == "" {
85+
return ""
86+
}
87+
return filepath.Join(filepath.Dir(identityPath), beaconCacheFilename)
88+
}
89+
90+
// saveBeaconCache writes the current addr list to disk as a fallback
91+
// for next cold-start. Best-effort: errors are returned but the caller
92+
// typically logs and continues.
93+
func saveBeaconCache(identityPath string, addrs []string) error {
94+
path := beaconCachePath(identityPath)
95+
if path == "" {
96+
return nil
97+
}
98+
entry := beaconCacheEntry{SavedAt: time.Now(), Addrs: addrs}
99+
data, err := json.Marshal(entry)
100+
if err != nil {
101+
return fmt.Errorf("marshal beacon cache: %w", err)
102+
}
103+
// Write atomically: write to temp file, then rename.
104+
tmp := path + ".tmp"
105+
if err := os.WriteFile(tmp, data, 0600); err != nil {
106+
return fmt.Errorf("write beacon cache: %w", err)
107+
}
108+
if err := os.Rename(tmp, path); err != nil {
109+
return fmt.Errorf("rename beacon cache: %w", err)
110+
}
111+
return nil
112+
}
113+
114+
// loadBeaconCache reads the on-disk cache. Returns (nil, nil) if no
115+
// cache exists or identityPath is empty (not an error). Returns an
116+
// error only on parse failures.
117+
func loadBeaconCache(identityPath string) ([]string, error) {
118+
path := beaconCachePath(identityPath)
119+
if path == "" {
120+
return nil, nil
121+
}
122+
data, err := os.ReadFile(path)
123+
if err != nil {
124+
if os.IsNotExist(err) {
125+
return nil, nil
126+
}
127+
return nil, fmt.Errorf("read beacon cache: %w", err)
128+
}
129+
var entry beaconCacheEntry
130+
if err := json.Unmarshal(data, &entry); err != nil {
131+
return nil, fmt.Errorf("parse beacon cache: %w", err)
132+
}
133+
return entry.Addrs, nil
134+
}
135+
136+
// beaconSelectionState tracks the daemon's beacon picks across refresh
137+
// ticks. Pure data — the refresh logic mutates it under its mutex,
138+
// and the daemon hot path reads via getCurrentPick().
139+
type beaconSelectionState struct {
140+
mu sync.Mutex
141+
bootstrap []string // operator-configured -beacon list
142+
currentList []string // last-known merged list
143+
currentPick string // address currently set on the tunnel
144+
}
145+
146+
func newBeaconSelectionState(bootstrap []string) *beaconSelectionState {
147+
return &beaconSelectionState{
148+
bootstrap: append([]string(nil), bootstrap...),
149+
}
150+
}
151+
152+
func (s *beaconSelectionState) getCurrentPick() string {
153+
s.mu.Lock()
154+
defer s.mu.Unlock()
155+
return s.currentPick
156+
}
157+
158+
// refreshDecision describes the outcome of a discovery tick: should
159+
// the daemon swap its beacon and what's the new picked address?
160+
type refreshDecision struct {
161+
NewList []string // merged bootstrap + discovered, deduped
162+
NewPick string // hash-of-pubkey selection from NewList
163+
ShouldSwap bool // true if NewPick != previous currentPick
164+
}
165+
166+
// computeRefreshDecision runs the merge-pick-compare logic on a snapshot
167+
// of state. Pure function; tests drive it directly without a registry.
168+
//
169+
// If discovered is nil/empty, the function still merges with bootstrap
170+
// — i.e. if discovery fails / returns nothing, fall back to bootstrap-
171+
// only. NewList is empty only if BOTH inputs are empty.
172+
//
173+
// ShouldSwap is true when:
174+
// - currentPick is empty (initial pick at startup), OR
175+
// - currentPick is no longer present in NewList (failover: the picked
176+
// beacon was scaled down / removed from the registry), OR
177+
// - hash-pick over NewList disagrees with currentPick (rare; happens
178+
// when the bootstrap list changes via config reload, not in steady
179+
// state since pubkey + list both stay constant).
180+
//
181+
// NOTE on stickiness vs failover: a hash-of-pubkey pick is stable as
182+
// long as the list set is stable. When a beacon is REMOVED from the
183+
// list, the modulo result for ~ all daemons hashing past that index
184+
// shifts — so the daemon migrates naturally. When a NEW beacon is added
185+
// at a higher index, only the daemons whose hash%N now points at the
186+
// new entry migrate. This is the standard mod-N failover; consistent
187+
// hashing would minimize migration but mod-N is fine at our scale.
188+
func computeRefreshDecision(state *beaconSelectionState, discovered []string, identityKey []byte) refreshDecision {
189+
state.mu.Lock()
190+
bootstrap := state.bootstrap
191+
previousPick := state.currentPick
192+
state.mu.Unlock()
193+
194+
merged := mergeBeaconLists(bootstrap, discovered)
195+
if len(merged) == 0 {
196+
return refreshDecision{NewList: nil, NewPick: "", ShouldSwap: false}
197+
}
198+
newPick := pickBeacon(merged, identityKey)
199+
200+
shouldSwap := previousPick == "" || newPick != previousPick
201+
// Also force a swap if currentPick was REMOVED from the new list
202+
// (covers the "beacon scaled down" case even if hash happens to
203+
// produce the same index).
204+
if !shouldSwap {
205+
stillPresent := false
206+
for _, a := range merged {
207+
if a == previousPick {
208+
stillPresent = true
209+
break
210+
}
211+
}
212+
if !stillPresent {
213+
shouldSwap = true
214+
}
215+
}
216+
return refreshDecision{
217+
NewList: merged,
218+
NewPick: newPick,
219+
ShouldSwap: shouldSwap,
220+
}
221+
}
222+
223+
// applyRefreshDecision commits a refresh outcome to the state struct.
224+
// Called by the production refresh loop after a successful SetBeaconAddr.
225+
func (s *beaconSelectionState) applyRefreshDecision(d refreshDecision) {
226+
s.mu.Lock()
227+
defer s.mu.Unlock()
228+
s.currentList = d.NewList
229+
if d.ShouldSwap && d.NewPick != "" {
230+
s.currentPick = d.NewPick
231+
}
232+
}
233+
234+
// initialJitter returns a duration in [0, beaconRefreshJitter) for
235+
// avoiding thundering-herd on the registry at fleet restart.
236+
func initialJitter() time.Duration {
237+
return time.Duration(rand.Int63n(int64(beaconRefreshJitter)))
238+
}
239+
240+
// beaconRefreshLoop runs in a background goroutine and refreshes the
241+
// daemon's beacon selection from the registry every beaconRefreshInterval
242+
// (60s) plus initial jitter. Started from Daemon.Start() after
243+
// registration. Exits when d.stopCh closes.
244+
//
245+
// Per-tick work:
246+
// 1. Call regConn.Send({"type":"beacon_list"}). On error: log+skip;
247+
// keep current pick. (Don't repick on registry blip.)
248+
// 2. Merge with bootstrap; compute decision against current state.
249+
// 3. If ShouldSwap: SetBeaconAddr(newPick), RegisterWithBeacon, log.
250+
// 4. Persist the merged list to disk so a future cold-start with
251+
// unreachable registry can fall back to a recent list.
252+
//
253+
// Cold-start fallback (registry unreachable on FIRST tick): if the
254+
// daemon's bootstrap list is single-entry AND fetch fails AND a cache
255+
// file exists, the cached list is merged into the bootstrap so the
256+
// daemon at least gets a chance at the previously-known beacons.
257+
// (We don't run this on every tick — only at first-tick to keep the
258+
// hot path simple.)
259+
func (d *Daemon) beaconRefreshLoop() {
260+
if d.beaconSelection == nil || d.regConn == nil || d.identity == nil {
261+
// Nothing to refresh — single-beacon static config without
262+
// identity/registry. Exit cleanly.
263+
return
264+
}
265+
266+
// Initial jitter (0..beaconRefreshJitter) avoids fleet thundering
267+
// herd when daemons restart simultaneously after a registry restart.
268+
select {
269+
case <-time.After(initialJitter()):
270+
case <-d.stopCh:
271+
return
272+
}
273+
274+
firstTick := true
275+
ticker := time.NewTicker(beaconRefreshInterval)
276+
defer ticker.Stop()
277+
278+
for {
279+
d.beaconRefreshTick(firstTick)
280+
firstTick = false
281+
282+
select {
283+
case <-d.stopCh:
284+
return
285+
case <-ticker.C:
286+
}
287+
}
288+
}
289+
290+
// beaconRefreshTick runs one iteration of the discovery loop. Extracted
291+
// from beaconRefreshLoop for testability — production drives this from
292+
// the loop; tests can drive it directly.
293+
func (d *Daemon) beaconRefreshTick(firstTick bool) {
294+
discovered, err := fetchBeaconList(d.regConn)
295+
if err != nil {
296+
// On the FIRST tick, try the on-disk cache as a fallback —
297+
// the registry may have been briefly unreachable at startup.
298+
// On subsequent ticks, just keep the current pick.
299+
if firstTick {
300+
cached, cacheErr := loadBeaconCache(d.config.IdentityPath)
301+
if cacheErr == nil && len(cached) > 0 {
302+
slog.Info("beacon discovery: using on-disk cache (registry unreachable)",
303+
"err", err, "cached_count", len(cached))
304+
discovered = cached
305+
} else {
306+
slog.Debug("beacon discovery skipped (registry error, no usable cache)",
307+
"err", err)
308+
return
309+
}
310+
} else {
311+
slog.Debug("beacon discovery skipped (registry error)", "err", err)
312+
return
313+
}
314+
}
315+
316+
decision := computeRefreshDecision(d.beaconSelection, discovered, d.identity.PublicKey)
317+
if !decision.ShouldSwap {
318+
// Refresh the cache even on no-op so it tracks the latest
319+
// known list — useful for cold-start fallback.
320+
if len(decision.NewList) > 0 {
321+
_ = saveBeaconCache(d.config.IdentityPath, decision.NewList)
322+
}
323+
return
324+
}
325+
326+
if decision.NewPick == "" {
327+
// Empty merged list — no beacons known. Keep current.
328+
return
329+
}
330+
331+
if err := d.tunnels.SetBeaconAddr(decision.NewPick); err != nil {
332+
slog.Warn("beacon refresh: SetBeaconAddr failed", "addr", decision.NewPick, "err", err)
333+
return
334+
}
335+
d.beaconSelection.applyRefreshDecision(decision)
336+
d.tunnels.RegisterWithBeacon()
337+
if err := saveBeaconCache(d.config.IdentityPath, decision.NewList); err != nil {
338+
slog.Debug("beacon cache save failed (non-fatal)", "err", err)
339+
}
340+
slog.Info("beacon refresh: pick changed",
341+
"new", decision.NewPick,
342+
"available", len(decision.NewList))
343+
}

0 commit comments

Comments
 (0)