Skip to content

Adds IPv6 support to Sparoid::Server#17

Open
antondalgren wants to merge 18 commits intomainfrom
server-ipv6
Open

Adds IPv6 support to Sparoid::Server#17
antondalgren wants to merge 18 commits intomainfrom
server-ipv6

Conversation

@antondalgren
Copy link
Contributor

@antondalgren antondalgren commented Jan 27, 2026

WHY are these changes introduced?

We want to support ipv6

WHAT is this pull request doing?

Adds a new message format that adds support for ipv6.

The message format is using the same initial format as v1 but adds a ip family byte before the IP and support for a range.
version + timestamp + nonce + family (ipv4 or ipv6) (`4`/`6`) + ip + range

I'm open for suggestions on this message format.

Config changes

  • nftablesv6-cmd a nftables command to run when a ipv6 packet is accepted.

HOW was this pull request tested?

Specs? Manual steps? Please fill me in.

end
def initialize(@version)
@ts = Time.utc.to_unix_ms
@nounce = uninitialized UInt8[16]
Copy link
Contributor

Choose a reason for hiding this comment

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

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

Suggested change
@nounce = uninitialized UInt8[16]
@nounce = Bytes.new 16

src/message.cr Outdated
String.build(39) do |str|
8.times do |i|
str << ':' unless i == 0
str << @ip[i * 2].to_s(16).rjust(2, '0')
Copy link
Contributor

Choose a reason for hiding this comment

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

This allocated two new strings and then concat it to the third.

Suggested change
str << @ip[i * 2].to_s(16).rjust(2, '0')
str << '0' if @ip[i * 2] < 0x10
@ip[i * 2].to_s(str, 16)

src/message.cr Outdated
self.new(version, ts, nounce, ip)
struct V2 < Base
getter family : UInt8
getter ip : StaticArray(UInt8, 16) | StaticArray(UInt8, 4)
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider if it should be a Bytes instead

@carlhoerberg
Copy link
Contributor

Are both the server and client backward compatible?

@antondalgren
Copy link
Contributor Author

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

@antondalgren antondalgren marked this pull request as ready for review February 3, 2026 13:22
@antondalgren antondalgren requested a review from a team as a code owner February 3, 2026 13:22
@oskgu360 oskgu360 requested a review from Copilot February 10, 2026 12:03
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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::Message versioning 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 separate nftablesv6-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.

Copy link
Contributor

@oskgu360 oskgu360 left a comment

Choose a reason for hiding this comment

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

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! 🚀

Comment on lines 39 to +40
nftables-cmd = add element inet filter sparoid { %s }
nftablesv6-cmd = add element inet filter sparoid6 { %s }
Copy link
Contributor

@oskgu360 oskgu360 Feb 10, 2026

Choose a reason for hiding this comment

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

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

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

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

@baelter
Copy link
Member

baelter commented Feb 19, 2026

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:

  • The server accepts both old and new client formats.
  • The client only needs to support the new (v2) format.
  • No need for clients to send multiple nonce formats or support handshake fallbacks.
  • We would just need to document that upgrading the server before the clients is required.

Would this be a reasonable change to make, so clients only need to send the v2 format?

@antondalgren
Copy link
Contributor Author

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:

  • The server accepts both old and new client formats.
  • The client only needs to support the new (v2) format.
  • No need for clients to send multiple nonce formats or support handshake fallbacks.
  • We would just need to document that upgrading the server before the clients is required.

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

Are both the server and client backward compatible?

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.
src/client.cr Outdated
end

private def self.local_ips(host : Socket::IPAddress) : Array(Bytes)
ipv4 = Slice[127u8, 0u8, 0u8, 1u8]
Copy link
Member

Choose a reason for hiding this comment

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

Should these be constants?

Copy link
Member

@baelter baelter left a comment

Choose a reason for hiding this comment

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

Nice work!

Replace slice_to_bytes with encode_ip that pattern-matches on
StaticArray types, and simplify generate_messages control flow.
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.
@carlhoerberg
Copy link
Contributor

I pushed a couple of suggestions.

But I'm also thinking if we can simply quite a lot by always sending a IPv6 address (StaticArray(UInt8, 16), or UInt128) in the V2 message. And then on the server side see if it's a ipv6 mapped ipv4 address (starting with ::ffff) or a real ipv6 address.

carlhoerberg and others added 4 commits February 23, 2026 11:04
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants