Skip to content

Commit e9e524e

Browse files
committed
implement IPv6 scoped addresses (RFC4007)
In order to translate interface names in such scoped addresses the required `LibC` binding to `if_nametoindex()` has been added. This method obviously only works for interfaces (devices) that are actually present on the system. The binding for the reverse operation `if_indextoname()` has also been added, although its usage is a bit more cumbersome due to LibC::Char* buffer handling. The necessary buffer length has been placed into the constant `LibC::IF_NAMESIZE`, which appears to be `16u8` on unix-like systems and `257u16` on windows. This could potentially be reworked via a preprocessor block at compile-time as indicated by some folks over on discord, I currently do not know how to achieve that though. Scoped identifiers are only valid for link-local (`fe80::`) addresses, e.g. `fe80::1%eth0` References: - https://datatracker.ietf.org/doc/html/rfc4007 Fixes #15264
1 parent 27cefce commit e9e524e

File tree

20 files changed

+265
-8
lines changed

20 files changed

+265
-8
lines changed

spec/std/socket/address_spec.cr

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,64 @@ describe Socket::IPAddress do
126126
Socket::IPAddress.new("::ffff:0:0", 443).address.should eq "::ffff:0.0.0.0"
127127
end
128128

129+
describe "#zone_id" do
130+
# loopback interface "lo" is supposed to *always* be the first interface and
131+
# enumerated with index 1
132+
loopback_iface = {% if flag?(:windows) %}
133+
"loopback_0"
134+
{% elsif flag?(:darwin) || flag?(:bsd) || flag?(:solaris) %}
135+
"lo0"
136+
{% else %}
137+
"lo"
138+
{% end %}
139+
140+
it "parses link-local IPv6 with interface scope" do
141+
address = Socket::IPAddress.new("fe80::3333:4444%3", 8081)
142+
address.address.should eq "fe80::3333:4444"
143+
address.zone_id.should eq 3
144+
end
145+
146+
it "ignores link-local zone identifier on non-LL addrs" do
147+
address = Socket::IPAddress.new("fd00::abcd%5", 443)
148+
address.address.should eq "fd00::abcd"
149+
address.zone_id.should eq 0
150+
end
151+
152+
it "looks up loopback interface index by name" do
153+
address = Socket::IPAddress.new("fe80::1111%#{loopback_iface}", 0)
154+
address.address.should eq "fe80::1111"
155+
address.zone_id.should eq 1
156+
end
157+
158+
it "looks up loopback interface name by index" do
159+
# loopback interface "lo" is supposed to *always* be the first interface and
160+
# enumerated with index 1
161+
address = Socket::IPAddress.new("fe80::1111%#{loopback_iface}", 0)
162+
ifname = address.link_local_interface
163+
ifname.should eq loopback_iface
164+
end
165+
166+
it "interface name lookup returns nil in unsupported cases" do
167+
Socket::IPAddress.new("fd03::3333%#{loopback_iface}", 0).link_local_interface.should eq nil
168+
Socket::IPAddress.new("192.168.10.10%#{loopback_iface}", 0).link_local_interface.should eq nil
169+
Socket::IPAddress.new("169.254.0.3%#{loopback_iface}", 0).link_local_interface.should eq nil
170+
Socket::IPAddress.new("fe80::4545", 0).link_local_interface.should eq nil
171+
end
172+
173+
it "fails on invalid link-local zone identifier" do
174+
expect_raises(ArgumentError, "Invalid IPv6 link-local zone index '0' in address 'fe80::c0ff:ee%0'") do
175+
Socket::IPAddress.new("fe80::c0ff:ee%0", port: 0)
176+
end
177+
end
178+
179+
it "fails on non-existent link-local zone interface" do
180+
# looking up an interface index obviously requires for said interface device to exist
181+
expect_raises(ArgumentError, "IPv6 link-local zone interface 'zzzzzzzzzzzzzzz' not found (in address 'fe80::0f0f:abcd%zzzzzzzzzzzzzzz'") do
182+
Socket::IPAddress.new("fe80::0f0f:abcd%zzzzzzzzzzzzzzz", port: 0)
183+
end
184+
end
185+
end
186+
129187
describe ".parse" do
130188
it "parses IPv4" do
131189
address = Socket::IPAddress.parse "ip://192.168.0.1:8081"
@@ -263,6 +321,7 @@ describe Socket::IPAddress do
263321
Socket::IPAddress.v6(0xfe80, 0, 0, 0, 0x4860, 0x4860, 0x4860, 0x1234, port: 55001).should eq Socket::IPAddress.new("fe80::4860:4860:4860:1234", 55001)
264322
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)
265323
Socket::IPAddress.v6(0, 0, 0, 0, 0, 0xffff, 0xc0a8, 0x0001, port: 0).should eq Socket::IPAddress.new("::ffff:192.168.0.1", 0)
324+
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)
266325
end
267326

268327
it "raises on out of bound field" do

src/lib_c/aarch64-android/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/aarch64-darwin/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/i386-linux-gnu/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/i386-linux-musl/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/x86_64-darwin/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/x86_64-freebsd/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/x86_64-netbsd/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/x86_64-openbsd/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/x86_64-solaris/c/net/if.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
require "../netinet/in"
2+
require "../stdint"
3+
4+
lib LibC
5+
IF_NAMESIZE = 16u8
6+
7+
fun if_nametoindex(ifname : Char*) : UInt
8+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
9+
end

src/lib_c/x86_64-windows-msvc/c/foobar

Whitespace-only changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
require "./in6addr"
2+
require "./inaddr"
3+
require "./stdint"
4+
5+
@[Link("iphlpapi")]
6+
lib LibC
7+
NDIS_IF_MAX_STRING_SIZE = 256u16
8+
IF_NAMESIZE = LibC::NDIS_IF_MAX_STRING_SIZE + 1 # need one more byte for terminating '\0'
9+
10+
fun if_nametoindex(ifname : Char*) : UInt
11+
fun if_indextoname(ifindex : UInt, ifname : LibC::Char*) : LibC::Char*
12+
end

src/socket/address.cr

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ class Socket
9090
BROADCAST6 = "ff0X::1"
9191

9292
getter port : Int32
93+
getter zone_id : UInt32
9394

9495
@addr : LibC::In6Addr | LibC::InAddr
9596

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

112-
if v4_fields = parse_v4_fields?(address)
117+
addr_part, _, zone_part = address.partition('%')
118+
if v4_fields = parse_v4_fields?(addr_part)
113119
addr = v4(v4_fields, port.to_u16!)
114-
elsif v6_fields = parse_v6_fields?(address)
115-
addr = v6(v6_fields, port.to_u16!)
120+
elsif v6_fields = parse_v6_fields?(addr_part)
121+
# `zone_id` is only relevant for link-local addresses, i.e. beginning with "fe80:".
122+
zone_id = 0u32
123+
if v6_fields[0] == 0xfe80 && !zone_part.empty?
124+
# Scope/Zone can be given either as a network interface name or directly as the interface index.
125+
# When given a name we need to find the corresponding interface index.
126+
if zone_part.to_u32?
127+
zone_id_parsed = zone_part.to_u32
128+
raise ArgumentError.new("Invalid IPv6 link-local zone index '#{zone_part}' in address '#{address}'") unless zone_id_parsed.positive?
129+
zone_id = zone_id_parsed
130+
else
131+
zone_id_parsed = LibC.if_nametoindex(zone_part).not_nil!
132+
raise ArgumentError.new("IPv6 link-local zone interface '#{zone_part}' not found (in address '#{address}').") unless zone_id_parsed.positive?
133+
zone_id = zone_id_parsed
134+
end
135+
end
136+
addr = v6(v6_fields, port.to_u16!, zone_id)
116137
else
117138
raise Error.new("Invalid IP address: #{address}")
118139
end
@@ -364,25 +385,26 @@ class Socket
364385
0 <= field <= 0xff ? field.to_u8! : raise Error.new("Invalid IPv4 field: #{field}")
365386
end
366387

367-
# Returns the IPv6 address with the given address *fields* and *port*
368-
# number.
369-
def self.v6(fields : UInt16[8], port : UInt16) : self
388+
# Returns the IPv6 address with the given address *fields*, *port* number
389+
# and scope identifier.
390+
def self.v6(fields : UInt16[8], port : UInt16, zone_id : UInt32 = 0u32) : self
370391
fields.map! { |field| endian_swap(field) }
371392
addr = LibC::SockaddrIn6.new(
372393
sin6_family: LibC::AF_INET6,
373394
sin6_port: endian_swap(port),
374395
sin6_addr: ipv6_from_addr16(fields),
396+
sin6_scope_id: zone_id,
375397
)
376398
new(pointerof(addr), sizeof(typeof(addr)))
377399
end
378400

379401
# Returns the IPv6 address `[x0:x1:x2:x3:x4:x5:x6:x7]:port`.
380402
#
381403
# Raises `Socket::Error` if any field or the port number is out of range.
382-
def self.v6(x0 : Int, x1 : Int, x2 : Int, x3 : Int, x4 : Int, x5 : Int, x6 : Int, x7 : Int, *, port : Int) : self
404+
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
383405
fields = StaticArray[x0, x1, x2, x3, x4, x5, x6, x7].map { |field| to_v6_field(field) }
384406
port = valid_port?(port) ? port.to_u16! : raise Error.new("Invalid port number: #{port}")
385-
v6(fields, port)
407+
v6(fields, port, zone_id)
386408
end
387409

388410
private def self.to_v6_field(field)
@@ -435,12 +457,14 @@ class Socket
435457
protected def initialize(sockaddr : LibC::SockaddrIn6*, @size)
436458
@family = Family::INET6
437459
@addr = sockaddr.value.sin6_addr
460+
@zone_id = sockaddr.value.sin6_scope_id
438461
@port = IPAddress.endian_swap(sockaddr.value.sin6_port).to_i
439462
end
440463

441464
protected def initialize(sockaddr : LibC::SockaddrIn*, @size)
442465
@family = Family::INET
443466
@addr = sockaddr.value.sin_addr
467+
@zone_id = 0u32
444468
@port = IPAddress.endian_swap(sockaddr.value.sin_port).to_i
445469
end
446470

@@ -717,6 +741,11 @@ class Socket
717741
sockaddr.value.sin6_family = family
718742
sockaddr.value.sin6_port = IPAddress.endian_swap(port.to_u16!)
719743
sockaddr.value.sin6_addr = addr
744+
if @family == Family::INET6 && link_local?
745+
sockaddr.value.sin6_scope_id = @zone_id
746+
else
747+
sockaddr.value.sin6_scope_id = 0
748+
end
720749
sockaddr.as(LibC::Sockaddr*)
721750
end
722751

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

760+
# Returns the interface name for a scoped/zoned link-local IPv6 address.
761+
# This only works on properly initialized link-local IPv6 address objects.
762+
# In any other case this will return nil.
763+
#
764+
# The LibC structs track the zone via a numerical interface index as
765+
# enumerated by the kernel. To keep our abstraction class in line, we
766+
# also only keep the interface index around.
767+
#
768+
# This helper method exists to look up the interface name based on the
769+
# associated zone_id property.
770+
def link_local_interface : String|Nil
771+
return nil if @zone_id.zero?
772+
return nil if @family == Socket::Family::INET
773+
return nil unless (@family == Socket::Family::INET6 && link_local?)
774+
buf = uninitialized StaticArray(UInt8, LibC::IF_NAMESIZE)
775+
LibC.if_indextoname(@zone_id, buf)
776+
String.new(buf.to_unsafe)
777+
end
778+
731779
protected def self.endian_swap(x : Int::Primitive) : Int::Primitive
732780
{% if IO::ByteFormat::NetworkEndian != IO::ByteFormat::SystemEndian %}
733781
x.byte_swap

src/socket/common.cr

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
{% if flag?(:win32) %}
22
require "c/ws2tcpip"
33
require "c/afunix"
4+
require "c/netioapi"
45
{% elsif flag?(:wasi) %}
56
require "c/arpa/inet"
67
require "c/netinet/in"
8+
require "c/net/if"
79
{% else %}
810
require "c/arpa/inet"
911
require "c/sys/un"
1012
require "c/netinet/in"
13+
require "c/net/if"
1114
{% end %}
1215

1316
class Socket < IO

0 commit comments

Comments
 (0)