mirror of
https://github.com/eunomia-bpf/bpf-developer-tutorial.git
synced 2026-10-29 03:34:44 +08:00
Add targeted TCP connection quarantine tool
This commit is contained in:
5
.github/workflows/test-libbpf.yml
vendored
5
.github/workflows/test-libbpf.yml
vendored
@@ -179,6 +179,11 @@ jobs:
|
||||
run: |
|
||||
make -C src/50-tcx
|
||||
|
||||
- name: test 51 TCP quarantine
|
||||
run: |
|
||||
make -C src/51-tcp-quarantine
|
||||
sudo make -C src/51-tcp-quarantine test
|
||||
|
||||
- name: test features bpf_token
|
||||
run: |
|
||||
make -C src/features/bpf_token
|
||||
|
||||
10
src/51-tcp-quarantine/.config
Normal file
10
src/51-tcp-quarantine/.config
Normal file
@@ -0,0 +1,10 @@
|
||||
level=Depth
|
||||
type=Security
|
||||
kernel_min=6.5
|
||||
kernel_min_basis=documented
|
||||
architectures=x86_64
|
||||
btf=required
|
||||
kernel_config=CONFIG_BPF=y,CONFIG_BPF_SYSCALL=y,CONFIG_DEBUG_INFO_BTF=y,CONFIG_INET=y
|
||||
hardware=none
|
||||
root=required
|
||||
test_status=ci-runtime
|
||||
12
src/51-tcp-quarantine/.gitignore
vendored
Normal file
12
src/51-tcp-quarantine/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Build artifacts
|
||||
.output/
|
||||
*.o
|
||||
*.skel.h
|
||||
|
||||
# Generated binaries
|
||||
tcp_quarantine
|
||||
|
||||
# Editor files
|
||||
*.swp
|
||||
*~
|
||||
.vscode/
|
||||
92
src/51-tcp-quarantine/Makefile
Normal file
92
src/51-tcp-quarantine/Makefile
Normal file
@@ -0,0 +1,92 @@
|
||||
# SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
|
||||
OUTPUT := .output
|
||||
CLANG ?= clang
|
||||
LIBBPF_SRC := $(abspath ../third_party/libbpf/src)
|
||||
BPFTOOL_SRC := $(abspath ../third_party/bpftool/src)
|
||||
LIBBPF_OBJ := $(abspath $(OUTPUT)/libbpf.a)
|
||||
BPFTOOL_OUTPUT ?= $(abspath $(OUTPUT)/bpftool)
|
||||
BPFTOOL ?= $(BPFTOOL_OUTPUT)/bootstrap/bpftool
|
||||
ARCH ?= $(shell uname -m | sed 's/x86_64/x86/' \
|
||||
| sed 's/arm.*/arm/' \
|
||||
| sed 's/aarch64/arm64/' \
|
||||
| sed 's/ppc64le/powerpc/' \
|
||||
| sed 's/mips.*/mips/' \
|
||||
| sed 's/riscv64/riscv/' \
|
||||
| sed 's/loongarch64/loongarch/')
|
||||
VMLINUX := ../third_party/vmlinux/$(ARCH)/vmlinux.h
|
||||
INCLUDES := -I$(OUTPUT) -I../third_party/libbpf/include/uapi -I$(dir $(VMLINUX)) -I.
|
||||
CFLAGS := -g -Wall
|
||||
ALL_LDFLAGS := $(LDFLAGS) $(EXTRA_LDFLAGS)
|
||||
|
||||
APPS = tcp_quarantine
|
||||
|
||||
CLANG_BPF_SYS_INCLUDES ?= $(shell $(CLANG) -v -E - </dev/null 2>&1 \
|
||||
| sed -n '/<...> search starts here:/,/End of search list./{ s| \(/.*\)|-idirafter \1|p }')
|
||||
|
||||
ifeq ($(V),1)
|
||||
Q =
|
||||
msg =
|
||||
else
|
||||
Q = @
|
||||
msg = @printf ' %-8s %s%s\n' "$(1)" \
|
||||
"$(patsubst $(abspath $(OUTPUT))/%,%,$(2))" \
|
||||
"$(if $(3), $(3))";
|
||||
MAKEFLAGS += --no-print-directory
|
||||
endif
|
||||
|
||||
define allow-override
|
||||
$(if $(or $(findstring environment,$(origin $(1))),\
|
||||
$(findstring command line,$(origin $(1)))),,\
|
||||
$(eval $(1) = $(2)))
|
||||
endef
|
||||
|
||||
$(call allow-override,CC,$(CROSS_COMPILE)cc)
|
||||
$(call allow-override,LD,$(CROSS_COMPILE)ld)
|
||||
|
||||
.PHONY: all clean test
|
||||
all: $(APPS)
|
||||
|
||||
clean:
|
||||
$(call msg,CLEAN)
|
||||
$(Q)rm -rf $(OUTPUT) $(APPS)
|
||||
|
||||
test: all
|
||||
python3 tests/test_tcp_quarantine.py ./tcp_quarantine
|
||||
|
||||
$(OUTPUT) $(OUTPUT)/libbpf $(BPFTOOL_OUTPUT):
|
||||
$(call msg,MKDIR,$@)
|
||||
$(Q)mkdir -p $@
|
||||
|
||||
$(LIBBPF_OBJ): $(wildcard $(LIBBPF_SRC)/*.[ch] $(LIBBPF_SRC)/Makefile) | $(OUTPUT)/libbpf
|
||||
$(call msg,LIB,$@)
|
||||
$(Q)$(MAKE) -C $(LIBBPF_SRC) BUILD_STATIC_ONLY=1 \
|
||||
OBJDIR=$(dir $@)/libbpf DESTDIR=$(dir $@) \
|
||||
INCLUDEDIR= LIBDIR= UAPIDIR= install
|
||||
|
||||
$(BPFTOOL): | $(BPFTOOL_OUTPUT)
|
||||
$(call msg,BPFTOOL,$@)
|
||||
$(Q)$(MAKE) ARCH= CROSS_COMPILE= OUTPUT=$(BPFTOOL_OUTPUT)/ -C $(BPFTOOL_SRC) bootstrap
|
||||
|
||||
$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(VMLINUX) | $(OUTPUT) $(BPFTOOL)
|
||||
$(call msg,BPF,$@)
|
||||
$(Q)$(CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) \
|
||||
$(INCLUDES) $(CLANG_BPF_SYS_INCLUDES) \
|
||||
-c $(filter %.c,$^) -o $(patsubst %.bpf.o,%.tmp.bpf.o,$@)
|
||||
$(Q)$(BPFTOOL) gen object $@ $(patsubst %.bpf.o,%.tmp.bpf.o,$@)
|
||||
|
||||
$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT) $(BPFTOOL)
|
||||
$(call msg,GEN-SKEL,$@)
|
||||
$(Q)$(BPFTOOL) gen skeleton $< > $@
|
||||
|
||||
$(patsubst %,$(OUTPUT)/%.o,$(APPS)): %.o: %.skel.h
|
||||
|
||||
$(OUTPUT)/%.o: %.c $(wildcard %.h) | $(OUTPUT)
|
||||
$(call msg,CC,$@)
|
||||
$(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@
|
||||
|
||||
$(APPS): %: $(OUTPUT)/%.o $(LIBBPF_OBJ) | $(OUTPUT)
|
||||
$(call msg,BINARY,$@)
|
||||
$(Q)$(CC) $(CFLAGS) $^ $(ALL_LDFLAGS) -lelf -lz -o $@
|
||||
|
||||
.DELETE_ON_ERROR:
|
||||
.SECONDARY:
|
||||
57
src/51-tcp-quarantine/tcp_quarantine.bpf.c
Normal file
57
src/51-tcp-quarantine/tcp_quarantine.bpf.c
Normal file
@@ -0,0 +1,57 @@
|
||||
// SPDX-License-Identifier: GPL-2.0
|
||||
#include "vmlinux.h"
|
||||
#include <bpf/bpf_core_read.h>
|
||||
#include <bpf/bpf_endian.h>
|
||||
#include <bpf/bpf_helpers.h>
|
||||
#include "tcp_quarantine.h"
|
||||
|
||||
#define AF_INET 2
|
||||
#define TCP_ESTABLISHED 1
|
||||
|
||||
char LICENSE[] SEC("license") = "GPL";
|
||||
|
||||
const volatile __u32 target_addr;
|
||||
const volatile __u16 target_port;
|
||||
const volatile bool apply;
|
||||
|
||||
struct quarantine_stats stats;
|
||||
|
||||
extern int bpf_sock_destroy(struct sock_common *sock) __weak __ksym;
|
||||
|
||||
SEC("iter/tcp")
|
||||
int quarantine_tcp(struct bpf_iter__tcp *ctx)
|
||||
{
|
||||
struct sock_common *sk = ctx->sk_common;
|
||||
__u32 dst_addr;
|
||||
__u16 dst_port;
|
||||
__u16 family;
|
||||
__u8 state;
|
||||
int err;
|
||||
|
||||
if (!sk)
|
||||
return 0;
|
||||
|
||||
stats.scanned++;
|
||||
family = BPF_CORE_READ(sk, skc_family);
|
||||
state = BPF_CORE_READ(sk, skc_state);
|
||||
if (family != AF_INET || state != TCP_ESTABLISHED)
|
||||
return 0;
|
||||
|
||||
stats.established++;
|
||||
dst_addr = BPF_CORE_READ(sk, skc_daddr);
|
||||
dst_port = BPF_CORE_READ(sk, skc_dport);
|
||||
if (dst_addr != target_addr || dst_port != bpf_htons(target_port))
|
||||
return 0;
|
||||
|
||||
stats.matched++;
|
||||
if (!apply)
|
||||
return 0;
|
||||
|
||||
err = bpf_sock_destroy(sk);
|
||||
if (err)
|
||||
stats.failed++;
|
||||
else
|
||||
stats.destroyed++;
|
||||
|
||||
return 0;
|
||||
}
|
||||
188
src/51-tcp-quarantine/tcp_quarantine.c
Normal file
188
src/51-tcp-quarantine/tcp_quarantine.c
Normal file
@@ -0,0 +1,188 @@
|
||||
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <getopt.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <bpf/bpf.h>
|
||||
#include <bpf/libbpf.h>
|
||||
#include "tcp_quarantine.h"
|
||||
#include "tcp_quarantine.skel.h"
|
||||
|
||||
static struct env {
|
||||
const char *destination;
|
||||
unsigned int port;
|
||||
bool apply;
|
||||
bool verbose;
|
||||
} env;
|
||||
|
||||
static int libbpf_print_fn(enum libbpf_print_level level, const char *format,
|
||||
va_list args)
|
||||
{
|
||||
if (level == LIBBPF_DEBUG && !env.verbose)
|
||||
return 0;
|
||||
return vfprintf(stderr, format, args);
|
||||
}
|
||||
|
||||
static void usage(const char *program)
|
||||
{
|
||||
fprintf(stderr,
|
||||
"Usage: %s --destination IPv4 --port PORT [--apply] [--verbose]\n"
|
||||
"\n"
|
||||
"Find established TCP client connections to an exact destination.\n"
|
||||
"The default is a safe dry run; --apply destroys matching sockets.\n"
|
||||
"\n"
|
||||
"Options:\n"
|
||||
" -d, --destination IPv4 exact remote IPv4 address\n"
|
||||
" -p, --port PORT exact remote TCP port (1-65535)\n"
|
||||
" -a, --apply destroy matching sockets\n"
|
||||
" -v, --verbose print libbpf diagnostics\n"
|
||||
" -h, --help show this help\n",
|
||||
program);
|
||||
}
|
||||
|
||||
static int parse_port(const char *value, unsigned int *port)
|
||||
{
|
||||
char *end = NULL;
|
||||
unsigned long parsed;
|
||||
|
||||
errno = 0;
|
||||
parsed = strtoul(value, &end, 10);
|
||||
if (errno || !end || *end || parsed == 0 || parsed > 65535)
|
||||
return -EINVAL;
|
||||
*port = parsed;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int parse_args(int argc, char **argv)
|
||||
{
|
||||
static const struct option options[] = {
|
||||
{ "destination", required_argument, NULL, 'd' },
|
||||
{ "port", required_argument, NULL, 'p' },
|
||||
{ "apply", no_argument, NULL, 'a' },
|
||||
{ "verbose", no_argument, NULL, 'v' },
|
||||
{ "help", no_argument, NULL, 'h' },
|
||||
{},
|
||||
};
|
||||
int option;
|
||||
|
||||
while ((option = getopt_long(argc, argv, "d:p:avh", options, NULL)) != -1) {
|
||||
switch (option) {
|
||||
case 'd':
|
||||
env.destination = optarg;
|
||||
break;
|
||||
case 'p':
|
||||
if (parse_port(optarg, &env.port)) {
|
||||
fprintf(stderr, "invalid TCP port: %s\n", optarg);
|
||||
return -EINVAL;
|
||||
}
|
||||
break;
|
||||
case 'a':
|
||||
env.apply = true;
|
||||
break;
|
||||
case 'v':
|
||||
env.verbose = true;
|
||||
break;
|
||||
case 'h':
|
||||
usage(argv[0]);
|
||||
exit(0);
|
||||
default:
|
||||
return -EINVAL;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.destination || !env.port || optind != argc)
|
||||
return -EINVAL;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int run_iterator(struct bpf_program *program)
|
||||
{
|
||||
struct bpf_link *link;
|
||||
char buffer[256];
|
||||
int iter_fd, length, err;
|
||||
|
||||
link = bpf_program__attach_iter(program, NULL);
|
||||
err = libbpf_get_error(link);
|
||||
if (err) {
|
||||
fprintf(stderr, "failed to attach TCP iterator: %s\n", strerror(-err));
|
||||
return err;
|
||||
}
|
||||
|
||||
iter_fd = bpf_iter_create(bpf_link__fd(link));
|
||||
if (iter_fd < 0) {
|
||||
err = iter_fd;
|
||||
fprintf(stderr, "failed to create TCP iterator: %s\n", strerror(-err));
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
while ((length = read(iter_fd, buffer, sizeof(buffer))) > 0)
|
||||
;
|
||||
if (length < 0) {
|
||||
err = -errno;
|
||||
fprintf(stderr, "failed while scanning TCP sockets: %s\n", strerror(errno));
|
||||
}
|
||||
close(iter_fd);
|
||||
|
||||
cleanup:
|
||||
bpf_link__destroy(link);
|
||||
return err;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
struct tcp_quarantine_bpf *skel = NULL;
|
||||
struct in_addr destination;
|
||||
int err;
|
||||
|
||||
err = parse_args(argc, argv);
|
||||
if (err) {
|
||||
usage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
if (inet_pton(AF_INET, env.destination, &destination) != 1) {
|
||||
fprintf(stderr, "invalid IPv4 destination: %s\n", env.destination);
|
||||
return 1;
|
||||
}
|
||||
|
||||
libbpf_set_print(libbpf_print_fn);
|
||||
skel = tcp_quarantine_bpf__open();
|
||||
if (!skel) {
|
||||
fprintf(stderr, "failed to open BPF skeleton\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
skel->rodata->target_addr = destination.s_addr;
|
||||
skel->rodata->target_port = env.port;
|
||||
skel->rodata->apply = env.apply;
|
||||
|
||||
err = tcp_quarantine_bpf__load(skel);
|
||||
if (err) {
|
||||
fprintf(stderr,
|
||||
"failed to load TCP quarantine program: %s\n"
|
||||
"This tool requires Linux 6.5+ with BTF, TCP BPF iterators, "
|
||||
"and the bpf_sock_destroy kfunc.\n",
|
||||
strerror(-err));
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
err = run_iterator(skel->progs.quarantine_tcp);
|
||||
if (err)
|
||||
goto cleanup;
|
||||
|
||||
printf("mode=%s destination=%s:%u scanned=%llu established=%llu "
|
||||
"matched=%llu destroyed=%llu failed=%llu\n",
|
||||
env.apply ? "apply" : "dry-run", env.destination, env.port,
|
||||
skel->bss->stats.scanned, skel->bss->stats.established,
|
||||
skel->bss->stats.matched, skel->bss->stats.destroyed,
|
||||
skel->bss->stats.failed);
|
||||
if (skel->bss->stats.failed)
|
||||
err = -EIO;
|
||||
|
||||
cleanup:
|
||||
tcp_quarantine_bpf__destroy(skel);
|
||||
return err != 0;
|
||||
}
|
||||
13
src/51-tcp-quarantine/tcp_quarantine.h
Normal file
13
src/51-tcp-quarantine/tcp_quarantine.h
Normal file
@@ -0,0 +1,13 @@
|
||||
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
|
||||
#ifndef __TCP_QUARANTINE_H
|
||||
#define __TCP_QUARANTINE_H
|
||||
|
||||
struct quarantine_stats {
|
||||
unsigned long long scanned;
|
||||
unsigned long long established;
|
||||
unsigned long long matched;
|
||||
unsigned long long destroyed;
|
||||
unsigned long long failed;
|
||||
};
|
||||
|
||||
#endif /* __TCP_QUARANTINE_H */
|
||||
109
src/51-tcp-quarantine/tests/test_tcp_quarantine.py
Normal file
109
src/51-tcp-quarantine/tests/test_tcp_quarantine.py
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end smoke test for the TCP quarantine tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import pathlib
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
BROKEN_ERRORS = {errno.ECONNABORTED, errno.ECONNRESET, errno.EPIPE}
|
||||
|
||||
|
||||
def open_connection() -> tuple[socket.socket, socket.socket, socket.socket, int]:
|
||||
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
listener.bind(("127.0.0.1", 0))
|
||||
listener.listen(1)
|
||||
client = socket.create_connection(listener.getsockname(), timeout=2)
|
||||
server, _ = listener.accept()
|
||||
client.settimeout(1)
|
||||
server.settimeout(1)
|
||||
return listener, client, server, listener.getsockname()[1]
|
||||
|
||||
|
||||
def round_trip(client: socket.socket, server: socket.socket, payload: bytes) -> None:
|
||||
client.sendall(payload)
|
||||
assert server.recv(len(payload)) == payload
|
||||
server.sendall(payload)
|
||||
assert client.recv(len(payload)) == payload
|
||||
|
||||
|
||||
def run_tool(binary: pathlib.Path, port: int, apply: bool) -> str:
|
||||
command = [str(binary), "--destination", "127.0.0.1", "--port", str(port)]
|
||||
if apply:
|
||||
command.append("--apply")
|
||||
result = subprocess.run(command, check=True, text=True, capture_output=True)
|
||||
print(result.stdout, end="")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def wait_for_destroy(client: socket.socket) -> None:
|
||||
deadline = time.monotonic() + 2
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
client.send(b"x")
|
||||
except OSError as error:
|
||||
if error.errno in BROKEN_ERRORS:
|
||||
return
|
||||
raise
|
||||
error = client.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
||||
if error in BROKEN_ERRORS:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("quarantined TCP socket remained usable")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit(f"usage: {sys.argv[0]} /path/to/tcp_quarantine")
|
||||
if not hasattr(socket, "SO_ERROR"):
|
||||
raise SystemExit("SO_ERROR is unavailable")
|
||||
|
||||
binary = pathlib.Path(sys.argv[1]).resolve()
|
||||
if not binary.is_file():
|
||||
raise SystemExit(f"missing binary: {binary}")
|
||||
|
||||
invalid = subprocess.run(
|
||||
[str(binary), "--destination", "not-an-ip", "--port", "1"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
assert invalid.returncode != 0
|
||||
assert "invalid IPv4 destination" in invalid.stderr
|
||||
|
||||
target = open_connection()
|
||||
control = open_connection()
|
||||
all_sockets = target[:3] + control[:3]
|
||||
try:
|
||||
round_trip(target[1], target[2], b"before-target")
|
||||
round_trip(control[1], control[2], b"before-control")
|
||||
|
||||
dry_run = run_tool(binary, target[3], apply=False)
|
||||
assert "mode=dry-run" in dry_run
|
||||
assert "matched=1" in dry_run
|
||||
assert "destroyed=0" in dry_run
|
||||
round_trip(target[1], target[2], b"after-dry-run")
|
||||
round_trip(control[1], control[2], b"control-after-dry-run")
|
||||
|
||||
applied = run_tool(binary, target[3], apply=True)
|
||||
assert "mode=apply" in applied
|
||||
assert "matched=1" in applied
|
||||
assert "destroyed=1" in applied
|
||||
assert "failed=0" in applied
|
||||
wait_for_destroy(target[1])
|
||||
round_trip(control[1], control[2], b"control-survives")
|
||||
finally:
|
||||
for sock in all_sockets:
|
||||
sock.close()
|
||||
|
||||
print("PASS: dry-run preserved both connections; apply destroyed only the target")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user