Skip to content

Add support for IPv6 scoped addresses (RFC4007) #15263

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5b253ee
implement IPv6 scoped addresses (RFC4007)
foxxx0 Oct 14, 2024
78a1101
remove totally unnecessary artifact
foxxx0 Apr 14, 2025
06d9a06
adjust RFC4007 implementation based on review feedback
foxxx0 Apr 14, 2025
280e074
remove trailing dot from exception messages
foxxx0 Apr 15, 2025
cd27591
add specs for invalid v4 addrs with zone identifier
foxxx0 Apr 15, 2025
e39b72e
clarify RFC4007 comment and fix #inspect example
foxxx0 Apr 15, 2025
8f84cd3
wrap references to methods in backticks
foxxx0 Apr 15, 2025
867b13e
optimize rfc4007 parsing approach, unify exceptions
foxxx0 Apr 15, 2025
fe90d21
rework rfc4007 parsing to subslice
foxxx0 Apr 15, 2025
67a91a7
revert public parse_v6_fields? return type
foxxx0 Apr 16, 2025
85a5d12
streamline rfc4007 zone subslice building
foxxx0 Apr 16, 2025
ff85550
use hexadecimal representation for expected values
foxxx0 May 16, 2025
070257e
remove unneeded type declaration for IF_NAMESIZE constant
foxxx0 May 16, 2025
83244d8
remove erroneous WASI c/net/if dependency
foxxx0 May 16, 2025
6ab3d90
polish `Socket::Address.#link_local_interface` comment
foxxx0 May 16, 2025
addd235
handle `LibC.if_indextoname` errno
foxxx0 May 16, 2025
a396d5a
raise NotImplementedError on wasi for `#link_local_interface`
foxxx0 May 18, 2025
12a944d
fix errno handling for `Socket::Address.#link_local_interface`
foxxx0 May 18, 2025
28af243
use `LibC.has_method?` to guard against missing implementations
foxxx0 May 19, 2025
a16ed4b
use normal Int32 for `zone_id`
foxxx0 May 19, 2025
72c8b56
restrict zone_id parameter to Int32
foxxx0 May 19, 2025
2227909
handle windows errno appropriately
foxxx0 May 19, 2025
1f45a62
rework `if_indextoname` error handling
foxxx0 May 19, 2025
6d8f516
only look at errno for supported platforms
foxxx0 May 19, 2025
5e379df
adjust specs to match previous commit
foxxx0 May 19, 2025
9109226
only skip errno inspection on `flag?(:win32)`
foxxx0 May 19, 2025
21b5b49
fix formatting
foxxx0 May 19, 2025
22675f9
raise exception from errno on non-windows platforms
foxxx0 May 19, 2025
a6f880f
expect proper darwin/bsd errno message for ENXIO
foxxx0 May 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions spec/std/socket/address_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,64 @@ describe Socket::IPAddress do
Socket::IPAddress.new("::ffff:0:0", 443).address.should eq "::ffff:0.0.0.0"
end

describe "#zone_id" do
# loopback interface "lo" is supposed to *always* be the first interface and
# enumerated with index 1
loopback_iface = {% if flag?(:windows) %}
"loopback_0"
{% elsif flag?(:darwin) || flag?(:bsd) || flag?(:solaris) %}
"lo0"
{% else %}
"lo"
{% end %}

it "parses link-local IPv6 with interface scope" do
address = Socket::IPAddress.new("fe80::3333:4444%3", 8081)
address.address.should eq "fe80::3333:4444"
address.zone_id.should eq 3
end

it "ignores link-local zone identifier on non-LL addrs" do
address = Socket::IPAddress.new("fd00::abcd%5", 443)
address.address.should eq "fd00::abcd"
address.zone_id.should eq 0
end

it "looks up loopback interface index by name" do
address = Socket::IPAddress.new("fe80::1111%#{loopback_iface}", 0)
address.address.should eq "fe80::1111"
address.zone_id.should eq 1
end

it "looks up loopback interface name by index" do
# loopback interface "lo" is supposed to *always* be the first interface and
# enumerated with index 1
address = Socket::IPAddress.new("fe80::1111%#{loopback_iface}", 0)
ifname = address.link_local_interface
ifname.should eq loopback_iface
end

it "interface name lookup returns nil in unsupported cases" do
Socket::IPAddress.new("fd03::3333%#{loopback_iface}", 0).link_local_interface.should eq nil
Socket::IPAddress.new("192.168.10.10%#{loopback_iface}", 0).link_local_interface.should eq nil
Socket::IPAddress.new("169.254.0.3%#{loopback_iface}", 0).link_local_interface.should eq nil
Socket::IPAddress.new("fe80::4545", 0).link_local_interface.should eq nil
end

it "fails on invalid link-local zone identifier" do
expect_raises(ArgumentError, "Invalid IPv6 link-local zone index '0' in address 'fe80::c0ff:ee%0'") do
Socket::IPAddress.new("fe80::c0ff:ee%0", port: 0)
end
end

it "fails on non-existent link-local zone interface" do
# looking up an interface index obviously requires for said interface device to exist
expect_raises(ArgumentError, "IPv6 link-local zone interface 'zzzzzzzzzzzzzzz' not found (in address 'fe80::0f0f:abcd%zzzzzzzzzzzzzzz'") do
Socket::IPAddress.new("fe80::0f0f:abcd%zzzzzzzzzzzzzzz", port: 0)
end
end
end

describe ".parse" do
it "parses IPv4" do
address = Socket::IPAddress.parse "ip://192.168.0.1:8081"
Expand Down Expand Up @@ -263,6 +321,7 @@ describe Socket::IPAddress do
Socket::IPAddress.v6(0xfe80, 0, 0, 0, 0x4860, 0x4860, 0x4860, 0x1234, port: 55001).should eq Socket::IPAddress.new("fe80::4860:4860:4860:1234", 55001)
Socket::IPAddress.v6(0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xfffe, port: 65535).should eq Socket::IPAddress.new("ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe", 65535)
Socket::IPAddress.v6(0, 0, 0, 0, 0, 0xffff, 0xc0a8, 0x0001, port: 0).should eq Socket::IPAddress.new("::ffff:192.168.0.1", 0)
Socket::IPAddress.v6(0xfe80, 0, 0, 0, 0x5971, 0x5971, 0x5971, 0xabcd, port: 44444, zone_id: 3).should eq Socket::IPAddress.new("fe80::5971:5971:5971:abcd%3", 44444)
end

it "raises on out of bound field" do
Expand Down
9 changes: 9 additions & 0 deletions src/lib_c/aarch64-android/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/aarch64-darwin/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/aarch64-linux-gnu/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/aarch64-linux-musl/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/arm-linux-gnueabihf/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/i386-linux-gnu/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/i386-linux-musl/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/x86_64-darwin/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/x86_64-dragonfly/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/x86_64-freebsd/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/x86_64-linux-gnu/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/x86_64-linux-musl/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/x86_64-netbsd/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/x86_64-openbsd/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
9 changes: 9 additions & 0 deletions src/lib_c/x86_64-solaris/c/net/if.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
require "../netinet/in"
require "../stdint"

lib LibC
IF_NAMESIZE = 16u8

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
Empty file.
12 changes: 12 additions & 0 deletions src/lib_c/x86_64-windows-msvc/c/netioapi.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
require "./in6addr"
require "./inaddr"
require "./stdint"

@[Link("iphlpapi")]
lib LibC
NDIS_IF_MAX_STRING_SIZE = 256u16
IF_NAMESIZE = LibC::NDIS_IF_MAX_STRING_SIZE + 1 # need one more byte for terminating '\0'

fun if_nametoindex(ifname : Char*) : UInt
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
end
64 changes: 56 additions & 8 deletions src/socket/address.cr
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class Socket
BROADCAST6 = "ff0X::1"

getter port : Int32
getter zone_id : UInt32

@addr : LibC::In6Addr | LibC::InAddr

Expand All @@ -100,19 +101,39 @@ class Socket
# Raises `Socket::Error` if *address* does not contain a valid IP address or
# the port number is out of range.
#
# Scoped/Zoned IPv6 link-local addresses are supported per RFC4007, e.g.
# `fe80::abcd%eth0`.
#
# ```
# require "socket"
#
# Socket::IPAddress.new("127.0.0.1", 8080) # => Socket::IPAddress(127.0.0.1:8080)
# Socket::IPAddress.new("fe80::2ab2:bdff:fe59:8e2c", 1234) # => Socket::IPAddress([fe80::2ab2:bdff:fe59:8e2c]:1234)
# Socket::IPAddress.new("fe80::4567:8:9%eth0", 443) # => Socket::IPAddress([fe80::4567:8:9]:443)
# ```
def self.new(address : String, port : Int32)
raise Error.new("Invalid port number: #{port}") unless IPAddress.valid_port?(port)

if v4_fields = parse_v4_fields?(address)
addr_part, _, zone_part = address.partition('%')
if v4_fields = parse_v4_fields?(addr_part)
addr = v4(v4_fields, port.to_u16!)
elsif v6_fields = parse_v6_fields?(address)
addr = v6(v6_fields, port.to_u16!)
elsif v6_fields = parse_v6_fields?(addr_part)
# `zone_id` is only relevant for link-local addresses, i.e. beginning with "fe80:".
zone_id = 0u32
if v6_fields[0] == 0xfe80 && !zone_part.empty?
# Scope/Zone can be given either as a network interface name or directly as the interface index.
# When given a name we need to find the corresponding interface index.
if zone_part.to_u32?
zone_id_parsed = zone_part.to_u32
raise ArgumentError.new("Invalid IPv6 link-local zone index '#{zone_part}' in address '#{address}'") unless zone_id_parsed.positive?
zone_id = zone_id_parsed
else
zone_id_parsed = LibC.if_nametoindex(zone_part).not_nil!
raise ArgumentError.new("IPv6 link-local zone interface '#{zone_part}' not found (in address '#{address}').") unless zone_id_parsed.positive?
zone_id = zone_id_parsed
end
end
addr = v6(v6_fields, port.to_u16!, zone_id)
else
raise Error.new("Invalid IP address: #{address}")
end
Expand Down Expand Up @@ -364,25 +385,26 @@ class Socket
0 <= field <= 0xff ? field.to_u8! : raise Error.new("Invalid IPv4 field: #{field}")
end

# Returns the IPv6 address with the given address *fields* and *port*
# number.
def self.v6(fields : UInt16[8], port : UInt16) : self
# Returns the IPv6 address with the given address *fields*, *port* number
# and scope identifier.
def self.v6(fields : UInt16[8], port : UInt16, zone_id : UInt32 = 0u32) : self
fields.map! { |field| endian_swap(field) }
addr = LibC::SockaddrIn6.new(
sin6_family: LibC::AF_INET6,
sin6_port: endian_swap(port),
sin6_addr: ipv6_from_addr16(fields),
sin6_scope_id: zone_id,
)
new(pointerof(addr), sizeof(typeof(addr)))
end

# Returns the IPv6 address `[x0:x1:x2:x3:x4:x5:x6:x7]:port`.
#
# Raises `Socket::Error` if any field or the port number is out of range.
def self.v6(x0 : Int, x1 : Int, x2 : Int, x3 : Int, x4 : Int, x5 : Int, x6 : Int, x7 : Int, *, port : Int) : self
def self.v6(x0 : Int, x1 : Int, x2 : Int, x3 : Int, x4 : Int, x5 : Int, x6 : Int, x7 : Int, *, port : Int, zone_id : UInt32 = 0u32) : self
fields = StaticArray[x0, x1, x2, x3, x4, x5, x6, x7].map { |field| to_v6_field(field) }
port = valid_port?(port) ? port.to_u16! : raise Error.new("Invalid port number: #{port}")
v6(fields, port)
v6(fields, port, zone_id)
end

private def self.to_v6_field(field)
Expand Down Expand Up @@ -435,12 +457,14 @@ class Socket
protected def initialize(sockaddr : LibC::SockaddrIn6*, @size)
@family = Family::INET6
@addr = sockaddr.value.sin6_addr
@zone_id = sockaddr.value.sin6_scope_id
@port = IPAddress.endian_swap(sockaddr.value.sin6_port).to_i
end

protected def initialize(sockaddr : LibC::SockaddrIn*, @size)
@family = Family::INET
@addr = sockaddr.value.sin_addr
@zone_id = 0u32
@port = IPAddress.endian_swap(sockaddr.value.sin_port).to_i
end

Expand Down Expand Up @@ -717,6 +741,11 @@ class Socket
sockaddr.value.sin6_family = family
sockaddr.value.sin6_port = IPAddress.endian_swap(port.to_u16!)
sockaddr.value.sin6_addr = addr
if @family == Family::INET6 && link_local?
sockaddr.value.sin6_scope_id = @zone_id
else
sockaddr.value.sin6_scope_id = 0
end
sockaddr.as(LibC::Sockaddr*)
end

Expand All @@ -728,6 +757,25 @@ class Socket
sockaddr.as(LibC::Sockaddr*)
end

# Returns the interface name for a scoped/zoned link-local IPv6 address.
# This only works on properly initialized link-local IPv6 address objects.
# In any other case this will return nil.
#
# The LibC structs track the zone via a numerical interface index as
# enumerated by the kernel. To keep our abstraction class in line, we
# also only keep the interface index around.
#
# This helper method exists to look up the interface name based on the
# associated zone_id property.
def link_local_interface : String | Nil
return nil if @zone_id.zero?
return nil if @family == Socket::Family::INET
return nil unless (@family == Socket::Family::INET6 && link_local?)
buf = uninitialized StaticArray(UInt8, LibC::IF_NAMESIZE)
LibC.if_indextoname(@zone_id, buf)
String.new(buf.to_unsafe)
end

protected def self.endian_swap(x : Int::Primitive) : Int::Primitive
{% if IO::ByteFormat::NetworkEndian != IO::ByteFormat::SystemEndian %}
x.byte_swap
Expand Down
3 changes: 3 additions & 0 deletions src/socket/common.cr
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
{% if flag?(:win32) %}
require "c/ws2tcpip"
require "c/afunix"
require "c/netioapi"
{% elsif flag?(:wasi) %}
require "c/arpa/inet"
require "c/netinet/in"
require "c/net/if"
{% else %}
require "c/arpa/inet"
require "c/sys/un"
require "c/netinet/in"
require "c/net/if"
{% end %}

class Socket < IO
Expand Down
Loading