Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
564e140
Adds ipv6 support to server. It introduces a new message format to al…
antondalgren Jan 27, 2026
109a2a9
Update test with proper cidrrange
antondalgren Feb 10, 2026
a2434b0
Validate cidr range, and make nftablesv6 usable on it own
antondalgren Feb 10, 2026
3ed4afb
Better boolean description
antondalgren Feb 10, 2026
7dfa36d
return nil if we cant resolve the ip
antondalgren Feb 11, 2026
a766b00
Merge branch 'main' into server-ipv6
antondalgren Feb 11, 2026
1893de2
Remove redundant test
antondalgren Feb 13, 2026
31f17a5
Test server backwards compatibility with v1 and v2 messages
antondalgren Feb 20, 2026
ec8a24d
Client only sends v2 messages
antondalgren Feb 20, 2026
d7a400b
Lint: use short block syntax
antondalgren Feb 20, 2026
b627f6f
Refactor IP encoding to use StaticArray directly
carlhoerberg Feb 23, 2026
a9c3e3a
Remove DNS-based public IP lookup, strip IPs at source
carlhoerberg Feb 23, 2026
0f82d2a
Store V2 IP as StaticArray(UInt8, 16), detect IPv4 by ::ffff prefix
carlhoerberg Feb 23, 2026
23aed61
Store V2 IP and range in IPv6 notation internally
carlhoerberg Feb 23, 2026
76244e6
Simplify client: use WaitGroup, clean up UDP send logic
carlhoerberg Feb 23, 2026
7387984
Add V2.from_ip(String) overload, simplify client IP handling
carlhoerberg Feb 23, 2026
34f1075
Strip range from V2 IPv4 ip_string, validate server-side
antondalgren Mar 2, 2026
84c8534
Narrow down msg type
antondalgren Mar 2, 2026
f313189
Simplify IPv6 detection: use UDP connect instead of getifaddrs/netlink
antondalgren Mar 6, 2026
20e22f4
Remove range from V2 message
antondalgren Mar 6, 2026
4f684ad
If local target host is ipv6, send ipv6 address
antondalgren Mar 6, 2026
d5b1f9f
Fix formatting and ameba lint
antondalgren Mar 6, 2026
87cb4f5
Replace V1/V2 with single Message struct, strip IPv4-mapped IPv6
antondalgren Mar 6, 2026
b15a486
Add comprehensive IPv6 global address check
antondalgren Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ With nftables:

```sh
cat > /etc/sparoid.ini << EOF
bind = 0.0.0.0
bind = ::
port = 8484
key = $SPAROID_KEY
hmac-key = $SPAROID_HMAC_KEY
nftables-cmd = add element inet filter sparoid { %s }
nftablesv6-cmd = add element inet filter sparoid6 { %s }
Comment on lines 39 to +40
Copy link
Contributor

@oskgu360 oskgu360 Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nftables-cmd = add element inet filter sparoid%{v} { %s }

on_accept = ->(ip_str : String, family : Socket::Family) : Nil {
  version_suffix = family == Socket::Family::INET6 ? "6" : ""
  cmd = sprintf(c.nftables_cmd.gsub("%{v}", version_suffix), ip_str)
  nft.run_cmd(cmd)
}

I think we could do something like this if we would like to reduce config options, but I guess its clearer seperating them anyway

EOF

cat > /etc/nftables.conf << EOF
Expand All @@ -46,7 +47,8 @@ flush ruleset
table inet filter {
chain prerouting {
type filter hook prerouting priority -300
udp dport 8484 meter rate-limit-sparoid { ip saddr limit rate over 1/second burst 1 packets } counter drop
udp dport 8484 meter rate-limit-sparoid { ip saddr limit rate over 1/second burst 8 packets } counter drop
udp dport 8484 meter rate-limit-sparoid6 { ip6 saddr limit rate over 1/second burst 8 packets } counter drop
udp dport 8484 notrack
}

Expand All @@ -60,6 +62,7 @@ table inet filter {
udp dport 8484 accept
ip saddr @jumphosts tcp dport ssh accept
ip saddr @sparoid tcp dport ssh accept
ip6 saddr @sparoid6 tcp dport ssh accept
}

set sparoid {
Expand All @@ -68,6 +71,12 @@ table inet filter {
timeout 5s
}

set sparoid6 {
type ipv6_addr
flags timeout, interval
timeout 5s
}

set jumphosts {
type ipv4_addr
elements = { 10.10.10.10 }
Expand Down
152 changes: 152 additions & 0 deletions spec/message_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
require "./spec_helper"

describe Sparoid::Message do
describe ".from_ip" do
it "creates message from IPv4 string" do
msg = Sparoid::Message.from_ip("192.168.1.100")
msg.family.should eq Socket::Family::INET
msg.ip_string.should eq "192.168.1.100"
msg.ip.size.should eq 4
end

it "creates message from IPv6 string" do
msg = Sparoid::Message.from_ip("2001:0db8:85a3::8a2e:0370:7334")
msg.family.should eq Socket::Family::INET6
msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
msg.ip.size.should eq 16
end

it "strips IPv4-mapped IPv6 to plain IPv4" do
msg = Sparoid::Message.from_ip("::ffff:192.168.1.1")
msg.family.should eq Socket::Family::INET
msg.ip_string.should eq "192.168.1.1"
msg.ip.size.should eq 4
end

it "raises on invalid string" do
expect_raises(Exception, "Invalid IP address: not-an-ip") do
Sparoid::Message.from_ip("not-an-ip")
end
end
end

describe "#ip_string" do
it "formats localhost" do
msg = Sparoid::Message.from_ip("127.0.0.1")
msg.ip_string.should eq "127.0.0.1"
end

it "formats 0.0.0.0" do
msg = Sparoid::Message.from_ip("0.0.0.0")
msg.ip_string.should eq "0.0.0.0"
end

it "formats 255.255.255.255" do
msg = Sparoid::Message.from_ip("255.255.255.255")
msg.ip_string.should eq "255.255.255.255"
end

it "formats ::1 (loopback)" do
msg = Sparoid::Message.from_ip("::1")
msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0001"
end

it "formats :: (all zeros)" do
msg = Sparoid::Message.from_ip("::")
msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0000"
end

it "formats 2001:db8::" do
msg = Sparoid::Message.from_ip("2001:db8::")
msg.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000"
end

it "formats fe80::1 (link-local)" do
msg = Sparoid::Message.from_ip("fe80::1")
msg.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001"
end

it "formats ff02::1 (multicast)" do
msg = Sparoid::Message.from_ip("ff02::1")
msg.ip_string.should eq "ff02:0000:0000:0000:0000:0000:0000:0001"
end
end

describe "serialization round-trip" do
it "serializes and deserializes IPv4" do
original = Sparoid::Message.from_ip("10.20.30.40")
slice = original.to_slice(IO::ByteFormat::NetworkEndian)
slice.size.should eq 32

io = IO::Memory.new(slice)
parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian)
parsed.family.should eq Socket::Family::INET
parsed.ip_string.should eq "10.20.30.40"
parsed.ts.should eq original.ts
parsed.nounce.should eq original.nounce
end

it "serializes and deserializes IPv6" do
original = Sparoid::Message.from_ip("2001:db8::1")
slice = original.to_slice(IO::ByteFormat::NetworkEndian)
slice.size.should eq 44

io = IO::Memory.new(slice)
parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian)
parsed.family.should eq Socket::Family::INET6
parsed.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0001"
parsed.ts.should eq original.ts
parsed.nounce.should eq original.nounce
end

it "round-trips IPv4-mapped IPv6 as plain IPv4" do
original = Sparoid::Message.from_ip("::ffff:10.20.30.40")
slice = original.to_slice(IO::ByteFormat::NetworkEndian)
slice.size.should eq 32

io = IO::Memory.new(slice)
parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian)
parsed.family.should eq Socket::Family::INET
parsed.ip_string.should eq "10.20.30.40"
parsed.ts.should eq original.ts
parsed.nounce.should eq original.nounce
end

it "preserves timestamp and nonce" do
original = Sparoid::Message.from_ip("1.2.3.4")
slice = original.to_slice(IO::ByteFormat::NetworkEndian)
io = IO::Memory.new(slice)
parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian)
parsed.ts.should eq original.ts
parsed.nounce.should eq original.nounce
parsed.ip.should eq original.ip
end
end

describe ".from_io" do
it "raises on unsupported version" do
slice = Bytes.new(32)
IO::ByteFormat::NetworkEndian.encode(99_i32, slice[0, 4])
io = IO::Memory.new(slice)
expect_raises(Exception, "Unsupported message version: 99") do
Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian)
end
end
end

describe "timestamp and nonce" do
it "generates unique nonces" do
msg1 = Sparoid::Message.from_ip("1.2.3.4")
msg2 = Sparoid::Message.from_ip("1.2.3.4")
msg1.nounce.should_not eq msg2.nounce
end

it "generates timestamps close to current time" do
before = Time.utc.to_unix_ms
msg = Sparoid::Message.from_ip("1.2.3.4")
after = Time.utc.to_unix_ms
msg.ts.should be >= before
msg.ts.should be <= after
end
end
end
50 changes: 28 additions & 22 deletions spec/sparoid_spec.cr
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "./spec_helper"
require "socket"

KEYS = Array(String).new(2) { Random::Secure.hex(32) }
HMAC_KEYS = Array(String).new(2) { Random::Secure.hex(32) }
Expand All @@ -7,7 +8,7 @@ ADDRESS = Socket::IPAddress.new("127.0.0.1", 8484)
describe Sparoid::Server do
it "works" do
last_ip = nil
cb = ->(ip : String) { last_ip = ip }
cb = ->(ip : String, _family : Socket::Family) { last_ip = ip }
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS)
s.bind
spawn s.listen
Expand All @@ -21,7 +22,7 @@ describe Sparoid::Server do
end

it "fails invalid packet lengths" do
cb = ->(ip : String) { ip.should be_nil }
cb = ->(ip : String, _family : Socket::Family) { ip.should be_nil }
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS)
s.bind
spawn s.listen
Expand All @@ -36,7 +37,7 @@ describe Sparoid::Server do
end

it "fails invalid key" do
cb = ->(ip : String) { ip.should be_nil }
cb = ->(ip : String, _family : Socket::Family) { ip.should be_nil }
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS)
s.bind
spawn s.listen
Expand All @@ -49,7 +50,7 @@ describe Sparoid::Server do
end

it "fails invalid hmac key" do
cb = ->(ip : String) { ip.should be_nil }
cb = ->(ip : String, _family : Socket::Family) { ip.should be_nil }
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS)
s.bind
spawn s.listen
Expand All @@ -63,7 +64,7 @@ describe Sparoid::Server do

it "client can cache IP" do
accepted = 0
cb = ->(_ip : String) { accepted += 1 }
cb = ->(_ip : String, _family : Socket::Family) { accepted += 1 }
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS)
s.bind
spawn s.listen
Expand All @@ -79,7 +80,7 @@ describe Sparoid::Server do

it "works with two keys" do
accepted = 0
cb = ->(_ip : String) { accepted += 1 }
cb = ->(_ip : String, _family : Socket::Family) { accepted += 1 }
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS)
s.bind
spawn s.listen
Expand All @@ -95,22 +96,40 @@ describe Sparoid::Server do

it "client can send another IP" do
last_ip = nil
cb = ->(ip : String) { last_ip = ip }
cb = ->(ip : String, _family : Socket::Family) { last_ip = ip }
address = Socket::IPAddress.new("0.0.0.0", ADDRESS.port)
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, address)
s.bind
spawn s.listen
Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "0.0.0.0", address.port, StaticArray[1u8, 1u8, 1u8, 1u8])
Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "0.0.0.0", address.port, "1.1.1.1")
Fiber.yield
s.@seen_nounces.size.should eq 1
last_ip.should eq "1.1.1.1"
ensure
s.try &.close
end

it "can parse manually constructed messages" do
last_ip = nil
cb = ->(ip : String, _family : Socket::Family) { last_ip = ip }
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS)
s.bind
spawn s.listen
msg = Sparoid::Message.from_ip("127.0.0.1")
data = Sparoid::Client.generate_package(KEYS.first, HMAC_KEYS.first, msg)
socket = UDPSocket.new
socket.send data, to: ADDRESS
socket.close
Fiber.yield
s.@seen_nounces.size.should eq 1
last_ip.should eq "127.0.0.1"
ensure
s.try &.close
end

it "can accept IPv4 connections on ::" do
last_ip = nil
cb = ->(ip : String) { last_ip = ip }
cb = ->(ip : String, _family : Socket::Family) { last_ip = ip }
address = Socket::IPAddress.new("::", ADDRESS.port)
s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, address)
s.bind
Expand All @@ -122,17 +141,4 @@ describe Sparoid::Server do
ensure
s.try &.close
end

it "raises on unsupported message version" do
ts = Time.utc.to_unix_ms
nounce = StaticArray(UInt8, 16).new(0_u8)
Random::Secure.random_bytes(nounce.to_slice)
msg = Sparoid::Message.new(2, ts, nounce, StaticArray[127u8, 0u8, 0u8, 1u8])

msg.to_slice(IO::ByteFormat::NetworkEndian).tap do |slice|
expect_raises(Exception, "Unsupported message version: 2") do
Sparoid::Message.from_io(IO::Memory.new(slice), IO::ByteFormat::NetworkEndian)
end
end
end
end
Loading
Loading