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 <james@tailscale.com>
This commit is contained in:
James Tucker 2023-11-02 18:39:55 -07:00
parent 228a82f178
commit 2e9a3e6b1f
No known key found for this signature in database
3 changed files with 255 additions and 0 deletions

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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
}