Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 32 additions & 65 deletions lib/sparoid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
"ipv4.icanhazip.com"
].freeze

GOOGLE_DNS_V6 = ["2001:4860:4860::8888", 53].freeze

# Send an authorization packet
def auth(key, hmac_key, host, port, open_for_ip: nil)
addrs = resolve_ip_addresses(host, port)
Expand Down Expand Up @@ -79,40 +81,16 @@ def fdpass(addrs, port, connect_timeout: 10) # rubocop:disable Metrics/AbcSize
private

def generate_messages(ip)
messages = if ip
create_messages(string_to_ip(ip))
else
generate_public_ip_messages
end

messages.flatten.sort_by!(&:bytesize)
end

def generate_public_ip_messages
messages = []
ipv6_native = false
public_ipv6_with_range.each do |addr, prefixlen|
ipv6 = Resolv::IPv6.create(addr)
messages << message_v2(ipv6, prefixlen)
ipv6_native = true
end

cached_public_ips.each do |ip|
next if ip.is_a?(Resolv::IPv6) && ipv6_native

messages << create_messages(ip)
end
messages
end

def create_messages(ip)
case ip
when Resolv::IPv4
[message(ip), message_v2(ip, 32)]
when Resolv::IPv6
[message_v2(ip, 128)]
if ip
[message(string_to_ip(ip))]
else
raise ArgumentError, "Unsupported IP type #{ip.class}"
ips = cached_public_ips
native_ipv6 = public_ipv6_by_udp
if native_ipv6
ips = ips.grep_v(Resolv::IPv6)
ips << Resolv::IPv6.create(native_ipv6)
Comment on lines +89 to +91
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I get this right, we use this IP instead of the one we get via the public lookup?

maybe a bit out of scope for this PR, but I wonder if we should not have a separate ipv4 and ipv6 paths instead of the first pass with ipv4 and ipv6 only to potentially throw away the ipv6 result here 😅

end
ips.map { |i| message(i) }
end
end

Expand Down Expand Up @@ -150,26 +128,13 @@ def prefix_hmac(hmac_key, data)
hmac + data
end

# Message format: version(4) + timestamp(8) + nonce(16) + ip(4 or 16)
# https://github.com/84codes/sparoid/blob/main/src/message.cr
def message(ip)
version = 1
ts = (Time.now.utc.to_f * 1000).floor
nounce = OpenSSL::Random.random_bytes(16)
[version, ts, nounce, ip.address].pack("N q> a16 a4")
end

# Message format can be found the server repository:
# https://github.com/84codes/sparoid/blob/main/src/message.cr
def message_v2(ip, range = nil)
version = 2
ts = (Time.now.utc.to_f * 1000).floor
nounce = OpenSSL::Random.random_bytes(16)
family = case ip
when Resolv::IPv4 then 4
when Resolv::IPv6 then 6
else raise ArgumentError, "Unsupported IP type #{ip.class}"
end
range ||= (family == 4 ? 32 : 128)
[version, ts, nounce, family, ip.address, range].pack("N q> a16 C a* C")
[version, ts, nounce, ip.address].pack("N q> a16 a*")
end

def cached_public_ips
Expand Down Expand Up @@ -261,24 +226,26 @@ def resolve_ip_addresses(host, port)
raise(ResolvError, "Sparoid failed to resolv #{host}")
end

def public_ipv6_with_range
global_ipv6_ifs = Socket.getifaddrs.select do |addr|
addrinfo = addr.addr
addrinfo&.ipv6? && global_ipv6?(addrinfo)
end

global_ipv6_ifs.map do |iface|
addrinfo = iface.addr
netmask_addr = IPAddr.new(iface.netmask.ip_address)
prefixlen = netmask_addr.to_i.to_s(2).count("1")
next addrinfo.ip_address, prefixlen
end
# Get the public IPv6 address by asking the OS which source address
# it would use to reach a well-known IPv6 destination.
# Returns nil if no global IPv6 address is available.
def public_ipv6_by_udp
socket = UDPSocket.new(Socket::AF_INET6)
socket.connect(*GOOGLE_DNS_V6)
addr = socket.local_address
return addr.ip_address if global_ipv6?(addr)

nil
rescue StandardError
nil
ensure
socket&.close
end

def global_ipv6?(addrinfo)
!(addrinfo.ipv6_mc_global? || addrinfo.ipv6_loopback? || addrinfo.ipv6_v4mapped? ||
addrinfo.ipv6_linklocal? || addrinfo.ipv6_multicast? || addrinfo.ipv6_sitelocal? ||
addrinfo.ip_address.start_with?("fd00"))
def global_ipv6?(addr)
!(addr.ipv6_loopback? || addr.ipv6_linklocal? || addr.ipv6_unspecified? ||
addr.ipv6_sitelocal? || addr.ipv6_multicast? || addr.ipv6_v4mapped? ||
addr.ip_address.start_with?("fd", "fc"))
end

class Error < StandardError; end
Expand Down
46 changes: 16 additions & 30 deletions test/sparoid_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,6 @@ def test_it_resolves_public_ip
assert(addresses.any? { |ip| ip.is_a?(Resolv::IPv4) || ip.is_a?(Resolv::IPv6) })
end

def test_it_creates_a_message
ip = Resolv::IPv4.create("127.0.0.1")
assert_equal 32, Sparoid.send(:message, ip).bytesize
end

def test_it_encrypts_messages
key = "0000000000000000000000000000000000000000000000000000000000000000"
ip = Resolv::IPv4.create("127.0.0.1")
Expand Down Expand Up @@ -131,18 +126,18 @@ def test_instance_sends_message
end
end

def test_message_v2_ipv4
def test_message_ipv4
ip = Resolv::IPv4.create("192.168.1.1")
msg = Sparoid.send(:message_v2, ip, 24)
# version(4) + timestamp(8) + nonce(16) + family(1) + ip(4) + range(1) = 34
assert_equal 34, msg.bytesize
msg = Sparoid.send(:message, ip)
# version(4) + timestamp(8) + nonce(16) + ip(4) = 32
assert_equal 32, msg.bytesize
end

def test_message_v2_ipv6
def test_message_ipv6
ip = Resolv::IPv6.create("2001:db8::1")
msg = Sparoid.send(:message_v2, ip, 64)
# version(4) + timestamp(8) + nonce(16) + family(1) + ip(16) + range(1) = 46
assert_equal 46, msg.bytesize
msg = Sparoid.send(:message, ip)
# version(4) + timestamp(8) + nonce(16) + ip(16) = 44
assert_equal 44, msg.bytesize
end

def test_string_to_ip_ipv4
Expand All @@ -162,25 +157,16 @@ def test_string_to_ip_invalid
end
end

def test_create_messages_ipv4_returns_two_messages
ip = Resolv::IPv4.create("127.0.0.1")
messages = Sparoid.send(:create_messages, ip)
# IPv4 returns both v2 and v1 message formats
assert_equal 2, messages.size
end

def test_generate_messages_v1_message_first
# v1 message (32 bytes) should come before v2 message (34 bytes) for backward compatibility
messages = Sparoid.send(:generate_messages, "127.0.0.1")
assert_equal 32, messages[0].bytesize
assert_equal 34, messages[1].bytesize
def test_generate_messages_ipv4
msgs = Sparoid.send(:generate_messages, "127.0.0.1")
# version(4) + timestamp(8) + nonce(16) + ip(4) = 32
assert_equal 32, msgs.first.bytesize
end

def test_create_messages_ipv6_returns_one_message
ip = Resolv::IPv6.create("::1")
messages = Sparoid.send(:create_messages, ip)
# IPv6 only returns v2 message format
assert_equal 1, messages.size
def test_generate_messages_ipv6
msgs = Sparoid.send(:generate_messages, "::1")
# version(4) + timestamp(8) + nonce(16) + ip(16) = 44
assert_equal 44, msgs.first.bytesize
end

def test_encrypt_raises_on_invalid_key_length
Expand Down