From dfe9ed69e18380f5e0cce4e1dba35d80f6698101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thore=20B=C3=B6decker?= Date: Mon, 14 Oct 2024 23:23:19 +0200 Subject: [PATCH] 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 TODO (to be resolved during the PR discussion): - discuss the preprocessor option for retrieving the constant value `IF_NAMESIZE` (No clue how do that, haven't found any event of this constant ever changing in the past, might not be worth the trouble?) - clarify whether scope_id parsing should raise `ArgumentError` or `Socket::Error` - clarify whether `Socket::Address` spec `#scope_id` `looks up interface name by index` should remain in the `Socket::Address` spec, since it only calls the `LibC` binding I'm happy to adjust/rebase if needed based on feedback or other merges into `master` in the mean time. --- spec/std/socket/address_spec.cr | 58 +++++++++++++++++++++ src/lib_c/aarch64-android/c/net/if.cr | 9 ++++ src/lib_c/aarch64-darwin/c/net/if.cr | 9 ++++ src/lib_c/aarch64-linux-gnu/c/net/if.cr | 9 ++++ src/lib_c/aarch64-linux-musl/c/net/if.cr | 9 ++++ src/lib_c/arm-linux-gnueabihf/c/net/if.cr | 9 ++++ src/lib_c/i386-linux-gnu/c/net/if.cr | 9 ++++ src/lib_c/i386-linux-musl/c/net/if.cr | 9 ++++ src/lib_c/x86_64-darwin/c/net/if.cr | 9 ++++ src/lib_c/x86_64-dragonfly/c/net/if.cr | 9 ++++ src/lib_c/x86_64-freebsd/c/net/if.cr | 9 ++++ src/lib_c/x86_64-linux-gnu/c/net/if.cr | 9 ++++ src/lib_c/x86_64-linux-musl/c/net/if.cr | 9 ++++ src/lib_c/x86_64-netbsd/c/net/if.cr | 9 ++++ src/lib_c/x86_64-openbsd/c/net/if.cr | 9 ++++ src/lib_c/x86_64-solaris/c/net/if.cr | 9 ++++ src/lib_c/x86_64-windows-msvc/c/foobar | 0 src/lib_c/x86_64-windows-msvc/c/netioapi.cr | 12 +++++ src/socket/address.cr | 42 ++++++++++++--- src/socket/common.cr | 3 ++ 20 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 src/lib_c/aarch64-android/c/net/if.cr create mode 100644 src/lib_c/aarch64-darwin/c/net/if.cr create mode 100644 src/lib_c/aarch64-linux-gnu/c/net/if.cr create mode 100644 src/lib_c/aarch64-linux-musl/c/net/if.cr create mode 100644 src/lib_c/arm-linux-gnueabihf/c/net/if.cr create mode 100644 src/lib_c/i386-linux-gnu/c/net/if.cr create mode 100644 src/lib_c/i386-linux-musl/c/net/if.cr create mode 100644 src/lib_c/x86_64-darwin/c/net/if.cr create mode 100644 src/lib_c/x86_64-dragonfly/c/net/if.cr create mode 100644 src/lib_c/x86_64-freebsd/c/net/if.cr create mode 100644 src/lib_c/x86_64-linux-gnu/c/net/if.cr create mode 100644 src/lib_c/x86_64-linux-musl/c/net/if.cr create mode 100644 src/lib_c/x86_64-netbsd/c/net/if.cr create mode 100644 src/lib_c/x86_64-openbsd/c/net/if.cr create mode 100644 src/lib_c/x86_64-solaris/c/net/if.cr create mode 100644 src/lib_c/x86_64-windows-msvc/c/foobar create mode 100644 src/lib_c/x86_64-windows-msvc/c/netioapi.cr diff --git a/spec/std/socket/address_spec.cr b/spec/std/socket/address_spec.cr index 08508940bc7d..b7b7b85ca2b6 100644 --- a/spec/std/socket/address_spec.cr +++ b/spec/std/socket/address_spec.cr @@ -126,6 +126,63 @@ describe Socket::IPAddress do Socket::IPAddress.new("::ffff:0:0", 443).address.should eq "::ffff:0.0.0.0" end + describe "#scope_id" do + 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.scope_id.should eq 3 + end + + it "ignores link-local scope identifier on non-LL addrs" do + address = Socket::IPAddress.new("fd00::abcd%5", 443) + address.address.should eq "fd00::abcd" + address.scope_id.should eq 0 + end + + it "looks up loopback interface index by name" 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 %} + address = Socket::IPAddress.new("fe80::1111%#{loopback_iface}", 0) + address.address.should eq "fe80::1111" + address.scope_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 + buf = uninitialized StaticArray(UInt8, LibC::IF_NAMESIZE) + LibC.if_indextoname(1, buf) + ifname = String.new(buf.to_unsafe) + {% if flag?(:windows) %} + ifname.should eq "loopback_0" + {% elsif flag?(:darwin) || flag?(:bsd) || flag?(:solaris) %} + ifname.should eq "lo0" + {% else %} + ifname.should eq "lo" + {% end %} + end + + it "fails on invalid link-local scope identifier" do + expect_raises(ArgumentError, "Invalid IPv6 link-local scope 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 scope interface" do + # looking up an interface index obviously requires for said interface device to exist + expect_raises(ArgumentError, "IPv6 link-local scope 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" @@ -263,6 +320,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, scope_id: 3).should eq Socket::IPAddress.new("fe80::5971:5971:5971:abcd%3", 44444) end it "raises on out of bound field" do diff --git a/src/lib_c/aarch64-android/c/net/if.cr b/src/lib_c/aarch64-android/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/aarch64-android/c/net/if.cr @@ -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 diff --git a/src/lib_c/aarch64-darwin/c/net/if.cr b/src/lib_c/aarch64-darwin/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/aarch64-darwin/c/net/if.cr @@ -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 diff --git a/src/lib_c/aarch64-linux-gnu/c/net/if.cr b/src/lib_c/aarch64-linux-gnu/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/aarch64-linux-gnu/c/net/if.cr @@ -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 diff --git a/src/lib_c/aarch64-linux-musl/c/net/if.cr b/src/lib_c/aarch64-linux-musl/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/aarch64-linux-musl/c/net/if.cr @@ -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 diff --git a/src/lib_c/arm-linux-gnueabihf/c/net/if.cr b/src/lib_c/arm-linux-gnueabihf/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/arm-linux-gnueabihf/c/net/if.cr @@ -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 diff --git a/src/lib_c/i386-linux-gnu/c/net/if.cr b/src/lib_c/i386-linux-gnu/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/i386-linux-gnu/c/net/if.cr @@ -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 diff --git a/src/lib_c/i386-linux-musl/c/net/if.cr b/src/lib_c/i386-linux-musl/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/i386-linux-musl/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-darwin/c/net/if.cr b/src/lib_c/x86_64-darwin/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/x86_64-darwin/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-dragonfly/c/net/if.cr b/src/lib_c/x86_64-dragonfly/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/x86_64-dragonfly/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-freebsd/c/net/if.cr b/src/lib_c/x86_64-freebsd/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/x86_64-freebsd/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-linux-gnu/c/net/if.cr b/src/lib_c/x86_64-linux-gnu/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/x86_64-linux-gnu/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-linux-musl/c/net/if.cr b/src/lib_c/x86_64-linux-musl/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/x86_64-linux-musl/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-netbsd/c/net/if.cr b/src/lib_c/x86_64-netbsd/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/x86_64-netbsd/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-openbsd/c/net/if.cr b/src/lib_c/x86_64-openbsd/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/x86_64-openbsd/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-solaris/c/net/if.cr b/src/lib_c/x86_64-solaris/c/net/if.cr new file mode 100644 index 000000000000..80ef01c1ce91 --- /dev/null +++ b/src/lib_c/x86_64-solaris/c/net/if.cr @@ -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 diff --git a/src/lib_c/x86_64-windows-msvc/c/foobar b/src/lib_c/x86_64-windows-msvc/c/foobar new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/lib_c/x86_64-windows-msvc/c/netioapi.cr b/src/lib_c/x86_64-windows-msvc/c/netioapi.cr new file mode 100644 index 000000000000..9247edf89d32 --- /dev/null +++ b/src/lib_c/x86_64-windows-msvc/c/netioapi.cr @@ -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 diff --git a/src/socket/address.cr b/src/socket/address.cr index bac36088152f..835d6fced730 100644 --- a/src/socket/address.cr +++ b/src/socket/address.cr @@ -90,6 +90,7 @@ class Socket BROADCAST6 = "ff0X::1" getter port : Int32 + getter scope_id : UInt32 @addr : LibC::In6Addr | LibC::InAddr @@ -109,10 +110,27 @@ class Socket 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, _, scope_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) + # `scope_id` is only relevant for link-local addresses, i.e. beginning with "fe80:". + scope_id = 0u32 + if v6_fields[0] == 0xfe80 && !scope_part.empty? + # Scope 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. + # TODO: clarify whether this should be an ArgumentError or a Socket::Error + if scope_part.to_u32? + scope_id_parsed = scope_part.to_u32 + raise ArgumentError.new("Invalid IPv6 link-local scope index '#{scope_part}' in address '#{address}'") unless scope_id_parsed.positive? + scope_id = scope_id_parsed + else + scope_id_parsed = LibC.if_nametoindex(scope_part).not_nil! + raise ArgumentError.new("IPv6 link-local scope interface '#{scope_part}' not found (in address '#{address}').") unless scope_id_parsed.positive? + scope_id = scope_id_parsed + end + end + addr = v6(v6_fields, port.to_u16!, scope_id) else raise Error.new("Invalid IP address: #{address}") end @@ -364,14 +382,15 @@ 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, scope_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: scope_id, ) new(pointerof(addr), sizeof(typeof(addr))) end @@ -379,10 +398,10 @@ class Socket # 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, scope_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, scope_id) end private def self.to_v6_field(field) @@ -435,12 +454,14 @@ class Socket protected def initialize(sockaddr : LibC::SockaddrIn6*, @size) @family = Family::INET6 @addr = sockaddr.value.sin6_addr + @scope_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 + @scope_id = 0u32 @port = IPAddress.endian_swap(sockaddr.value.sin_port).to_i end @@ -717,6 +738,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 = @scope_id + else + sockaddr.value.sin6_scope_id = 0 + end sockaddr.as(LibC::Sockaddr*) end diff --git a/src/socket/common.cr b/src/socket/common.cr index 19f1700a2cbd..7aa56a4cd8ac 100644 --- a/src/socket/common.cr +++ b/src/socket/common.cr @@ -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