sparoid 1.1.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: 71f77a00597cb908e5b898603a34ed4e9111fd7fdcd255def5ea1633089c6041
4
- data.tar.gz: d4ba950c8fa66e6083e7bf43431618023aa8b163c1d12f6170835532c719ee03
3
+ metadata.gz: 7838b331849e520e484b7c061e7f0b176b5e21127f4e56cb5b82226fea8724ee
4
+ data.tar.gz: 530254c56b3311043f4264243d358c736e7c3253e29c0d8c1a89dd1d5473bb43
5
5
  SHA512:
6
- metadata.gz: 3f50ca4d5ebf7c68badec3afe5f49ea3c7d23eaa932448f2f3534fd6dc594b8bcecc8ad7c9e5f4e0b4453538fdbf45884ba6c3a5ab3c645eb6feb4ed5eabbff2
7
- data.tar.gz: 77c377f37dfa0dc257af730d25fb7600cb4cc6fe45e1e809e455c55c3fce5bce429abbbc82080cafa1a1f7fbb6dfcfe4fb5dede8d0eea2e904ee023403197d1b
6
+ metadata.gz: '085c9e8b9fc3684f79501c146320dc332e4d0303e68a7e359ff38d30bfd32e87dd8826cf7e9c82259df50214738be52fc5862b4e5adc6bade33aee3ed0f9d224'
7
+ data.tar.gz: b931da160e4bf5603b147b695ac1e4858a059652d93732ba629f6ee334275e51e77208d72dbf165ad397f15da3f590571cdd383fc56c96daec67b0f6e7e84d28
@@ -1,18 +1,22 @@
1
1
  name: Ruby
2
2
 
3
- on: [push,pull_request]
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
4
8
 
5
9
  jobs:
6
10
  build:
7
11
  strategy:
8
12
  fail-fast: false
9
13
  matrix:
10
- ruby: [ 2.7, '3.0', 3.1, ruby-head ]
14
+ ruby: [ 3.2, 3.3, 3.4, 4.0, ruby-head ]
11
15
  runs-on: ubuntu-latest
12
16
  steps:
13
- - uses: actions/checkout@v3
17
+ - uses: actions/checkout@v4
14
18
  - uses: ruby/setup-ruby@v1
15
19
  with:
16
20
  ruby-version: ${{ matrix.ruby }}
17
- - run: bundle install
21
+ bundler-cache: true
18
22
  - run: bundle exec rake
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,12 @@
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
+
6
+ ## [1.2.0] - 2024-07-24
7
+
8
+ - Lookup public IP using http://checkip.amazonaws.com, as it's more reliable for IPv6 connections than the DNS method
9
+
1
10
  ## [1.1.0] - 2023-03-03
2
11
 
3
12
  - Allow override of public ip, open for someone else by passing `:open_for_ip` to `Sparoid.auth(..., open_for_ip:)` (#11)
data/CODEOWNERS ADDED
@@ -0,0 +1 @@
1
+ * @84codes/server
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.1.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,44 +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
153
- Resolv::DNS.open(nameserver: ["208.67.222.222", "208.67.220.220"]) do |dns|
154
- dns.getresource("myip.opendns.com", Resolv::DNS::Resource::IN::A).address
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 "
228
+
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)
237
+ end
238
+ end
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}"
155
252
  end
156
253
  end
157
254
 
158
255
  def resolve_ip_addresses(host, port)
159
- addresses = Addrinfo.getaddrinfo(host, port, :INET, :DGRAM)
256
+ addresses = Addrinfo.getaddrinfo(host, port)
160
257
  raise(ResolvError, "Sparoid failed to resolv #{host}") if addresses.empty?
161
258
 
162
- addresses
259
+ addresses.select { |addr| addr.socktype == Socket::SOCK_DGRAM }
163
260
  rescue SocketError
164
261
  raise(ResolvError, "Sparoid failed to resolv #{host}")
165
262
  end
166
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
+
167
284
  class Error < StandardError; end
168
285
 
169
286
  class ResolvError < Error; end
170
287
 
171
- # Instance of SPAroid that only resolved public_ip once
288
+ # Instance of SPAroid that only resolved public_ips once
172
289
  class Instance
173
290
  include Sparoid
174
291
 
175
- private
176
-
177
- def public_ip
178
- @public_ip ||= super
292
+ def public_ips(*args)
293
+ @public_ips ||= super
179
294
  end
180
295
 
181
- def cached_public_ip
182
- public_ip
296
+ def cached_public_ips
297
+ public_ips
183
298
  end
184
299
  end
185
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.1.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: 2023-03-03 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:
@@ -36,6 +34,7 @@ files:
36
34
  - ".gitignore"
37
35
  - ".rubocop.yml"
38
36
  - CHANGELOG.md
37
+ - CODEOWNERS
39
38
  - Gemfile
40
39
  - LICENSE.txt
41
40
  - README.md
@@ -55,7 +54,6 @@ metadata:
55
54
  source_code_uri: https://github.com/84codes/sparoid.rb
56
55
  changelog_uri: https://raw.githubusercontent.com/84codes/sparoid.rb/main/CHANGELOG.md
57
56
  rubygems_mfa_required: 'true'
58
- post_install_message:
59
57
  rdoc_options: []
60
58
  require_paths:
61
59
  - lib
@@ -63,15 +61,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
63
61
  requirements:
64
62
  - - ">="
65
63
  - !ruby/object:Gem::Version
66
- version: 2.5.0
64
+ version: 3.2.0
67
65
  required_rubygems_version: !ruby/object:Gem::Requirement
68
66
  requirements:
69
67
  - - ">="
70
68
  - !ruby/object:Gem::Version
71
69
  version: '0'
72
70
  requirements: []
73
- rubygems_version: 3.4.5
74
- signing_key:
71
+ rubygems_version: 3.6.9
75
72
  specification_version: 4
76
73
  summary: Single Packet Authorisation client
77
74
  test_files: []