sparoid 1.2.0 → 2.1.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: df9de779b1b4fcfd29f3f0ad3ad986001ecc39b61fbe3aeff36c9b3cb5409327
4
+ data.tar.gz: 56789005516d3c0c2e3b4799f1cf3d6c9873465ff3516b27edee0649b2f5d373
5
5
  SHA512:
6
- metadata.gz: 8ded4f653d9e8526b3dc4cfe6391deb2df878dd004f7e5c7c694514f251efc865e992f6a0779fa50be7cfeeb88d92b82a982f3de98a89e020e8062b7fcd8b77a
7
- data.tar.gz: eb545dc80266d74df92a228b09266f33edf0a9612715196d82282a6bb506ece3bf62cb361a0523516a5e7d1af9e989c1ee606e3212d7471c0b8f9daea318571e
6
+ metadata.gz: ce6cca5c5219a9f7f99fb6fa34b16efee1d60acae4e5f6bf639524e8ebfd5d4cc805953e9df48656aae11211b25aa824b28751478691a8a293ad3704bd464416
7
+ data.tar.gz: bc288e38f49a5c54f10f338d943169dd662932f516fbdccc6c7d5dc75857da7e0c73dbf2dcb3e5082ee7e769f70552deb12f23a11f8f5cca7f993cc4d6104ad0
@@ -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,14 @@
1
+ ## [2.1.0] - 2026-03-10
2
+
3
+ - Aligning CLI with Crystal client interface (`send`/`connect` subcommands with flags)
4
+ - Detect public IPv6 via UDP socket probe instead of interface enumeration
5
+ - Remove v2 message format, only send v1 (with IPv4 or IPv6 address)
6
+
7
+ ## [2.0.0] - 2026-02-19
8
+
9
+ - Add IPv6 support
10
+ - Add Timeout.timeout safety net around Socket.tcp for Ruby 3.3+ Happy Eyeballs v2 compatibility
11
+
1
12
  ## [1.2.0] - 2024-07-24
2
13
 
3
14
  - Lookup public IP using http://checkip.amazonaws.com, as it's more reliable for IPv6 connections than the DNS method
data/exe/sparoid CHANGED
@@ -1,9 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- begin
5
- require_relative "../lib/sparoid/cli"
6
- Sparoid::CLI.start
7
- rescue Interrupt
8
- exit 1
9
- end
4
+ require_relative "../lib/sparoid/cli"
5
+ Sparoid::CLI.run
data/lib/sparoid/cli.rb CHANGED
@@ -1,55 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thor"
3
+ require "optparse"
4
4
  require_relative "../sparoid"
5
5
 
6
6
  module Sparoid
7
7
  # CLI
8
- class CLI < Thor
9
- map "-v" => :version
8
+ module CLI
9
+ def self.run(args = ARGV) # rubocop:disable Metrics/AbcSize
10
+ subcommand = args.shift
11
+ host = "0.0.0.0"
12
+ port = 8484
13
+ tcp_port = 22
14
+ config_path = "~/.sparoid.ini"
10
15
 
11
- desc "auth HOST [PORT]", "Send a authorization packet"
12
- method_option :config, desc: "Path to a config file, INI format, with key and hmac-key", default: "~/.sparoid.ini"
13
- def auth(host, port = 8484)
14
- send_auth(host, port, options[:config])
15
- rescue Errno::ENOENT
16
- abort "Sparoid: Config not found"
17
- rescue StandardError => e
18
- abort "Sparoid: #{e.message} (#{host})"
19
- end
20
-
21
- desc "connect HOST PORT [SPA-PORT]", "Send a SPA, TCP connect, and then pass the FD back to the parent"
22
- method_option :config, desc: "Path to a config file, INI format, with key and hmac-key", default: "~/.sparoid.ini"
23
- def connect(host, port, spa_port = 8484)
24
- ips = send_auth(host, spa_port, options[:config])
25
- Sparoid.fdpass(ips, port)
16
+ case subcommand
17
+ when "keygen"
18
+ Sparoid.keygen
19
+ when "send"
20
+ parse_send_options!(args, binding)
21
+ key, hmac_key = read_keys(config_path)
22
+ Sparoid.auth(key, hmac_key, host, port)
23
+ when "connect"
24
+ parse_connect_options!(args, binding)
25
+ key, hmac_key = read_keys(config_path)
26
+ ips = Sparoid.auth(key, hmac_key, host, port)
27
+ Sparoid.fdpass(ips, tcp_port)
28
+ when "--version"
29
+ puts Sparoid::VERSION
30
+ else
31
+ puts "Usage: sparoid [subcommand] [options]"
32
+ puts ""
33
+ puts "Subcommands: keygen, send, connect"
34
+ puts "Use --version to show version"
35
+ exit 1
36
+ end
26
37
  rescue StandardError => e
27
- abort "Sparoid: #{e.message} (#{host})"
38
+ warn "Sparoid error: #{e.message}"
39
+ exit 1
28
40
  end
29
41
 
30
- desc "keygen", "Generate an encryption key and a HMAC key"
31
- def keygen
32
- Sparoid.keygen
42
+ def self.parse_send_options!(args, ctx, banner: "send")
43
+ OptionParser.new do |p|
44
+ p.banner = "Usage: sparoid #{banner} [options]"
45
+ p.on("-h HOST", "--host=HOST", "Host to send to") { |v| ctx.local_variable_set(:host, v) }
46
+ p.on("-p PORT", "--port=PORT", "UDP port (default: 8484)") { |v| ctx.local_variable_set(:port, v.to_i) }
47
+ p.on("-c PATH", "--config=PATH", "Path to config file") { |v| ctx.local_variable_set(:config_path, v) }
48
+ yield p if block_given?
49
+ end.parse!(args)
33
50
  end
34
51
 
35
- desc "version", "Show version and exit"
36
- def version
37
- puts "#{Sparoid::VERSION} (ruby)"
52
+ def self.parse_connect_options!(args, ctx)
53
+ parse_send_options!(args, ctx, banner: "connect") do |p|
54
+ p.on("-P PORT", "--tcp-port=PORT", "TCP port (default: 22)") { |v| ctx.local_variable_set(:tcp_port, v.to_i) }
55
+ end
38
56
  end
39
57
 
40
- def self.exit_on_failure?
41
- true
58
+ def self.read_keys(config_path)
59
+ parse_ini(config_path).values_at("key", "hmac-key")
42
60
  end
43
61
 
44
- private
45
-
46
- def send_auth(host, port, config)
47
- key, hmac_key = get_keys(parse_ini(config))
48
- Sparoid.auth(key, hmac_key, host, port.to_i)
49
- end
50
-
51
- def parse_ini(path)
52
- File.readlines(File.expand_path(path)).map! { |line| line.split("=", 2).map!(&:strip) }.to_h
62
+ def self.parse_ini(path)
63
+ File.readlines(File.expand_path(path)).to_h { |line| line.split("=", 2).map(&:strip) }
53
64
  rescue Errno::ENOENT
54
65
  {
55
66
  "key" => ENV.fetch("SPAROID_KEY", nil),
@@ -57,8 +68,6 @@ module Sparoid
57
68
  }
58
69
  end
59
70
 
60
- def get_keys(config)
61
- config.values_at("key", "hmac-key")
62
- end
71
+ private_class_method :parse_send_options!, :parse_connect_options!, :read_keys, :parse_ini
63
72
  end
64
73
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sparoid
4
- VERSION = "1.2.0"
4
+ VERSION = "2.1.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,30 @@ 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
+
20
+ GOOGLE_DNS_V6 = ["2001:4860:4860::8888", 53].freeze
21
+
14
22
  # Send an authorization packet
15
- def auth(key, hmac_key, host, port, open_for_ip: cached_public_ip)
23
+ def auth(key, hmac_key, host, port, open_for_ip: nil)
16
24
  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)
25
+ addrs.each do |addr|
26
+ messages = generate_messages(open_for_ip)
27
+ data = messages.map do |message|
28
+ prefix_hmac(hmac_key, encrypt(key, message))
29
+ end
30
+ sendmsg(addr, data)
31
+ end
21
32
 
22
33
  # wait some time for the server to actually open the port
23
34
  # if we don't wait the next SYN package will be dropped
24
35
  # and it have to be redelivered, adding 1 second delay
25
36
  sleep 0.02
26
37
 
27
- addrs.map(&:ip_address) # return resolved IP(s)
38
+ addrs
28
39
  end
29
40
 
30
41
  # Generate new aes and hmac keys, print to stdout
@@ -37,11 +48,11 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
37
48
  end
38
49
 
39
50
  # 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
51
+ def fdpass(addrs, port, connect_timeout: 10) # rubocop:disable Metrics/AbcSize
41
52
  # 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)
53
+ sockets = addrs.map do |addr|
54
+ Socket.new(addr.afamily, Socket::SOCK_STREAM).tap do |s|
55
+ s.connect_nonblock(Socket.sockaddr_in(port, addr.ip_address), exception: false)
45
56
  end
46
57
  end
47
58
  # wait for any socket to be connected
@@ -51,9 +62,9 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
51
62
  writeable.each do |s|
52
63
  idx = sockets.index(s)
53
64
  sockets.delete_at(idx) # don't retry this socket again
54
- ip = ips.delete_at(idx) # find the IP for the socket
65
+ addr = addrs.delete_at(idx) # find the IP for the socket
55
66
  begin
56
- s.connect_nonblock(Socket.sockaddr_in(port, ip)) # check for errors
67
+ s.connect_nonblock(Socket.sockaddr_in(port, addr.ip_address)) # check for errors
57
68
  rescue Errno::EISCONN
58
69
  # already connected, continue
59
70
  rescue SystemCallError
@@ -69,11 +80,25 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
69
80
 
70
81
  private
71
82
 
72
- def sendmsg(addrs, data)
73
- socket = UDPSocket.new
83
+ def generate_messages(ip)
84
+ if ip
85
+ [message(string_to_ip(ip))]
86
+ else
87
+ ips = cached_public_ips
88
+ native_ipv6 = public_ipv6_by_udp
89
+ if native_ipv6
90
+ ips = ips.grep_v(Resolv::IPv6)
91
+ ips << Resolv::IPv6.create(native_ipv6)
92
+ end
93
+ ips.map { |i| message(i) }
94
+ end
95
+ end
96
+
97
+ def sendmsg(addr, data)
98
+ socket = UDPSocket.new(addr.afamily)
74
99
  socket.nonblock = false
75
- addrs.each do |addr|
76
- socket.sendmsg data, 0, addr
100
+ data.each do |packet|
101
+ socket.sendmsg packet, 0, addr
77
102
  rescue StandardError => e
78
103
  warn "Sparoid error: #{e.message}"
79
104
  end
@@ -103,14 +128,16 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
103
128
  hmac + data
104
129
  end
105
130
 
131
+ # Message format: version(4) + timestamp(8) + nonce(16) + ip(4 or 16)
132
+ # https://github.com/84codes/sparoid/blob/main/src/message.cr
106
133
  def message(ip)
107
134
  version = 1
108
135
  ts = (Time.now.utc.to_f * 1000).floor
109
136
  nounce = OpenSSL::Random.random_bytes(16)
110
- [version, ts, nounce, ip.address].pack("N q> a16 a4")
137
+ [version, ts, nounce, ip.address].pack("N q> a16 a*")
111
138
  end
112
139
 
113
- def cached_public_ip
140
+ def cached_public_ips
114
141
  if up_to_date_cache?
115
142
  read_cache
116
143
  else
@@ -118,7 +145,7 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
118
145
  end
119
146
  rescue StandardError => e
120
147
  warn "Sparoid: #{e.inspect}"
121
- public_ip
148
+ public_ips
122
149
  end
123
150
 
124
151
  def up_to_date_cache?
@@ -131,7 +158,9 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
131
158
  def read_cache
132
159
  File.open(SPAROID_CACHE_PATH, "r") do |f|
133
160
  f.flock(File::LOCK_SH)
134
- Resolv::IPv4.create f.read
161
+ f.readlines(chomp: true).map do |line|
162
+ string_to_ip(line)
163
+ end
135
164
  end
136
165
  rescue ArgumentError => e
137
166
  return write_cache if /cannot interpret as IPv4 address/.match?(e.message)
@@ -142,54 +171,97 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
142
171
  def write_cache
143
172
  File.open(SPAROID_CACHE_PATH, File::WRONLY | File::CREAT, 0o0644) do |f|
144
173
  f.flock(File::LOCK_EX)
145
- ip = public_ip
174
+ ips = public_ips
175
+ warn "Sparoid: Failed to retrieve public IPs" if ips.empty?
146
176
  f.truncate(0)
147
- f.write ip.to_s
148
- ip
177
+ f.rewind
178
+ ips.each do |ip|
179
+ f.puts ip.to_s
180
+ end
181
+ ips
149
182
  end
150
183
  end
151
184
 
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 "
185
+ def public_ips(port = 80) # rubocop:disable Metrics/AbcSize
186
+ URLS.map do |host|
187
+ Timeout.timeout(5) do
188
+ Socket.tcp(host, port, connect_timeout: 3, resolv_timeout: 3) do |sock|
189
+ sock.sync = true
190
+ sock.print "GET / HTTP/1.1\r\nHost: #{host}\r\nConnection: close\r\n\r\n"
191
+ status = sock.readline(chomp: true)
192
+ raise(ResolvError, "#{host}:#{port} response: #{status}") unless status.start_with? "HTTP/1.1 200 "
158
193
 
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
194
+ content_length = 0
195
+ until (header = sock.readline(chomp: true)).empty?
196
+ if (m = header.match(/^Content-Length: (\d+)/))
197
+ content_length = m[1].to_i
198
+ end
199
+ end
200
+ ip = sock.read(content_length).chomp
201
+ string_to_ip(ip)
163
202
  end
164
203
  end
165
- ip = sock.read(content_length).chomp
166
- Resolv::IPv4.create ip
204
+ rescue StandardError
205
+ nil
206
+ end.compact
207
+ end
208
+
209
+ def string_to_ip(ip)
210
+ case ip
211
+ when Resolv::IPv4::Regex
212
+ Resolv::IPv4.create(ip)
213
+ when Resolv::IPv6::Regex
214
+ Resolv::IPv6.create(ip)
215
+ else
216
+ raise ArgumentError, "Unsupported IP format #{ip}"
167
217
  end
168
218
  end
169
219
 
170
220
  def resolve_ip_addresses(host, port)
171
- addresses = Addrinfo.getaddrinfo(host, port, :INET, :DGRAM)
221
+ addresses = Addrinfo.getaddrinfo(host, port)
172
222
  raise(ResolvError, "Sparoid failed to resolv #{host}") if addresses.empty?
173
223
 
174
- addresses
224
+ addresses.select { |addr| addr.socktype == Socket::SOCK_DGRAM }
175
225
  rescue SocketError
176
226
  raise(ResolvError, "Sparoid failed to resolv #{host}")
177
227
  end
178
228
 
229
+ # Get the public IPv6 address by asking the OS which source address
230
+ # it would use to reach a well-known IPv6 destination.
231
+ # Returns nil if no global IPv6 address is available.
232
+ def public_ipv6_by_udp
233
+ socket = UDPSocket.new(Socket::AF_INET6)
234
+ socket.connect(*GOOGLE_DNS_V6)
235
+ addr = socket.local_address
236
+ return addr.ip_address if global_ipv6?(addr)
237
+
238
+ nil
239
+ rescue StandardError
240
+ nil
241
+ ensure
242
+ socket&.close
243
+ end
244
+
245
+ def global_ipv6?(addr)
246
+ !(addr.ipv6_loopback? || addr.ipv6_linklocal? || addr.ipv6_unspecified? ||
247
+ addr.ipv6_sitelocal? || addr.ipv6_multicast? || addr.ipv6_v4mapped? ||
248
+ addr.ip_address.start_with?("fd", "fc"))
249
+ end
250
+
179
251
  class Error < StandardError; end
180
252
 
181
253
  class ResolvError < Error; end
182
254
 
183
- # Instance of SPAroid that only resolved public_ip once
255
+ # Instance of SPAroid that only resolved public_ips once
184
256
  class Instance
185
257
  include Sparoid
186
258
 
187
- def public_ip(*args)
188
- @public_ip ||= super
259
+ def public_ips(*args)
260
+ @public_ips ||= super
189
261
  end
190
262
 
191
- def cached_public_ip
192
- public_ip
263
+ def cached_public_ips
264
+ public_ips
193
265
  end
194
266
  end
195
267
  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
@@ -26,6 +26,5 @@ Gem::Specification.new do |spec|
26
26
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
27
27
  spec.require_paths = ["lib"]
28
28
 
29
- spec.add_dependency "thor"
30
29
  spec.metadata["rubygems_mfa_required"] = "true"
31
30
  end
metadata CHANGED
@@ -1,30 +1,14 @@
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.1.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
12
- dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: thor
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '0'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: '0'
27
- description:
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
28
12
  email:
29
13
  - carl@84codes.com
30
14
  executables:
@@ -56,7 +40,6 @@ metadata:
56
40
  source_code_uri: https://github.com/84codes/sparoid.rb
57
41
  changelog_uri: https://raw.githubusercontent.com/84codes/sparoid.rb/main/CHANGELOG.md
58
42
  rubygems_mfa_required: 'true'
59
- post_install_message:
60
43
  rdoc_options: []
61
44
  require_paths:
62
45
  - lib
@@ -64,15 +47,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
64
47
  requirements:
65
48
  - - ">="
66
49
  - !ruby/object:Gem::Version
67
- version: 2.5.0
50
+ version: 3.2.0
68
51
  required_rubygems_version: !ruby/object:Gem::Requirement
69
52
  requirements:
70
53
  - - ">="
71
54
  - !ruby/object:Gem::Version
72
55
  version: '0'
73
56
  requirements: []
74
- rubygems_version: 3.5.11
75
- signing_key:
57
+ rubygems_version: 3.6.9
76
58
  specification_version: 4
77
59
  summary: Single Packet Authorisation client
78
60
  test_files: []