From 2e9a3e6b1fb37c94cd753e6a636c5c61aef18170 Mon Sep 17 00:00:00 2001 From: James Tucker Date: Thu, 2 Nov 2023 18:39:55 -0700 Subject: [PATCH] net/icmplistener: add a way to create ICMP DGRAM sockets This can later be used in netns as the default underlying listener type and the net/ping package updated to not short-circuit when not running as root to perform ICMP pings without privileges. The approach probably also works on other platforms, but they should be tested independently. Signed-off-by: James Tucker --- net/icmplistener/icmplistener.go | 121 +++++++++++++++++++ net/icmplistener/icmplistener_test.go | 121 +++++++++++++++++++ net/icmplistener/icmplistener_unsupported.go | 13 ++ 3 files changed, 255 insertions(+) create mode 100644 net/icmplistener/icmplistener.go create mode 100644 net/icmplistener/icmplistener_test.go create mode 100644 net/icmplistener/icmplistener_unsupported.go diff --git a/net/icmplistener/icmplistener.go b/net/icmplistener/icmplistener.go new file mode 100644 index 000000000..f5da75d0d --- /dev/null +++ b/net/icmplistener/icmplistener.go @@ -0,0 +1,121 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux || darwin + +// Package icmplistener implements a net.ListenConfig interface that overrides +// the handling of "ip:icmp" and "ip6:icmp" networks to use datagram sockets +// instead of raw sockets. +// +// In the 2000s the prevalence of ICMP based internet attacks led to broad +// consensus that raw sockets must be highly priveleged, causing all ICMP to +// become unavailable to unprivileged processes. In more recent years, standing +// concerns about extending privelege to keep `ping` working have lead to a new +// emerging consensus that ICMP Echo specifically should be allowed, and the +// mechanism for doing so is to send ICMP Echo packets via a SOCK_DGRAM socket +// type. +// +// This behavior is implemented by macOS and Linux (in Linux this is contingent +// on `net.ipv4.ping_group_range` covering the users range, which it typically +// does). +// +// The Go net abstraction does not directly lend itself to this kind of +// reimplementation, as such some edge case behaviors may differ in deliberately +// undocumented ways. Those behaviors may later change to fit intended use cases +// (initially sending ICMP Echo from userspace). +package icmplistener + +import ( + "context" + "net" + "net/netip" + "os" + + "golang.org/x/sys/unix" +) + +type ListenConfig struct { + net.ListenConfig +} + +func (lc *ListenConfig) ListenPacket(ctx context.Context, network, address string) (net.PacketConn, error) { + switch network { + case "ip:icmp", "ip6:icmp", "ip4:icmp", "ip6:icmp-ipv6": + return lc.listenICMP(ctx, network, address) + default: + return lc.ListenConfig.ListenPacket(ctx, network, address) + } +} + +func (lc *ListenConfig) listenICMP(ctx context.Context, network, address string) (net.PacketConn, error) { + // If running as root, just fall back to the default behavior as SOCK_RAW + // should be available. + if os.Geteuid() == 0 { + return lc.ListenConfig.ListenPacket(ctx, network, address) + } + + af := unix.AF_INET6 + pr := unix.IPPROTO_ICMPV6 + switch network { + case "ip:icmp", "ip4:icmp": + af = unix.AF_INET + pr = unix.IPPROTO_ICMP + case "ip6:icmp", "ip6:icmp-ipv6": + default: + // TODO: perhaps one day reimplement the full secret "favorite family" + // behavior from the stdlib. + + // TODO: resolve, too + addr, err := netip.ParseAddr(address) + if err != nil { + // TODO: appropriate error type + return nil, err + } + if addr.Is4() { + af = unix.AF_INET + pr = unix.IPPROTO_ICMP + } + } + + // technically the dup'd fd will get upgraded to nonblock and cloexec, but + // the behaviors and side effects are not entirely documented (and cloexec + // correctness in concurrent runtimes is very very complicated, especially + // if we're in a cgo program). + fd, err := unix.Socket(af, unix.SOCK_DGRAM|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, pr) + if err != nil { + // TODO: convert to net error + return nil, os.NewSyscallError("socket", err) + } + // close after the filepacketconn performs the dupfd + defer unix.Close(fd) + + // TODO: handle configuration correctly: + if af == unix.AF_INET6 { + if err := unix.SetsockoptInt(fd, unix.IPPROTO_IPV6, unix.IPV6_V6ONLY, 0); err != nil { + // TODO: convert to net error + return nil, err + } + } + + f := os.NewFile(uintptr(fd), address) + if lc.Control != nil { + rc, err := f.SyscallConn() + // TODO: convert to net error + if err != nil { + return nil, err + } + lc.Control(network, address, rc) + } + + if af == unix.AF_INET6 { + err = unix.Bind(fd, &unix.SockaddrInet6{Port: 0}) + } else { + err = unix.Bind(fd, &unix.SockaddrInet4{Port: 0}) + } + if err != nil { + // TODO: convert to net error + return nil, err + } + + return net.FilePacketConn(f) +} diff --git a/net/icmplistener/icmplistener_test.go b/net/icmplistener/icmplistener_test.go new file mode 100644 index 000000000..dcc8d5212 --- /dev/null +++ b/net/icmplistener/icmplistener_test.go @@ -0,0 +1,121 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package icmplistener + +import ( + "context" + "net" + "os" + "syscall" + "testing" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/sys/unix" +) + +func TestListenPacket(t *testing.T) { + ctx := context.Background() + var lc ListenConfig + pc, err := lc.ListenPacket(ctx, "ip:icmp", "0.0.0.0") + if err != nil { + t.Fatal(err) + } + defer pc.Close() + + rc, err := pc.(syscall.Conn).SyscallConn() + if err != nil { + t.Fatal(err) + } + + assertSockOpt := func(name string, fd uintptr, opt, want int) { + got, err := syscall.GetsockoptInt(int(fd), syscall.SOL_SOCKET, opt) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("unexpected sockopt %s: got %v, want %v", name, got, want) + } + } + + assertFcntl := func(name string, fd uintptr, cmd, arg, want int) { + got, err := unix.FcntlInt(fd, cmd, arg) + if err != nil { + t.Fatal(err) + } + if cmd == syscall.F_GETFL { + if arg&got != 0 { + got = 1 + } else { + got = 0 + } + } + if got != want { + t.Fatalf("unexpected fcntl %s: got %v, want %v", name, got, want) + } + } + + rc.Control(func(fd uintptr) { + wantTyp := syscall.SOCK_DGRAM + if os.Geteuid() == 0 { + wantTyp = syscall.SOCK_RAW + } + + assertSockOpt("TYPE", fd, syscall.SO_TYPE, wantTyp) + assertSockOpt("PROTOCOL", fd, syscall.SO_PROTOCOL, syscall.IPPROTO_ICMPV6) + // TODO: check IPV6_V6ONLY. + + // Most of these options are set by the stdlib wrapper on the way to a + // pollable, but they're worth checking as failure to set them is a + // significant change on various axes, such as performance. + assertSockOpt("REUSEADDR", fd, syscall.SO_REUSEADDR, 1) + assertFcntl("NONBLOCK", fd, syscall.F_GETFL, syscall.O_NONBLOCK, 1) + assertFcntl("CLOEXEC", fd, syscall.F_GETFD, syscall.O_CLOEXEC, 1) + }) +} + +func TestPing(t *testing.T) { + ctx := context.Background() + var lc ListenConfig + pc, err := lc.ListenPacket(ctx, "ip:icmp", "0.0.0.0") + if err != nil { + t.Fatal(err) + } + + localhost := "127.0.0.1:1" + dst, err := net.ResolveUDPAddr("udp", localhost) + if err != nil { + t.Fatal(err) + } + b, err := (&icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ + ID: 0, + Seq: 0, + Data: []byte("hello"), + }, + }).Marshal(nil) + if err != nil { + t.Fatal(err) + } + if _, err := pc.WriteTo(b, dst); err != nil { + t.Fatal(err) + } + b = make([]byte, 1500) + n, _, err := pc.ReadFrom(b) + if err != nil { + t.Fatal(err) + } + m, err := icmp.ParseMessage(1, b[:n]) + if err != nil { + t.Fatal(err) + } + if m.Type != ipv4.ICMPTypeEchoReply { + t.Fatalf("got ICMP type %v, want %v", m.Type, ipv4.ICMPTypeEchoReply) + } + if string(m.Body.(*icmp.Echo).Data) != "hello" { + t.Fatalf("got ICMP body %q, want %q", m.Body, "hello") + } +} diff --git a/net/icmplistener/icmplistener_unsupported.go b/net/icmplistener/icmplistener_unsupported.go new file mode 100644 index 000000000..59a974d31 --- /dev/null +++ b/net/icmplistener/icmplistener_unsupported.go @@ -0,0 +1,13 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !(linux || darwin) + +package icmplistener + +import "net" + +// ListenConfig on this platform is simply a wrapper around net.ListenConfig. +type ListenConfig struct { + net.ListenConfig +}