1
0

punchy.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. package nebula
  2. import (
  3. "context"
  4. "log/slog"
  5. "net/netip"
  6. "sync/atomic"
  7. "time"
  8. "github.com/rcrowley/go-metrics"
  9. "github.com/slackhq/nebula/config"
  10. "github.com/slackhq/nebula/header"
  11. "github.com/slackhq/nebula/udp"
  12. )
  13. // holepunchQueueSize buffers the channel that pending holepunchJobs land on after their delay timer fires.
  14. const holepunchQueueSize = 64
  15. // holepunchJob is one scheduled item delivered to the worker goroutine.
  16. // - target valid -> send a UDP punch to target. vpnAddr, if set, is the peer's vpn addr carried for log context.
  17. // - target invalid, vpnAddr valid -> send an encrypted test packet to vpnAddr (a "punchback").
  18. type holepunchJob struct {
  19. target netip.AddrPort
  20. vpnAddr netip.Addr
  21. }
  22. // lighthouseChecker is the slice of LightHouse that Punchy actually needs.
  23. // Defined here so Punchy doesn't take a *LightHouse dependency (LightHouse
  24. // already holds a *Punchy, and the bidirectional pointer reference is awkward
  25. // even within the same package). Tests can also substitute a fake.
  26. type lighthouseChecker interface {
  27. IsAnyLighthouseAddr(vpnAddrs []netip.Addr) bool
  28. }
  29. type Punchy struct {
  30. punch atomic.Bool
  31. respond atomic.Bool
  32. delay atomic.Int64
  33. respondDelay atomic.Int64
  34. punchEverything atomic.Bool
  35. sched *Scheduler[holepunchJob]
  36. punchConn udp.Conn
  37. metricHolepunchTx metrics.Counter
  38. metricPunchyTx metrics.Counter
  39. ctx context.Context
  40. ifce EncWriter
  41. hm *HostMap
  42. lh lighthouseChecker
  43. l *slog.Logger
  44. }
  45. func NewPunchyFromConfig(l *slog.Logger, c *config.C, punchConn udp.Conn) *Punchy {
  46. p := &Punchy{
  47. l: l,
  48. punchConn: punchConn,
  49. sched: NewScheduler[holepunchJob](holepunchQueueSize),
  50. metricPunchyTx: metrics.GetOrRegisterCounter("messages.tx.punchy", nil),
  51. }
  52. if c.GetBool("stats.lighthouse_metrics", false) {
  53. p.metricHolepunchTx = metrics.GetOrRegisterCounter("messages.tx.holepunch", nil)
  54. } else {
  55. p.metricHolepunchTx = metrics.NilCounter{}
  56. }
  57. p.reload(c, true)
  58. c.RegisterReloadCallback(func(c *config.C, r *config.Reload) {
  59. r.RestartRequiredIfChanged("stats.lighthouse_metrics")
  60. p.reload(c, false)
  61. })
  62. return p
  63. }
  64. func (p *Punchy) reload(c *config.C, initial bool) {
  65. if initial || c.HasChanged("punchy.punch") || c.HasChanged("punchy") {
  66. var yes bool
  67. if c.IsSet("punchy.punch") {
  68. yes = c.GetBool("punchy.punch", false)
  69. } else {
  70. // Deprecated fallback
  71. yes = c.GetBool("punchy", false)
  72. }
  73. old := p.punch.Swap(yes)
  74. switch {
  75. case initial && yes:
  76. p.l.Info("punchy enabled")
  77. case initial:
  78. p.l.Info("punchy disabled")
  79. case old != yes:
  80. p.l.Info("punchy.punch changed", "punch", yes)
  81. }
  82. }
  83. if initial || c.HasChanged("punchy.respond") || c.HasChanged("punch_back") {
  84. var yes bool
  85. if c.IsSet("punchy.respond") {
  86. yes = c.GetBool("punchy.respond", false)
  87. } else {
  88. // Deprecated fallback
  89. yes = c.GetBool("punch_back", false)
  90. }
  91. old := p.respond.Swap(yes)
  92. if !initial && old != yes {
  93. p.l.Info("punchy.respond changed", "respond", yes)
  94. }
  95. }
  96. //NOTE: this will not apply to any in progress operations, only the next one
  97. if initial || c.HasChanged("punchy.delay") {
  98. newDelay := int64(c.GetDuration("punchy.delay", time.Second))
  99. old := p.delay.Swap(newDelay)
  100. if !initial && old != newDelay {
  101. p.l.Info("punchy.delay changed", "delay", time.Duration(newDelay))
  102. }
  103. }
  104. if initial || c.HasChanged("punchy.target_all_remotes") {
  105. yes := c.GetBool("punchy.target_all_remotes", false)
  106. old := p.punchEverything.Swap(yes)
  107. if !initial && old != yes {
  108. p.l.Info("punchy.target_all_remotes changed", "target_all_remotes", yes)
  109. }
  110. }
  111. if initial || c.HasChanged("punchy.respond_delay") {
  112. newDelay := int64(c.GetDuration("punchy.respond_delay", 5*time.Second))
  113. old := p.respondDelay.Swap(newDelay)
  114. if !initial && old != newDelay {
  115. p.l.Info("punchy.respond_delay changed", "respond_delay", time.Duration(newDelay))
  116. }
  117. }
  118. }
  119. // Schedule queues a punch packet to target, to be sent after the configured delay.
  120. // vpnAddr is the peer's vpn addr, used for log context when the packet actually fires.
  121. // No-op if target is not a valid AddrPort or if Start has not yet been called. Safe to call from any goroutine.
  122. func (p *Punchy) Schedule(target netip.AddrPort, vpnAddr netip.Addr) {
  123. if !target.IsValid() || p.ctx == nil {
  124. return
  125. }
  126. p.scheduleJob(holepunchJob{target: target, vpnAddr: vpnAddr}, time.Duration(p.delay.Load()))
  127. }
  128. // ScheduleRespond queues a punchback test packet to vpnAddr after the configured respond delay,
  129. // gated on punchy.respond. No-op when respond is disabled or before Start has been called.
  130. func (p *Punchy) ScheduleRespond(vpnAddr netip.Addr) {
  131. if !p.respond.Load() || p.ctx == nil {
  132. return
  133. }
  134. p.scheduleJob(holepunchJob{vpnAddr: vpnAddr}, time.Duration(p.respondDelay.Load()))
  135. }
  136. // scheduleJob delegates to the pooled Scheduler.
  137. // The callback observes p.ctx so a job that becomes due after Stop is dropped instead of queued.
  138. func (p *Punchy) scheduleJob(job holepunchJob, delay time.Duration) {
  139. p.sched.Schedule(p.ctx, job, delay)
  140. }
  141. // SendPunch sends an immediate keepalive punch for an idle hostinfo.
  142. // The configured punchy.target_all_remotes mode picks the targets. Gated on punchy.punch and the lighthouse-skip rule
  143. // (lighthouses don't get keepalive punches because the regular update interval keeps their NAT state warm).
  144. func (p *Punchy) SendPunch(hostinfo *HostInfo) {
  145. if !p.punch.Load() {
  146. return
  147. }
  148. if p.lh.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
  149. return
  150. }
  151. if p.punchEverything.Load() {
  152. p.sendPunchToAllRemotes(hostinfo)
  153. } else if hr := hostinfo.GetRemote(); hr.IsValid() {
  154. p.metricPunchyTx.Inc(1)
  155. p.punchConn.WriteTo([]byte{1}, hr)
  156. }
  157. }
  158. // SendPunchToAll punches every known remote for hostinfo, but only when punchy.target_all_remotes is enabled.
  159. // The connection manager calls this during outbound-only traffic: the outbound traffic itself keeps the primary's
  160. // NAT state warm, but non-primary remotes need separate refresh, so we fan out to all of them (the redundant
  161. // primary punch is harmless). Gated on punchy.punch and the lighthouse-skip rule.
  162. func (p *Punchy) SendPunchToAll(hostinfo *HostInfo) {
  163. if !p.punchEverything.Load() {
  164. return
  165. }
  166. if !p.punch.Load() {
  167. return
  168. }
  169. if p.lh.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
  170. return
  171. }
  172. p.sendPunchToAllRemotes(hostinfo)
  173. }
  174. func (p *Punchy) sendPunchToAllRemotes(hostinfo *HostInfo) {
  175. hostinfo.remotes.ForEach(p.hm.GetPreferredRanges(), func(addr netip.AddrPort, preferred bool) {
  176. p.metricPunchyTx.Inc(1)
  177. p.punchConn.WriteTo([]byte{1}, addr)
  178. })
  179. }
  180. // Start wires the runtime dependencies and spawns the scheduler worker.
  181. func (p *Punchy) Start(ctx context.Context, ifce EncWriter, hm *HostMap, lh lighthouseChecker) {
  182. p.ctx = ctx
  183. p.ifce = ifce
  184. p.hm = hm
  185. p.lh = lh
  186. nb := make([]byte, 12, 12)
  187. out := make([]byte, mtu)
  188. empty := []byte{0}
  189. go p.sched.Run(ctx, func(job holepunchJob) {
  190. switch {
  191. case job.target.IsValid():
  192. if p.l.Enabled(context.Background(), slog.LevelDebug) {
  193. p.l.Debug("Punching", "target", job.target, "vpnAddr", job.vpnAddr)
  194. }
  195. p.metricHolepunchTx.Inc(1)
  196. p.punchConn.WriteTo(empty, job.target)
  197. case job.vpnAddr.IsValid():
  198. // A nebula test packet to the host trying to contact us.
  199. // In the case of a double nat or other difficult scenario, this may help establish a tunnel.
  200. if p.l.Enabled(context.Background(), slog.LevelDebug) {
  201. p.l.Debug("Sending a nebula test packet", "vpnAddr", job.vpnAddr)
  202. }
  203. p.ifce.SendMessageToVpnAddr(header.Test, header.TestRequest, job.vpnAddr, []byte(""), nb, out)
  204. }
  205. })
  206. }