Skip to content

Commit 992ab16

Browse files
authored
Make Sparoid::Instance public-IP resolution thread-safe and self-healing (#25)
* Make Sparoid::Instance public-IP resolution thread-safe and empty-aware Sparoid::Instance memoized with `@public_ips ||= super`, which is neither thread-safe nor empty-aware: `[]` is truthy, so a transient empty resolution (icanhazip down or empty body) was pinned for the process lifetime and every later knock then sent zero SPA packets. Under a shared instance hit by a thread pool, the first wave of knocks also resolved concurrently and one slow/empty response clobbered a good value via last-writer-wins. Move the caching into Instance#cached_public_ips: resolve behind a per-instance mutex with a double-check (kills the thundering herd and the clobber) and cache only a non-empty result, so a transient empty resolution self-heals on the next knock instead of pinning the instance. Also raise when generate_messages would otherwise produce no messages: an empty public-IP resolution with no IPv6 fallback used to let auth report success while sending zero packets. It now raises Sparoid::PublicIPError (a sibling of ResolvError under Error) so the caller can tell 'I cannot resolve my own public IP' apart from 'the target host is unreachable', instead of a silent no-op. * Stub icanhazip in tests instead of allowing real network Drop WebMock.allow_net_connect! and stub icanhazip in test_it_resolves_public_ip so the suite no longer depends on a working internet connection or icanhazip being up (which would make CI flaky and let mistyped stubs fall through to the network). * Initialize @public_ips to [] and drop safe-navigation checks @public_ips is now always an array, so the empty/cached check is a plain .any? instead of &.any?.
1 parent edc79cb commit 992ab16

4 files changed

Lines changed: 56 additions & 4 deletions

File tree

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ gem "rake", "~> 13.0"
1111
gem "rubocop", "~> 1.7"
1212
gem "rubocop-minitest", require: false
1313
gem "rubocop-rake", require: false
14+
gem "webmock"

lib/sparoid.rb

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ def generate_messages(ip)
103103
ips = ips.grep_v(Resolv::IPv6)
104104
ips << Resolv::IPv6.create(native_ipv6)
105105
end
106+
raise PublicIPError, "Sparoid could not resolve a public IP to knock from" if ips.empty?
107+
106108
ips.map { |i| message(i) }
107109
end
108110
end
@@ -253,16 +255,31 @@ class Error < StandardError; end
253255

254256
class ResolvError < Error; end
255257

256-
# Instance of SPAroid that only resolved public_ips once
258+
class PublicIPError < Error; end
259+
260+
# Instance of SPAroid that resolves its public IP once and reuses it
257261
class Instance
258262
include Sparoid
259263

260-
def public_ips(*args)
261-
@public_ips ||= super
264+
def initialize
265+
@resolve_mutex = Mutex.new
266+
@public_ips = []
262267
end
263268

264269
def cached_public_ips
265-
public_ips
270+
return @public_ips if @public_ips.any?
271+
272+
@resolve_mutex.synchronize do
273+
return @public_ips if @public_ips.any?
274+
275+
ips = public_ips
276+
if ips.empty?
277+
warn "Sparoid: Failed to retrieve public IPs"
278+
else
279+
@public_ips = ips
280+
end
281+
ips
282+
end
266283
end
267284
end
268285
end

test/sparoid_test.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ def test_that_it_has_a_version_number
88
end
99

1010
def test_it_resolves_public_ip
11+
stub_icanhazip(ipv4_body: "203.0.113.7\n", ipv6_body: "2001:db8::1\n")
1112
addresses = Sparoid.send(:public_ips)
1213
assert(addresses.any? { |ip| ip.is_a?(Resolv::IPv4) || ip.is_a?(Resolv::IPv6) })
1314
end
@@ -228,6 +229,38 @@ def test_keygen_outputs_keys
228229
assert_match(/^hmac-key = [0-9a-f]{64}$/, output.lines[1].chomp)
229230
end
230231

232+
def stub_icanhazip(ipv4_body:, ipv6_body: "")
233+
stub_request(:get, "http://ipv6.icanhazip.com/").to_return(status: 200, body: ipv6_body)
234+
stub_request(:get, "http://ipv4.icanhazip.com/").to_return(status: 200, body: ipv4_body)
235+
end
236+
237+
def test_instance_retries_and_recovers_instead_of_pinning_empty
238+
instance = Sparoid::Instance.new
239+
240+
stub_icanhazip(ipv4_body: "", ipv6_body: "")
241+
_out, err = capture_io { assert_empty instance.cached_public_ips }
242+
assert_match(/Failed to retrieve public IPs/, err)
243+
assert_empty instance.instance_variable_get(:@public_ips), "empty result must not be memoized"
244+
245+
stub_icanhazip(ipv4_body: "203.0.113.7\n")
246+
assert_equal ["203.0.113.7"], instance.cached_public_ips.map(&:to_s)
247+
end
248+
249+
def test_auth_raises_when_no_public_ip_resolved
250+
key = "0000000000000000000000000000000000000000000000000000000000000000"
251+
hmac_key = "0000000000000000000000000000000000000000000000000000000000000000"
252+
s = Sparoid::Instance.new
253+
254+
s.stub(:cached_public_ips, []) do
255+
s.stub(:public_ipv6_by_udp, nil) do
256+
err = assert_raises(Sparoid::PublicIPError) do
257+
s.auth(key, hmac_key, "127.0.0.1", 1337)
258+
end
259+
assert_match(/could not resolve a public IP/, err.message)
260+
end
261+
end
262+
end
263+
231264
def test_fdpass_closes_socket_when_connect_fails_synchronously
232265
addr = Addrinfo.tcp("127.0.0.1", 0)
233266
closed = false

test/test_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99

1010
require "minitest/stub_const"
1111
require "minitest/autorun"
12+
require "webmock/minitest"

0 commit comments

Comments
 (0)