From 564e1400022527aa84df6cf47bad17daf810991e Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Tue, 27 Jan 2026 13:02:07 +0100 Subject: [PATCH 01/24] Adds ipv6 support to server. It introduces a new message format to allow for ipv6 addresses. --- README.md | 15 +- spec/message_spec.cr | 337 +++++++++++++++++++++++++++++++++++++++++++ spec/sparoid_spec.cr | 35 ++--- src/client.cr | 116 ++++++++++++--- src/config.cr | 16 +- src/ipv6.cr | 77 ++++++++++ src/message.cr | 206 ++++++++++++++++++++++---- src/public_ip.cr | 49 +++---- src/server-cli.cr | 17 ++- src/server.cr | 17 +-- 10 files changed, 769 insertions(+), 116 deletions(-) create mode 100644 spec/message_spec.cr create mode 100644 src/ipv6.cr diff --git a/README.md b/README.md index 9b49e7f..b337670 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,12 @@ With nftables: ```sh cat > /etc/sparoid.ini << EOF -bind = 0.0.0.0 +bind = :: # starts a listener on both ipv4 and ipv6 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 } EOF cat > /etc/nftables.conf << EOF @@ -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 } @@ -60,11 +62,18 @@ 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 { type ipv4_addr - flags timeout + flags timeout, interval + timeout 5s + } + + set sparoid6 { + type ipv6_addr + flags timeout, interval timeout 5s } diff --git a/spec/message_spec.cr b/spec/message_spec.cr new file mode 100644 index 0000000..28101bb --- /dev/null +++ b/spec/message_spec.cr @@ -0,0 +1,337 @@ +require "./spec_helper" + +describe Sparoid::Message do + describe "V1" do + it "creates message with IPv4 address" do + ip = StaticArray[192_u8, 168_u8, 1_u8, 100_u8] + msg = Sparoid::Message::V1.new(ip) + msg.version.should eq 1 + msg.ip.should eq ip + msg.ip_string.should eq "192.168.1.100" + end + + it "serializes and deserializes correctly" do + ip = StaticArray[10_u8, 0_u8, 0_u8, 1_u8] + original = Sparoid::Message::V1.new(ip) + + # Serialize + slice = original.to_slice(IO::ByteFormat::NetworkEndian) + slice.size.should eq 32 + + # Deserialize + io = IO::Memory.new(slice) + parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + parsed.should be_a(Sparoid::Message::V1) + + v1 = parsed.as(Sparoid::Message::V1) + v1.version.should eq 1 + v1.ip.should eq ip + v1.ts.should eq original.ts + v1.nounce.should eq original.nounce + end + + it "formats localhost correctly" do + ip = StaticArray[127_u8, 0_u8, 0_u8, 1_u8] + msg = Sparoid::Message::V1.new(ip) + msg.ip_string.should eq "127.0.0.1" + end + + it "formats 0.0.0.0 correctly" do + ip = StaticArray[0_u8, 0_u8, 0_u8, 0_u8] + msg = Sparoid::Message::V1.new(ip) + msg.ip_string.should eq "0.0.0.0" + end + + it "formats 255.255.255.255 correctly" do + ip = StaticArray[255_u8, 255_u8, 255_u8, 255_u8] + msg = Sparoid::Message::V1.new(ip) + msg.ip_string.should eq "255.255.255.255" + end + end + + describe "V2" do + describe "#from_ip" do + it "creates message from IPv4 address" do + ip = StaticArray[192_u8, 168_u8, 1_u8, 100_u8] + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.version.should eq 2 + msg.family.should eq Socket::Family::INET + msg.range.should eq 32_u8 + msg.ip_string.should eq "192.168.1.100/32" + end + + it "creates message from IPv4 with custom range" do + ip = StaticArray[10_u8, 0_u8, 0_u8, 0_u8] + msg = Sparoid::Message::V2.from_ip(ip.to_slice, 24_u8) + msg.version.should eq 2 + msg.family.should eq Socket::Family::INET + msg.range.should eq 24_u8 + msg.ip_string.should eq "10.0.0.0/24" + end + + it "creates message from full IPv6 address" do + # 2001:0db8:85a3:0000:0000:8a2e:0370:7334 + ip = StaticArray[ + 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, + 0x85_u8, 0xa3_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x8a_u8, 0x2e_u8, + 0x03_u8, 0x70_u8, 0x73_u8, 0x34_u8, + ] + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.version.should eq 2 + msg.family.should eq Socket::Family::INET6 + msg.range.should eq 128_u8 + msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334/128" + end + + it "creates message from IPv6 with custom range" do + ip = StaticArray[ + 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + ] + msg = Sparoid::Message::V2.from_ip(ip.to_slice, 64_u8) + msg.version.should eq 2 + msg.family.should eq Socket::Family::INET6 + msg.range.should eq 64_u8 + end + + it "formats ::1 (loopback) correctly" do + ip = StaticArray[ + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, + ] + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0001/128" + end + + it "formats :: (all zeros) correctly" do + ip = StaticArray(UInt8, 16).new(0_u8) + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0000/128" + end + + it "formats 2001:db8:: correctly" do + ip = StaticArray[ + 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + ] + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000/128" + end + + it "formats ::ffff:192.168.1.1 (IPv4-mapped) correctly" do + # ::ffff:192.168.1.1 = 0000:0000:0000:0000:0000:ffff:c0a8:0101 + ip = StaticArray[ + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0xff_u8, 0xff_u8, + 0xc0_u8, 0xa8_u8, 0x01_u8, 0x01_u8, + ] + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.ip_string.should eq "0000:0000:0000:0000:0000:ffff:c0a8:0101/128" + end + + it "formats fe80::1 (link-local) correctly" do + ip = StaticArray[ + 0xfe_u8, 0x80_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, + ] + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001/128" + end + + it "formats ff02::1 (multicast) correctly" do + ip = StaticArray[ + 0xff_u8, 0x02_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, + ] + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.ip_string.should eq "ff02:0000:0000:0000:0000:0000:0000:0001/128" + end + + it "formats 2001:db8:85a3::8a2e:370:7334 correctly" do + ip = StaticArray[ + 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, + 0x85_u8, 0xa3_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x8a_u8, 0x2e_u8, + 0x03_u8, 0x70_u8, 0x73_u8, 0x34_u8, + ] + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334/128" + end + + it "formats ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff correctly" do + ip = StaticArray(UInt8, 16).new(0xff_u8) + msg = Sparoid::Message::V2.from_ip(ip.to_slice) + msg.ip_string.should eq "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128" + end + + it "raises on invalid IP size" do + ip = Bytes.new(8) # neither 4 nor 16 + expect_raises(Exception, "IP must be 4 (IPv4) or 16 (IPv6) bytes, got 8") do + Sparoid::Message::V2.from_ip(ip) + end + end + end + + describe "serialization round-trip" do + it "serializes and deserializes IPv4 correctly" do + ip = StaticArray[10_u8, 20_u8, 30_u8, 40_u8] + original = Sparoid::Message::V2.from_ip(ip.to_slice) + + slice = original.to_slice(IO::ByteFormat::NetworkEndian) + io = IO::Memory.new(slice) + parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + + parsed.should be_a(Sparoid::Message::V2) + v2 = parsed.as(Sparoid::Message::V2) + v2.version.should eq 2 + v2.family.should eq Socket::Family::INET + v2.range.should eq 32_u8 + v2.ip_string.should eq "10.20.30.40/32" + v2.ts.should eq original.ts + v2.nounce.should eq original.nounce + end + + it "serializes and deserializes IPv4 with custom range" do + ip = StaticArray[192_u8, 168_u8, 0_u8, 0_u8] + original = Sparoid::Message::V2.from_ip(ip.to_slice, 16_u8) + + slice = original.to_slice(IO::ByteFormat::NetworkEndian) + io = IO::Memory.new(slice) + parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + + parsed.should be_a(Sparoid::Message::V2) + v2 = parsed.as(Sparoid::Message::V2) + v2.family.should eq Socket::Family::INET + v2.range.should eq 16_u8 + v2.ip_string.should eq "192.168.0.0/16" + end + + it "serializes and deserializes IPv6 correctly" do + ip = StaticArray[ + 0xfe_u8, 0x80_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, + ] + original = Sparoid::Message::V2.from_ip(ip.to_slice) + + slice = original.to_slice(IO::ByteFormat::NetworkEndian) + io = IO::Memory.new(slice) + parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + + parsed.should be_a(Sparoid::Message::V2) + v2 = parsed.as(Sparoid::Message::V2) + v2.version.should eq 2 + v2.family.should eq Socket::Family::INET6 + v2.range.should eq 128_u8 + v2.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001/128" + v2.ts.should eq original.ts + v2.nounce.should eq original.nounce + end + + it "serializes and deserializes IPv6 with custom range" do + ip = StaticArray[ + 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + ] + original = Sparoid::Message::V2.from_ip(ip.to_slice, 48_u8) + + slice = original.to_slice(IO::ByteFormat::NetworkEndian) + io = IO::Memory.new(slice) + parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + + parsed.should be_a(Sparoid::Message::V2) + v2 = parsed.as(Sparoid::Message::V2) + v2.family.should eq Socket::Family::INET6 + v2.range.should eq 48_u8 + v2.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000/48" + end + + it "preserves timestamp and nonce through serialization" do + ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] + original = Sparoid::Message::V2.from_ip(ip.to_slice) + + 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 unknown family in stream" do + # Manually craft bytes with invalid family (99) + slice = Bytes.new(46) + IO::ByteFormat::NetworkEndian.encode(2_i32, slice[0, 4]) # version + IO::ByteFormat::NetworkEndian.encode(0_i64, slice[4, 8]) # timestamp + # nounce at [12, 16] - zeros + slice[28] = 99_u8 # invalid family + + io = IO::Memory.new(slice) + expect_raises(Exception, "Unknown IP family: 99") do + Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + end + end + end + end + + describe "version detection" do + it "parses V1 messages" do + ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] + original = Sparoid::Message::V1.new(ip) + slice = original.to_slice(IO::ByteFormat::NetworkEndian) + + io = IO::Memory.new(slice) + parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + parsed.version.should eq 1 + parsed.should be_a(Sparoid::Message::V1) + end + + it "raises on unsupported version" do + # Create a fake message with version 99 + slice = Bytes.new(46) + 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 + ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] + msg1 = Sparoid::Message::V1.new(ip) + msg2 = Sparoid::Message::V1.new(ip) + msg1.nounce.should_not eq msg2.nounce + end + + it "generates timestamps close to current time" do + ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] + before = Time.utc.to_unix_ms + msg = Sparoid::Message::V1.new(ip) + after = Time.utc.to_unix_ms + + msg.ts.should be >= before + msg.ts.should be <= after + end + end +end diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 527f363..6dca58c 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -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) } @@ -7,21 +8,21 @@ 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 s.@seen_nounces.size.should eq 0 Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, ADDRESS.address, ADDRESS.port) Fiber.yield - s.@seen_nounces.size.should eq 1 - last_ip.should eq "127.0.0.1" + s.@seen_nounces.size.should eq 2 + last_ip.should eq "127.0.0.1/32" ensure s.try &.close 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 @@ -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 @@ -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 @@ -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 @@ -71,15 +72,15 @@ describe Sparoid::Server do c = Sparoid::Client.new(KEYS.first, HMAC_KEYS.first) c.send(ADDRESS.address, ADDRESS.port) Fiber.yield - s.@seen_nounces.size.should eq 1 - accepted.should eq 1 + s.@seen_nounces.size.should eq 2 + accepted.should eq 2 ensure s.try &.close end 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 @@ -87,22 +88,22 @@ describe Sparoid::Server do Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, ADDRESS.address, ADDRESS.port) Sparoid::Client.send(KEYS.last, HMAC_KEYS.last, ADDRESS.address, ADDRESS.port) Fiber.yield - s.@seen_nounces.size.should eq 2 - accepted.should eq 2 + s.@seen_nounces.size.should eq 4 + accepted.should eq 4 ensure s.try &.close end 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]) Fiber.yield - s.@seen_nounces.size.should eq 1 + s.@seen_nounces.size.should eq 2 last_ip.should eq "1.1.1.1" ensure s.try &.close @@ -110,15 +111,15 @@ describe Sparoid::Server do 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 spawn s.listen Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "127.0.0.1", address.port) Fiber.yield - s.@seen_nounces.size.should eq 1 - last_ip.should eq "127.0.0.1" + s.@seen_nounces.size.should eq 2 + last_ip.should eq "127.0.0.1/32" ensure s.try &.close end diff --git a/src/client.cr b/src/client.cr index 544b0b8..0129dba 100644 --- a/src/client.cr +++ b/src/client.cr @@ -6,6 +6,7 @@ require "fdpass" require "./message" require "./public_ip" require "ini" +require "./ipv6" module Sparoid class Client @@ -27,37 +28,34 @@ module Sparoid self.new(key, hmac_key) end - def initialize(@key : String, @hmac_key : String, @ip = PublicIP.by_http) + def initialize(@key : String, @hmac_key : String) end def send(host : String, port : Int32) - self.class.send(@key, @hmac_key, host, port, @ip) + self.class.send(@key, @hmac_key, host, port) end - def self.send(key : String, hmac_key : String, host : String, port : Int32, ip = PublicIP.by_http) : Array(String) - ip = StaticArray[127u8, 0u8, 0u8, 1u8] if {"localhost", "127.0.0.1"}.includes? host - package = generate_package(key, hmac_key, ip) - udp_send(host, port, package).tap do + def self.send(key : String, hmac_key : String, host : String, port : Int32, ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) : Array(String) + udp_send(host, port, key, hmac_key, ip).tap do sleep 20.milliseconds # sleep a short while to allow the receiver to parse and execute the packet end end - def self.generate_package(key, hmac_key, ip) : Bytes + def self.generate_package(key, hmac_key, message : Message::Base) : Bytes key = key.hexbytes hmac_key = hmac_key.hexbytes raise ArgumentError.new("Key must be 32 bytes hex encoded") if key.bytesize != 32 raise ArgumentError.new("HMAC key must be 32 bytes hex encoded") if hmac_key.bytesize != 32 - - msg = Message.new(ip) - encrypt(key, hmac_key, msg.to_slice(IO::ByteFormat::NetworkEndian)) + encrypt(key, hmac_key, message.to_slice(IO::ByteFormat::NetworkEndian)) end def self.fdpass(ips, port) : NoReturn ch = Channel(Nil).new ips.each do |ip| spawn do - socket = TCPSocket.new - socket.connect(Socket::IPAddress.new(ip, port), timeout: 10) + ipaddr = Socket::IPAddress.new(ip, port) + socket = TCPSocket.new ipaddr.family + socket.connect(ipaddr, timeout: 10) FDPass.send_fd(1, socket.fd) # exit as soon as possible so no other fiber also succefully connects exit 0 @@ -69,21 +67,28 @@ module Sparoid exit 1 # only if all connects fails end - # Send to all resolved IPs for the hostname - private def self.udp_send(host, port, data) : Array(String) - host_addresses = Socket::Addrinfo.udp(host, port, Socket::Family::INET) - socket = Socket.udp(Socket::Family::INET) - Socket.set_blocking(socket.fd, true) + # Send to all resolved IPs for the hostname, prioritizing IPv6 + private def self.udp_send(host, port, key : String, hmac_key : String, ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) : Array(String) + host_addresses = Socket::Addrinfo.udp(host, port) host_addresses.each do |addrinfo| + packages = generate_messages(addrinfo.ip_address, ip).map { |message| generate_package(key, hmac_key, message) } begin - socket.send data, to: addrinfo.ip_address + socket = case addrinfo.family + when Socket::Family::INET6 + UDPSocket.new(Socket::Family::INET6) + else + UDPSocket.new(Socket::Family::INET) + end + packages.each do |data| + socket.send data, to: addrinfo.ip_address + end rescue ex STDERR << "Sparoid error sending " << ex.inspect << "\n" + ensure + socket.try &.close end end host_addresses.map &.ip_address.address - ensure - socket.close if socket end private def self.encrypt(key, hmac_key, data) : Bytes @@ -109,5 +114,76 @@ module Sparoid STDOUT << "key = " << cipher.random_key.hexstring << "\n" STDOUT << "hmac-key = " << Random::Secure.hex(32) << "\n" end + + private def self.slice_to_bytes(ip : Slice(UInt16) | Slice(UInt8), format : IO::ByteFormat) : Bytes + return ip.dup if ip.is_a?(Slice(UInt8)) + + buffer = IO::Memory.new(16) + ip.each do |segment| + buffer.write_bytes segment, format + end + + buffer.to_slice + end + + # Messages of version 1 is being prepended in the messages array so that they are sent first. This ensures + # complete backwards compatibility with IPv4-only receivers due to the rate-limit defined in the nftables examples + # in the README. + private def self.generate_messages(host : Socket::IPAddress, ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) : Array(Message::Base) + messages = [] of Message::Base + if ip + ip_bytes = slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian) + messages << Message::V2.from_ip(ip_bytes) + if ip_bytes.size == 4 + messages.unshift(Message::V1.new(ip)) + end + return messages + end + + if host.loopback? || host.unspecified? + ips = local_ips(host) + ips.each do |i| + messages << Message::V2.from_ip(i) + + if i.size == 4 + static_array = uninitialized UInt8[4] + i.copy_to static_array.to_slice + messages.unshift(Message::V1.new(static_array)) + end + end + else + ipv6_added = false + IPv6.public_ipv6_with_range do |ipv6, cidr| + ipv6_added = true + messages << Message::V2.from_ip(slice_to_bytes(ipv6.ipv6_addr.to_slice, IO::ByteFormat::NetworkEndian), cidr) + end + + public_ips = PublicIP.by_http + public_ips.each do |ip_str| + if ip = Socket::IPAddress.parse_v4_fields?(ip_str.strip) + messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) + messages.unshift(Message::V1.new(ip)) + elsif ip = Socket::IPAddress.parse_v6_fields?(ip_str.strip) + messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) unless ipv6_added + end + end + end + messages + end + + private def self.local_ips(host : Socket::IPAddress) : Array(Bytes) + ipv4 = Slice[127u8, 0u8, 0u8, 1u8] + ipv6 = Slice[ + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, + 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, + ] + if host.family == Socket::Family::INET + [ipv4] + else + [ipv6, ipv4] + end + end end end diff --git a/src/config.cr b/src/config.cr index 4ff1942..4820102 100644 --- a/src/config.cr +++ b/src/config.cr @@ -12,6 +12,7 @@ module Sparoid getter close_cmd = "" getter config_file = "/etc/sparoid.ini" getter nftables_cmd = "" + getter nftablesv6_cmd = "" def initialize parse_options @@ -47,13 +48,14 @@ module Sparoid # ignore sections, assume there's only the empty values.each do |k, v| case k - when "key" then @keys << v - when "hmac-key" then @hmac_keys << v - when "bind" then @host = v - when "port" then @port = v.to_i - when "open-cmd" then @open_cmd = v - when "close-cmd" then @close_cmd = v - when "nftables-cmd" then @nftables_cmd = v + when "key" then @keys << v + when "hmac-key" then @hmac_keys << v + when "bind" then @host = v + when "port" then @port = v.to_i + when "open-cmd" then @open_cmd = v + when "close-cmd" then @close_cmd = v + when "nftables-cmd" then @nftables_cmd = v + when "nftablesv6-cmd" then @nftablesv6_cmd = v end end end diff --git a/src/ipv6.cr b/src/ipv6.cr new file mode 100644 index 0000000..202fc86 --- /dev/null +++ b/src/ipv6.cr @@ -0,0 +1,77 @@ +require "socket" + +lib LibC + struct IfAddrs + ifa_next : IfAddrs* # ameba:disable Lint/UselessAssign + ifa_name : Char* # ameba:disable Lint/UselessAssign + ifa_flags : UInt32 # ameba:disable Lint/UselessAssign + ifa_addr : Sockaddr* # ameba:disable Lint/UselessAssign + ifa_netmask : Sockaddr* # ameba:disable Lint/UselessAssign + ifa_dstaddr : Sockaddr* # ameba:disable Lint/UselessAssign + ifa_data : Void* # ameba:disable Lint/UselessAssign + end + + fun getifaddrs(ifap : IfAddrs**) : Int32 + fun freeifaddrs(ifa : IfAddrs*) +end + +class Socket + struct IPAddress < Address + # Monkey patch to expose address_value + def address_value + previous_def + end + + def ipv6_addr + if family != Family::INET6 + raise "Socket::IPAddress is not IPv6" + end + + ipv6_addr8(@addr.as(LibC::In6Addr)) + end + end +end + +class IPv6 + # Helper to count bits in the netmask (Calculates the /64, /128, etc.) + private def self.count_cidr(netmask_ptr : Pointer(LibC::Sockaddr)) : UInt8 + return 0_u8 if netmask_ptr.null? + + ipaddress = Socket::IPAddress.from(netmask_ptr, sizeof(LibC::SockaddrIn6)) + ipaddress.address_value.popcount.to_u8 + end + + def self.public_ipv6_with_range(& : (Socket::IPAddress, UInt8, String) -> Nil) + ifap = Pointer(LibC::IfAddrs).null + + if LibC.getifaddrs(pointerof(ifap)) == -1 + raise "Failed to get interface addresses" + end + + begin + current = ifap + while current + unless current.value.ifa_addr.null? + family = current.value.ifa_addr.value.sa_family + + if family == LibC::AF_INET6 + # 1. Get the Single IP + ip = Socket::IPAddress.from(current.value.ifa_addr, sizeof(LibC::SockaddrIn6)) + if ip.nil? || ip.loopback? || ip.link_local? || ip.unspecified? || ip.private? + current = current.value.ifa_next + next + end + + # 2. Get the Netmask CIDR (The range size) + cidr = count_cidr(current.value.ifa_netmask) + + yield ip, cidr, String.new(current.value.ifa_name) + end + end + current = current.value.ifa_next + end + ensure + LibC.freeifaddrs(ifap) + end + end +end diff --git a/src/message.cr b/src/message.cr index ba05a4d..92fc823 100644 --- a/src/message.cr +++ b/src/message.cr @@ -1,43 +1,193 @@ require "random/secure" module Sparoid - struct Message - getter version : Int32, ts : Int64, nounce : StaticArray(UInt8, 16), ip : StaticArray(UInt8, 4) + module Message + abstract struct Base + getter version : Int32, ts : Int64, nounce : StaticArray(UInt8, 16) - def initialize(@version, @ts, @nounce, @ip) + def initialize(@version) + @ts = Time.utc.to_unix_ms + @nounce = uninitialized UInt8[16] + Random::Secure.random_bytes(@nounce.to_slice) + end + + def initialize(@version, @ts, @nounce) + end + + abstract def to_io(io : IO, format : IO::ByteFormat) + abstract def to_slice(format : IO::ByteFormat) : Bytes + abstract def ip_string : String end - def initialize(@ip) - @version = 1 - @ts = Time.utc.to_unix_ms - @nounce = uninitialized UInt8[16] - Random::Secure.random_bytes(@nounce.to_slice) + def self.from_io(io : IO, format : IO::ByteFormat) : Base + version = Int32.from_io(io, format) + case version + when 1 + V1.from_io(io, format) + when 2 + V2.from_io(io, format) + else + raise "Unsupported message version: #{version}" + end end - def to_io(io, format) - io.write_bytes @version, format - io.write_bytes @ts, format - io.write @nounce - io.write @ip + def self.ipv4_to_string(ip : Bytes | StaticArray(UInt8, 4), range : UInt8? = nil) : String + String.build(18) do |str| + 4.times do |i| + str << '.' unless i == 0 + str << ip[i] + end + if range + str << '/' + str << range + end + end end - def to_slice(format : IO::ByteFormat) : Bytes - slice = Bytes.new(32) # version (4) + timestamp (8) + nounce (16) + ip (4) - format.encode(@version, slice[0, 4]) - format.encode(@ts, slice[4, 8]) - @nounce.to_slice.copy_to slice[12, @nounce.size] - @ip.to_slice.copy_to slice[28, @ip.size] - slice + def self.ipv6_to_string(ip : Bytes, range : UInt8? = nil) : String + String.build(43) do |str| + 8.times do |i| + str << ':' unless i == 0 + str << '0' if ip[i * 2] < 0x10 + ip[i * 2].to_s(str, 16) + str << '0' if ip[i * 2 + 1] < 0x10 + ip[i * 2 + 1].to_s(str, 16) + end + if range + str << '/' + str << range + end + end end - def self.from_io(io, format) - version = Int32.from_io(io, format) - ts = Int64.from_io(io, format) - nounce = uninitialized UInt8[16] - io.read_fully(nounce.to_slice) - ip = uninitialized UInt8[4] - io.read_fully(ip.to_slice) - self.new(version, ts, nounce, ip) + struct V1 < Base + getter ip : StaticArray(UInt8, 4) + getter family = Socket::Family::INET + + def initialize(@ts, @nounce, @ip) + super(1, @ts, @nounce) + end + + def initialize(@ip) + super(1) + end + + def to_io(io, format) + io.write_bytes @version, format + io.write_bytes @ts, format + io.write @nounce + io.write @ip + end + + def to_slice(format : IO::ByteFormat) : Bytes + slice = Bytes.new(32) # version (4) + timestamp (8) + nounce (16) + ip (4) + format.encode(@version, slice[0, 4]) + format.encode(@ts, slice[4, 8]) + @nounce.to_slice.copy_to slice[12, @nounce.size] + @ip.to_slice.copy_to slice[28, @ip.size] + slice + end + + def ip_string : String + Message.ipv4_to_string(@ip) + end + + def self.from_io(io, format) : V1 + ts = Int64.from_io(io, format) + nounce = uninitialized UInt8[16] + io.read_fully(nounce.to_slice) + ip = uninitialized UInt8[4] + io.read_fully(ip.to_slice) + self.new(ts, nounce, ip) + end + end + + struct V2 < Base + getter ip : Bytes + getter family : Socket::Family + getter range : UInt8 + + # Add ranges to ip, e.g 192.168.1.1/32 and same for ipv6. /128 + + def initialize(@ts, @nounce, @ip, range : UInt8? = nil) + super(2, @ts, @nounce) + case @ip.size + when 4 + @range = range || 32u8 + @family = Socket::Family::INET + when 16 + @range = range || 128u8 + @family = Socket::Family::INET6 + else + raise "IP must be 4 (IPv4) or 16 (IPv6) bytes, got #{@ip.size}" + end + end + + def initialize(@ip, range : UInt8? = nil) + super(2) + case @ip.size + when 4 + @range = range || 32u8 + @family = Socket::Family::INET + when 16 + @range = range || 128u8 + @family = Socket::Family::INET6 + else + raise "IP must be 4 (IPv4) or 16 (IPv6) bytes, got #{@ip.size}" + end + end + + def self.from_ip(ip : Bytes, range : UInt8? = nil) : V2 + V2.new(ip, range) + end + + def to_io(io, format) + io.write_bytes @version, format + io.write_bytes @ts, format + io.write @nounce + io.write_bytes @family == Socket::Family::INET ? 4u8 : 6u8, format + io.write @ip + io.write_bytes @range, format + end + + def to_slice(format : IO::ByteFormat) : Bytes + slice = Bytes.new(46) # version (4) + timestamp (8) + nounce (16) + family (1) + ip (16) + range (1) + format.encode(@version, slice[0, 4]) + format.encode(@ts, slice[4, 8]) + @nounce.to_slice.copy_to slice[12, @nounce.size] + slice[28] = @family == Socket::Family::INET ? 4_u8 : 6_u8 + @ip.copy_to slice[29, @ip.size] + slice[29 + @ip.size] = @range + slice + end + + def ip_string : String + case @family + when Socket::Family::INET + Message.ipv4_to_string(@ip, @range) + when Socket::Family::INET6 + Message.ipv6_to_string(@ip, @range) + else + raise "Unknown IP family: #{@family}" + end + end + + def self.from_io(io, format) : V2 + ts = Int64.from_io(io, format) + nounce = uninitialized UInt8[16] + io.read_fully(nounce.to_slice) + family = UInt8.from_io(io, format) + ip = if family == 4_u8 + Bytes.new(4) + elsif family == 6_u8 + Bytes.new(16) + else + raise "Unknown IP family: #{family}" + end + io.read_fully(ip.to_slice) + range = UInt8.from_io(io, format) + self.new(ts, nounce, ip, range) + end end end end diff --git a/src/public_ip.cr b/src/public_ip.cr index 808158a..055ab1d 100644 --- a/src/public_ip.cr +++ b/src/public_ip.cr @@ -32,28 +32,27 @@ module Sparoid end end - # ifconfig.co/ip is another option - def self.by_http : StaticArray(UInt8, 4) - with_cache do - resp = HTTP::Client.get("http://checkip.amazonaws.com") - raise "Could not retrive public ip" unless resp.status_code == 200 - str_to_arr resp.body - end - end + URLS = [ + "http://ipv6.icanhazip.com", + "http://ipv4.icanhazip.com", + ] - private def self.str_to_arr(str : String) : StaticArray(UInt8, 4) - ip = StaticArray(UInt8, 4).new(0_u8) - i = 0 - str.split(".") do |part| - ip[i] = part.to_u8 - i += 1 + # icanhazip.com is from Cloudflare + def self.by_http : Array(String) + with_cache do + ips = URLS.compact_map do |url| + resp = HTTP::Client.get(url) + next unless resp.status_code == 200 + resp.body + end + raise "No valid response from icanhazip.com" if ips.empty? + ips end - ip end CACHE_PATH = ENV.fetch("SPAROID_CACHE_PATH", "/tmp/.sparoid_public_ip") - private def self.with_cache(&blk : -> StaticArray(UInt8, 4)) : StaticArray(UInt8, 4) + private def self.with_cache(&blk : -> Array(String)) : Array(String) if up_to_date_cache? read_cache else @@ -68,23 +67,23 @@ module Sparoid false end - private def self.read_cache : StaticArray(UInt8, 4) + private def self.read_cache : Array(String) File.open(CACHE_PATH, "r") do |file| file.flock_shared - str_to_arr(file.gets_to_end) + file.gets_to_end.split("\n").map(&.strip) end end - private def self.write_cache(& : -> StaticArray(UInt8, 4)) : StaticArray(UInt8, 4) + private def self.write_cache(& : -> Array(String)) : Array(String) File.open(CACHE_PATH, "a", 0o0644) do |file| file.flock_exclusive - ip = yield - file.truncate - ip.each_with_index do |e, i| - file.print '.' unless i.zero? - file.print e + ips = yield + file.truncate(0) + file.rewind + ips.each do |ip| + file.puts ip end - ip + ips end end end diff --git a/src/server-cli.cr b/src/server-cli.cr index b284639..9b19671 100644 --- a/src/server-cli.cr +++ b/src/server-cli.cr @@ -11,13 +11,24 @@ begin if c.nftables_cmd.bytesize > 0 puts "nftables command: #{c.nftables_cmd}" nft = Nftables.new - on_accept = ->(ip_str : String) { - nft.run_cmd sprintf(c.nftables_cmd, ip_str) + on_accept = ->(ip_str : String, family : Socket::Family) : Nil { + case family + when Socket::Family::INET6 + if c.nftablesv6_cmd.bytesize > 0 + puts "Running nftablesv6 command for #{ip_str}" + nft.run_cmd sprintf(c.nftablesv6_cmd, ip_str) + else + puts "WARNING: no nftablesv6-cmd configured, skipping #{ip_str}" + end + when Socket::Family::INET + puts "Running nftables command for #{ip_str}" + nft.run_cmd sprintf(c.nftables_cmd, ip_str) + end } else puts "Open command: #{c.open_cmd}" puts "Close command: #{c.close_cmd}" - on_accept = ->(ip_str : String) : Nil { + on_accept = ->(ip_str : String, _family : Socket::Family) : Nil { spawn do system sprintf(c.open_cmd, ip_str) unless c.close_cmd.empty? diff --git a/src/server.cr b/src/server.cr index c6c846f..0d77d7e 100644 --- a/src/server.cr +++ b/src/server.cr @@ -8,7 +8,7 @@ module Sparoid @keys : Array(Bytes) @hmac_keys : Array(Bytes) - def initialize(keys : Enumerable(String), hmac_keys : Enumerable(String), @on_accept : Proc(String, Nil), @address : Socket::IPAddress) + def initialize(keys : Enumerable(String), hmac_keys : Enumerable(String), @on_accept : Proc(String, Socket::Family, Nil), @address : Socket::IPAddress) @keys = keys.map &.hexbytes @hmac_keys = hmac_keys.map &.hexbytes raise ArgumentError.new("Key must be 32 bytes hex encoded") if @keys.any? { |k| k.bytesize != 32 } @@ -43,8 +43,8 @@ module Sparoid msg = Message.from_io(plain, IO::ByteFormat::NetworkEndian) verify_ts(msg.ts) verify_nounce(msg.nounce) - ip_str = ip_to_s(msg.ip) - @on_accept.call(ip_str) + ip_str = msg.ip_string + @on_accept.call(ip_str, msg.family) ip_str end @@ -57,7 +57,7 @@ module Sparoid private def verify_nounce(nounce) if @seen_nounces.includes? nounce - raise "reply-attack, nounce seen before" + raise "replay-attack, nounce seen before" end @seen_nounces.shift if @seen_nounces.size >= MAX_NOUNCES @seen_nounces.push nounce @@ -71,15 +71,6 @@ module Sparoid end end - private def ip_to_s(ip) - String.build(15) do |str| - ip.each_with_index do |part, i| - str << '.' unless i == 0 - str << part - end - end - end - private def verify_packet(data : Bytes) : Bytes packet_mac = data[0, 32] data += 32 From 109a2a90b99629066a988be098c7d93be01e5f6b Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Tue, 10 Feb 2026 14:40:33 +0100 Subject: [PATCH 02/24] Update test with proper cidrrange --- spec/sparoid_spec.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 6dca58c..2ad9d98 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -104,7 +104,7 @@ describe Sparoid::Server do Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "0.0.0.0", address.port, StaticArray[1u8, 1u8, 1u8, 1u8]) Fiber.yield s.@seen_nounces.size.should eq 2 - last_ip.should eq "1.1.1.1" + last_ip.should eq "1.1.1.1/32" ensure s.try &.close end From a2434b04e694c8351902cd65682a68d2069b747b Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Tue, 10 Feb 2026 14:45:55 +0100 Subject: [PATCH 03/24] Validate cidr range, and make nftablesv6 usable on it own --- README.md | 2 +- src/message.cr | 12 ++++++++++++ src/server-cli.cr | 11 +++++++---- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b337670..1dc37ec 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ With nftables: ```sh cat > /etc/sparoid.ini << EOF -bind = :: # starts a listener on both ipv4 and ipv6 +bind = :: port = 8484 key = $SPAROID_KEY hmac-key = $SPAROID_HMAC_KEY diff --git a/src/message.cr b/src/message.cr index 92fc823..904f2c3 100644 --- a/src/message.cr +++ b/src/message.cr @@ -113,9 +113,15 @@ module Sparoid super(2, @ts, @nounce) case @ip.size when 4 + if range && range > 32 + raise "Invalid range for IPv4: #{range}, must be between 0 and 32" + end @range = range || 32u8 @family = Socket::Family::INET when 16 + if range && range > 128 + raise "Invalid range for IPv6: #{range}, must be between 0 and 128" + end @range = range || 128u8 @family = Socket::Family::INET6 else @@ -127,9 +133,15 @@ module Sparoid super(2) case @ip.size when 4 + if range && range > 32 + raise "Invalid range for IPv4: #{range}, must be between 0 and 32" + end @range = range || 32u8 @family = Socket::Family::INET when 16 + if range && range > 128 + raise "Invalid range for IPv6: #{range}, must be between 0 and 128" + end @range = range || 128u8 @family = Socket::Family::INET6 else diff --git a/src/server-cli.cr b/src/server-cli.cr index 9b19671..0689cfd 100644 --- a/src/server-cli.cr +++ b/src/server-cli.cr @@ -8,21 +8,24 @@ begin puts "Listening: #{c.host}:#{c.port}" puts "Keys: #{c.keys.size}" puts "HMAC keys: #{c.hmac_keys.size}" - if c.nftables_cmd.bytesize > 0 + if c.nftables_cmd.bytesize > 0 || c.nftablesv6_cmd.bytesize > 0 puts "nftables command: #{c.nftables_cmd}" + puts "nftablesv6 command: #{c.nftablesv6_cmd}" nft = Nftables.new on_accept = ->(ip_str : String, family : Socket::Family) : Nil { case family when Socket::Family::INET6 if c.nftablesv6_cmd.bytesize > 0 - puts "Running nftablesv6 command for #{ip_str}" nft.run_cmd sprintf(c.nftablesv6_cmd, ip_str) else puts "WARNING: no nftablesv6-cmd configured, skipping #{ip_str}" end when Socket::Family::INET - puts "Running nftables command for #{ip_str}" - nft.run_cmd sprintf(c.nftables_cmd, ip_str) + if c.nftables_cmd.bytesize > 0 + nft.run_cmd sprintf(c.nftables_cmd, ip_str) + else + puts "WARNING: no nftables-cmd configured, skipping #{ip_str}" + end end } else From 3ed4afb26126a4e443bf34344287c5dd2b1d9efd Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Tue, 10 Feb 2026 16:44:33 +0100 Subject: [PATCH 04/24] Better boolean description --- src/client.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/client.cr b/src/client.cr index 0129dba..2ed74ae 100644 --- a/src/client.cr +++ b/src/client.cr @@ -152,9 +152,9 @@ module Sparoid end end else - ipv6_added = false + ipv6_native = false IPv6.public_ipv6_with_range do |ipv6, cidr| - ipv6_added = true + ipv6_native = true messages << Message::V2.from_ip(slice_to_bytes(ipv6.ipv6_addr.to_slice, IO::ByteFormat::NetworkEndian), cidr) end @@ -164,7 +164,7 @@ module Sparoid messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) messages.unshift(Message::V1.new(ip)) elsif ip = Socket::IPAddress.parse_v6_fields?(ip_str.strip) - messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) unless ipv6_added + messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) unless ipv6_native end end end From 7dfa36dccc7fac171e6b8ca00d6a68a9317b9fad Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Wed, 11 Feb 2026 14:51:29 +0100 Subject: [PATCH 05/24] return nil if we cant resolve the ip --- src/public_ip.cr | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/public_ip.cr b/src/public_ip.cr index 055ab1d..467dd30 100644 --- a/src/public_ip.cr +++ b/src/public_ip.cr @@ -44,6 +44,8 @@ module Sparoid resp = HTTP::Client.get(url) next unless resp.status_code == 200 resp.body + rescue + nil end raise "No valid response from icanhazip.com" if ips.empty? ips From 1893de2450456b496c592698535cf1d8d263595a Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 13 Feb 2026 13:21:12 +0100 Subject: [PATCH 06/24] Remove redundant test --- spec/sparoid_spec.cr | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 5a0f52f..2ad9d98 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -123,17 +123,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 From 31f17a596ca2c124d94141d79503e5ec556c656d Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 20 Feb 2026 09:29:55 +0100 Subject: [PATCH 07/24] Test server backwards compatibility with v1 and v2 messages --- spec/sparoid_spec.cr | 50 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 2ad9d98..8d08bd0 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -15,7 +15,7 @@ describe Sparoid::Server do s.@seen_nounces.size.should eq 0 Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, ADDRESS.address, ADDRESS.port) Fiber.yield - s.@seen_nounces.size.should eq 2 + s.@seen_nounces.size.should eq 1 last_ip.should eq "127.0.0.1/32" ensure s.try &.close @@ -72,8 +72,8 @@ describe Sparoid::Server do c = Sparoid::Client.new(KEYS.first, HMAC_KEYS.first) c.send(ADDRESS.address, ADDRESS.port) Fiber.yield - s.@seen_nounces.size.should eq 2 - accepted.should eq 2 + s.@seen_nounces.size.should eq 1 + accepted.should eq 1 ensure s.try &.close end @@ -88,8 +88,8 @@ describe Sparoid::Server do Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, ADDRESS.address, ADDRESS.port) Sparoid::Client.send(KEYS.last, HMAC_KEYS.last, ADDRESS.address, ADDRESS.port) Fiber.yield - s.@seen_nounces.size.should eq 4 - accepted.should eq 4 + s.@seen_nounces.size.should eq 2 + accepted.should eq 2 ensure s.try &.close end @@ -103,12 +103,48 @@ describe Sparoid::Server do spawn s.listen Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "0.0.0.0", address.port, StaticArray[1u8, 1u8, 1u8, 1u8]) Fiber.yield - s.@seen_nounces.size.should eq 2 + s.@seen_nounces.size.should eq 1 last_ip.should eq "1.1.1.1/32" ensure s.try &.close end + it "can parse v1 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 + v1_msg = Sparoid::Message::V1.new(StaticArray[127u8, 0u8, 0u8, 1u8]) + data = Sparoid::Client.generate_package(KEYS.first, HMAC_KEYS.first, v1_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 parse v2 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 + v2_msg = Sparoid::Message::V2.from_ip(Slice[127u8, 0u8, 0u8, 1u8]) + data = Sparoid::Client.generate_package(KEYS.first, HMAC_KEYS.first, v2_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/32" + ensure + s.try &.close + end + it "can accept IPv4 connections on ::" do last_ip = nil cb = ->(ip : String, _family : Socket::Family) { last_ip = ip } @@ -118,7 +154,7 @@ describe Sparoid::Server do spawn s.listen Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "127.0.0.1", address.port) Fiber.yield - s.@seen_nounces.size.should eq 2 + s.@seen_nounces.size.should eq 1 last_ip.should eq "127.0.0.1/32" ensure s.try &.close From ec8a24d426a8fe8461e5a7a72ada158cbb4c1e39 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 20 Feb 2026 09:33:52 +0100 Subject: [PATCH 08/24] Client only sends v2 messages Remove v1 message generation from the client. Servers must be upgraded before clients to maintain backwards compatibility. --- src/client.cr | 47 +++++++++++++++++++---------------------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/src/client.cr b/src/client.cr index 2ed74ae..a1999b1 100644 --- a/src/client.cr +++ b/src/client.cr @@ -126,17 +126,11 @@ module Sparoid buffer.to_slice end - # Messages of version 1 is being prepended in the messages array so that they are sent first. This ensures - # complete backwards compatibility with IPv4-only receivers due to the rate-limit defined in the nftables examples - # in the README. - private def self.generate_messages(host : Socket::IPAddress, ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) : Array(Message::Base) - messages = [] of Message::Base + private def self.generate_messages(host : Socket::IPAddress, ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) : Array(Message::V2) + messages = [] of Message::V2 if ip ip_bytes = slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian) messages << Message::V2.from_ip(ip_bytes) - if ip_bytes.size == 4 - messages.unshift(Message::V1.new(ip)) - end return messages end @@ -144,30 +138,27 @@ module Sparoid ips = local_ips(host) ips.each do |i| messages << Message::V2.from_ip(i) - - if i.size == 4 - static_array = uninitialized UInt8[4] - i.copy_to static_array.to_slice - messages.unshift(Message::V1.new(static_array)) - end - end - else - ipv6_native = false - IPv6.public_ipv6_with_range do |ipv6, cidr| - ipv6_native = true - messages << Message::V2.from_ip(slice_to_bytes(ipv6.ipv6_addr.to_slice, IO::ByteFormat::NetworkEndian), cidr) end + return messages + end - public_ips = PublicIP.by_http - public_ips.each do |ip_str| - if ip = Socket::IPAddress.parse_v4_fields?(ip_str.strip) - messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) - messages.unshift(Message::V1.new(ip)) - elsif ip = Socket::IPAddress.parse_v6_fields?(ip_str.strip) - messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) unless ipv6_native - end + ipv6_native = false + IPv6.public_ipv6_with_range do |ipv6, cidr| + ipv6_native = true + messages << Message::V2.from_ip(slice_to_bytes(ipv6.ipv6_addr.to_slice, IO::ByteFormat::NetworkEndian), cidr) + end + + public_ips = PublicIP.by_http + public_ips.each do |ip_str| + if ip = Socket::IPAddress.parse_v4_fields?(ip_str.strip) + messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) + elsif ip = Socket::IPAddress.parse_v6_fields?(ip_str.strip) + messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) unless ipv6_native end end + + # Sort messages by family to prioritize IPv4 address in case there is a rate limit on the receiver side and it can only process 1 packet / s + messages.sort_by! { |msg| msg.family } messages end From d7a400bd05684fbaa7b61390a9508e351372340c Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 20 Feb 2026 09:37:12 +0100 Subject: [PATCH 09/24] Lint: use short block syntax --- src/client.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.cr b/src/client.cr index a1999b1..d2e05b0 100644 --- a/src/client.cr +++ b/src/client.cr @@ -158,7 +158,7 @@ module Sparoid end # Sort messages by family to prioritize IPv4 address in case there is a rate limit on the receiver side and it can only process 1 packet / s - messages.sort_by! { |msg| msg.family } + messages.sort_by!(&.family) messages end From b627f6f1a26479a882cb9e481dca5b4002a2b72c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl=20H=C3=B6rberg?= Date: Mon, 23 Feb 2026 09:40:07 +0100 Subject: [PATCH 10/24] Refactor IP encoding to use StaticArray directly Replace slice_to_bytes with encode_ip that pattern-matches on StaticArray types, and simplify generate_messages control flow. --- src/client.cr | 53 ++++++++++++++++++++++++-------------------------- src/message.cr | 2 +- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/src/client.cr b/src/client.cr index d2e05b0..129cf00 100644 --- a/src/client.cr +++ b/src/client.cr @@ -115,45 +115,42 @@ module Sparoid STDOUT << "hmac-key = " << Random::Secure.hex(32) << "\n" end - private def self.slice_to_bytes(ip : Slice(UInt16) | Slice(UInt8), format : IO::ByteFormat) : Bytes - return ip.dup if ip.is_a?(Slice(UInt8)) - - buffer = IO::Memory.new(16) - ip.each do |segment| - buffer.write_bytes segment, format + private def self.encode_ip(ip : StaticArray) : Bytes + case ip + in StaticArray(UInt8, 4) + Bytes.new(4).tap do |bytes| + ip.each_with_index { |byte, i| bytes[i] = byte } + end + in StaticArray(UInt8, 16) + Bytes.new(16).tap do |bytes| + ip.each_with_index { |byte, i| bytes[i] = byte } + end + in StaticArray(UInt16, 8) + Bytes.new(16).tap do |bytes| + ip.each_with_index do |segment, i| + IO::ByteFormat::NetworkEndian.encode(segment, bytes[i * 2, 2]) + end + end end - - buffer.to_slice end - private def self.generate_messages(host : Socket::IPAddress, ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) : Array(Message::V2) - messages = [] of Message::V2 - if ip - ip_bytes = slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian) - messages << Message::V2.from_ip(ip_bytes) - return messages - end - - if host.loopback? || host.unspecified? - ips = local_ips(host) - ips.each do |i| - messages << Message::V2.from_ip(i) - end - return messages - end + private def self.generate_messages(host : Socket::IPAddress, public_ip : StaticArray? = nil) : Array(Message::V2) + return [Message::V2.from_ip(encode_ip(public_ip))] if public_ip + return local_ips(host).map { |ip| Message::V2.from_ip(ip) } if host.loopback? || host.unspecified? + messages = Array(Message::V2).new ipv6_native = false IPv6.public_ipv6_with_range do |ipv6, cidr| ipv6_native = true - messages << Message::V2.from_ip(slice_to_bytes(ipv6.ipv6_addr.to_slice, IO::ByteFormat::NetworkEndian), cidr) + messages << Message::V2.from_ip(encode_ip(ipv6.ipv6_addr), cidr) end public_ips = PublicIP.by_http public_ips.each do |ip_str| - if ip = Socket::IPAddress.parse_v4_fields?(ip_str.strip) - messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) - elsif ip = Socket::IPAddress.parse_v6_fields?(ip_str.strip) - messages << Message::V2.from_ip(slice_to_bytes(ip.to_slice, IO::ByteFormat::NetworkEndian)) unless ipv6_native + if ipv4 = Socket::IPAddress.parse_v4_fields?(ip_str.strip) + messages << Message::V2.from_ip(encode_ip(ipv4)) + elsif ipv6 = Socket::IPAddress.parse_v6_fields?(ip_str.strip) + messages << Message::V2.from_ip(encode_ip(ipv6)) unless ipv6_native end end diff --git a/src/message.cr b/src/message.cr index 904f2c3..2382f41 100644 --- a/src/message.cr +++ b/src/message.cr @@ -129,7 +129,7 @@ module Sparoid end end - def initialize(@ip, range : UInt8? = nil) + def initialize(@ip : Bytes, range : UInt8? = nil) super(2) case @ip.size when 4 From a9c3e3af8e430d5c766e1eccd1e595c6778938ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl=20H=C3=B6rberg?= Date: Mon, 23 Feb 2026 10:23:26 +0100 Subject: [PATCH 11/24] Remove DNS-based public IP lookup, strip IPs at source Remove the unused `by_dns` method and move string stripping into `by_http` and `read_cache` so callers don't need to handle it. Change URLS to a Tuple and simplify cache reading. --- src/client.cr | 7 +++---- src/public_ip.cr | 41 +++++++++-------------------------------- 2 files changed, 12 insertions(+), 36 deletions(-) diff --git a/src/client.cr b/src/client.cr index 129cf00..1dfc3e4 100644 --- a/src/client.cr +++ b/src/client.cr @@ -145,11 +145,10 @@ module Sparoid messages << Message::V2.from_ip(encode_ip(ipv6.ipv6_addr), cidr) end - public_ips = PublicIP.by_http - public_ips.each do |ip_str| - if ipv4 = Socket::IPAddress.parse_v4_fields?(ip_str.strip) + PublicIP.by_http.each do |ip_str| + if ipv4 = Socket::IPAddress.parse_v4_fields?(ip_str) messages << Message::V2.from_ip(encode_ip(ipv4)) - elsif ipv6 = Socket::IPAddress.parse_v6_fields?(ip_str.strip) + elsif ipv6 = Socket::IPAddress.parse_v6_fields?(ip_str) messages << Message::V2.from_ip(encode_ip(ipv6)) unless ipv6_native end end diff --git a/src/public_ip.cr b/src/public_ip.cr index 467dd30..79caf72 100644 --- a/src/public_ip.cr +++ b/src/public_ip.cr @@ -4,46 +4,19 @@ require "http/client" module Sparoid class PublicIP - # https://code.blogs.iiidefix.net/posts/get-public-ip-using-dns/ - def self.by_dns : StaticArray(UInt8, 4) - with_cache do - socket = UDPSocket.new - socket.connect("208.67.222.222", 53) # resolver1.opendns.com - header = DNS::Header.new(op_code: DNS::OpCode::Query, recursion_desired: false) - message = DNS::Message.new(header: header) - message.questions << DNS::Question.new(name: DNS::Name.new("myip.opendns.com"), query_type: DNS::RecordType::A) - message.to_socket socket - response = DNS::Message.from_socket socket - if answer = response.answers.first? - data = answer.data - case data - when DNS::IPv4Address - ip = data.to_slice - StaticArray(UInt8, 4).new do |i| - ip[i] - end - else raise "Unexpected response type from DNS request: #{data.inspect}" - end - else - raise "No A response from myip.opendns.com" - end - ensure - socket.try &.close - end - end - - URLS = [ + URLS = { "http://ipv6.icanhazip.com", "http://ipv4.icanhazip.com", - ] + } # icanhazip.com is from Cloudflare + # returns stripped IP addresses as strings, one per URL in URLS def self.by_http : Array(String) with_cache do ips = URLS.compact_map do |url| resp = HTTP::Client.get(url) next unless resp.status_code == 200 - resp.body + resp.body.chomp rescue nil end @@ -72,7 +45,11 @@ module Sparoid private def self.read_cache : Array(String) File.open(CACHE_PATH, "r") do |file| file.flock_shared - file.gets_to_end.split("\n").map(&.strip) + Array(String).new.tap do |ips| + while line = file.gets + ips << line + end + end end end From 0f82d2a63219e37c25567b2fd7786264edcb1345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl=20H=C3=B6rberg?= Date: Mon, 23 Feb 2026 11:04:33 +0100 Subject: [PATCH 12/24] Store V2 IP as StaticArray(UInt8, 16), detect IPv4 by ::ffff prefix IPv4 addresses are stored as IPv4-mapped IPv6 (::ffff:x.x.x.x). Family is now a computed property checking the prefix instead of a stored field. Added from_ip overloads for StaticArray(UInt8, 4), StaticArray(UInt8, 16), and StaticArray(UInt16, 8), removing the encode_ip helper from the client. --- spec/message_spec.cr | 3 +- src/client.cr | 27 ++-------- src/message.cr | 126 ++++++++++++++++++++++++------------------- 3 files changed, 76 insertions(+), 80 deletions(-) diff --git a/spec/message_spec.cr b/spec/message_spec.cr index 28101bb..39dd8ac 100644 --- a/spec/message_spec.cr +++ b/spec/message_spec.cr @@ -134,7 +134,8 @@ describe Sparoid::Message do 0xc0_u8, 0xa8_u8, 0x01_u8, 0x01_u8, ] msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "0000:0000:0000:0000:0000:ffff:c0a8:0101/128" + msg.family.should eq Socket::Family::INET + msg.ip_string.should eq "192.168.1.1/32" end it "formats fe80::1 (link-local) correctly" do diff --git a/src/client.cr b/src/client.cr index 1dfc3e4..c15b33a 100644 --- a/src/client.cr +++ b/src/client.cr @@ -115,41 +115,22 @@ module Sparoid STDOUT << "hmac-key = " << Random::Secure.hex(32) << "\n" end - private def self.encode_ip(ip : StaticArray) : Bytes - case ip - in StaticArray(UInt8, 4) - Bytes.new(4).tap do |bytes| - ip.each_with_index { |byte, i| bytes[i] = byte } - end - in StaticArray(UInt8, 16) - Bytes.new(16).tap do |bytes| - ip.each_with_index { |byte, i| bytes[i] = byte } - end - in StaticArray(UInt16, 8) - Bytes.new(16).tap do |bytes| - ip.each_with_index do |segment, i| - IO::ByteFormat::NetworkEndian.encode(segment, bytes[i * 2, 2]) - end - end - end - end - private def self.generate_messages(host : Socket::IPAddress, public_ip : StaticArray? = nil) : Array(Message::V2) - return [Message::V2.from_ip(encode_ip(public_ip))] if public_ip + return [Message::V2.from_ip(public_ip)] if public_ip return local_ips(host).map { |ip| Message::V2.from_ip(ip) } if host.loopback? || host.unspecified? messages = Array(Message::V2).new ipv6_native = false IPv6.public_ipv6_with_range do |ipv6, cidr| ipv6_native = true - messages << Message::V2.from_ip(encode_ip(ipv6.ipv6_addr), cidr) + messages << Message::V2.from_ip(ipv6.ipv6_addr, cidr) end PublicIP.by_http.each do |ip_str| if ipv4 = Socket::IPAddress.parse_v4_fields?(ip_str) - messages << Message::V2.from_ip(encode_ip(ipv4)) + messages << Message::V2.from_ip(ipv4) elsif ipv6 = Socket::IPAddress.parse_v6_fields?(ip_str) - messages << Message::V2.from_ip(encode_ip(ipv6)) unless ipv6_native + messages << Message::V2.from_ip(ipv6) unless ipv6_native end end diff --git a/src/message.cr b/src/message.cr index 2382f41..a712ee2 100644 --- a/src/message.cr +++ b/src/message.cr @@ -103,62 +103,71 @@ module Sparoid end struct V2 < Base - getter ip : Bytes - getter family : Socket::Family + getter ip : StaticArray(UInt8, 16) getter range : UInt8 - # Add ranges to ip, e.g 192.168.1.1/32 and same for ipv6. /128 + IPV4_PREFIX = StaticArray[0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0xff_u8, 0xff_u8] - def initialize(@ts, @nounce, @ip, range : UInt8? = nil) + def family : Socket::Family + ipv4_mapped? ? Socket::Family::INET : Socket::Family::INET6 + end + + def ipv4_mapped? : Bool + @ip.to_slice[0, 12] == IPV4_PREFIX.to_slice + end + + def initialize(@ts, @nounce, @ip, @range) super(2, @ts, @nounce) - case @ip.size - when 4 - if range && range > 32 - raise "Invalid range for IPv4: #{range}, must be between 0 and 32" - end - @range = range || 32u8 - @family = Socket::Family::INET - when 16 - if range && range > 128 - raise "Invalid range for IPv6: #{range}, must be between 0 and 128" - end - @range = range || 128u8 - @family = Socket::Family::INET6 - else - raise "IP must be 4 (IPv4) or 16 (IPv6) bytes, got #{@ip.size}" - end end - def initialize(@ip : Bytes, range : UInt8? = nil) + def initialize(@ip, @range : UInt8) super(2) - case @ip.size - when 4 - if range && range > 32 - raise "Invalid range for IPv4: #{range}, must be between 0 and 32" - end - @range = range || 32u8 - @family = Socket::Family::INET - when 16 - if range && range > 128 - raise "Invalid range for IPv6: #{range}, must be between 0 and 128" - end - @range = range || 128u8 - @family = Socket::Family::INET6 - else - raise "IP must be 4 (IPv4) or 16 (IPv6) bytes, got #{@ip.size}" + end + + def self.from_ip(ip : StaticArray(UInt8, 4), range : UInt8? = nil) : V2 + sa = StaticArray(UInt8, 16).new(0_u8) + sa[10] = 0xff_u8 + sa[11] = 0xff_u8 + ip.each_with_index { |byte, i| sa[i + 12] = byte } + V2.new(sa, range || 32_u8) + end + + def self.from_ip(ip : StaticArray(UInt8, 16), range : UInt8? = nil) : V2 + default_range = ip.to_slice[0, 12] == IPV4_PREFIX.to_slice ? 32_u8 : 128_u8 + V2.new(ip, range || default_range) + end + + def self.from_ip(ip : StaticArray(UInt16, 8), range : UInt8? = nil) : V2 + sa = StaticArray(UInt8, 16).new(0_u8) + ip.each_with_index do |segment, i| + IO::ByteFormat::NetworkEndian.encode(segment, sa.to_slice[i * 2, 2]) end + default_range = sa.to_slice[0, 12] == IPV4_PREFIX.to_slice ? 32_u8 : 128_u8 + V2.new(sa, range || default_range) end def self.from_ip(ip : Bytes, range : UInt8? = nil) : V2 - V2.new(ip, range) + case ip.size + when 4 + from_ip(StaticArray(UInt8, 4).new { |i| ip[i] }, range) + when 16 + from_ip(StaticArray(UInt8, 16).new { |i| ip[i] }, range) + else + raise "IP must be 4 (IPv4) or 16 (IPv6) bytes, got #{ip.size}" + end end def to_io(io, format) io.write_bytes @version, format io.write_bytes @ts, format io.write @nounce - io.write_bytes @family == Socket::Family::INET ? 4u8 : 6u8, format - io.write @ip + if ipv4_mapped? + io.write_bytes 4_u8, format + io.write @ip.to_slice[12, 4] + else + io.write_bytes 6_u8, format + io.write @ip + end io.write_bytes @range, format end @@ -167,20 +176,23 @@ module Sparoid format.encode(@version, slice[0, 4]) format.encode(@ts, slice[4, 8]) @nounce.to_slice.copy_to slice[12, @nounce.size] - slice[28] = @family == Socket::Family::INET ? 4_u8 : 6_u8 - @ip.copy_to slice[29, @ip.size] - slice[29 + @ip.size] = @range + if ipv4_mapped? + slice[28] = 4_u8 + @ip.to_slice[12, 4].copy_to(slice[29, 4]) + slice[33] = @range + else + slice[28] = 6_u8 + @ip.to_slice.copy_to(slice[29, 16]) + slice[45] = @range + end slice end def ip_string : String - case @family - when Socket::Family::INET - Message.ipv4_to_string(@ip, @range) - when Socket::Family::INET6 - Message.ipv6_to_string(@ip, @range) + if ipv4_mapped? + Message.ipv4_to_string(@ip.to_slice[12, 4], @range) else - raise "Unknown IP family: #{@family}" + Message.ipv6_to_string(@ip.to_slice, @range) end end @@ -189,14 +201,16 @@ module Sparoid nounce = uninitialized UInt8[16] io.read_fully(nounce.to_slice) family = UInt8.from_io(io, format) - ip = if family == 4_u8 - Bytes.new(4) - elsif family == 6_u8 - Bytes.new(16) - else - raise "Unknown IP family: #{family}" - end - io.read_fully(ip.to_slice) + ip = StaticArray(UInt8, 16).new(0_u8) + if family == 4_u8 + io.read_fully(ip.to_slice[12, 4]) + ip[10] = 0xff_u8 + ip[11] = 0xff_u8 + elsif family == 6_u8 + io.read_fully(ip.to_slice) + else + raise "Unknown IP family: #{family}" + end range = UInt8.from_io(io, format) self.new(ts, nounce, ip, range) end From 23aed619282851f4702e98e4bcc414c6cc55acf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl=20H=C3=B6rberg?= Date: Mon, 23 Feb 2026 11:24:35 +0100 Subject: [PATCH 13/24] Store V2 IP and range in IPv6 notation internally IPv4 ranges are stored +96 (e.g. /32 becomes /128) to match the IPv4-mapped IPv6 address. The range getter subtracts 96 for IPv4, and serialization always writes family=6 with all 16 IP bytes. Old family=4 messages are still handled in from_io for compat. --- src/message.cr | 50 ++++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/message.cr b/src/message.cr index a712ee2..c6172f5 100644 --- a/src/message.cr +++ b/src/message.cr @@ -102,9 +102,11 @@ module Sparoid end end + # V2 messages store IP and range in IPv6 notation. + # IPv4 addresses are stored as IPv4-mapped IPv6 (::ffff:x.x.x.x) with range + 96. struct V2 < Base getter ip : StaticArray(UInt8, 16) - getter range : UInt8 + @range : UInt8 IPV4_PREFIX = StaticArray[0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0xff_u8, 0xff_u8] @@ -112,6 +114,10 @@ module Sparoid ipv4_mapped? ? Socket::Family::INET : Socket::Family::INET6 end + def range : UInt8 + ipv4_mapped? ? @range - 96 : @range + end + def ipv4_mapped? : Bool @ip.to_slice[0, 12] == IPV4_PREFIX.to_slice end @@ -129,12 +135,15 @@ module Sparoid sa[10] = 0xff_u8 sa[11] = 0xff_u8 ip.each_with_index { |byte, i| sa[i + 12] = byte } - V2.new(sa, range || 32_u8) + V2.new(sa, (range || 32_u8) + 96) end def self.from_ip(ip : StaticArray(UInt8, 16), range : UInt8? = nil) : V2 - default_range = ip.to_slice[0, 12] == IPV4_PREFIX.to_slice ? 32_u8 : 128_u8 - V2.new(ip, range || default_range) + if ip.to_slice[0, 12] == IPV4_PREFIX.to_slice + V2.new(ip, (range || 32_u8) + 96) + else + V2.new(ip, range || 128_u8) + end end def self.from_ip(ip : StaticArray(UInt16, 8), range : UInt8? = nil) : V2 @@ -142,8 +151,11 @@ module Sparoid ip.each_with_index do |segment, i| IO::ByteFormat::NetworkEndian.encode(segment, sa.to_slice[i * 2, 2]) end - default_range = sa.to_slice[0, 12] == IPV4_PREFIX.to_slice ? 32_u8 : 128_u8 - V2.new(sa, range || default_range) + if sa.to_slice[0, 12] == IPV4_PREFIX.to_slice + V2.new(sa, (range || 32_u8) + 96) + else + V2.new(sa, range || 128_u8) + end end def self.from_ip(ip : Bytes, range : UInt8? = nil) : V2 @@ -161,13 +173,8 @@ module Sparoid io.write_bytes @version, format io.write_bytes @ts, format io.write @nounce - if ipv4_mapped? - io.write_bytes 4_u8, format - io.write @ip.to_slice[12, 4] - else - io.write_bytes 6_u8, format - io.write @ip - end + io.write_bytes 6_u8, format + io.write @ip io.write_bytes @range, format end @@ -176,21 +183,15 @@ module Sparoid format.encode(@version, slice[0, 4]) format.encode(@ts, slice[4, 8]) @nounce.to_slice.copy_to slice[12, @nounce.size] - if ipv4_mapped? - slice[28] = 4_u8 - @ip.to_slice[12, 4].copy_to(slice[29, 4]) - slice[33] = @range - else - slice[28] = 6_u8 - @ip.to_slice.copy_to(slice[29, 16]) - slice[45] = @range - end + slice[28] = 6_u8 + @ip.to_slice.copy_to(slice[29, 16]) + slice[45] = @range slice end def ip_string : String if ipv4_mapped? - Message.ipv4_to_string(@ip.to_slice[12, 4], @range) + Message.ipv4_to_string(@ip.to_slice[12, 4], range) else Message.ipv6_to_string(@ip.to_slice, @range) end @@ -206,12 +207,13 @@ module Sparoid io.read_fully(ip.to_slice[12, 4]) ip[10] = 0xff_u8 ip[11] = 0xff_u8 + range = UInt8.from_io(io, format) + 96 elsif family == 6_u8 io.read_fully(ip.to_slice) + range = UInt8.from_io(io, format) else raise "Unknown IP family: #{family}" end - range = UInt8.from_io(io, format) self.new(ts, nounce, ip, range) end end From 76244e660f41fe2cb9b2d6215b1c15b799e1673f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl=20H=C3=B6rberg?= Date: Mon, 23 Feb 2026 11:43:37 +0100 Subject: [PATCH 14/24] Simplify client: use WaitGroup, clean up UDP send logic Replace Channel-based fiber synchronization with WaitGroup in fdpass. Simplify UDPSocket creation and remove redundant .try on close. Relax public_ip parameter type and add comments to generate_messages. --- src/client.cr | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/client.cr b/src/client.cr index c15b33a..c65da47 100644 --- a/src/client.cr +++ b/src/client.cr @@ -7,6 +7,7 @@ require "./message" require "./public_ip" require "ini" require "./ipv6" +require "wait_group" module Sparoid class Client @@ -35,8 +36,8 @@ module Sparoid self.class.send(@key, @hmac_key, host, port) end - def self.send(key : String, hmac_key : String, host : String, port : Int32, ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) : Array(String) - udp_send(host, port, key, hmac_key, ip).tap do + def self.send(key : String, hmac_key : String, host : String, port : Int32, public_ip = nil) : Array(String) + udp_send(host, port, key, hmac_key, public_ip).tap do sleep 20.milliseconds # sleep a short while to allow the receiver to parse and execute the packet end end @@ -50,42 +51,34 @@ module Sparoid end def self.fdpass(ips, port) : NoReturn - ch = Channel(Nil).new + wg = WaitGroup.new ips.each do |ip| - spawn do + wg.spawn do ipaddr = Socket::IPAddress.new(ip, port) socket = TCPSocket.new ipaddr.family socket.connect(ipaddr, timeout: 10) FDPass.send_fd(1, socket.fd) - # exit as soon as possible so no other fiber also succefully connects - exit 0 - rescue - ch.send(nil) + exit 0 # exit as soon as possible so no other fiber also succefully connects end end - ips.size.times { ch.receive } + wg.wait exit 1 # only if all connects fails end # Send to all resolved IPs for the hostname, prioritizing IPv6 - private def self.udp_send(host, port, key : String, hmac_key : String, ip : StaticArray(UInt8, 4) | StaticArray(UInt8, 16)? = nil) : Array(String) + private def self.udp_send(host, port, key : String, hmac_key : String, public_ip = nil) : Array(String) host_addresses = Socket::Addrinfo.udp(host, port) host_addresses.each do |addrinfo| - packages = generate_messages(addrinfo.ip_address, ip).map { |message| generate_package(key, hmac_key, message) } + packages = generate_messages(addrinfo.ip_address, public_ip).map { |message| generate_package(key, hmac_key, message) } + socket = UDPSocket.new(addrinfo.family) begin - socket = case addrinfo.family - when Socket::Family::INET6 - UDPSocket.new(Socket::Family::INET6) - else - UDPSocket.new(Socket::Family::INET) - end packages.each do |data| socket.send data, to: addrinfo.ip_address end rescue ex STDERR << "Sparoid error sending " << ex.inspect << "\n" ensure - socket.try &.close + socket.close end end host_addresses.map &.ip_address.address @@ -115,6 +108,9 @@ module Sparoid STDOUT << "hmac-key = " << Random::Secure.hex(32) << "\n" end + # Generate messages for all local IPs and public IPs. + # Look up public IPs via HTTP if public ip is not provided as an argument. + # If the host is loopback or unspecified, only generate for local IPs since the receiver won't be able to connect back to the public IPs. private def self.generate_messages(host : Socket::IPAddress, public_ip : StaticArray? = nil) : Array(Message::V2) return [Message::V2.from_ip(public_ip)] if public_ip return local_ips(host).map { |ip| Message::V2.from_ip(ip) } if host.loopback? || host.unspecified? From 7387984517c8e507ef50e4463b8f1a6ff81a26d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl=20H=C3=B6rberg?= Date: Mon, 23 Feb 2026 12:31:20 +0100 Subject: [PATCH 15/24] Add V2.from_ip(String) overload, simplify client IP handling Move IP string parsing into Message::V2.from_ip(String) so client.cr can pass IP strings directly instead of parsing them first. Co-Authored-By: Claude Opus 4.6 --- spec/message_spec.cr | 36 ++++++++++++++++++++++++++++++++++++ spec/sparoid_spec.cr | 2 +- src/client.cr | 26 ++++++++------------------ src/message.cr | 11 +++++++++++ 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/spec/message_spec.cr b/spec/message_spec.cr index 39dd8ac..dab3443 100644 --- a/spec/message_spec.cr +++ b/spec/message_spec.cr @@ -185,6 +185,42 @@ describe Sparoid::Message do end end + describe ".from_ip(String)" do + it "parses IPv4 string" do + msg = Sparoid::Message::V2.from_ip("192.168.1.100") + msg.family.should eq Socket::Family::INET + msg.range.should eq 32_u8 + msg.ip_string.should eq "192.168.1.100/32" + end + + it "parses IPv6 string" do + msg = Sparoid::Message::V2.from_ip("2001:0db8:85a3::8a2e:0370:7334") + msg.family.should eq Socket::Family::INET6 + msg.range.should eq 128_u8 + msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334/128" + end + + it "parses IPv4 string with range" do + msg = Sparoid::Message::V2.from_ip("10.0.0.0", 24_u8) + msg.family.should eq Socket::Family::INET + msg.range.should eq 24_u8 + msg.ip_string.should eq "10.0.0.0/24" + end + + it "parses IPv6 string with range" do + msg = Sparoid::Message::V2.from_ip("2001:db8::", 48_u8) + msg.family.should eq Socket::Family::INET6 + msg.range.should eq 48_u8 + msg.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000/48" + end + + it "raises on invalid string" do + expect_raises(Exception, "Invalid IP address: not-an-ip") do + Sparoid::Message::V2.from_ip("not-an-ip") + end + end + end + describe "serialization round-trip" do it "serializes and deserializes IPv4 correctly" do ip = StaticArray[10_u8, 20_u8, 30_u8, 40_u8] diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 8d08bd0..022fac7 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -101,7 +101,7 @@ describe Sparoid::Server do 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/32" diff --git a/src/client.cr b/src/client.cr index c65da47..ca0c49e 100644 --- a/src/client.cr +++ b/src/client.cr @@ -36,7 +36,7 @@ module Sparoid self.class.send(@key, @hmac_key, host, port) end - def self.send(key : String, hmac_key : String, host : String, port : Int32, public_ip = nil) : Array(String) + def self.send(key : String, hmac_key : String, host : String, port : Int32, public_ip : String? = nil) : Array(String) udp_send(host, port, key, hmac_key, public_ip).tap do sleep 20.milliseconds # sleep a short while to allow the receiver to parse and execute the packet end @@ -66,7 +66,7 @@ module Sparoid end # Send to all resolved IPs for the hostname, prioritizing IPv6 - private def self.udp_send(host, port, key : String, hmac_key : String, public_ip = nil) : Array(String) + private def self.udp_send(host, port, key : String, hmac_key : String, public_ip : String? = nil) : Array(String) host_addresses = Socket::Addrinfo.udp(host, port) host_addresses.each do |addrinfo| packages = generate_messages(addrinfo.ip_address, public_ip).map { |message| generate_package(key, hmac_key, message) } @@ -111,7 +111,7 @@ module Sparoid # Generate messages for all local IPs and public IPs. # Look up public IPs via HTTP if public ip is not provided as an argument. # If the host is loopback or unspecified, only generate for local IPs since the receiver won't be able to connect back to the public IPs. - private def self.generate_messages(host : Socket::IPAddress, public_ip : StaticArray? = nil) : Array(Message::V2) + private def self.generate_messages(host : Socket::IPAddress, public_ip : String? = nil) : Array(Message::V2) return [Message::V2.from_ip(public_ip)] if public_ip return local_ips(host).map { |ip| Message::V2.from_ip(ip) } if host.loopback? || host.unspecified? @@ -123,11 +123,8 @@ module Sparoid end PublicIP.by_http.each do |ip_str| - if ipv4 = Socket::IPAddress.parse_v4_fields?(ip_str) - messages << Message::V2.from_ip(ipv4) - elsif ipv6 = Socket::IPAddress.parse_v6_fields?(ip_str) - messages << Message::V2.from_ip(ipv6) unless ipv6_native - end + next if ipv6_native && ip_str.includes?(':') + messages << Message::V2.from_ip(ip_str) end # Sort messages by family to prioritize IPv4 address in case there is a rate limit on the receiver side and it can only process 1 packet / s @@ -135,18 +132,11 @@ module Sparoid messages end - private def self.local_ips(host : Socket::IPAddress) : Array(Bytes) - ipv4 = Slice[127u8, 0u8, 0u8, 1u8] - ipv6 = Slice[ - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, - ] + private def self.local_ips(host : Socket::IPAddress) : Array(String) if host.family == Socket::Family::INET - [ipv4] + ["127.0.0.1"] else - [ipv6, ipv4] + ["::1", "127.0.0.1"] end end end diff --git a/src/message.cr b/src/message.cr index c6172f5..406111d 100644 --- a/src/message.cr +++ b/src/message.cr @@ -1,4 +1,5 @@ require "random/secure" +require "socket" module Sparoid module Message @@ -169,6 +170,16 @@ module Sparoid end end + def self.from_ip(ip : String, range : UInt8? = nil) : V2 + if fields = Socket::IPAddress.parse_v4_fields?(ip) + from_ip(fields, range) + elsif fields = Socket::IPAddress.parse_v6_fields?(ip) + from_ip(fields, range) + else + raise "Invalid IP address: #{ip}" + end + end + def to_io(io, format) io.write_bytes @version, format io.write_bytes @ts, format From 34f1075a4a743dfef75898268a0fc487bece2299 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Mon, 2 Mar 2026 11:53:54 +0100 Subject: [PATCH 16/24] Strip range from V2 IPv4 ip_string, validate server-side --- README.md | 2 +- spec/message_spec.cr | 14 +++++++------- spec/sparoid_spec.cr | 26 ++++++++++++++++++++++---- src/message.cr | 8 ++------ src/server.cr | 7 +++++++ 5 files changed, 39 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 1dc37ec..0cbabfd 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ table inet filter { set sparoid { type ipv4_addr - flags timeout, interval + flags timeout timeout 5s } diff --git a/spec/message_spec.cr b/spec/message_spec.cr index dab3443..4836cec 100644 --- a/spec/message_spec.cr +++ b/spec/message_spec.cr @@ -57,7 +57,7 @@ describe Sparoid::Message do msg.version.should eq 2 msg.family.should eq Socket::Family::INET msg.range.should eq 32_u8 - msg.ip_string.should eq "192.168.1.100/32" + msg.ip_string.should eq "192.168.1.100" end it "creates message from IPv4 with custom range" do @@ -66,7 +66,7 @@ describe Sparoid::Message do msg.version.should eq 2 msg.family.should eq Socket::Family::INET msg.range.should eq 24_u8 - msg.ip_string.should eq "10.0.0.0/24" + msg.ip_string.should eq "10.0.0.0" end it "creates message from full IPv6 address" do @@ -135,7 +135,7 @@ describe Sparoid::Message do ] msg = Sparoid::Message::V2.from_ip(ip.to_slice) msg.family.should eq Socket::Family::INET - msg.ip_string.should eq "192.168.1.1/32" + msg.ip_string.should eq "192.168.1.1" end it "formats fe80::1 (link-local) correctly" do @@ -190,7 +190,7 @@ describe Sparoid::Message do msg = Sparoid::Message::V2.from_ip("192.168.1.100") msg.family.should eq Socket::Family::INET msg.range.should eq 32_u8 - msg.ip_string.should eq "192.168.1.100/32" + msg.ip_string.should eq "192.168.1.100" end it "parses IPv6 string" do @@ -204,7 +204,7 @@ describe Sparoid::Message do msg = Sparoid::Message::V2.from_ip("10.0.0.0", 24_u8) msg.family.should eq Socket::Family::INET msg.range.should eq 24_u8 - msg.ip_string.should eq "10.0.0.0/24" + msg.ip_string.should eq "10.0.0.0" end it "parses IPv6 string with range" do @@ -235,7 +235,7 @@ describe Sparoid::Message do v2.version.should eq 2 v2.family.should eq Socket::Family::INET v2.range.should eq 32_u8 - v2.ip_string.should eq "10.20.30.40/32" + v2.ip_string.should eq "10.20.30.40" v2.ts.should eq original.ts v2.nounce.should eq original.nounce end @@ -252,7 +252,7 @@ describe Sparoid::Message do v2 = parsed.as(Sparoid::Message::V2) v2.family.should eq Socket::Family::INET v2.range.should eq 16_u8 - v2.ip_string.should eq "192.168.0.0/16" + v2.ip_string.should eq "192.168.0.0" end it "serializes and deserializes IPv6 correctly" do diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 022fac7..00e2086 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -16,7 +16,7 @@ describe Sparoid::Server do Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, ADDRESS.address, ADDRESS.port) Fiber.yield s.@seen_nounces.size.should eq 1 - last_ip.should eq "127.0.0.1/32" + last_ip.should eq "127.0.0.1" ensure s.try &.close end @@ -104,7 +104,7 @@ describe Sparoid::Server do 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/32" + last_ip.should eq "1.1.1.1" ensure s.try &.close end @@ -140,7 +140,25 @@ describe Sparoid::Server do socket.close Fiber.yield s.@seen_nounces.size.should eq 1 - last_ip.should eq "127.0.0.1/32" + last_ip.should eq "127.0.0.1" + ensure + s.try &.close + end + + it "rejects v2 IPv4 messages with non-/32 range" do + accepted = 0 + cb = ->(_ip : String, _family : Socket::Family) { accepted += 1 } + s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS) + s.bind + spawn s.listen + v2_msg = Sparoid::Message::V2.from_ip(Slice[10u8, 0u8, 0u8, 0u8], 24_u8) + data = Sparoid::Client.generate_package(KEYS.first, HMAC_KEYS.first, v2_msg) + socket = UDPSocket.new + socket.send data, to: ADDRESS + socket.close + Fiber.yield + s.@seen_nounces.size.should eq 1 + accepted.should eq 0 ensure s.try &.close end @@ -155,7 +173,7 @@ describe Sparoid::Server do Sparoid::Client.send(KEYS.first, HMAC_KEYS.first, "127.0.0.1", address.port) Fiber.yield s.@seen_nounces.size.should eq 1 - last_ip.should eq "127.0.0.1/32" + last_ip.should eq "127.0.0.1" ensure s.try &.close end diff --git a/src/message.cr b/src/message.cr index 406111d..0d9a2b4 100644 --- a/src/message.cr +++ b/src/message.cr @@ -32,16 +32,12 @@ module Sparoid end end - def self.ipv4_to_string(ip : Bytes | StaticArray(UInt8, 4), range : UInt8? = nil) : String + def self.ipv4_to_string(ip : Bytes | StaticArray(UInt8, 4)) : String String.build(18) do |str| 4.times do |i| str << '.' unless i == 0 str << ip[i] end - if range - str << '/' - str << range - end end end @@ -202,7 +198,7 @@ module Sparoid def ip_string : String if ipv4_mapped? - Message.ipv4_to_string(@ip.to_slice[12, 4], range) + Message.ipv4_to_string(@ip.to_slice[12, 4]) else Message.ipv6_to_string(@ip.to_slice, @range) end diff --git a/src/server.cr b/src/server.cr index 0d77d7e..9161484 100644 --- a/src/server.cr +++ b/src/server.cr @@ -43,6 +43,7 @@ module Sparoid msg = Message.from_io(plain, IO::ByteFormat::NetworkEndian) verify_ts(msg.ts) verify_nounce(msg.nounce) + verify_ip_range(msg) ip_str = msg.ip_string @on_accept.call(ip_str, msg.family) ip_str @@ -55,6 +56,12 @@ module Sparoid MAX_NOUNCES = 65536 # 65536 * 16 = 1MB @seen_nounces = Deque(StaticArray(UInt8, 16)).new(MAX_NOUNCES) + private def verify_ip_range(msg : Message::Base) + if msg.family == Socket::Family::INET && msg.version == 2 + raise "Does not support interval for IPv4 messages" unless msg.range == 32 + end + end + private def verify_nounce(nounce) if @seen_nounces.includes? nounce raise "replay-attack, nounce seen before" From 84c8534e22084cb71e3b6f48f13db05bc707bd54 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Mon, 2 Mar 2026 12:30:08 +0100 Subject: [PATCH 17/24] Narrow down msg type --- src/server.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server.cr b/src/server.cr index 9161484..6f1ce6a 100644 --- a/src/server.cr +++ b/src/server.cr @@ -57,7 +57,7 @@ module Sparoid @seen_nounces = Deque(StaticArray(UInt8, 16)).new(MAX_NOUNCES) private def verify_ip_range(msg : Message::Base) - if msg.family == Socket::Family::INET && msg.version == 2 + if msg.family == Socket::Family::INET && msg.is_a?(Message::V2) raise "Does not support interval for IPv4 messages" unless msg.range == 32 end end From f313189072bd6648fecbe9051eca6ee7a95e3d4f Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 6 Mar 2026 10:51:15 +0100 Subject: [PATCH 18/24] Simplify IPv6 detection: use UDP connect instead of getifaddrs/netlink Replace complex platform-specific interface enumeration (getifaddrs on macOS, netlink on Linux) with a simple UDP connect to get the OS-selected outgoing IPv6 address. Fall back to icanhazip.com if no global address. Co-Authored-By: Claude Opus 4.6 --- src/client.cr | 26 ++++++---------- src/ipv6.cr | 80 ++++++++---------------------------------------- src/public_ip.cr | 8 +++++ 3 files changed, 30 insertions(+), 84 deletions(-) diff --git a/src/client.cr b/src/client.cr index ca0c49e..7e635bd 100644 --- a/src/client.cr +++ b/src/client.cr @@ -108,28 +108,22 @@ module Sparoid STDOUT << "hmac-key = " << Random::Secure.hex(32) << "\n" end - # Generate messages for all local IPs and public IPs. - # Look up public IPs via HTTP if public ip is not provided as an argument. - # If the host is loopback or unspecified, only generate for local IPs since the receiver won't be able to connect back to the public IPs. + # Generate messages for all public IPs (IPv4 first, server may rate-limit). private def self.generate_messages(host : Socket::IPAddress, public_ip : String? = nil) : Array(Message::V2) return [Message::V2.from_ip(public_ip)] if public_ip return local_ips(host).map { |ip| Message::V2.from_ip(ip) } if host.loopback? || host.unspecified? - messages = Array(Message::V2).new - ipv6_native = false - IPv6.public_ipv6_with_range do |ipv6, cidr| - ipv6_native = true - messages << Message::V2.from_ip(ipv6.ipv6_addr, cidr) - end + [public_ipv4, public_ipv6].compact.map { |ip| Message::V2.from_ip(ip) } + end - PublicIP.by_http.each do |ip_str| - next if ipv6_native && ip_str.includes?(':') - messages << Message::V2.from_ip(ip_str) - end + # IPv4: from icanhazip + private def self.public_ipv4 : String? + PublicIP.ipv4 + end - # Sort messages by family to prioritize IPv4 address in case there is a rate limit on the receiver side and it can only process 1 packet / s - messages.sort_by!(&.family) - messages + # IPv6: prefer OS-selected outgoing address, fall back to icanhazip + private def self.public_ipv6 : String? + IPv6.public_ipv6 || PublicIP.ipv6 end private def self.local_ips(host : Socket::IPAddress) : Array(String) diff --git a/src/ipv6.cr b/src/ipv6.cr index 202fc86..b6576eb 100644 --- a/src/ipv6.cr +++ b/src/ipv6.cr @@ -1,77 +1,21 @@ require "socket" -lib LibC - struct IfAddrs - ifa_next : IfAddrs* # ameba:disable Lint/UselessAssign - ifa_name : Char* # ameba:disable Lint/UselessAssign - ifa_flags : UInt32 # ameba:disable Lint/UselessAssign - ifa_addr : Sockaddr* # ameba:disable Lint/UselessAssign - ifa_netmask : Sockaddr* # ameba:disable Lint/UselessAssign - ifa_dstaddr : Sockaddr* # ameba:disable Lint/UselessAssign - ifa_data : Void* # ameba:disable Lint/UselessAssign - end - - fun getifaddrs(ifap : IfAddrs**) : Int32 - fun freeifaddrs(ifa : IfAddrs*) -end - -class Socket - struct IPAddress < Address - # Monkey patch to expose address_value - def address_value - previous_def - end - - def ipv6_addr - if family != Family::INET6 - raise "Socket::IPAddress is not IPv6" - end - - ipv6_addr8(@addr.as(LibC::In6Addr)) - end - end -end - class IPv6 - # Helper to count bits in the netmask (Calculates the /64, /128, etc.) - private def self.count_cidr(netmask_ptr : Pointer(LibC::Sockaddr)) : UInt8 - return 0_u8 if netmask_ptr.null? - - ipaddress = Socket::IPAddress.from(netmask_ptr, sizeof(LibC::SockaddrIn6)) - ipaddress.address_value.popcount.to_u8 - end - - def self.public_ipv6_with_range(& : (Socket::IPAddress, UInt8, String) -> Nil) - ifap = Pointer(LibC::IfAddrs).null - - if LibC.getifaddrs(pointerof(ifap)) == -1 - raise "Failed to get interface addresses" - end + GOOGLE_DNS = Socket::IPAddress.new("2001:4860:4860::8888", 53) + # Get the public IPv6 address by asking the OS which source address + # it would use to reach a well-known IPv6 destination. + # Returns nil if no global IPv6 address is available. + def self.public_ipv6 : String? + socket = UDPSocket.new(Socket::Family::INET6) begin - current = ifap - while current - unless current.value.ifa_addr.null? - family = current.value.ifa_addr.value.sa_family - - if family == LibC::AF_INET6 - # 1. Get the Single IP - ip = Socket::IPAddress.from(current.value.ifa_addr, sizeof(LibC::SockaddrIn6)) - if ip.nil? || ip.loopback? || ip.link_local? || ip.unspecified? || ip.private? - current = current.value.ifa_next - next - end - - # 2. Get the Netmask CIDR (The range size) - cidr = count_cidr(current.value.ifa_netmask) - - yield ip, cidr, String.new(current.value.ifa_name) - end - end - current = current.value.ifa_next - end + socket.connect(GOOGLE_DNS) + addr = socket.local_address + return addr.address unless addr.loopback? || addr.link_local? || addr.unspecified? + rescue ensure - LibC.freeifaddrs(ifap) + socket.close end + nil end end diff --git a/src/public_ip.cr b/src/public_ip.cr index 79caf72..6f91126 100644 --- a/src/public_ip.cr +++ b/src/public_ip.cr @@ -9,6 +9,14 @@ module Sparoid "http://ipv4.icanhazip.com", } + def self.ipv4 : String? + by_http.find { |ip| !ip.includes?(':') } + end + + def self.ipv6 : String? + by_http.find { |ip| ip.includes?(':') } + end + # icanhazip.com is from Cloudflare # returns stripped IP addresses as strings, one per URL in URLS def self.by_http : Array(String) From 20e22f4681f057e1a0e3dafd4f2d17d6a1b7445f Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 6 Mar 2026 10:56:03 +0100 Subject: [PATCH 19/24] Remove range from V2 message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Range/CIDR is no longer needed since we send single IP addresses. Simplifies V2 serialization (46→45 bytes plaintext, still 96 bytes encrypted), removes verify_ip_range from server, and cleans up all from_ip overloads. Co-Authored-By: Claude Opus 4.6 --- spec/message_spec.cr | 114 +++++-------------------------------------- spec/sparoid_spec.cr | 18 ------- src/message.cr | 63 ++++++++---------------- src/server.cr | 7 --- 4 files changed, 32 insertions(+), 170 deletions(-) diff --git a/spec/message_spec.cr b/spec/message_spec.cr index 4836cec..373e088 100644 --- a/spec/message_spec.cr +++ b/spec/message_spec.cr @@ -56,19 +56,9 @@ describe Sparoid::Message do msg = Sparoid::Message::V2.from_ip(ip.to_slice) msg.version.should eq 2 msg.family.should eq Socket::Family::INET - msg.range.should eq 32_u8 msg.ip_string.should eq "192.168.1.100" end - it "creates message from IPv4 with custom range" do - ip = StaticArray[10_u8, 0_u8, 0_u8, 0_u8] - msg = Sparoid::Message::V2.from_ip(ip.to_slice, 24_u8) - msg.version.should eq 2 - msg.family.should eq Socket::Family::INET - msg.range.should eq 24_u8 - msg.ip_string.should eq "10.0.0.0" - end - it "creates message from full IPv6 address" do # 2001:0db8:85a3:0000:0000:8a2e:0370:7334 ip = StaticArray[ @@ -80,21 +70,7 @@ describe Sparoid::Message do msg = Sparoid::Message::V2.from_ip(ip.to_slice) msg.version.should eq 2 msg.family.should eq Socket::Family::INET6 - msg.range.should eq 128_u8 - msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334/128" - end - - it "creates message from IPv6 with custom range" do - ip = StaticArray[ - 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - ] - msg = Sparoid::Message::V2.from_ip(ip.to_slice, 64_u8) - msg.version.should eq 2 - msg.family.should eq Socket::Family::INET6 - msg.range.should eq 64_u8 + msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334" end it "formats ::1 (loopback) correctly" do @@ -105,13 +81,13 @@ describe Sparoid::Message do 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, ] msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0001/128" + msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0001" end it "formats :: (all zeros) correctly" do ip = StaticArray(UInt8, 16).new(0_u8) msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0000/128" + msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0000" end it "formats 2001:db8:: correctly" do @@ -122,11 +98,10 @@ describe Sparoid::Message do 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, ] msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000/128" + msg.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000" end it "formats ::ffff:192.168.1.1 (IPv4-mapped) correctly" do - # ::ffff:192.168.1.1 = 0000:0000:0000:0000:0000:ffff:c0a8:0101 ip = StaticArray[ 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, @@ -146,7 +121,7 @@ describe Sparoid::Message do 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, ] msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001/128" + msg.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001" end it "formats ff02::1 (multicast) correctly" do @@ -157,24 +132,13 @@ describe Sparoid::Message do 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, ] msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "ff02:0000:0000:0000:0000:0000:0000:0001/128" - end - - it "formats 2001:db8:85a3::8a2e:370:7334 correctly" do - ip = StaticArray[ - 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, - 0x85_u8, 0xa3_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x8a_u8, 0x2e_u8, - 0x03_u8, 0x70_u8, 0x73_u8, 0x34_u8, - ] - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334/128" + msg.ip_string.should eq "ff02:0000:0000:0000:0000:0000:0000:0001" end it "formats ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff correctly" do ip = StaticArray(UInt8, 16).new(0xff_u8) msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128" + msg.ip_string.should eq "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" end it "raises on invalid IP size" do @@ -189,29 +153,13 @@ describe Sparoid::Message do it "parses IPv4 string" do msg = Sparoid::Message::V2.from_ip("192.168.1.100") msg.family.should eq Socket::Family::INET - msg.range.should eq 32_u8 msg.ip_string.should eq "192.168.1.100" end it "parses IPv6 string" do msg = Sparoid::Message::V2.from_ip("2001:0db8:85a3::8a2e:0370:7334") msg.family.should eq Socket::Family::INET6 - msg.range.should eq 128_u8 - msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334/128" - end - - it "parses IPv4 string with range" do - msg = Sparoid::Message::V2.from_ip("10.0.0.0", 24_u8) - msg.family.should eq Socket::Family::INET - msg.range.should eq 24_u8 - msg.ip_string.should eq "10.0.0.0" - end - - it "parses IPv6 string with range" do - msg = Sparoid::Message::V2.from_ip("2001:db8::", 48_u8) - msg.family.should eq Socket::Family::INET6 - msg.range.should eq 48_u8 - msg.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000/48" + msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334" end it "raises on invalid string" do @@ -234,27 +182,11 @@ describe Sparoid::Message do v2 = parsed.as(Sparoid::Message::V2) v2.version.should eq 2 v2.family.should eq Socket::Family::INET - v2.range.should eq 32_u8 v2.ip_string.should eq "10.20.30.40" v2.ts.should eq original.ts v2.nounce.should eq original.nounce end - it "serializes and deserializes IPv4 with custom range" do - ip = StaticArray[192_u8, 168_u8, 0_u8, 0_u8] - original = Sparoid::Message::V2.from_ip(ip.to_slice, 16_u8) - - slice = original.to_slice(IO::ByteFormat::NetworkEndian) - io = IO::Memory.new(slice) - parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) - - parsed.should be_a(Sparoid::Message::V2) - v2 = parsed.as(Sparoid::Message::V2) - v2.family.should eq Socket::Family::INET - v2.range.should eq 16_u8 - v2.ip_string.should eq "192.168.0.0" - end - it "serializes and deserializes IPv6 correctly" do ip = StaticArray[ 0xfe_u8, 0x80_u8, 0x00_u8, 0x00_u8, @@ -272,32 +204,11 @@ describe Sparoid::Message do v2 = parsed.as(Sparoid::Message::V2) v2.version.should eq 2 v2.family.should eq Socket::Family::INET6 - v2.range.should eq 128_u8 - v2.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001/128" + v2.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001" v2.ts.should eq original.ts v2.nounce.should eq original.nounce end - it "serializes and deserializes IPv6 with custom range" do - ip = StaticArray[ - 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - ] - original = Sparoid::Message::V2.from_ip(ip.to_slice, 48_u8) - - slice = original.to_slice(IO::ByteFormat::NetworkEndian) - io = IO::Memory.new(slice) - parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) - - parsed.should be_a(Sparoid::Message::V2) - v2 = parsed.as(Sparoid::Message::V2) - v2.family.should eq Socket::Family::INET6 - v2.range.should eq 48_u8 - v2.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000/48" - end - it "preserves timestamp and nonce through serialization" do ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] original = Sparoid::Message::V2.from_ip(ip.to_slice) @@ -314,11 +225,9 @@ describe Sparoid::Message do describe "from_io" do it "raises on unknown family in stream" do - # Manually craft bytes with invalid family (99) - slice = Bytes.new(46) + slice = Bytes.new(45) IO::ByteFormat::NetworkEndian.encode(2_i32, slice[0, 4]) # version IO::ByteFormat::NetworkEndian.encode(0_i64, slice[4, 8]) # timestamp - # nounce at [12, 16] - zeros slice[28] = 99_u8 # invalid family io = IO::Memory.new(slice) @@ -342,8 +251,7 @@ describe Sparoid::Message do end it "raises on unsupported version" do - # Create a fake message with version 99 - slice = Bytes.new(46) + slice = Bytes.new(45) IO::ByteFormat::NetworkEndian.encode(99_i32, slice[0, 4]) io = IO::Memory.new(slice) diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 00e2086..0544952 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -145,24 +145,6 @@ describe Sparoid::Server do s.try &.close end - it "rejects v2 IPv4 messages with non-/32 range" do - accepted = 0 - cb = ->(_ip : String, _family : Socket::Family) { accepted += 1 } - s = Sparoid::Server.new(KEYS, HMAC_KEYS, cb, ADDRESS) - s.bind - spawn s.listen - v2_msg = Sparoid::Message::V2.from_ip(Slice[10u8, 0u8, 0u8, 0u8], 24_u8) - data = Sparoid::Client.generate_package(KEYS.first, HMAC_KEYS.first, v2_msg) - socket = UDPSocket.new - socket.send data, to: ADDRESS - socket.close - Fiber.yield - s.@seen_nounces.size.should eq 1 - accepted.should eq 0 - ensure - s.try &.close - end - it "can accept IPv4 connections on ::" do last_ip = nil cb = ->(ip : String, _family : Socket::Family) { last_ip = ip } diff --git a/src/message.cr b/src/message.cr index 0d9a2b4..0d834c5 100644 --- a/src/message.cr +++ b/src/message.cr @@ -41,8 +41,8 @@ module Sparoid end end - def self.ipv6_to_string(ip : Bytes, range : UInt8? = nil) : String - String.build(43) do |str| + def self.ipv6_to_string(ip : Bytes) : String + String.build(39) do |str| 8.times do |i| str << ':' unless i == 0 str << '0' if ip[i * 2] < 0x10 @@ -50,10 +50,6 @@ module Sparoid str << '0' if ip[i * 2 + 1] < 0x10 ip[i * 2 + 1].to_s(str, 16) end - if range - str << '/' - str << range - end end end @@ -99,11 +95,10 @@ module Sparoid end end - # V2 messages store IP and range in IPv6 notation. - # IPv4 addresses are stored as IPv4-mapped IPv6 (::ffff:x.x.x.x) with range + 96. + # V2 messages store IP in IPv6 notation. + # IPv4 addresses are stored as IPv4-mapped IPv6 (::ffff:x.x.x.x). struct V2 < Base getter ip : StaticArray(UInt8, 16) - @range : UInt8 IPV4_PREFIX = StaticArray[0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0xff_u8, 0xff_u8] @@ -111,66 +106,54 @@ module Sparoid ipv4_mapped? ? Socket::Family::INET : Socket::Family::INET6 end - def range : UInt8 - ipv4_mapped? ? @range - 96 : @range - end - def ipv4_mapped? : Bool @ip.to_slice[0, 12] == IPV4_PREFIX.to_slice end - def initialize(@ts, @nounce, @ip, @range) + def initialize(@ts, @nounce, @ip) super(2, @ts, @nounce) end - def initialize(@ip, @range : UInt8) + def initialize(@ip) super(2) end - def self.from_ip(ip : StaticArray(UInt8, 4), range : UInt8? = nil) : V2 + def self.from_ip(ip : StaticArray(UInt8, 4)) : V2 sa = StaticArray(UInt8, 16).new(0_u8) sa[10] = 0xff_u8 sa[11] = 0xff_u8 ip.each_with_index { |byte, i| sa[i + 12] = byte } - V2.new(sa, (range || 32_u8) + 96) + V2.new(sa) end - def self.from_ip(ip : StaticArray(UInt8, 16), range : UInt8? = nil) : V2 - if ip.to_slice[0, 12] == IPV4_PREFIX.to_slice - V2.new(ip, (range || 32_u8) + 96) - else - V2.new(ip, range || 128_u8) - end + def self.from_ip(ip : StaticArray(UInt8, 16)) : V2 + V2.new(ip) end - def self.from_ip(ip : StaticArray(UInt16, 8), range : UInt8? = nil) : V2 + def self.from_ip(ip : StaticArray(UInt16, 8)) : V2 sa = StaticArray(UInt8, 16).new(0_u8) ip.each_with_index do |segment, i| IO::ByteFormat::NetworkEndian.encode(segment, sa.to_slice[i * 2, 2]) end - if sa.to_slice[0, 12] == IPV4_PREFIX.to_slice - V2.new(sa, (range || 32_u8) + 96) - else - V2.new(sa, range || 128_u8) - end + V2.new(sa) end - def self.from_ip(ip : Bytes, range : UInt8? = nil) : V2 + def self.from_ip(ip : Bytes) : V2 case ip.size when 4 - from_ip(StaticArray(UInt8, 4).new { |i| ip[i] }, range) + from_ip(StaticArray(UInt8, 4).new { |i| ip[i] }) when 16 - from_ip(StaticArray(UInt8, 16).new { |i| ip[i] }, range) + from_ip(StaticArray(UInt8, 16).new { |i| ip[i] }) else raise "IP must be 4 (IPv4) or 16 (IPv6) bytes, got #{ip.size}" end end - def self.from_ip(ip : String, range : UInt8? = nil) : V2 + def self.from_ip(ip : String) : V2 if fields = Socket::IPAddress.parse_v4_fields?(ip) - from_ip(fields, range) + from_ip(fields) elsif fields = Socket::IPAddress.parse_v6_fields?(ip) - from_ip(fields, range) + from_ip(fields) else raise "Invalid IP address: #{ip}" end @@ -182,17 +165,15 @@ module Sparoid io.write @nounce io.write_bytes 6_u8, format io.write @ip - io.write_bytes @range, format end def to_slice(format : IO::ByteFormat) : Bytes - slice = Bytes.new(46) # version (4) + timestamp (8) + nounce (16) + family (1) + ip (16) + range (1) + slice = Bytes.new(45) # version (4) + timestamp (8) + nounce (16) + family (1) + ip (16) format.encode(@version, slice[0, 4]) format.encode(@ts, slice[4, 8]) @nounce.to_slice.copy_to slice[12, @nounce.size] slice[28] = 6_u8 @ip.to_slice.copy_to(slice[29, 16]) - slice[45] = @range slice end @@ -200,7 +181,7 @@ module Sparoid if ipv4_mapped? Message.ipv4_to_string(@ip.to_slice[12, 4]) else - Message.ipv6_to_string(@ip.to_slice, @range) + Message.ipv6_to_string(@ip.to_slice) end end @@ -214,14 +195,12 @@ module Sparoid io.read_fully(ip.to_slice[12, 4]) ip[10] = 0xff_u8 ip[11] = 0xff_u8 - range = UInt8.from_io(io, format) + 96 elsif family == 6_u8 io.read_fully(ip.to_slice) - range = UInt8.from_io(io, format) else raise "Unknown IP family: #{family}" end - self.new(ts, nounce, ip, range) + self.new(ts, nounce, ip) end end end diff --git a/src/server.cr b/src/server.cr index 6f1ce6a..0d77d7e 100644 --- a/src/server.cr +++ b/src/server.cr @@ -43,7 +43,6 @@ module Sparoid msg = Message.from_io(plain, IO::ByteFormat::NetworkEndian) verify_ts(msg.ts) verify_nounce(msg.nounce) - verify_ip_range(msg) ip_str = msg.ip_string @on_accept.call(ip_str, msg.family) ip_str @@ -56,12 +55,6 @@ module Sparoid MAX_NOUNCES = 65536 # 65536 * 16 = 1MB @seen_nounces = Deque(StaticArray(UInt8, 16)).new(MAX_NOUNCES) - private def verify_ip_range(msg : Message::Base) - if msg.family == Socket::Family::INET && msg.is_a?(Message::V2) - raise "Does not support interval for IPv4 messages" unless msg.range == 32 - end - end - private def verify_nounce(nounce) if @seen_nounces.includes? nounce raise "replay-attack, nounce seen before" From 4f684ade98de9970692c0a95615d87247432706a Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 6 Mar 2026 11:00:32 +0100 Subject: [PATCH 20/24] If local target host is ipv6, send ipv6 address --- src/client.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.cr b/src/client.cr index 7e635bd..5f1c5d4 100644 --- a/src/client.cr +++ b/src/client.cr @@ -130,7 +130,7 @@ module Sparoid if host.family == Socket::Family::INET ["127.0.0.1"] else - ["::1", "127.0.0.1"] + ["::1"] end end end From d5b1f9f65316c3953556a9d156684c696163a821 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 6 Mar 2026 11:24:57 +0100 Subject: [PATCH 21/24] Fix formatting and ameba lint Co-Authored-By: Claude Opus 4.6 --- spec/message_spec.cr | 2 +- src/public_ip.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/message_spec.cr b/spec/message_spec.cr index 373e088..92de40c 100644 --- a/spec/message_spec.cr +++ b/spec/message_spec.cr @@ -228,7 +228,7 @@ describe Sparoid::Message do slice = Bytes.new(45) IO::ByteFormat::NetworkEndian.encode(2_i32, slice[0, 4]) # version IO::ByteFormat::NetworkEndian.encode(0_i64, slice[4, 8]) # timestamp - slice[28] = 99_u8 # invalid family + slice[28] = 99_u8 # invalid family io = IO::Memory.new(slice) expect_raises(Exception, "Unknown IP family: 99") do diff --git a/src/public_ip.cr b/src/public_ip.cr index 6f91126..14434cb 100644 --- a/src/public_ip.cr +++ b/src/public_ip.cr @@ -14,7 +14,7 @@ module Sparoid end def self.ipv6 : String? - by_http.find { |ip| ip.includes?(':') } + by_http.find(&.includes?(':')) end # icanhazip.com is from Cloudflare From 87cb4f5284e3b493fc06422f57772ebaf78505b8 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 6 Mar 2026 12:50:08 +0100 Subject: [PATCH 22/24] Replace V1/V2 with single Message struct, strip IPv4-mapped IPv6 Simplify to one message type where IP is 4 or 16 bytes, determined by plaintext size after decryption. Both produce 96-byte encrypted packets. IPv4-mapped IPv6 (::ffff:x.x.x.x) is normalized to plain IPv4 so the correct nftables command is used. Co-Authored-By: Claude Opus 4.6 --- spec/message_spec.cr | 312 +++++++++++++------------------------------ spec/sparoid_spec.cr | 24 +--- src/client.cr | 10 +- src/message.cr | 245 +++++++++------------------------ 4 files changed, 163 insertions(+), 428 deletions(-) diff --git a/spec/message_spec.cr b/spec/message_spec.cr index 92de40c..8d49dec 100644 --- a/spec/message_spec.cr +++ b/spec/message_spec.cr @@ -1,259 +1,132 @@ require "./spec_helper" describe Sparoid::Message do - describe "V1" do - it "creates message with IPv4 address" do - ip = StaticArray[192_u8, 168_u8, 1_u8, 100_u8] - msg = Sparoid::Message::V1.new(ip) - msg.version.should eq 1 - msg.ip.should eq ip + 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 "serializes and deserializes correctly" do - ip = StaticArray[10_u8, 0_u8, 0_u8, 1_u8] - original = Sparoid::Message::V1.new(ip) - - # Serialize - slice = original.to_slice(IO::ByteFormat::NetworkEndian) - slice.size.should eq 32 + 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 - # Deserialize - io = IO::Memory.new(slice) - parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) - parsed.should be_a(Sparoid::Message::V1) + 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 - v1 = parsed.as(Sparoid::Message::V1) - v1.version.should eq 1 - v1.ip.should eq ip - v1.ts.should eq original.ts - v1.nounce.should eq original.nounce + 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 - it "formats localhost correctly" do - ip = StaticArray[127_u8, 0_u8, 0_u8, 1_u8] - msg = Sparoid::Message::V1.new(ip) + 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 correctly" do - ip = StaticArray[0_u8, 0_u8, 0_u8, 0_u8] - msg = Sparoid::Message::V1.new(ip) + 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 correctly" do - ip = StaticArray[255_u8, 255_u8, 255_u8, 255_u8] - msg = Sparoid::Message::V1.new(ip) + 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 - end - - describe "V2" do - describe "#from_ip" do - it "creates message from IPv4 address" do - ip = StaticArray[192_u8, 168_u8, 1_u8, 100_u8] - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.version.should eq 2 - msg.family.should eq Socket::Family::INET - msg.ip_string.should eq "192.168.1.100" - end - - it "creates message from full IPv6 address" do - # 2001:0db8:85a3:0000:0000:8a2e:0370:7334 - ip = StaticArray[ - 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, - 0x85_u8, 0xa3_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x8a_u8, 0x2e_u8, - 0x03_u8, 0x70_u8, 0x73_u8, 0x34_u8, - ] - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.version.should eq 2 - msg.family.should eq Socket::Family::INET6 - msg.ip_string.should eq "2001:0db8:85a3:0000:0000:8a2e:0370:7334" - end - - it "formats ::1 (loopback) correctly" do - ip = StaticArray[ - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, - ] - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0001" - end - it "formats :: (all zeros) correctly" do - ip = StaticArray(UInt8, 16).new(0_u8) - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "0000:0000:0000:0000:0000:0000:0000:0000" - end - - it "formats 2001:db8:: correctly" do - ip = StaticArray[ - 0x20_u8, 0x01_u8, 0x0d_u8, 0xb8_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - ] - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "2001:0db8:0000:0000:0000:0000:0000:0000" - end - - it "formats ::ffff:192.168.1.1 (IPv4-mapped) correctly" do - ip = StaticArray[ - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0xff_u8, 0xff_u8, - 0xc0_u8, 0xa8_u8, 0x01_u8, 0x01_u8, - ] - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.family.should eq Socket::Family::INET - msg.ip_string.should eq "192.168.1.1" - end - - it "formats fe80::1 (link-local) correctly" do - ip = StaticArray[ - 0xfe_u8, 0x80_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, - ] - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001" - end - - it "formats ff02::1 (multicast) correctly" do - ip = StaticArray[ - 0xff_u8, 0x02_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, - ] - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "ff02:0000:0000:0000:0000:0000:0000:0001" - end - - it "formats ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff correctly" do - ip = StaticArray(UInt8, 16).new(0xff_u8) - msg = Sparoid::Message::V2.from_ip(ip.to_slice) - msg.ip_string.should eq "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - end - - it "raises on invalid IP size" do - ip = Bytes.new(8) # neither 4 nor 16 - expect_raises(Exception, "IP must be 4 (IPv4) or 16 (IPv6) bytes, got 8") do - Sparoid::Message::V2.from_ip(ip) - end - 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 - describe ".from_ip(String)" do - it "parses IPv4 string" do - msg = Sparoid::Message::V2.from_ip("192.168.1.100") - msg.family.should eq Socket::Family::INET - msg.ip_string.should eq "192.168.1.100" - end - - it "parses IPv6 string" do - msg = Sparoid::Message::V2.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" - end - - it "raises on invalid string" do - expect_raises(Exception, "Invalid IP address: not-an-ip") do - Sparoid::Message::V2.from_ip("not-an-ip") - end - 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 - describe "serialization round-trip" do - it "serializes and deserializes IPv4 correctly" do - ip = StaticArray[10_u8, 20_u8, 30_u8, 40_u8] - original = Sparoid::Message::V2.from_ip(ip.to_slice) - - slice = original.to_slice(IO::ByteFormat::NetworkEndian) - io = IO::Memory.new(slice) - parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) - - parsed.should be_a(Sparoid::Message::V2) - v2 = parsed.as(Sparoid::Message::V2) - v2.version.should eq 2 - v2.family.should eq Socket::Family::INET - v2.ip_string.should eq "10.20.30.40" - v2.ts.should eq original.ts - v2.nounce.should eq original.nounce - 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 "serializes and deserializes IPv6 correctly" do - ip = StaticArray[ - 0xfe_u8, 0x80_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x00_u8, - 0x00_u8, 0x00_u8, 0x00_u8, 0x01_u8, - ] - original = Sparoid::Message::V2.from_ip(ip.to_slice) + 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 - slice = original.to_slice(IO::ByteFormat::NetworkEndian) - io = IO::Memory.new(slice) - parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + 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 - parsed.should be_a(Sparoid::Message::V2) - v2 = parsed.as(Sparoid::Message::V2) - v2.version.should eq 2 - v2.family.should eq Socket::Family::INET6 - v2.ip_string.should eq "fe80:0000:0000:0000:0000:0000:0000:0001" - v2.ts.should eq original.ts - v2.nounce.should eq original.nounce - 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 - it "preserves timestamp and nonce through serialization" do - ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] - original = Sparoid::Message::V2.from_ip(ip.to_slice) + 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 - slice = original.to_slice(IO::ByteFormat::NetworkEndian) - io = IO::Memory.new(slice) - parsed = Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) + 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 - parsed.ts.should eq original.ts - parsed.nounce.should eq original.nounce - parsed.ip.should eq original.ip - end + 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 - describe "from_io" do - it "raises on unknown family in stream" do - slice = Bytes.new(45) - IO::ByteFormat::NetworkEndian.encode(2_i32, slice[0, 4]) # version - IO::ByteFormat::NetworkEndian.encode(0_i64, slice[4, 8]) # timestamp - slice[28] = 99_u8 # invalid family + 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) - expect_raises(Exception, "Unknown IP family: 99") do - Sparoid::Message.from_io(io, IO::ByteFormat::NetworkEndian) - end - end + 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 - end - describe "version detection" do - it "parses V1 messages" do - ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] - original = Sparoid::Message::V1.new(ip) + 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.version.should eq 1 - parsed.should be_a(Sparoid::Message::V1) + 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(45) + 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) @@ -263,18 +136,15 @@ describe Sparoid::Message do describe "timestamp and nonce" do it "generates unique nonces" do - ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] - msg1 = Sparoid::Message::V1.new(ip) - msg2 = Sparoid::Message::V1.new(ip) + 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 - ip = StaticArray[1_u8, 2_u8, 3_u8, 4_u8] before = Time.utc.to_unix_ms - msg = Sparoid::Message::V1.new(ip) + 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 diff --git a/spec/sparoid_spec.cr b/spec/sparoid_spec.cr index 0544952..9030ddf 100644 --- a/spec/sparoid_spec.cr +++ b/spec/sparoid_spec.cr @@ -109,32 +109,14 @@ describe Sparoid::Server do s.try &.close end - it "can parse v1 messages" do + 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 - v1_msg = Sparoid::Message::V1.new(StaticArray[127u8, 0u8, 0u8, 1u8]) - data = Sparoid::Client.generate_package(KEYS.first, HMAC_KEYS.first, v1_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 parse v2 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 - v2_msg = Sparoid::Message::V2.from_ip(Slice[127u8, 0u8, 0u8, 1u8]) - data = Sparoid::Client.generate_package(KEYS.first, HMAC_KEYS.first, v2_msg) + 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 diff --git a/src/client.cr b/src/client.cr index 5f1c5d4..284b86d 100644 --- a/src/client.cr +++ b/src/client.cr @@ -42,7 +42,7 @@ module Sparoid end end - def self.generate_package(key, hmac_key, message : Message::Base) : Bytes + def self.generate_package(key, hmac_key, message : Message) : Bytes key = key.hexbytes hmac_key = hmac_key.hexbytes raise ArgumentError.new("Key must be 32 bytes hex encoded") if key.bytesize != 32 @@ -109,11 +109,11 @@ module Sparoid end # Generate messages for all public IPs (IPv4 first, server may rate-limit). - private def self.generate_messages(host : Socket::IPAddress, public_ip : String? = nil) : Array(Message::V2) - return [Message::V2.from_ip(public_ip)] if public_ip - return local_ips(host).map { |ip| Message::V2.from_ip(ip) } if host.loopback? || host.unspecified? + private def self.generate_messages(host : Socket::IPAddress, public_ip : String? = nil) : Array(Message) + return [Message.from_ip(public_ip)] if public_ip + return local_ips(host).map { |ip| Message.from_ip(ip) } if host.loopback? || host.unspecified? - [public_ipv4, public_ipv6].compact.map { |ip| Message::V2.from_ip(ip) } + [public_ipv4, public_ipv6].compact.map { |ip| Message.from_ip(ip) } end # IPv4: from icanhazip diff --git a/src/message.cr b/src/message.cr index 0d834c5..6665402 100644 --- a/src/message.cr +++ b/src/message.cr @@ -2,205 +2,88 @@ require "random/secure" require "socket" module Sparoid - module Message - abstract struct Base - getter version : Int32, ts : Int64, nounce : StaticArray(UInt8, 16) + struct Message + VERSION = 1_i32 - def initialize(@version) - @ts = Time.utc.to_unix_ms - @nounce = uninitialized UInt8[16] - Random::Secure.random_bytes(@nounce.to_slice) - end - - def initialize(@version, @ts, @nounce) - end + getter ts : Int64, nounce : StaticArray(UInt8, 16), ip : Bytes - abstract def to_io(io : IO, format : IO::ByteFormat) - abstract def to_slice(format : IO::ByteFormat) : Bytes - abstract def ip_string : String + def initialize(@ts, @nounce, @ip : Bytes) end - def self.from_io(io : IO, format : IO::ByteFormat) : Base - version = Int32.from_io(io, format) - case version - when 1 - V1.from_io(io, format) - when 2 - V2.from_io(io, format) - else - raise "Unsupported message version: #{version}" - end + def initialize(@ip : Bytes) + @ts = Time.utc.to_unix_ms + @nounce = uninitialized UInt8[16] + Random::Secure.random_bytes(@nounce.to_slice) end - def self.ipv4_to_string(ip : Bytes | StaticArray(UInt8, 4)) : String - String.build(18) do |str| - 4.times do |i| - str << '.' unless i == 0 - str << ip[i] - end - end + def family : Socket::Family + @ip.size == 4 ? Socket::Family::INET : Socket::Family::INET6 end - def self.ipv6_to_string(ip : Bytes) : String - String.build(39) do |str| - 8.times do |i| - str << ':' unless i == 0 - str << '0' if ip[i * 2] < 0x10 - ip[i * 2].to_s(str, 16) - str << '0' if ip[i * 2 + 1] < 0x10 - ip[i * 2 + 1].to_s(str, 16) + def ip_string : String + if @ip.size == 4 + String.build(15) do |str| + 4.times do |i| + str << '.' unless i == 0 + str << @ip[i] + end + end + else + String.build(39) do |str| + 8.times do |i| + str << ':' unless i == 0 + str << '0' if @ip[i * 2] < 0x10 + @ip[i * 2].to_s(str, 16) + str << '0' if @ip[i * 2 + 1] < 0x10 + @ip[i * 2 + 1].to_s(str, 16) + end end end end - struct V1 < Base - getter ip : StaticArray(UInt8, 4) - getter family = Socket::Family::INET - - def initialize(@ts, @nounce, @ip) - super(1, @ts, @nounce) - end - - def initialize(@ip) - super(1) - end - - def to_io(io, format) - io.write_bytes @version, format - io.write_bytes @ts, format - io.write @nounce - io.write @ip - end - - def to_slice(format : IO::ByteFormat) : Bytes - slice = Bytes.new(32) # version (4) + timestamp (8) + nounce (16) + ip (4) - format.encode(@version, slice[0, 4]) - format.encode(@ts, slice[4, 8]) - @nounce.to_slice.copy_to slice[12, @nounce.size] - @ip.to_slice.copy_to slice[28, @ip.size] - slice - end - - def ip_string : String - Message.ipv4_to_string(@ip) - end - - def self.from_io(io, format) : V1 - ts = Int64.from_io(io, format) - nounce = uninitialized UInt8[16] - io.read_fully(nounce.to_slice) - ip = uninitialized UInt8[4] - io.read_fully(ip.to_slice) - self.new(ts, nounce, ip) - end + def to_slice(format : IO::ByteFormat) : Bytes + size = 28 + @ip.size # version (4) + timestamp (8) + nounce (16) + ip (4 or 16) + slice = Bytes.new(size) + format.encode(VERSION, slice[0, 4]) + format.encode(@ts, slice[4, 8]) + @nounce.to_slice.copy_to(slice[12, 16]) + @ip.copy_to(slice[28, @ip.size]) + slice end - # V2 messages store IP in IPv6 notation. - # IPv4 addresses are stored as IPv4-mapped IPv6 (::ffff:x.x.x.x). - struct V2 < Base - getter ip : StaticArray(UInt8, 16) - - IPV4_PREFIX = StaticArray[0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0_u8, 0xff_u8, 0xff_u8] - - def family : Socket::Family - ipv4_mapped? ? Socket::Family::INET : Socket::Family::INET6 - end - - def ipv4_mapped? : Bool - @ip.to_slice[0, 12] == IPV4_PREFIX.to_slice - end - - def initialize(@ts, @nounce, @ip) - super(2, @ts, @nounce) - end - - def initialize(@ip) - super(2) - end - - def self.from_ip(ip : StaticArray(UInt8, 4)) : V2 - sa = StaticArray(UInt8, 16).new(0_u8) - sa[10] = 0xff_u8 - sa[11] = 0xff_u8 - ip.each_with_index { |byte, i| sa[i + 12] = byte } - V2.new(sa) - end - - def self.from_ip(ip : StaticArray(UInt8, 16)) : V2 - V2.new(ip) - end - - def self.from_ip(ip : StaticArray(UInt16, 8)) : V2 - sa = StaticArray(UInt8, 16).new(0_u8) - ip.each_with_index do |segment, i| - IO::ByteFormat::NetworkEndian.encode(segment, sa.to_slice[i * 2, 2]) - end - V2.new(sa) - end - - def self.from_ip(ip : Bytes) : V2 - case ip.size - when 4 - from_ip(StaticArray(UInt8, 4).new { |i| ip[i] }) - when 16 - from_ip(StaticArray(UInt8, 16).new { |i| ip[i] }) - else - raise "IP must be 4 (IPv4) or 16 (IPv6) bytes, got #{ip.size}" - end - end - - def self.from_ip(ip : String) : V2 - if fields = Socket::IPAddress.parse_v4_fields?(ip) - from_ip(fields) - elsif fields = Socket::IPAddress.parse_v6_fields?(ip) - from_ip(fields) - else - raise "Invalid IP address: #{ip}" - end - end - - def to_io(io, format) - io.write_bytes @version, format - io.write_bytes @ts, format - io.write @nounce - io.write_bytes 6_u8, format - io.write @ip - end - - def to_slice(format : IO::ByteFormat) : Bytes - slice = Bytes.new(45) # version (4) + timestamp (8) + nounce (16) + family (1) + ip (16) - format.encode(@version, slice[0, 4]) - format.encode(@ts, slice[4, 8]) - @nounce.to_slice.copy_to slice[12, @nounce.size] - slice[28] = 6_u8 - @ip.to_slice.copy_to(slice[29, 16]) - slice - end + def self.from_io(io : IO, format : IO::ByteFormat) : Message + version = Int32.from_io(io, format) + raise "Unsupported message version: #{version}" unless version == VERSION + ts = Int64.from_io(io, format) + nounce = uninitialized UInt8[16] + io.read_fully(nounce.to_slice) + buf = Bytes.new(16) + count = io.read(buf) + raise "Invalid IP: expected 4 or 16 bytes, got #{count}" unless count == 4 || count == 16 + Message.new(ts, nounce, strip_mapped_ipv4(buf[0, count])) + end - def ip_string : String - if ipv4_mapped? - Message.ipv4_to_string(@ip.to_slice[12, 4]) - else - Message.ipv6_to_string(@ip.to_slice) + def self.from_ip(ip : String) : Message + if fields = Socket::IPAddress.parse_v4_fields?(ip) + Message.new(Bytes[fields[0], fields[1], fields[2], fields[3]]) + elsif fields = Socket::IPAddress.parse_v6_fields?(ip) + ip_bytes = Bytes.new(16) + fields.each_with_index do |segment, i| + IO::ByteFormat::NetworkEndian.encode(segment, ip_bytes[i * 2, 2]) end + Message.new(strip_mapped_ipv4(ip_bytes)) + else + raise "Invalid IP address: #{ip}" end + end - def self.from_io(io, format) : V2 - ts = Int64.from_io(io, format) - nounce = uninitialized UInt8[16] - io.read_fully(nounce.to_slice) - family = UInt8.from_io(io, format) - ip = StaticArray(UInt8, 16).new(0_u8) - if family == 4_u8 - io.read_fully(ip.to_slice[12, 4]) - ip[10] = 0xff_u8 - ip[11] = 0xff_u8 - elsif family == 6_u8 - io.read_fully(ip.to_slice) - else - raise "Unknown IP family: #{family}" - end - self.new(ts, nounce, ip) + # Convert ::ffff:x.x.x.x (IPv4-mapped IPv6) to plain 4-byte IPv4 + private def self.strip_mapped_ipv4(ip : Bytes) : Bytes + if ip.size == 16 && + ip[0, 12] == Bytes[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff] + ip[12, 4].dup + else + ip.dup end end end From b15a4863d5015cffb0f985fecbc9eb9dda47cfb1 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Fri, 6 Mar 2026 13:17:00 +0100 Subject: [PATCH 23/24] Add comprehensive IPv6 global address check Filter out reserved/non-global ranges per IETF RFC 4291, RFC 6890 in the UDP connect probe. Based on Rust std::net::Ipv6Addr::is_global. Co-Authored-By: Claude Opus 4.6 --- src/ipv6.cr | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/src/ipv6.cr b/src/ipv6.cr index b6576eb..6ab1fc1 100644 --- a/src/ipv6.cr +++ b/src/ipv6.cr @@ -11,11 +11,90 @@ class IPv6 begin socket.connect(GOOGLE_DNS) addr = socket.local_address - return addr.address unless addr.loopback? || addr.link_local? || addr.unspecified? + return addr.address if global?(addr.address) rescue ensure socket.close end nil end + + # Check if an IPv6 address is globally reachable. + # Based on Rust std::net::Ipv6Addr::is_global (IETF RFC 4291, RFC 6890, etc.) + # ameba:disable Metrics/CyclomaticComplexity + def self.global?(ip : String) : Bool + s = Socket::IPAddress.parse_v6_fields?(ip) + return false unless s + return false if unspecified?(s) || loopback?(s) + return false if ipv4_mapped?(s) + return false if ipv4_ipv6_translation?(s) + return false if discard_only?(s) + return false if ietf_protocol_non_global?(s) + return false if sixto4?(s) + return false if documentation?(s) + return false if segment_routing?(s) + return false if unique_local?(s) + return false if link_local?(s) + true + end + + private def self.unspecified?(s) : Bool + s == StaticArray[0u16, 0, 0, 0, 0, 0, 0, 0] + end + + private def self.loopback?(s) : Bool + s == StaticArray[0u16, 0, 0, 0, 0, 0, 0, 1] + end + + # ::ffff:0:0/96 + private def self.ipv4_mapped?(s) : Bool + s[0] == 0 && s[1] == 0 && s[2] == 0 && s[3] == 0 && s[4] == 0 && s[5] == 0xffff + end + + # 64:ff9b:1::/48 + private def self.ipv4_ipv6_translation?(s) : Bool + s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1 + end + + # 100::/64 + private def self.discard_only?(s) : Bool + s[0] == 0x100 && s[1] == 0 && s[2] == 0 && s[3] == 0 + end + + # 2001::/23 minus globally reachable sub-ranges + # ameba:disable Metrics/CyclomaticComplexity + private def self.ietf_protocol_non_global?(s) : Bool + return false unless s[0] == 0x2001 && s[1] < 0x200 + # PCP/TURN Anycast (2001:1::1, 2001:1::2) + return false if s[1] == 1 && s[2] == 0 && s[3] == 0 && s[4] == 0 && s[5] == 0 && s[6] == 0 && (s[7] == 1 || s[7] == 2) + return false if s[1] == 3 # AMT (2001:3::/32) + return false if s[1] == 4 && s[2] == 0x112 # AS112-v6 (2001:4:112::/48) + return false if s[1] >= 0x20 && s[1] <= 0x3f # ORCHIDv2 / Drone DETs + true + end + + # 2002::/16 + private def self.sixto4?(s) : Bool + s[0] == 0x2002 + end + + # 2001:db8::/32, 3fff:0000::/20 + private def self.documentation?(s) : Bool + (s[0] == 0x2001 && s[1] == 0xdb8) || (s[0] == 0x3fff && s[1] <= 0x0fff) + end + + # 5f00::/16 + private def self.segment_routing?(s) : Bool + s[0] == 0x5f00 + end + + # fc00::/7 + private def self.unique_local?(s) : Bool + s[0] & 0xfe00 == 0xfc00 + end + + # fe80::/10 + private def self.link_local?(s) : Bool + s[0] & 0xffc0 == 0xfe80 + end end From 7c87b5f55dd260db8e4874eaaf05367c354aef20 Mon Sep 17 00:00:00 2001 From: Anton Dalgren Date: Tue, 10 Mar 2026 09:17:13 +0100 Subject: [PATCH 24/24] remove interval from nftables config example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0cbabfd..08ce532 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ table inet filter { set sparoid6 { type ipv6_addr - flags timeout, interval + flags timeout timeout 5s }