punchy.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  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) {
  59. p.reload(c, false)
  60. })
  61. return p
  62. }
  63. func (p *Punchy) reload(c *config.C, initial bool) {
  64. if initial || c.HasChanged("punchy.punch") || c.HasChanged("punchy") {
  65. var yes bool
  66. if c.IsSet("punchy.punch") {
  67. yes = c.GetBool("punchy.punch", false)
  68. } else {
  69. // Deprecated fallback
  70. yes = c.GetBool("punchy", false)
  71. }
  72. old := p.punch.Swap(yes)
  73. switch {
  74. case initial && yes:
  75. p.l.Info("punchy enabled")
  76. case initial:
  77. p.l.Info("punchy disabled")
  78. case old != yes:
  79. p.l.Info("punchy.punch changed", "punch", yes)
  80. }
  81. }
  82. if initial || c.HasChanged("punchy.respond") || c.HasChanged("punch_back") {
  83. var yes bool
  84. if c.IsSet("punchy.respond") {
  85. yes = c.GetBool("punchy.respond", false)
  86. } else {
  87. // Deprecated fallback
  88. yes = c.GetBool("punch_back", false)
  89. }
  90. old := p.respond.Swap(yes)
  91. if !initial && old != yes {
  92. p.l.Info("punchy.respond changed", "respond", yes)
  93. }
  94. }
  95. //NOTE: this will not apply to any in progress operations, only the next one
  96. if initial || c.HasChanged("punchy.delay") {
  97. newDelay := int64(c.GetDuration("punchy.delay", time.Second))
  98. old := p.delay.Swap(newDelay)
  99. if !initial && old != newDelay {
  100. p.l.Info("punchy.delay changed", "delay", time.Duration(newDelay))
  101. }
  102. }
  103. if initial || c.HasChanged("punchy.target_all_remotes") {
  104. yes := c.GetBool("punchy.target_all_remotes", false)
  105. old := p.punchEverything.Swap(yes)
  106. if !initial && old != yes {
  107. p.l.Info("punchy.target_all_remotes changed", "target_all_remotes", yes)
  108. }
  109. }
  110. if initial || c.HasChanged("punchy.respond_delay") {
  111. newDelay := int64(c.GetDuration("punchy.respond_delay", 5*time.Second))
  112. old := p.respondDelay.Swap(newDelay)
  113. if !initial && old != newDelay {
  114. p.l.Info("punchy.respond_delay changed", "respond_delay", time.Duration(newDelay))
  115. }
  116. }
  117. }
  118. // Schedule queues a punch packet to target, to be sent after the configured delay.
  119. // vpnAddr is the peer's vpn addr, used for log context when the packet actually fires.
  120. // No-op if target is not a valid AddrPort or if Start has not yet been called. Safe to call from any goroutine.
  121. func (p *Punchy) Schedule(target netip.AddrPort, vpnAddr netip.Addr) {
  122. if !target.IsValid() || p.ctx == nil {
  123. return
  124. }
  125. p.scheduleJob(holepunchJob{target: target, vpnAddr: vpnAddr}, time.Duration(p.delay.Load()))
  126. }
  127. // ScheduleRespond queues a punchback test packet to vpnAddr after the configured respond delay,
  128. // gated on punchy.respond. No-op when respond is disabled or before Start has been called.
  129. func (p *Punchy) ScheduleRespond(vpnAddr netip.Addr) {
  130. if !p.respond.Load() || p.ctx == nil {
  131. return
  132. }
  133. p.scheduleJob(holepunchJob{vpnAddr: vpnAddr}, time.Duration(p.respondDelay.Load()))
  134. }
  135. // scheduleJob delegates to the pooled Scheduler.
  136. // The callback observes p.ctx so a job that becomes due after Stop is dropped instead of queued.
  137. func (p *Punchy) scheduleJob(job holepunchJob, delay time.Duration) {
  138. p.sched.Schedule(p.ctx, job, delay)
  139. }
  140. // SendPunch sends an immediate keepalive punch for an idle hostinfo.
  141. // The configured punchy.target_all_remotes mode picks the targets. Gated on punchy.punch and the lighthouse-skip rule
  142. // (lighthouses don't get keepalive punches because the regular update interval keeps their NAT state warm).
  143. func (p *Punchy) SendPunch(hostinfo *HostInfo) {
  144. if !p.punch.Load() {
  145. return
  146. }
  147. if p.lh.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
  148. return
  149. }
  150. if p.punchEverything.Load() {
  151. p.sendPunchToAllRemotes(hostinfo)
  152. } else if hr := hostinfo.GetRemote(); hr.IsValid() {
  153. p.metricPunchyTx.Inc(1)
  154. p.punchConn.WriteTo([]byte{1}, hr)
  155. }
  156. }
  157. // SendPunchToAll punches every known remote for hostinfo, but only when punchy.target_all_remotes is enabled.
  158. // The connection manager calls this during outbound-only traffic: the outbound traffic itself keeps the primary's
  159. // NAT state warm, but non-primary remotes need separate refresh, so we fan out to all of them (the redundant
  160. // primary punch is harmless). Gated on punchy.punch and the lighthouse-skip rule.
  161. func (p *Punchy) SendPunchToAll(hostinfo *HostInfo) {
  162. if !p.punchEverything.Load() {
  163. return
  164. }
  165. if !p.punch.Load() {
  166. return
  167. }
  168. if p.lh.IsAnyLighthouseAddr(hostinfo.vpnAddrs) {
  169. return
  170. }
  171. p.sendPunchToAllRemotes(hostinfo)
  172. }
  173. func (p *Punchy) sendPunchToAllRemotes(hostinfo *HostInfo) {
  174. hostinfo.remotes.ForEach(p.hm.GetPreferredRanges(), func(addr netip.AddrPort, preferred bool) {
  175. p.metricPunchyTx.Inc(1)
  176. p.punchConn.WriteTo([]byte{1}, addr)
  177. })
  178. }
  179. // Start wires the runtime dependencies and spawns the scheduler worker.
  180. func (p *Punchy) Start(ctx context.Context, ifce EncWriter, hm *HostMap, lh lighthouseChecker) {
  181. p.ctx = ctx
  182. p.ifce = ifce
  183. p.hm = hm
  184. p.lh = lh
  185. nb := make([]byte, 12, 12)
  186. out := make([]byte, mtu)
  187. empty := []byte{0}
  188. go p.sched.Run(ctx, func(job holepunchJob) {
  189. switch {
  190. case job.target.IsValid():
  191. if p.l.Enabled(context.Background(), slog.LevelDebug) {
  192. p.l.Debug("Punching", "target", job.target, "vpnAddr", job.vpnAddr)
  193. }
  194. p.metricHolepunchTx.Inc(1)
  195. p.punchConn.WriteTo(empty, job.target)
  196. case job.vpnAddr.IsValid():
  197. // A nebula test packet to the host trying to contact us.
  198. // In the case of a double nat or other difficult scenario, this may help establish a tunnel.
  199. if p.l.Enabled(context.Background(), slog.LevelDebug) {
  200. p.l.Debug("Sending a nebula test packet", "vpnAddr", job.vpnAddr)
  201. }
  202. p.ifce.SendMessageToVpnAddr(header.Test, header.TestRequest, job.vpnAddr, []byte(""), nb, out)
  203. }
  204. })
  205. }