sparoid 2.0.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: 7838b331849e520e484b7c061e7f0b176b5e21127f4e56cb5b82226fea8724ee
4
- data.tar.gz: 530254c56b3311043f4264243d358c736e7c3253e29c0d8c1a89dd1d5473bb43
3
+ metadata.gz: df9de779b1b4fcfd29f3f0ad3ad986001ecc39b61fbe3aeff36c9b3cb5409327
4
+ data.tar.gz: 56789005516d3c0c2e3b4799f1cf3d6c9873465ff3516b27edee0649b2f5d373
5
5
  SHA512:
6
- metadata.gz: '085c9e8b9fc3684f79501c146320dc332e4d0303e68a7e359ff38d30bfd32e87dd8826cf7e9c82259df50214738be52fc5862b4e5adc6bade33aee3ed0f9d224'
7
- data.tar.gz: b931da160e4bf5603b147b695ac1e4858a059652d93732ba629f6ee334275e51e77208d72dbf165ad397f15da3f590571cdd383fc56c96daec67b0f6e7e84d28
6
+ metadata.gz: ce6cca5c5219a9f7f99fb6fa34b16efee1d60acae4e5f6bf639524e8ebfd5d4cc805953e9df48656aae11211b25aa824b28751478691a8a293ad3704bd464416
7
+ data.tar.gz: bc288e38f49a5c54f10f338d943169dd662932f516fbdccc6c7d5dc75857da7e0c73dbf2dcb3e5082ee7e769f70552deb12f23a11f8f5cca7f993cc4d6104ad0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
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
+
1
7
  ## [2.0.0] - 2026-02-19
2
8
 
3
9
  - Add IPv6 support
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,56 +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
- pp e.backtrace
19
- abort "Sparoid: #{e.message} (#{host})"
20
- end
21
-
22
- desc "connect HOST PORT [SPA-PORT]", "Send a SPA, TCP connect, and then pass the FD back to the parent"
23
- method_option :config, desc: "Path to a config file, INI format, with key and hmac-key", default: "~/.sparoid.ini"
24
- def connect(host, port, spa_port = 8484)
25
- ips = send_auth(host, spa_port, options[:config])
26
- 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
27
37
  rescue StandardError => e
28
- abort "Sparoid: #{e.message} (#{host})"
38
+ warn "Sparoid error: #{e.message}"
39
+ exit 1
29
40
  end
30
41
 
31
- desc "keygen", "Generate an encryption key and a HMAC key"
32
- def keygen
33
- 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)
34
50
  end
35
51
 
36
- desc "version", "Show version and exit"
37
- def version
38
- 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
39
56
  end
40
57
 
41
- def self.exit_on_failure?
42
- true
58
+ def self.read_keys(config_path)
59
+ parse_ini(config_path).values_at("key", "hmac-key")
43
60
  end
44
61
 
45
- private
46
-
47
- def send_auth(host, port, config)
48
- key, hmac_key = get_keys(parse_ini(config))
49
- Sparoid.auth(key, hmac_key, host, port.to_i)
50
- end
51
-
52
- def parse_ini(path)
53
- 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) }
54
64
  rescue Errno::ENOENT
55
65
  {
56
66
  "key" => ENV.fetch("SPAROID_KEY", nil),
@@ -58,8 +68,6 @@ module Sparoid
58
68
  }
59
69
  end
60
70
 
61
- def get_keys(config)
62
- config.values_at("key", "hmac-key")
63
- end
71
+ private_class_method :parse_send_options!, :parse_connect_options!, :read_keys, :parse_ini
64
72
  end
65
73
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sparoid
4
- VERSION = "2.0.0"
4
+ VERSION = "2.1.0"
5
5
  end
data/lib/sparoid.rb CHANGED
@@ -17,6 +17,8 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
17
17
  "ipv4.icanhazip.com"
18
18
  ].freeze
19
19
 
20
+ GOOGLE_DNS_V6 = ["2001:4860:4860::8888", 53].freeze
21
+
20
22
  # Send an authorization packet
21
23
  def auth(key, hmac_key, host, port, open_for_ip: nil)
22
24
  addrs = resolve_ip_addresses(host, port)
@@ -79,40 +81,16 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
79
81
  private
80
82
 
81
83
  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)]
84
+ if ip
85
+ [message(string_to_ip(ip))]
114
86
  else
115
- raise ArgumentError, "Unsupported IP type #{ip.class}"
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) }
116
94
  end
117
95
  end
118
96
 
@@ -150,26 +128,13 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
150
128
  hmac + data
151
129
  end
152
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
153
133
  def message(ip)
154
134
  version = 1
155
135
  ts = (Time.now.utc.to_f * 1000).floor
156
136
  nounce = OpenSSL::Random.random_bytes(16)
157
- [version, ts, nounce, ip.address].pack("N q> a16 a4")
158
- end
159
-
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")
137
+ [version, ts, nounce, ip.address].pack("N q> a16 a*")
173
138
  end
174
139
 
175
140
  def cached_public_ips
@@ -261,24 +226,26 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
261
226
  raise(ResolvError, "Sparoid failed to resolv #{host}")
262
227
  end
263
228
 
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
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
276
243
  end
277
244
 
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"))
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"))
282
249
  end
283
250
 
284
251
  class Error < StandardError; end
data/sparoid.gemspec CHANGED
@@ -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,28 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sparoid
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Hörberg
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: thor
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
11
+ dependencies: []
26
12
  email:
27
13
  - carl@84codes.com
28
14
  executables: