|
| 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