sparoid 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c24825e92fdb072067eb45465af26badb031dae413445c2acb4ec979bfe4afaf
4
- data.tar.gz: e77fe5c9f7ad1c014534a51420bd9d1144f8f5fadbbcc1f28fcc8d4c0d52cf2c
3
+ metadata.gz: 7838b331849e520e484b7c061e7f0b176b5e21127f4e56cb5b82226fea8724ee
4
+ data.tar.gz: 530254c56b3311043f4264243d358c736e7c3253e29c0d8c1a89dd1d5473bb43
5
5
  SHA512:
6
- metadata.gz: 8ded4f653d9e8526b3dc4cfe6391deb2df878dd004f7e5c7c694514f251efc865e992f6a0779fa50be7cfeeb88d92b82a982f3de98a89e020e8062b7fcd8b77a
7
- data.tar.gz: eb545dc80266d74df92a228b09266f33edf0a9612715196d82282a6bb506ece3bf62cb361a0523516a5e7d1af9e989c1ee606e3212d7471c0b8f9daea318571e
6
+ metadata.gz: '085c9e8b9fc3684f79501c146320dc332e4d0303e68a7e359ff38d30bfd32e87dd8826cf7e9c82259df50214738be52fc5862b4e5adc6bade33aee3ed0f9d224'
7
+ data.tar.gz: b931da160e4bf5603b147b695ac1e4858a059652d93732ba629f6ee334275e51e77208d72dbf165ad397f15da3f590571cdd383fc56c96daec67b0f6e7e84d28
@@ -11,7 +11,7 @@ jobs:
11
11
  strategy:
12
12
  fail-fast: false
13
13
  matrix:
14
- ruby: [ 2.7, 3.1, 3.2, 3.3, ruby-head ]
14
+ ruby: [ 3.2, 3.3, 3.4, 4.0, ruby-head ]
15
15
  runs-on: ubuntu-latest
16
16
  steps:
17
17
  - uses: actions/checkout@v4
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 2.5
2
+ TargetRubyVersion: 3.2
3
3
  NewCops: enable
4
4
  SuggestExtensions: false
5
5
 
@@ -13,3 +13,6 @@ Style/StringLiteralsInInterpolation:
13
13
 
14
14
  Layout/LineLength:
15
15
  Max: 120
16
+
17
+ Metrics/MethodLength:
18
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## [2.0.0] - 2026-02-19
2
+
3
+ - Add IPv6 support
4
+ - Add Timeout.timeout safety net around Socket.tcp for Ruby 3.3+ Happy Eyeballs v2 compatibility
5
+
1
6
  ## [1.2.0] - 2024-07-24
2
7
 
3
8
  - Lookup public IP using http://checkip.amazonaws.com, as it's more reliable for IPv6 connections than the DNS method
data/lib/sparoid/cli.rb CHANGED
@@ -15,6 +15,7 @@ module Sparoid
15
15
  rescue Errno::ENOENT
16
16
  abort "Sparoid: Config not found"
17
17
  rescue StandardError => e
18
+ pp e.backtrace
18
19
  abort "Sparoid: #{e.message} (#{host})"
19
20
  end
20
21
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sparoid
4
- VERSION = "1.2.0"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/sparoid.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "sparoid/version"
4
4
  require "socket"
5
5
  require "openssl"
6
6
  require "resolv"
7
+ require "timeout"
7
8
 
8
9
  # Single Packet Authorisation client
9
10
  module Sparoid # rubocop:disable Metrics/ModuleLength
@@ -11,20 +12,28 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
11
12
 
12
13
  SPAROID_CACHE_PATH = ENV.fetch("SPAROID_CACHE_PATH", "/tmp/.sparoid_public_ip")
13
14
 
15
+ URLS = [
16
+ "ipv6.icanhazip.com",
17
+ "ipv4.icanhazip.com"
18
+ ].freeze
19
+
14
20
  # Send an authorization packet
15
- def auth(key, hmac_key, host, port, open_for_ip: cached_public_ip)
21
+ def auth(key, hmac_key, host, port, open_for_ip: nil)
16
22
  addrs = resolve_ip_addresses(host, port)
17
- ip = Resolv::IPv4.create(open_for_ip)
18
- msg = message(ip)
19
- data = prefix_hmac(hmac_key, encrypt(key, msg))
20
- sendmsg(addrs, data)
23
+ addrs.each do |addr|
24
+ messages = generate_messages(open_for_ip)
25
+ data = messages.map do |message|
26
+ prefix_hmac(hmac_key, encrypt(key, message))
27
+ end
28
+ sendmsg(addr, data)
29
+ end
21
30
 
22
31
  # wait some time for the server to actually open the port
23
32
  # if we don't wait the next SYN package will be dropped
24
33
  # and it have to be redelivered, adding 1 second delay
25
34
  sleep 0.02
26
35
 
27
- addrs.map(&:ip_address) # return resolved IP(s)
36
+ addrs
28
37
  end
29
38
 
30
39
  # Generate new aes and hmac keys, print to stdout
@@ -37,11 +46,11 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
37
46
  end
38
47
 
39
48
  # Connect to a TCP server and pass the FD to the parent
40
- def fdpass(ips, port, connect_timeout: 10) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
49
+ def fdpass(addrs, port, connect_timeout: 10) # rubocop:disable Metrics/AbcSize
41
50
  # try connect to all IPs
42
- sockets = ips.map do |ip|
43
- Socket.new(Socket::AF_INET, Socket::SOCK_STREAM).tap do |s|
44
- s.connect_nonblock(Socket.sockaddr_in(port, ip), exception: false)
51
+ sockets = addrs.map do |addr|
52
+ Socket.new(addr.afamily, Socket::SOCK_STREAM).tap do |s|
53
+ s.connect_nonblock(Socket.sockaddr_in(port, addr.ip_address), exception: false)
45
54
  end
46
55
  end
47
56
  # wait for any socket to be connected
@@ -51,9 +60,9 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
51
60
  writeable.each do |s|
52
61
  idx = sockets.index(s)
53
62
  sockets.delete_at(idx) # don't retry this socket again
54
- ip = ips.delete_at(idx) # find the IP for the socket
63
+ addr = addrs.delete_at(idx) # find the IP for the socket
55
64
  begin
56
- s.connect_nonblock(Socket.sockaddr_in(port, ip)) # check for errors
65
+ s.connect_nonblock(Socket.sockaddr_in(port, addr.ip_address)) # check for errors
57
66
  rescue Errno::EISCONN
58
67
  # already connected, continue
59
68
  rescue SystemCallError
@@ -69,11 +78,49 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
69
78
 
70
79
  private
71
80
 
72
- def sendmsg(addrs, data)
73
- socket = UDPSocket.new
81
+ def generate_messages(ip)
82
+ messages = if ip
83
+ create_messages(string_to_ip(ip))
84
+ else
85
+ generate_public_ip_messages
86
+ end
87
+
88
+ messages.flatten.sort_by!(&:bytesize)
89
+ end
90
+
91
+ def generate_public_ip_messages
92
+ messages = []
93
+ ipv6_native = false
94
+ public_ipv6_with_range.each do |addr, prefixlen|
95
+ ipv6 = Resolv::IPv6.create(addr)
96
+ messages << message_v2(ipv6, prefixlen)
97
+ ipv6_native = true
98
+ end
99
+
100
+ cached_public_ips.each do |ip|
101
+ next if ip.is_a?(Resolv::IPv6) && ipv6_native
102
+
103
+ messages << create_messages(ip)
104
+ end
105
+ messages
106
+ end
107
+
108
+ def create_messages(ip)
109
+ case ip
110
+ when Resolv::IPv4
111
+ [message(ip), message_v2(ip, 32)]
112
+ when Resolv::IPv6
113
+ [message_v2(ip, 128)]
114
+ else
115
+ raise ArgumentError, "Unsupported IP type #{ip.class}"
116
+ end
117
+ end
118
+
119
+ def sendmsg(addr, data)
120
+ socket = UDPSocket.new(addr.afamily)
74
121
  socket.nonblock = false
75
- addrs.each do |addr|
76
- socket.sendmsg data, 0, addr
122
+ data.each do |packet|
123
+ socket.sendmsg packet, 0, addr
77
124
  rescue StandardError => e
78
125
  warn "Sparoid error: #{e.message}"
79
126
  end
@@ -110,7 +157,22 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
110
157
  [version, ts, nounce, ip.address].pack("N q> a16 a4")
111
158
  end
112
159
 
113
- def cached_public_ip
160
+ # Message format can be found the server repository:
161
+ # https://github.com/84codes/sparoid/blob/main/src/message.cr
162
+ def message_v2(ip, range = nil)
163
+ version = 2
164
+ ts = (Time.now.utc.to_f * 1000).floor
165
+ nounce = OpenSSL::Random.random_bytes(16)
166
+ family = case ip
167
+ when Resolv::IPv4 then 4
168
+ when Resolv::IPv6 then 6
169
+ else raise ArgumentError, "Unsupported IP type #{ip.class}"
170
+ end
171
+ range ||= (family == 4 ? 32 : 128)
172
+ [version, ts, nounce, family, ip.address, range].pack("N q> a16 C a* C")
173
+ end
174
+
175
+ def cached_public_ips
114
176
  if up_to_date_cache?
115
177
  read_cache
116
178
  else
@@ -118,7 +180,7 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
118
180
  end
119
181
  rescue StandardError => e
120
182
  warn "Sparoid: #{e.inspect}"
121
- public_ip
183
+ public_ips
122
184
  end
123
185
 
124
186
  def up_to_date_cache?
@@ -131,7 +193,9 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
131
193
  def read_cache
132
194
  File.open(SPAROID_CACHE_PATH, "r") do |f|
133
195
  f.flock(File::LOCK_SH)
134
- Resolv::IPv4.create f.read
196
+ f.readlines(chomp: true).map do |line|
197
+ string_to_ip(line)
198
+ end
135
199
  end
136
200
  rescue ArgumentError => e
137
201
  return write_cache if /cannot interpret as IPv4 address/.match?(e.message)
@@ -142,54 +206,95 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
142
206
  def write_cache
143
207
  File.open(SPAROID_CACHE_PATH, File::WRONLY | File::CREAT, 0o0644) do |f|
144
208
  f.flock(File::LOCK_EX)
145
- ip = public_ip
209
+ ips = public_ips
210
+ warn "Sparoid: Failed to retrieve public IPs" if ips.empty?
146
211
  f.truncate(0)
147
- f.write ip.to_s
148
- ip
212
+ f.rewind
213
+ ips.each do |ip|
214
+ f.puts ip.to_s
215
+ end
216
+ ips
149
217
  end
150
218
  end
151
219
 
152
- def public_ip(host = "checkip.amazonaws.com", port = 80) # rubocop:disable Metrics/MethodLength
153
- Socket.tcp(host, port, connect_timeout: 3) do |sock|
154
- sock.sync = true
155
- sock.print "GET / HTTP/1.1\r\nHost: #{host}\r\nConnection: close\r\n\r\n"
156
- status = sock.readline(chomp: true)
157
- raise(ResolvError, "#{host}:#{port} response: #{status}") unless status.start_with? "HTTP/1.1 200 "
220
+ def public_ips(port = 80) # rubocop:disable Metrics/AbcSize
221
+ URLS.map do |host|
222
+ Timeout.timeout(5) do
223
+ Socket.tcp(host, port, connect_timeout: 3, resolv_timeout: 3) do |sock|
224
+ sock.sync = true
225
+ sock.print "GET / HTTP/1.1\r\nHost: #{host}\r\nConnection: close\r\n\r\n"
226
+ status = sock.readline(chomp: true)
227
+ raise(ResolvError, "#{host}:#{port} response: #{status}") unless status.start_with? "HTTP/1.1 200 "
158
228
 
159
- content_length = 0
160
- until (header = sock.readline(chomp: true)).empty?
161
- if (m = header.match(/^Content-Length: (\d+)/))
162
- content_length = m[1].to_i
229
+ content_length = 0
230
+ until (header = sock.readline(chomp: true)).empty?
231
+ if (m = header.match(/^Content-Length: (\d+)/))
232
+ content_length = m[1].to_i
233
+ end
234
+ end
235
+ ip = sock.read(content_length).chomp
236
+ string_to_ip(ip)
163
237
  end
164
238
  end
165
- ip = sock.read(content_length).chomp
166
- Resolv::IPv4.create ip
239
+ rescue StandardError
240
+ nil
241
+ end.compact
242
+ end
243
+
244
+ def string_to_ip(ip)
245
+ case ip
246
+ when Resolv::IPv4::Regex
247
+ Resolv::IPv4.create(ip)
248
+ when Resolv::IPv6::Regex
249
+ Resolv::IPv6.create(ip)
250
+ else
251
+ raise ArgumentError, "Unsupported IP format #{ip}"
167
252
  end
168
253
  end
169
254
 
170
255
  def resolve_ip_addresses(host, port)
171
- addresses = Addrinfo.getaddrinfo(host, port, :INET, :DGRAM)
256
+ addresses = Addrinfo.getaddrinfo(host, port)
172
257
  raise(ResolvError, "Sparoid failed to resolv #{host}") if addresses.empty?
173
258
 
174
- addresses
259
+ addresses.select { |addr| addr.socktype == Socket::SOCK_DGRAM }
175
260
  rescue SocketError
176
261
  raise(ResolvError, "Sparoid failed to resolv #{host}")
177
262
  end
178
263
 
264
+ def public_ipv6_with_range
265
+ global_ipv6_ifs = Socket.getifaddrs.select do |addr|
266
+ addrinfo = addr.addr
267
+ addrinfo&.ipv6? && global_ipv6?(addrinfo)
268
+ end
269
+
270
+ global_ipv6_ifs.map do |iface|
271
+ addrinfo = iface.addr
272
+ netmask_addr = IPAddr.new(iface.netmask.ip_address)
273
+ prefixlen = netmask_addr.to_i.to_s(2).count("1")
274
+ next addrinfo.ip_address, prefixlen
275
+ end
276
+ end
277
+
278
+ def global_ipv6?(addrinfo)
279
+ !(addrinfo.ipv6_mc_global? || addrinfo.ipv6_loopback? || addrinfo.ipv6_v4mapped? ||
280
+ addrinfo.ipv6_linklocal? || addrinfo.ipv6_multicast? || addrinfo.ipv6_sitelocal? ||
281
+ addrinfo.ip_address.start_with?("fd00"))
282
+ end
283
+
179
284
  class Error < StandardError; end
180
285
 
181
286
  class ResolvError < Error; end
182
287
 
183
- # Instance of SPAroid that only resolved public_ip once
288
+ # Instance of SPAroid that only resolved public_ips once
184
289
  class Instance
185
290
  include Sparoid
186
291
 
187
- def public_ip(*args)
188
- @public_ip ||= super
292
+ def public_ips(*args)
293
+ @public_ips ||= super
189
294
  end
190
295
 
191
- def cached_public_ip
192
- public_ip
296
+ def cached_public_ips
297
+ public_ips
193
298
  end
194
299
  end
195
300
  end
data/sparoid.gemspec CHANGED
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Single Packet Authorisation client"
12
12
  spec.homepage = "https://github.com/84codes/sparoid.rb"
13
13
  spec.license = "MIT"
14
- spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
14
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0")
15
15
 
16
16
  spec.metadata["homepage_uri"] = spec.homepage
17
17
  spec.metadata["source_code_uri"] = spec.homepage
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sparoid
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Hörberg
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2024-07-24 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: thor
@@ -24,7 +23,6 @@ dependencies:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
25
  version: '0'
27
- description:
28
26
  email:
29
27
  - carl@84codes.com
30
28
  executables:
@@ -56,7 +54,6 @@ metadata:
56
54
  source_code_uri: https://github.com/84codes/sparoid.rb
57
55
  changelog_uri: https://raw.githubusercontent.com/84codes/sparoid.rb/main/CHANGELOG.md
58
56
  rubygems_mfa_required: 'true'
59
- post_install_message:
60
57
  rdoc_options: []
61
58
  require_paths:
62
59
  - lib
@@ -64,15 +61,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
64
61
  requirements:
65
62
  - - ">="
66
63
  - !ruby/object:Gem::Version
67
- version: 2.5.0
64
+ version: 3.2.0
68
65
  required_rubygems_version: !ruby/object:Gem::Requirement
69
66
  requirements:
70
67
  - - ">="
71
68
  - !ruby/object:Gem::Version
72
69
  version: '0'
73
70
  requirements: []
74
- rubygems_version: 3.5.11
75
- signing_key:
71
+ rubygems_version: 3.6.9
76
72
  specification_version: 4
77
73
  summary: Single Packet Authorisation client
78
74
  test_files: []