diff --git a/tools/testing/selftests/net/Makefile b/tools/testing/selftests/net/Makefile
index 7b6918d5f4af..e9908a976f5e 100644
--- a/tools/testing/selftests/net/Makefile
+++ b/tools/testing/selftests/net/Makefile
@@ -93,6 +93,7 @@ TEST_PROGS += test_bridge_backup_port.sh
 TEST_PROGS += fdb_flush.sh
 TEST_PROGS += fq_band_pktlimit.sh
 TEST_PROGS += vlan_hw_filter.sh
+TEST_PROGS += netpoll.sh
 
 TEST_FILES := settings
 TEST_FILES += in_netns.sh lib.sh net_helper.sh setup_loopback.sh setup_veth.sh
diff --git a/tools/testing/selftests/net/config b/tools/testing/selftests/net/config
index 5e4390cac17e..bc3b6991b1aa 100644
--- a/tools/testing/selftests/net/config
+++ b/tools/testing/selftests/net/config
@@ -100,3 +100,4 @@ CONFIG_NETFILTER_XT_MATCH_POLICY=m
 CONFIG_CRYPTO_ARIA=y
 CONFIG_XFRM_INTERFACE=m
 CONFIG_XFRM_USER=m
+CONFIG_NETCONSOLE=m
diff --git a/tools/testing/selftests/net/netpoll.sh b/tools/testing/selftests/net/netpoll.sh
new file mode 100755
index 000000000000..ab4acd756ca1
--- /dev/null
+++ b/tools/testing/selftests/net/netpoll.sh
@@ -0,0 +1,226 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-2.0
+#
+# Test netpoll and netconsole in various interface scenarios:
+# ethernet (veth), raw IPv4 (gre, ipip, ip6tnl), raw IPv6 (gre, sit, ip6tnl)
+#
+# +-----------------------------------------+
+# | $NS1                                    |
+# |              $H1 +---+ $T1              |
+# |     192.0.2.1/30 |     192.0.2.65/30    |
+# | 2001:db8:1::1/64 |     2001:db8:2::1/64 |
+# +------------------|----------------------+
+#                    |
+# +------------------|----------------------+
+# | $NS2             |                      |
+# |              $H2 +---+ $T2              |
+# |     192.0.2.2/30       192.0.2.66/30    |
+# | 2001:db8:1::2/64       2001:db8:2::2/64 |
+# +-----------------------------------------+
+# shellcheck disable=SC3043 # allow 'local' keyword
+set -e
+
+readonly ksft_skip=4
+readonly H1A4=192.0.2.1/30
+readonly H1A6=2001:db8:1::1/64
+readonly H2A4=192.0.2.2/30
+readonly H2A6=2001:db8:1::2/64
+readonly T1A4=192.0.2.65/30
+readonly T1A6=2001:db8:2::1/64
+readonly T2A4=192.0.2.66/30
+readonly T2A6=2001:db8:2::2/64
+
+# shellcheck disable=SC2155 # declare and assign separately
+readonly BASE="ksft-$(mktemp -u XXXXXX)"
+readonly NS1="$BASE-n1"
+readonly NS2="$BASE-n2"
+readonly H1="$BASE-h1"
+readonly H2="$BASE-h2"
+readonly T1="$BASE-i1"
+readonly T2="$BASE-i2"
+
+
+# Utilities
+
+setup() {
+	ip netns add "$NS1"
+	ip netns exec "$NS1" sysctl -wq net.ipv4.ip_forward=1
+	ip netns exec "$NS1" sysctl -wq net.ipv6.conf.all.forwarding=1
+	ip netns exec "$NS1" sysctl -wq net.ipv6.conf.default.forwarding=1
+
+	ip netns add "$NS2"
+	ip netns exec "$NS2" sysctl -wq net.ipv4.ip_forward=1
+	ip netns exec "$NS2" sysctl -wq net.ipv6.conf.all.forwarding=1
+	ip netns exec "$NS2" sysctl -wq net.ipv6.conf.default.forwarding=1
+
+	ip link add "$H1" type veth peer name "$H2"
+
+	ip link set dev "$H1" netns "$NS1"
+	ip -n "$NS1" link set dev "$H1" up
+	ip -n "$NS1" addr add "$H1A4" dev "$H1"
+	ip -n "$NS1" addr add "$H1A6" dev "$H1" nodad
+
+	ip link set dev "$H2" netns "$NS2"
+	ip -n "$NS2" link set dev "$H2" up
+	ip -n "$NS2" addr add "$H2A4" dev "$H2"
+	ip -n "$NS2" addr add "$H2A6" dev "$H2" nodad
+
+	grep -q '^netcon' /proc/consoles || modprobe netconsole
+}
+
+cleanup() {
+	set +e
+	for d in /sys/kernel/config/netconsole/"$BASE"*; do
+		[ -d "$d" ] || continue
+		rmdir "$d"
+	done
+	for ns in "$NS1" "$NS2"; do
+		if ip -n "$ns" link show >/dev/null 2>&1; then
+			ip netns del "$ns"
+		fi
+	done
+	# In case we exit before the veth got moved into its namespace
+	if [ -e /sys/class/net/"$H1" ]; then
+		ip link del dev "$H1"
+	fi
+}
+
+trap cleanup EXIT
+
+if ! command -v socat >/dev/null 2>&1; then
+	echo "SKIP: netconsole tests need socat"
+	exit $ksft_skip
+fi
+
+
+test_netconsole() {
+	local name="$1"; shift
+	local outif="$1"; shift
+	local outip="$1"; shift
+	local inif="$1"; shift
+	local inip="$1"; shift
+	local ret=0
+
+	local msg="kselftest/net/netpoll: netconsole test message for $name"
+	# Setup logger and background it, it will exit when it receives the message
+	timeout 3s ip netns exec "$NS2" socat -u -S 0 \
+		udp6-recv:6666,reuseaddr,so-bindtodevice="$inif",ipv6only=0 \
+		system:"grep -m 1 -q \'$(printf '%s' "$msg" | sed -e 's/:/\\:/g')\$\'" &
+	# Setup netconsole
+	ip netns exec "$NS1" sh -s <<EOF
+set -e
+mount -t configfs none /sys/kernel/config
+cd /sys/kernel/config/netconsole
+mkdir "$outif" && cd "$outif"
+echo "$outif" > dev_name
+echo "${outip%%/*}" > local_ip
+echo "${inip%%/*}" > remote_ip
+echo 6666 > remote_port
+echo 1 > enabled
+EOF
+
+	# Send message and wait for it to arrive or timeout
+	echo "<1>$msg" > /dev/kmsg
+	if wait %+; then
+		echo "PASS: netconsole ($name)"
+	else
+		ret=$?
+		echo "FAIL: netconsole ($name): message did not arrive in time ($err)"
+	fi
+	rmdir /sys/kernel/config/netconsole/"$outif"
+	return $ret
+}
+
+
+# Test starts here
+
+ret=0
+mark_failed() {
+	# Make sure failure isn't overwritten by skip
+	if [ "$ret" = 0 ] || [ "$ret" = $ksft_skip ]; then
+		ret="$1"
+	fi
+}
+
+setup
+
+# Test ethernet interfaces
+
+test_netconsole eth/ipv4 "$H1" "$H1A4" "$H2" "$H2A4" || mark_failed $?
+test_netconsole eth/ipv6 "$H1" "$H1A6" "$H2" "$H2A6" || mark_failed $?
+
+# Test tunnel interfaces
+
+setup_tunnel() {
+	local type="$1"; shift
+	local h1a="${1%%/*}"; shift
+	local h2a="${1%%/*}"; shift
+	local t1a="$1"; shift
+	local t2a="$1"; shift
+
+	ip -n "$NS1" link add "$T1" type "$type" local "$h1a" remote "$h2a" "$@" \
+		|| return $ksft_skip # Skip test if tunnel type is not available
+	ip -n "$NS1" addr add "$t1a" dev "$T1"
+	ip -n "$NS1" link set "$T1" up
+
+	ip -n "$NS2" link add "$T2" type "$type" local "$h2a" remote "$h1a" "$@" \
+		|| return $ksft_skip # Skip test if tunnel type is not available
+	ip -n "$NS2" addr add "$t2a" dev "$T2"
+	ip -n "$NS2" link set "$T2" up
+}
+
+teardown_tunnel() {
+	ip -n "$NS1" link del dev "$T1"
+	ip -n "$NS2" link del dev "$T2"
+}
+
+test_tunnel_4in4() {
+	local type="$1"; shift
+	local ret=0
+	setup_tunnel "$type" "$H1A4" "$H2A4" "$T1A4" "$T2A4" "$@"
+	test_netconsole "ipv4/$type/ipv4" "$T1" "$T1A4" "$T2" "$T2A4" || ret=$?
+	teardown_tunnel
+	return $ret
+}
+
+test_tunnel_4in6() {
+	local type="$1"; shift
+	local ret=0
+	setup_tunnel "$type" "$H1A6" "$H2A6" "$T1A4" "$T2A4" "$@"
+	test_netconsole "ipv6/$type/ipv4" "$T1" "$T1A4" "$T2" "$T2A4" || ret=$?
+	teardown_tunnel
+	return $ret
+}
+
+test_tunnel_6in4() {
+	local type="$1"; shift
+	local ret=0
+	setup_tunnel "$type" "$H1A4" "$H2A4" "$T1A6" "$T2A6" "$@"
+	test_netconsole "ipv4/$type/ipv6" "$T1" "$T1A6" "$T2" "$T2A6" || ret=$?
+	teardown_tunnel
+	return $ret
+}
+
+test_tunnel_6in6() {
+	local type="$1"; shift
+	local ret=0
+	setup_tunnel "$type" "$H1A6" "$H2A6" "$T1A6" "$T2A6" "$@"
+	test_netconsole "ipv6/$type/ipv6" "$T1" "$T1A6" "$T2" "$T2A6" || ret=$?
+	teardown_tunnel
+	return $ret
+}
+
+test_tunnel_4in4 gre    || mark_failed $?
+test_tunnel_6in4 gre    || mark_failed $?
+test_tunnel_4in6 ip6gre || mark_failed $?
+test_tunnel_6in6 ip6gre || mark_failed $?
+test_tunnel_4in4 ipip   || mark_failed $?
+test_tunnel_6in4 sit    || mark_failed $?
+test_tunnel_4in6 ip6tnl mode ipip6  || mark_failed $?
+test_tunnel_6in6 ip6tnl mode ip6ip6 || mark_failed $?
+
+
+# Test done
+
+cleanup
+exit "$ret"
