Adds IPv6 support to Sparoid::Server#17
Conversation
3ab1f42 to
437064c
Compare
| end | ||
| def initialize(@version) | ||
| @ts = Time.utc.to_unix_ms | ||
| @nounce = uninitialized UInt8[16] |
There was a problem hiding this comment.
I know it's not introduced here but stack allocated variables (that are not copied) are dangerous to use as instances variables, pointer to unallocated stack memory will result in a seg fault
| @nounce = uninitialized UInt8[16] | |
| @nounce = Bytes.new 16 |
| String.build(39) do |str| | ||
| 8.times do |i| | ||
| str << ':' unless i == 0 | ||
| str << @ip[i * 2].to_s(16).rjust(2, '0') |
There was a problem hiding this comment.
This allocated two new strings and then concat it to the third.
| str << @ip[i * 2].to_s(16).rjust(2, '0') | |
| str << '0' if @ip[i * 2] < 0x10 | |
| @ip[i * 2].to_s(str, 16) |
| self.new(version, ts, nounce, ip) | ||
| struct V2 < Base | ||
| getter family : UInt8 | ||
| getter ip : StaticArray(UInt8, 16) | StaticArray(UInt8, 4) |
There was a problem hiding this comment.
Consider if it should be a Bytes instead
|
Are both the server and client backward compatible? |
The server is backwards compatible. I started to look at the client but couldn't figure out a good solution for that, so it's still in the works! Would it be reasonable to add a CLI arg to the client to enforce ipv4 (version 1)? Or maybe always resort to a v1 message fallback at the end of this list? See comment at the end of the method. # 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)
host_addresses.each do |addrinfo|
begin
socket.send data, to: addrinfo.ip_address
rescue ex
STDERR << "Sparoid error sending " << ex.inspect << "\n"
end
end
# Loop over all host_addresses using IPv4
# Create a V1 message and send to all those hosts
host_addresses.map &.ip_address.address
ensure
socket.close if socket
end |
There was a problem hiding this comment.
Pull request overview
Adds IPv6-awareness to Sparoid’s UDP message format and client/server plumbing so receivers can distinguish IPv4 vs IPv6 sources and apply different acceptance actions (e.g., nftables rules).
Changes:
- Introduces
Sparoid::Messageversioning with new V2 format (adds IP family + optional CIDR range; supports IPv6). - Updates server callback signature to include
Socket::Family, and extends CLI/config to optionally run a separatenftablesv6-cmd. - Updates client to generate/send multiple messages (V1 + V2) and adds IPv6 address discovery helpers + expanded specs/docs.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/server.cr | Updates server initialization/binding and passes IP family to accept callback. |
| src/server-cli.cr | Adds nftables IPv6 command path selection based on accepted packet family. |
| src/public_ip.cr | Changes public IP discovery to return IPv4/IPv6 strings and caches multiple results. |
| src/message.cr | Refactors message into versioned formats (V1 + new V2 with family/range + IPv6 string formatting). |
| src/ipv6.cr | Adds IPv6 interface inspection + CIDR inference via getifaddrs. |
| src/config.cr | Adds nftablesv6-cmd config parsing. |
| src/client.cr | Sends V1+V2 messages, prioritizes IPv6 resolution, and integrates IPv6 discovery. |
| spec/sparoid_spec.cr | Adjusts expectations for multiple packets per send and updated callback signature. |
| spec/message_spec.cr | Adds comprehensive tests for V1/V2 parsing/serialization and IPv6 formatting. |
| README.md | Updates example configuration and nftables rules for IPv6 support. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…low for ipv6 addresses.
bfa0286 to
564e140
Compare
oskgu360
left a comment
There was a problem hiding this comment.
Looks good, Server team tested it out on a CloudAMQP machine to verify its backwardscompatible to the old clients (ruby and crystal). And working with ipv6.
My mind got stuck on what a Message really is, so wanted to rename it to something else more decriptive, but messy to change and I have no good suggestions (PortKnockingProtocolPacket? 😀 ). So maybe better to just keep, once I got familiar with the code, it made sense.
I have hard time giving a proper review on the low level stuff, so if you want opinions there, someone else has to hehe. But it works, and this app isn't that performance sensitive, so good to go imo.
Good job! 🚀
|
About the client/server compatibility, Currently, both the client and server are backwards compatible with each other—a new client can speak to an old server, and vice versa. This increases complexity, since clients must handle both old and new handshake/nonce formats. It can become very complex and limiting if we always want to keep 2-way comparability. Do we actually need this? If we instead require that servers are always upgraded before clients (which is quite common), we could simplify things so only servers are backwards compatible:
Would this be a reasonable change to make, so clients only need to send the v2 format? |
Well, I had that solution in the first iteration of the PR. Can't really seem to figure out why I went with the client keep sending the v1 format if I'm being honest. Maybe the following comment tricked me :D
If there is a problem with the client sending the v2 format all it has to do is to downgrade the client version. Let's revert the client changes to only send v2 messages. |
Remove v1 message generation from the client. Servers must be upgraded before clients to maintain backwards compatibility.
|
I pushed a couple of suggestions. But I'm also thinking if we can simply quite a lot by always sending a IPv6 address ( |
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.
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.
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
|
Is the PR description still valid? |
updated! :) |
|
Will copilot check after merge? 🧪 |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| Array(String).new.tap do |ips| | ||
| while line = file.gets | ||
| ips << line | ||
| end | ||
| end |
There was a problem hiding this comment.
read_cache appends lines from the cache file without stripping newlines/whitespace. That can produce values like "1.2.3.4\n", which will fail strict IP parsing later (e.g., Message.from_ip). Strip/chomp each line (and consider skipping blank lines) before pushing to ips.
| 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 |
There was a problem hiding this comment.
fdpass no longer rescues connection errors or closes sockets on failure. Unhandled exceptions in the spawned fibers can spam logs and a failed connect can leak the socket until GC. Wrap the connect/send in begin/rescue/ensure (closing the socket in ensure) and treat failures as a normal outcome before wg.wait.
| 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) |
There was a problem hiding this comment.
IPv6.global? currently returns true for multicast IPv6 ranges (ff00::/8), e.g. ff02::1, because there is no multicast check. Add an explicit multicast exclusion (and any other non-global ranges you intend to match Rust’s Ipv6Addr::is_global) so the predicate matches its documentation.
| # Send to all resolved IPs for the hostname, prioritizing IPv6 | ||
| 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| |
There was a problem hiding this comment.
The comment says udp_send is "prioritizing IPv6", but Socket::Addrinfo.udp(host, port) relies on resolver/system ordering and doesn’t guarantee IPv6-first. Either enforce the intended ordering (e.g., query INET6 then INET) or adjust the comment to avoid promising behavior the code doesn’t ensure.
| # 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 |
There was a problem hiding this comment.
New IPv6 classification logic (IPv6.global? and helpers) is non-trivial but has no spec coverage. Add unit specs for representative addresses (multicast, unique-local, link-local, documentation, global unicast) to guard against regressions and ensure the predicate matches the intended RFC/Rust behavior.
| 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.chomp | ||
| rescue | ||
| nil | ||
| end | ||
| raise "No valid response from icanhazip.com" if ips.empty? |
There was a problem hiding this comment.
PublicIP.by_http fetches the client’s public IP over plain HTTP from icanhazip.com using HTTP::Client.get, and this value is then used by Sparoid::Client to decide which remote address is authorized. An attacker who can MITM or hijack DNS for this HTTP request can forge a different IP address and cause the server to open access for an unauthorized host. Use HTTPS (with certificate validation) or another integrity-protected mechanism for obtaining the public IP before using it in security-sensitive firewall rules.
|
@copilot open a new pull request to apply changes based on the comments in this thread |
| end | ||
| end | ||
| ips.size.times { ch.receive } | ||
| wg.wait |
There was a problem hiding this comment.
since we never call #done, won't it get stuck here, or how does it work?
There was a problem hiding this comment.
def spawn(*, name : String? = nil, &block) : Fiber
Increment the counter by 1, perform the work inside the block in a separate fiber, decrementing the counter after it completes or raises. Returns the Fiber that was spawned.wg = WaitGroup.new wg.spawn { do_something } wg.wait
It will wait until all fibers spawned by the waitgroup have finished.
There was a problem hiding this comment.
Ah thanks, I was reading https://crystal-lang.org/api/1.19.1/WaitGroup.html#overview, but I see now it has a number in the constructor, is that what makes it different?
There was a problem hiding this comment.
When looking at the source code for WaitGroup that looks to be the case. The n is setting a start value for the counter, and spawn is wrapped around a add and done call. So, effectively, the n counter seems redundant 🤔
There was a problem hiding this comment.
I think that may have been the case before the #spawn method was introduced!
WHY are these changes introduced?
We want to support ipv6
WHAT is this pull request doing?
Adds support for ipv6 by allowing the ip part of the message to be 16 bytes.
Config changes
nftablesv6-cmda nftables command to run when a ipv6 packet is accepted.HOW was this pull request tested?
Specs? Manual steps? Please fill me in.