sparoid 1.2.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 +4 -4
- data/.github/workflows/main.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +5 -0
- data/lib/sparoid/cli.rb +1 -0
- data/lib/sparoid/version.rb +1 -1
- data/lib/sparoid.rb +146 -41
- data/sparoid.gemspec +1 -1
- metadata +4 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7838b331849e520e484b7c061e7f0b176b5e21127f4e56cb5b82226fea8724ee
|
|
4
|
+
data.tar.gz: 530254c56b3311043f4264243d358c736e7c3253e29c0d8c1a89dd1d5473bb43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '085c9e8b9fc3684f79501c146320dc332e4d0303e68a7e359ff38d30bfd32e87dd8826cf7e9c82259df50214738be52fc5862b4e5adc6bade33aee3ed0f9d224'
|
|
7
|
+
data.tar.gz: b931da160e4bf5603b147b695ac1e4858a059652d93732ba629f6ee334275e51e77208d72dbf165ad397f15da3f590571cdd383fc56c96daec67b0f6e7e84d28
|
data/.github/workflows/main.yml
CHANGED
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
|
|
1
6
|
## [1.2.0] - 2024-07-24
|
|
2
7
|
|
|
3
8
|
- Lookup public IP using http://checkip.amazonaws.com, as it's more reliable for IPv6 connections than the DNS method
|
data/lib/sparoid/cli.rb
CHANGED
data/lib/sparoid/version.rb
CHANGED
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:
|
|
21
|
+
def auth(key, hmac_key, host, port, open_for_ip: nil)
|
|
16
22
|
addrs = resolve_ip_addresses(host, port)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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(
|
|
49
|
+
def fdpass(addrs, port, connect_timeout: 10) # rubocop:disable Metrics/AbcSize
|
|
41
50
|
# try connect to all IPs
|
|
42
|
-
sockets =
|
|
43
|
-
Socket.new(
|
|
44
|
-
s.connect_nonblock(Socket.sockaddr_in(port,
|
|
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
|
-
|
|
63
|
+
addr = addrs.delete_at(idx) # find the IP for the socket
|
|
55
64
|
begin
|
|
56
|
-
s.connect_nonblock(Socket.sockaddr_in(port,
|
|
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
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
socket.sendmsg
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,54 +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
|
-
|
|
209
|
+
ips = public_ips
|
|
210
|
+
warn "Sparoid: Failed to retrieve public IPs" if ips.empty?
|
|
146
211
|
f.truncate(0)
|
|
147
|
-
f.
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 "
|
|
158
228
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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)
|
|
163
237
|
end
|
|
164
238
|
end
|
|
165
|
-
|
|
166
|
-
|
|
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}"
|
|
167
252
|
end
|
|
168
253
|
end
|
|
169
254
|
|
|
170
255
|
def resolve_ip_addresses(host, port)
|
|
171
|
-
addresses = Addrinfo.getaddrinfo(host, port
|
|
256
|
+
addresses = Addrinfo.getaddrinfo(host, port)
|
|
172
257
|
raise(ResolvError, "Sparoid failed to resolv #{host}") if addresses.empty?
|
|
173
258
|
|
|
174
|
-
addresses
|
|
259
|
+
addresses.select { |addr| addr.socktype == Socket::SOCK_DGRAM }
|
|
175
260
|
rescue SocketError
|
|
176
261
|
raise(ResolvError, "Sparoid failed to resolv #{host}")
|
|
177
262
|
end
|
|
178
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
|
+
|
|
179
284
|
class Error < StandardError; end
|
|
180
285
|
|
|
181
286
|
class ResolvError < Error; end
|
|
182
287
|
|
|
183
|
-
# Instance of SPAroid that only resolved
|
|
288
|
+
# Instance of SPAroid that only resolved public_ips once
|
|
184
289
|
class Instance
|
|
185
290
|
include Sparoid
|
|
186
291
|
|
|
187
|
-
def
|
|
188
|
-
@
|
|
292
|
+
def public_ips(*args)
|
|
293
|
+
@public_ips ||= super
|
|
189
294
|
end
|
|
190
295
|
|
|
191
|
-
def
|
|
192
|
-
|
|
296
|
+
def cached_public_ips
|
|
297
|
+
public_ips
|
|
193
298
|
end
|
|
194
299
|
end
|
|
195
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.
|
|
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:
|
|
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:
|
|
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:
|
|
@@ -56,7 +54,6 @@ metadata:
|
|
|
56
54
|
source_code_uri: https://github.com/84codes/sparoid.rb
|
|
57
55
|
changelog_uri: https://raw.githubusercontent.com/84codes/sparoid.rb/main/CHANGELOG.md
|
|
58
56
|
rubygems_mfa_required: 'true'
|
|
59
|
-
post_install_message:
|
|
60
57
|
rdoc_options: []
|
|
61
58
|
require_paths:
|
|
62
59
|
- lib
|
|
@@ -64,15 +61,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
64
61
|
requirements:
|
|
65
62
|
- - ">="
|
|
66
63
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: 2.
|
|
64
|
+
version: 3.2.0
|
|
68
65
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
66
|
requirements:
|
|
70
67
|
- - ">="
|
|
71
68
|
- !ruby/object:Gem::Version
|
|
72
69
|
version: '0'
|
|
73
70
|
requirements: []
|
|
74
|
-
rubygems_version: 3.
|
|
75
|
-
signing_key:
|
|
71
|
+
rubygems_version: 3.6.9
|
|
76
72
|
specification_version: 4
|
|
77
73
|
summary: Single Packet Authorisation client
|
|
78
74
|
test_files: []
|