diff --git a/Gemfile b/Gemfile index 5485a72..7bea176 100644 --- a/Gemfile +++ b/Gemfile @@ -11,3 +11,4 @@ gem "rake", "~> 13.0" gem "rubocop", "~> 1.7" gem "rubocop-minitest", require: false gem "rubocop-rake", require: false +gem "webmock" diff --git a/lib/sparoid.rb b/lib/sparoid.rb index 90b1ef7..ee77311 100644 --- a/lib/sparoid.rb +++ b/lib/sparoid.rb @@ -103,6 +103,8 @@ def generate_messages(ip) ips = ips.grep_v(Resolv::IPv6) ips << Resolv::IPv6.create(native_ipv6) end + raise PublicIPError, "Sparoid could not resolve a public IP to knock from" if ips.empty? + ips.map { |i| message(i) } end end @@ -253,16 +255,31 @@ class Error < StandardError; end class ResolvError < Error; end - # Instance of SPAroid that only resolved public_ips once + class PublicIPError < Error; end + + # Instance of SPAroid that resolves its public IP once and reuses it class Instance include Sparoid - def public_ips(*args) - @public_ips ||= super + def initialize + @resolve_mutex = Mutex.new + @public_ips = [] end def cached_public_ips - public_ips + return @public_ips if @public_ips.any? + + @resolve_mutex.synchronize do + return @public_ips if @public_ips.any? + + ips = public_ips + if ips.empty? + warn "Sparoid: Failed to retrieve public IPs" + else + @public_ips = ips + end + ips + end end end end diff --git a/test/sparoid_test.rb b/test/sparoid_test.rb index 8322260..1deb83b 100644 --- a/test/sparoid_test.rb +++ b/test/sparoid_test.rb @@ -8,6 +8,7 @@ def test_that_it_has_a_version_number end def test_it_resolves_public_ip + stub_icanhazip(ipv4_body: "203.0.113.7\n", ipv6_body: "2001:db8::1\n") addresses = Sparoid.send(:public_ips) assert(addresses.any? { |ip| ip.is_a?(Resolv::IPv4) || ip.is_a?(Resolv::IPv6) }) end @@ -228,6 +229,38 @@ def test_keygen_outputs_keys assert_match(/^hmac-key = [0-9a-f]{64}$/, output.lines[1].chomp) end + def stub_icanhazip(ipv4_body:, ipv6_body: "") + stub_request(:get, "http://ipv6.icanhazip.com/").to_return(status: 200, body: ipv6_body) + stub_request(:get, "http://ipv4.icanhazip.com/").to_return(status: 200, body: ipv4_body) + end + + def test_instance_retries_and_recovers_instead_of_pinning_empty + instance = Sparoid::Instance.new + + stub_icanhazip(ipv4_body: "", ipv6_body: "") + _out, err = capture_io { assert_empty instance.cached_public_ips } + assert_match(/Failed to retrieve public IPs/, err) + assert_empty instance.instance_variable_get(:@public_ips), "empty result must not be memoized" + + stub_icanhazip(ipv4_body: "203.0.113.7\n") + assert_equal ["203.0.113.7"], instance.cached_public_ips.map(&:to_s) + end + + def test_auth_raises_when_no_public_ip_resolved + key = "0000000000000000000000000000000000000000000000000000000000000000" + hmac_key = "0000000000000000000000000000000000000000000000000000000000000000" + s = Sparoid::Instance.new + + s.stub(:cached_public_ips, []) do + s.stub(:public_ipv6_by_udp, nil) do + err = assert_raises(Sparoid::PublicIPError) do + s.auth(key, hmac_key, "127.0.0.1", 1337) + end + assert_match(/could not resolve a public IP/, err.message) + end + end + end + def test_fdpass_closes_socket_when_connect_fails_synchronously addr = Addrinfo.tcp("127.0.0.1", 0) closed = false diff --git a/test/test_helper.rb b/test/test_helper.rb index 809f7fb..ae53a90 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -9,3 +9,4 @@ require "minitest/stub_const" require "minitest/autorun" +require "webmock/minitest"