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 +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 +32 -65
- 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: df9de779b1b4fcfd29f3f0ad3ad986001ecc39b61fbe3aeff36c9b3cb5409327
|
|
4
|
+
data.tar.gz: 56789005516d3c0c2e3b4799f1cf3d6c9873465ff3516b27edee0649b2f5d373
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
@@ -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
|
|
@@ -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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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?(
|
|
279
|
-
!(
|
|
280
|
-
|
|
281
|
-
|
|
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
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.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:
|