sparoid 2.0.0 → 2.1.1
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 +4 -4
- data/CHANGELOG.md +6 -0
- data/exe/sparoid +2 -6
- data/lib/sparoid/cli.rb +48 -40
- data/lib/sparoid/version.rb +1 -1
- data/lib/sparoid.rb +41 -84
- data/sparoid.gemspec +0 -1
- metadata +2 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e31fd430f74d9eabc1cae19dfe70b1ad40511850ecf77d1e5b436e5dfc206836
|
|
4
|
+
data.tar.gz: 6fc3c4fa8f4a9e0cc2d63e26138e89b03c689481c1a3b42be46493aad653d2ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c445dc3e34df5a0a51c654d0e8447a7d96cb8ba675d105680bfa74a0b0fdedc98364123a375ac3fe7213e1c17200b1ce1181a19f944d22f4998d2f730239bfdd
|
|
7
|
+
data.tar.gz: 3a91e79c4030419da559578051f4abef33aceaa8e20a3fd1a3202959342030941f5d6bb7c33e31fe1dfa2107dea2b63bcda1719eb2b50cd0551a23630b5f1c78
|
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
data/lib/sparoid/cli.rb
CHANGED
|
@@ -1,56 +1,66 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "optparse"
|
|
4
4
|
require_relative "../sparoid"
|
|
5
5
|
|
|
6
6
|
module Sparoid
|
|
7
7
|
# CLI
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
38
|
+
warn "Sparoid error: #{e.message}"
|
|
39
|
+
exit 1
|
|
29
40
|
end
|
|
30
41
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
42
|
-
|
|
58
|
+
def self.read_keys(config_path)
|
|
59
|
+
parse_ini(config_path).values_at("key", "hmac-key")
|
|
43
60
|
end
|
|
44
61
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
data/lib/sparoid/version.rb
CHANGED
data/lib/sparoid.rb
CHANGED
|
@@ -4,7 +4,7 @@ require_relative "sparoid/version"
|
|
|
4
4
|
require "socket"
|
|
5
5
|
require "openssl"
|
|
6
6
|
require "resolv"
|
|
7
|
-
require "
|
|
7
|
+
require "net/http"
|
|
8
8
|
|
|
9
9
|
# Single Packet Authorisation client
|
|
10
10
|
module Sparoid # rubocop:disable Metrics/ModuleLength
|
|
@@ -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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -217,25 +182,15 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
|
|
|
217
182
|
end
|
|
218
183
|
end
|
|
219
184
|
|
|
220
|
-
def public_ips(port = 80)
|
|
185
|
+
def public_ips(port = 80)
|
|
221
186
|
URLS.map do |host|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
187
|
+
http = Net::HTTP.new(host, port)
|
|
188
|
+
http.open_timeout = 3
|
|
189
|
+
http.read_timeout = 5
|
|
190
|
+
response = http.get("/")
|
|
191
|
+
raise(ResolvError, "#{host}:#{port} response: #{response.code}") unless response.is_a?(Net::HTTPSuccess)
|
|
192
|
+
|
|
193
|
+
string_to_ip(response.body.chomp)
|
|
239
194
|
rescue StandardError
|
|
240
195
|
nil
|
|
241
196
|
end.compact
|
|
@@ -261,24 +216,26 @@ module Sparoid # rubocop:disable Metrics/ModuleLength
|
|
|
261
216
|
raise(ResolvError, "Sparoid failed to resolv #{host}")
|
|
262
217
|
end
|
|
263
218
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
219
|
+
# Get the public IPv6 address by asking the OS which source address
|
|
220
|
+
# it would use to reach a well-known IPv6 destination.
|
|
221
|
+
# Returns nil if no global IPv6 address is available.
|
|
222
|
+
def public_ipv6_by_udp
|
|
223
|
+
socket = UDPSocket.new(Socket::AF_INET6)
|
|
224
|
+
socket.connect(*GOOGLE_DNS_V6)
|
|
225
|
+
addr = socket.local_address
|
|
226
|
+
return addr.ip_address if global_ipv6?(addr)
|
|
227
|
+
|
|
228
|
+
nil
|
|
229
|
+
rescue StandardError
|
|
230
|
+
nil
|
|
231
|
+
ensure
|
|
232
|
+
socket&.close
|
|
276
233
|
end
|
|
277
234
|
|
|
278
|
-
def global_ipv6?(
|
|
279
|
-
!(
|
|
280
|
-
|
|
281
|
-
|
|
235
|
+
def global_ipv6?(addr)
|
|
236
|
+
!(addr.ipv6_loopback? || addr.ipv6_linklocal? || addr.ipv6_unspecified? ||
|
|
237
|
+
addr.ipv6_sitelocal? || addr.ipv6_multicast? || addr.ipv6_v4mapped? ||
|
|
238
|
+
addr.ip_address.start_with?("fd", "fc"))
|
|
282
239
|
end
|
|
283
240
|
|
|
284
241
|
class Error < StandardError; end
|
data/sparoid.gemspec
CHANGED
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.
|
|
4
|
+
version: 2.1.1
|
|
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:
|