From 639b5f7ca7c12c0b00ef78aff7605cfeb4348972 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:57:06 -0600 Subject: [PATCH 01/32] live: add namespace-based test runner --- .github/workflows/live.yaml | 113 +++ README.md | 6 + lib/__init__.py | 1 + lib/common.py | 582 ++++++++++++ live/.gitignore | 1 + live/README.md | 250 +++++ live/run.py | 1715 +++++++++++++++++++++++++++++++++++ run.py | 423 ++------- 8 files changed, 2743 insertions(+), 348 deletions(-) create mode 100644 .github/workflows/live.yaml create mode 100644 lib/__init__.py create mode 100644 lib/common.py create mode 100644 live/.gitignore create mode 100644 live/README.md create mode 100755 live/run.py diff --git a/.github/workflows/live.yaml b/.github/workflows/live.yaml new file mode 100644 index 0000000000..b9cac6e22a --- /dev/null +++ b/.github/workflows/live.yaml @@ -0,0 +1,113 @@ +name: Live Tests + +on: + workflow_dispatch: + push: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + DEBIAN_FRONTEND: "noninteractive" + +jobs: + live: + runs-on: ubuntu-latest + container: + image: ubuntu:26.04 + options: --privileged + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + + - name: Install dependencies + run: | + apt update + apt install -y \ + autoconf \ + automake \ + build-essential \ + cargo \ + cbindgen \ + clang \ + curl \ + ethtool \ + fuse-overlayfs \ + git \ + hping3 \ + inetutils-ping \ + iproute2 \ + iptables \ + jq \ + libcap-ng-dev \ + libcap-ng0 \ + libevent-dev \ + libhiredis-dev \ + libjansson-dev \ + liblua5.1-dev \ + libmagic-dev \ + libnet1-dev \ + libnetfilter-queue-dev \ + libnetfilter-queue1 \ + libnfnetlink-dev \ + libnfnetlink0 \ + libnss3-dev \ + libpcap-dev \ + libpcre2-dev \ + libssl-dev \ + libtool \ + libxdp-dev \ + libyaml-0-2 \ + libyaml-dev \ + make \ + podman \ + procps \ + python3 \ + python3-yaml \ + rustc \ + slirp4netns \ + software-properties-common \ + tcpdump \ + tcpreplay \ + uidmap \ + wget \ + zlib1g \ + zlib1g-dev + + - run: | + apt-get install -y caddy tshark + + - name: Enable Podman in Docker + run: | + cat > /etc/containers/containers.conf <<'EOF' + [containers] + cgroups = "disabled" + events_logger = "file" + EOF + + - name: Clone Suricata + run: | + git clone https://github.com/OISF/suricata + + - name: Build Suricata + working-directory: suricata + run: | + ./autogen.sh + ./configure --enable-nfqueue --enable-debug --enable-ebpf --enable-ebpf-build + make -j$(nproc) + + - name: Run live tests + run: | + ../live/run.py + working-directory: suricata + + - name: Upload live test outputs artifact on failure + if: ${{ failure() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + with: + name: live-test-output + path: live/tests/**/output/** + if-no-files-found: ignore diff --git a/README.md b/README.md index 097bf7e52d..6bcbec70d6 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,12 @@ requires: files: - src/detect-ipaddr.c + # Require that one or more host commands exist in PATH. If a command is + # missing, the test will be skipped. + command: + - jq + - xargs + # Don't require a pcap file to be present. By default a test will be skipped # if there is no pcap file in the test directory. Not applicable if a # command is provided. diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000000..e92639cd0f --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +"""Shared helpers for Suricata verify runners.""" diff --git a/lib/common.py b/lib/common.py new file mode 100644 index 0000000000..4a7fdc352f --- /dev/null +++ b/lib/common.py @@ -0,0 +1,582 @@ +"""Shared runner helpers for Suricata verify runners.""" + +import os +import platform +import shutil +import subprocess +import filecmp +import json +import re + + +class UnsatisfiedRequirementError(Exception): + pass + + +class ImpossibleRequirementError(Exception): + pass + + +def check_required_commands(requires, unsatisfied_error=UnsatisfiedRequirementError): + """Validate host command requirements from a requires mapping.""" + if not isinstance(requires, dict): + raise ValueError("requires must be a mapping") + commands = requires.get("command", []) + if commands is None: + return + if not isinstance(commands, list) or any( + not isinstance(command, str) for command in commands + ): + raise ValueError("requires.command must be an array of strings") + for command in commands: + if shutil.which(command) is None: + raise unsatisfied_error("requires command {}".format(command)) + + +def check_requires( + requires, + suricata_config, + is_version_compatible, + test_dir=None, + version_is_lt=None, + eval_globals=None, + unsatisfied_error=UnsatisfiedRequirementError, + impossible_error=ImpossibleRequirementError, + unknown_error=ValueError, + unknown_message="unknown requires type: {key}", + gt_message="for version greater than {version}", + script_message="requires script returned false", + include_script_error=False, +): + """Validate a test requires mapping. + + Runner-specific version parsing is supplied by is_version_compatible so this + helper can be shared by both the classic and live runners. + """ + check_required_commands(requires, unsatisfied_error) + + if ( + version_is_lt is not None + and "gt-version" in requires + and "lt-version" in requires + and not version_is_lt(requires["gt-version"], requires["lt-version"]) + ): + raise impossible_error( + "test has both lt-version {} and gt-version {}".format( + requires["lt-version"], requires["gt-version"] + ) + ) + + suri_version = suricata_config.version + for key in requires: + if key == "min-version": + min_version = requires["min-version"] + if not is_version_compatible(min_version, suri_version, "gte"): + raise unsatisfied_error( + "requires at least version {}".format(min_version) + ) + elif key == "lt-version": + lt_version = requires["lt-version"] + if not is_version_compatible(lt_version, suri_version, "lt"): + raise unsatisfied_error("for version less than {}".format(lt_version)) + elif key == "gt-version": + gt_version = requires["gt-version"] + if not is_version_compatible(gt_version, suri_version, "gt"): + raise unsatisfied_error(gt_message.format(version=gt_version)) + elif key == "version": + req_version = requires["version"] + if not is_version_compatible(req_version, suri_version, "equal"): + raise unsatisfied_error("only for version {}".format(req_version)) + elif key == "features": + for feature in requires["features"]: + if not suricata_config.has_feature(feature): + raise unsatisfied_error("requires feature {}".format(feature)) + elif key == "command": + pass + elif key == "env": + for env in requires["env"]: + if env not in os.environ: + raise unsatisfied_error("requires env var {}".format(env)) + elif key == "files": + for filename in requires["files"]: + if test_dir and not os.path.isabs(filename): + filename = os.path.join(test_dir, filename) + if not os.path.exists(filename): + raise unsatisfied_error("requires file {}".format(filename)) + elif key == "script": + for script in requires["script"]: + try: + subprocess.check_call("{}".format(script), shell=True) + except Exception as err: + if include_script_error: + raise unsatisfied_error( + "{}: {}".format(script_message, err) + ) from err + raise unsatisfied_error(script_message) + elif key == "pcap": + pass + elif key == "lambda": + if eval_globals is None: + lambda_result = eval(requires["lambda"]) + else: + lambda_result = eval(requires["lambda"], eval_globals) + if not lambda_result: + raise unsatisfied_error(requires["lambda"]) + elif key == "os": + cur_platform = platform.system().lower() + if not cur_platform.startswith(requires["os"].lower()): + raise unsatisfied_error(requires["os"]) + elif key == "arch": + cur_arch = platform.machine().lower() + if not cur_arch.startswith(requires["arch"].lower()): + raise unsatisfied_error(requires["arch"]) + else: + raise unknown_error(unknown_message.format(key=key)) + +COMPARISON_OPERATORS = { + "__gt": ">", + "__gte": ">=", + "__lt": "<", + "__lte": "<=", +} + +MATCH_OPERATORS = ( + "__contains", + "__find", + "__startswith", + "__endswith", +) + + +class CheckResult: + """Result returned by shared check implementations.""" + + def __init__(self, failures=None, warnings=None): + self.failures = list(failures or []) + self.warnings = list(warnings or []) + + def ok(self): + return not self.failures + + +def _comparison_operators(comparison_operators=None): + return comparison_operators or COMPARISON_OPERATORS + + +def _operator_suffixes(comparison_operators=None): + return set(MATCH_OPERATORS) | set(_comparison_operators(comparison_operators)) + + +def _validate_keys(config, allowed, check_type): + for key in config: + if key not in allowed: + raise ValueError("Unexpected key in {} check: {}".format(check_type, key)) + + +def find_value(name, obj, comparison_operators=None): + """Find the value in an object for a field specified by name. + + Example names: + event_type + alert.signature_id + smtp.rcpt_to[0] + """ + parts = name.split(".") + operator_suffixes = _operator_suffixes(comparison_operators) + for part in parts: + if part == "__len": + try: + return len(obj) + except Exception: + return -1 + + if part in operator_suffixes: + break + + index = None + m = re.match(r"^(.*)\[(\d+)\]$", part) + if m: + key = m.group(1) + index = m.group(2) + else: + key = part + + if not isinstance(obj, dict) or key not in obj: + return None + obj = obj[key] + + if index is not None: + try: + obj = obj[int(index)] + except Exception: + return None + + return obj + + +def get_comparison_operator(key, comparison_operators=None): + """Return the comparison operator suffix from a check key, if present.""" + suffix = key.rsplit(".", 1)[-1] + if suffix in _comparison_operators(comparison_operators): + return suffix + return None + + +def compare_values(actual, expected, operator): + """Compare two numeric values using a comparison operator suffix.""" + if isinstance(actual, bool) or isinstance(expected, bool): + return False + if not isinstance(actual, (int, float)) or not isinstance(expected, (int, float)): + return False + if operator == "__gt": + return actual > expected + if operator == "__gte": + return actual >= expected + if operator == "__lt": + return actual < expected + if operator == "__lte": + return actual <= expected + raise ValueError("unknown comparison operator: {}".format(operator)) + + +def _as_list(value): + if isinstance(value, list): + return value + return [value] + + +def _contains(value, expected): + if value is None: + return False + try: + return expected in value + except TypeError: + return False + + +def match_event(config, event, comparison_operators=None, type_mismatch_callback=None): + for key, expected in config["match"].items(): + if key == "has-key": + for item in _as_list(expected): + if find_value(item, event, comparison_operators) is None: + return False + elif key == "not-has-key": + for item in _as_list(expected): + if find_value(item, event, comparison_operators) is not None: + return False + else: + val = find_value(key, event, comparison_operators) + if key.endswith("__find"): + if val is None or str(val).find(str(expected)) < 0: + return False + elif key.endswith("__contains"): + if not _contains(val, expected): + return False + elif key.endswith("__startswith"): + if val is None or not str(val).startswith(str(expected)): + return False + elif key.endswith("__endswith"): + if val is None or not str(val).endswith(str(expected)): + return False + else: + operator = get_comparison_operator(key, comparison_operators) + if operator is not None: + if not compare_values(val, expected, operator): + return False + elif val != expected: + if ( + type_mismatch_callback is not None + and str(val) == str(expected) + ): + type_mismatch_callback(val, expected) + return False + return True + + +def check_requirements( + requires, + require_checker=None, + suricata_config=None, + test_dir=None, + skip_as_warning=False, + skip_message=None, +): + if require_checker is None: + return CheckResult() + try: + require_checker(requires, suricata_config, test_dir) + except UnsatisfiedRequirementError as err: + if skip_as_warning: + if skip_message is None: + skip_message = "SKIP: check skipped: {}" + return CheckResult(warnings=[skip_message.format(err)]) + raise + return CheckResult() + + +class StatsCheck: + """Check values in the last stats event of eve.json.""" + + def __init__(self, config, output_dir, comparison_operators=None): + self.config = config + self.output_dir = output_dir + self.comparison_operators = comparison_operators + + def run(self): + eve_json_path = os.path.join(self.output_dir, "eve.json") + if not os.path.exists(eve_json_path): + return CheckResult(failures=["eve.json not found: {}".format(eve_json_path)]) + + stats = None + with open(eve_json_path, "r", encoding="utf-8") as fileobj: + for line in fileobj: + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("event_type") == "stats": + stats = event["stats"] + + if stats is None: + return CheckResult(failures=["no stats event found in eve.json"]) + + failures = [] + for key, expected in self.config.items(): + val = find_value(key, stats, self.comparison_operators) + operator = get_comparison_operator(key, self.comparison_operators) + if operator is not None: + if not compare_values(val, expected, operator): + symbol = _comparison_operators(self.comparison_operators)[operator] + failures.append( + "stats.{}: expected {} {}; got {}".format( + key, symbol, expected, val + ) + ) + elif val != expected: + failures.append( + "stats.{}: expected {}; got {}".format(key, expected, val) + ) + return CheckResult(failures=failures) + + +class FileCompareCheck: + def __init__(self, config, directory, output_dir, windows=False): + _validate_keys(config, ["requires", "filename", "expected"], "file-compare") + self.config = config + self.directory = directory + self.output_dir = output_dir + self.windows = windows + + def run(self): + if self.windows: + raise UnsatisfiedRequirementError("shell check not supported on Windows") + + expected = os.path.join(self.directory, self.config["expected"]) + filename = self.config["filename"] + if self.output_dir and not os.path.isabs(filename): + filename = os.path.join(self.output_dir, filename) + try: + if filecmp.cmp(expected, filename): + return CheckResult() + return CheckResult( + failures=[ + "{} {} \nFAILED: verification failed".format(expected, filename) + ] + ) + except Exception as err: + return CheckResult( + failures=["file-compare check failed with exception: {}".format(err)] + ) + + +class ShellCheck: + """Run a shell command in the test output directory.""" + + def __init__( + self, + config, + env, + output_dir, + suricata_config=None, + test_dir=None, + require_checker=None, + skip_as_warning=False, + use_bash=False, + windows=False, + ): + _validate_keys(config, ["requires", "args", "expect"], "shell") + self.config = config + self.env = env + self.output_dir = output_dir + self.suricata_config = suricata_config + self.test_dir = test_dir + self.require_checker = require_checker + self.skip_as_warning = skip_as_warning + self.use_bash = use_bash + self.windows = windows + + def run(self): + if not self.config or "args" not in self.config: + return CheckResult(failures=["shell check missing args"]) + + requires = self.config.get("requires", {}) + result = check_requirements( + requires, + self.require_checker, + self.suricata_config, + self.test_dir, + self.skip_as_warning, + "SKIP: shell check skipped: {}", + ) + if result.warnings: + return result + + if self.windows: + if self.skip_as_warning: + return CheckResult( + warnings=["SKIP: shell check skipped: shell check not supported on Windows"] + ) + raise UnsatisfiedRequirementError("shell check not supported on Windows") + + if self.use_bash: + cmd = ["bash", "-c", self.config["args"]] + run_kwargs = {"shell": False} + else: + cmd = self.config["args"] + run_kwargs = {"shell": True} + + completed = subprocess.run( + cmd, + cwd=self.output_dir, + env=self.env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + check=False, + **run_kwargs + ) + if completed.returncode != 0: + details = [] + if completed.stdout.strip(): + details.append("stdout={!r}".format(completed.stdout.strip())) + if completed.stderr.strip(): + details.append("stderr={!r}".format(completed.stderr.strip())) + suffix = " ({})".format(", ".join(details)) if details else "" + return CheckResult( + failures=[ + "shell command failed with exit code {}: {!r}{}".format( + completed.returncode, self.config["args"], suffix + ) + ] + ) + + if "expect" in self.config: + output = completed.stdout.strip() + if str(self.config["expect"]) != output: + return CheckResult( + failures=[ + "shell check expected {!r}; got {!r}".format( + self.config["expect"], output + ) + ] + ) + + return CheckResult() + + +class FilterCheck: + """Filter JSON lines output and count matching events.""" + + def __init__( + self, + config, + output_dir, + suricata_config=None, + test_dir=None, + require_checker=None, + skip_as_warning=False, + test_version=None, + version_compat_checker=None, + comparison_operators=None, + type_mismatch_callback=None, + ): + _validate_keys( + config, ["count", "match", "filename", "requires", "comment"], "filter" + ) + self.config = config + self.output_dir = output_dir + self.suricata_config = suricata_config + self.test_dir = test_dir + self.require_checker = require_checker + self.skip_as_warning = skip_as_warning + self.test_version = test_version + self.version_compat_checker = version_compat_checker + self.comparison_operators = comparison_operators + self.type_mismatch_callback = type_mismatch_callback + + def run(self): + if "count" not in self.config: + return CheckResult(failures=["filter check missing count"]) + if "match" not in self.config: + return CheckResult(failures=["filter check missing match"]) + + requires = self.config.get("requires", {}) + if self.version_compat_checker is not None: + self.version_compat_checker(requires, self.test_version) + result = check_requirements( + requires, + self.require_checker, + self.suricata_config, + self.test_dir, + self.skip_as_warning, + "SKIP: filter check skipped: {}", + ) + if result.warnings: + return result + + if "filename" in self.config: + json_filename = self.config["filename"] + if not os.path.isabs(json_filename): + json_filename = os.path.join(self.output_dir, json_filename) + else: + json_filename = os.path.join(self.output_dir, "eve.json") + if not os.path.exists(json_filename): + return CheckResult(failures=["{} does not exist".format(json_filename)]) + + count = 0 + try: + with open(json_filename, "r", encoding="utf-8") as fileobj: + for line in fileobj: + event = json.loads(line) + if self.match(event): + count += 1 + except Exception as err: + return CheckResult( + failures=["filter check failed for {}: {}".format(json_filename, err)] + ) + + if count == self.config["count"]: + return CheckResult() + if "comment" in self.config: + return CheckResult( + failures=[ + "{}: expected {}, got {}".format( + self.config["comment"], self.config["count"], count + ) + ] + ) + return CheckResult( + failures=[ + "expected {} matches; got {} for filter {}".format( + self.config["count"], count, self.config + ) + ] + ) + + def match(self, event): + return match_event( + self.config, + event, + self.comparison_operators, + self.type_mismatch_callback, + ) diff --git a/live/.gitignore b/live/.gitignore new file mode 100644 index 0000000000..77ebff9b52 --- /dev/null +++ b/live/.gitignore @@ -0,0 +1 @@ +__* diff --git a/live/README.md b/live/README.md new file mode 100644 index 0000000000..0f71ce368e --- /dev/null +++ b/live/README.md @@ -0,0 +1,250 @@ +# Suricata-Verify Live Tests + +This directory contains a harness for tests that run Suricata against live +traffic. A live test runs Suricata in IDS or IPS mode while a real client and +server exchange traffic in an isolated network environment. + +## Running Tests + +Run all live tests from a built Suricata source directory: + +``` +sudo ../suricata-verify/live/run.py +``` + +As with the non-live runner, positional arguments select tests by name. Multiple +patterns may be provided, and `--exact` changes them from substring matches to +exact names. Use `--skip-tests` with a comma-separated list to exclude tests. + +``` +sudo ../suricata-verify/live/run.py simple-http pcap-ids +sudo ../suricata-verify/live/run.py --exact simple-http-ids +sudo ../suricata-verify/live/run.py --skip-tests=nfq,dpdk +``` + +## Supported Environments + +- tap: Creates a bridge in the DUT namespace so Suricata can passively monitor + traffic between the client and server namespaces. The Linux bridge acts like + a span port on a switch. Suricata can attach to its `br0` interface with a + compatible capture mechanism such as AF_PACKET or libpcap. + +- inline: Creates an inline topology in which Suricata forwards all traffic + between the client and server. This is useful for IPS testing with capture + mechanisms such as AF_PACKET or DPDK. + +- nfq: Creates a routed topology in which NFQUEUE intercepts traffic between + the client and server. + +Each test's `environment` selects its network topology. Its `args` select the +Suricata capture mechanism and run mode, using arguments such as `--pcap=br0`, +`--af-packet`, `--dpdk`, or `-q 0`. + +## Linux Network Namespaces + +Linux network namespaces provide an isolated network. The runner creates these +namespaces: + +- `dut`: The device under test, where Suricata runs. +- `server0`: The server namespace, where applications such as an HTTP server + run. +- `client0`: The client namespace, where user-controlled scripts run tools such + as `curl`. + +Client and server namespaces are numbered by network. The default topology has +one network (`client0`/`server0`), and there is only ever one `dut` namespace. +Endpoint interfaces are named `client` and `server`. DUT interfaces are named +`client0`/`server0`, `client1`/`server1`, and so on. + +## Default Topologies + +All three environments share the same default single-network layout: a +`client0` and a `server0` namespace, each wired to the `dut` namespace with a +veth pair. They differ in how the two DUT interfaces are connected to each +other. + +### tap + +The DUT interfaces are members of a Linux bridge, `br0`, which forwards traffic +on its own. Suricata attaches to `br0` (e.g., with `--pcap=br0` or +`--af-packet=br0`) and passively observes: + +``` ++------------+ +---------------------+ +------------+ +| client0 | | dut | | server0 | +| | | | | | +| client |-----| client0 server0 |-----| server | +| 10.200.0.2 | | | | | | 10.200.0.1 | ++------------+ | +----br0----+ | +------------+ + | | | + | Suricata | + +---------------------+ +``` + +### inline + +No bridge is created. The client and server can communicate only when Suricata +forwards traffic between the two DUT interfaces, using, for example, AF_PACKET +copy mode or DPDK: + +``` ++------------+ +---------------------+ +------------+ +| client0 | | dut | | server0 | +| | | | | | +| client |-----| client0 server0 |-----| server | +| 10.200.0.2 | | | | | | 10.200.0.1 | ++------------+ | +--Suricata-+ | +------------+ + +---------------------+ +``` + +### nfq + +The DUT is a router: it owns an address on each network, the endpoints use it +as their default gateway, and `iptables` queues all forwarded traffic to +NFQUEUE 0, where Suricata (`-q 0`) provides the verdict: + +``` ++------------+ +-----------------------------+ +------------+ +| client0 | | dut | | server0 | +| | | | | | +| client |-----| client0 server0 |-----| server | +| 10.200.1.2 | | 10.200.1.254 10.200.0.254 | | 10.200.0.1 | ++------------+ | | | | +------------+ + | +----NFQUEUE----+ | + | Suricata (-q 0) | + +-----------------------------+ +``` + +The client's default route points at `10.200.1.254`, the server's at +`10.200.0.254`, and the DUT has `ip_forward` enabled. + +## Per-test Inline Topologies + +Inline tests may replace the default single-network, unbonded, MTU-1500 layout +with a `topology` mapping. A custom topology must contain at least one network, +and every network must specify client and server IPv4 CIDRs in the same subnet. +Omit `bond` and `bond-mode` for an unbonded network. Set `bond: true` to make +both the endpoint and DUT logical interfaces bonds with two veth members. A +bonded network must also set `bond-mode`; there is no default bond mode. +Supported modes are `balance-rr`, `active-backup`, `balance-xor`, `broadcast`, +`802.3ad`, `balance-tlb`, and `balance-alb`. The optional topology MTU defaults +to 1500. + +``` +environment: inline + +topology: + mtu: 9000 + networks: + - client: 10.200.0.2/24 + server: 10.200.0.1/24 + bond: true + bond-mode: balance-rr + - client: 10.200.1.2/24 + server: 10.200.1.1/24 + bond: true + bond-mode: balance-rr +``` + +This creates endpoint namespaces `client0`, `server0`, `client1`, and +`server1`, plus `dut`. Endpoint scripts continue to use logical interfaces +named `client` and `server`; Suricata uses `client0`, `server0`, `client1`, and +`server1` in the DUT. All generated physical member names stay within Linux's +15-character interface-name limit. Custom topologies are rejected for the tap +and NFQ environments. + +The example above is the dual-network, dual-bond topology used by the +`afp-ips-bond-two-networks` test. Each `a`/`b` link below is a veth pair that +acts as a bond member. Endpoint members are named `client-a`/`client-b` (and +likewise `server-a`/`server-b`); DUT members are named +`client0-a`/`client0-b`, and so on. Suricata runs inline across each network's +bond pair (e.g., in AF_PACKET copy mode between `client0`/`server0` and between +`client1`/`server1`): + +``` ++------------+ +---------------------+ +------------+ +| client0 | | dut | | server0 | +| | | | | | +| client |--a--| client0 server0 |--a--| server | +| (bond) |--b--| (bond) (bond) |--b--| (bond) | +| 10.200.0.2 | | | | | | 10.200.0.1 | ++------------+ | +--Suricata-+ | +------------+ + | | ++------------+ | | +------------+ +| client1 | | | | server1 | +| | | | | | +| client |--a--| client1 server1 |--a--| server | +| (bond) |--b--| (bond) (bond) |--b--| (bond) | +| 10.200.1.2 | | | | | | 10.200.1.1 | ++------------+ | +--Suricata-+ | +------------+ + +---------------------+ +``` + +Omitting `topology` uses the default single-network, unbonded, MTU-1500 layout. + +## Test Requirements + +Tests can declare required host commands in `test.yaml`. If a required command +is missing, the test is skipped. Tests in the NFQ environment also implicitly +require Suricata's `NFQ` build feature. + +``` +requires: + command: + - podman + - curl +``` + +## Suricata Arguments + +Tests must provide Suricata command-line arguments with the `args` key in +`test.yaml`, including the capture mechanism and any required run-mode option. +Each entry is parsed using shell syntax after variable substitution. The +supported variables are `SRCDIR`, `TESTDIR`, `TEST_DIR`, `OUTDIR`, and +`OUTPUT_DIR`. + +``` +args: + - --pcap=br0 + - --set stream.checksum-validation=no +``` + +## Life Cycle of a Test + +- First, the runner creates the network namespaces and virtual interfaces. In + the tap environment, it creates a Linux bridge that acts like a switch or + span port. In the NFQ environment, it configures `iptables` for routing and + packet interception. + +- The optional `before` script performs additional per-test setup. Tests that + use Podman should build their containers here (e.g., with `podman build`). + +- The runner starts Suricata and waits for its "Engine started" message. The + test fails if Suricata does not become ready. + +- The runner starts the optional server script. A server may be Caddy, a Python + script, or another long-running service. It must remain alive while the client + runs. + + The server is considered ready after it remains alive for a short grace + period. The test fails if it exits unexpectedly. + +- The runner executes the client script, which drives the test by sending + traffic to the server. The test fails if the client exits with a nonzero + status. + +- After the client exits, the runner stops the server, sends SIGTERM to + Suricata, waits for Suricata to exit, and tears down the network environment. + +- Finally, the runner performs the configured verification checks. + +## Failing a Test + +Common failure conditions include: + +1. Suricata does not become ready. + +2. A setup, server, or client script exits unexpectedly or with a nonzero + status. + +3. A configured verification check fails. diff --git a/live/run.py b/live/run.py new file mode 100755 index 0000000000..f291fe22e5 --- /dev/null +++ b/live/run.py @@ -0,0 +1,1715 @@ +#!/usr/bin/env python3 + +# Script to prepare live IPS namespace labs for Suricata testing. + +from __future__ import annotations + +import argparse +import fcntl +import glob +import ipaddress +import os +import platform +import re +import shlex +import signal +import string +import shutil +import subprocess +import sys + +sys.dont_write_bytecode = True + +import tempfile +import threading +import time +from collections import namedtuple +from dataclasses import dataclass +from typing import IO + +import yaml + +SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) +VERIFY_DIR = os.path.dirname(SCRIPT_DIR) +if VERIFY_DIR not in sys.path: + sys.path.insert(0, VERIFY_DIR) + +from lib.common import ( + FilterCheck, + ShellCheck, + StatsCheck, + UnsatisfiedRequirementError, + check_required_commands as check_common_required_commands, + check_requires as check_common_requires, +) + +CLIENT_NS = "client0" +SERVER_NS = "server0" +DUT_NS = "dut" + +MTU = "1500" + +CLIENT_IF = "client" +SERVER_IF = "server" +DUT_CLIENT_IF = "client0" +DUT_SERVER_IF = "server0" +DUT_BRIDGE_IF = "br0" + +TMP_CLIENT_IF = "ptp-client" +TMP_SERVER_IF = "ptp-server" +TMP_DUT_CLIENT_IF = "ptp-client0" +TMP_DUT_SERVER_IF = "ptp-server0" + +L2_CLIENT_IP = "10.200.0.2/24" +L2_SERVER_IP = "10.200.0.1/24" + +NFQ_CLIENT_IP = "10.200.1.2/24" +NFQ_DUT_CLIENT_IP = "10.200.1.254/24" +NFQ_SERVER_IP = "10.200.0.1/24" +NFQ_DUT_SERVER_IP = "10.200.0.254/24" +NFQ_CLIENT_GW = "10.200.1.254" +NFQ_SERVER_GW = "10.200.0.254" +NFQ_QUEUE_NUM = "0" + +ALL_NAMESPACES = (CLIENT_NS, SERVER_NS, DUT_NS) +ROOT_LINKS = (TMP_CLIENT_IF, TMP_SERVER_IF, TMP_DUT_CLIENT_IF, TMP_DUT_SERVER_IF) +ENVIRONMENTS = ("inline", "tap", "nfq") + +verbose = False +suricata_config_cache = {} + +RUNNER_LOCK_PATH = "/tmp/suricata-verify-live.lock" + + +BOND_MODES = { + "balance-rr", + "active-backup", + "balance-xor", + "broadcast", + "802.3ad", + "balance-tlb", + "balance-alb", +} + + +@dataclass(frozen=True) +class TopologyNetwork: + client: str + server: str + bond: bool + bond_mode: str | None + + +@dataclass(frozen=True) +class TestTopology: + mtu: int + networks: tuple[TopologyNetwork, ...] + + +def parse_topology( + config: dict, environment: str, path: str = "test.yaml" +) -> TestTopology | None: + """Parse the optional custom inline topology from a test definition.""" + raw = config.get("topology") + if raw is None: + return None + if environment != "inline": + raise ValueError(f'{path}: custom "topology" is only supported in the inline environment') + if not isinstance(raw, dict): + raise ValueError(f'{path}: "topology" must be a mapping') + + unknown = set(raw) - {"mtu", "networks"} + if unknown: + raise ValueError( + f"{path}: unknown topology key(s): {', '.join(sorted(map(str, unknown)))}" + ) + + mtu = raw.get("mtu", 1500) + if not isinstance(mtu, int) or isinstance(mtu, bool) or not 68 <= mtu <= 65535: + raise ValueError(f'{path}: topology "mtu" must be an integer from 68 to 65535') + + raw_networks = raw.get("networks") + if not isinstance(raw_networks, list) or not raw_networks: + raise ValueError(f'{path}: topology "networks" must contain at least one entry') + + networks = [] + for index, raw_network in enumerate(raw_networks): + prefix = f'{path}: topology network {index}' + if not isinstance(raw_network, dict): + raise ValueError(f"{prefix} must be a mapping") + unknown = set(raw_network) - {"client", "server", "bond", "bond-mode"} + if unknown: + raise ValueError( + f"{prefix} has unknown key(s): {', '.join(sorted(map(str, unknown)))}" + ) + if "client" not in raw_network or "server" not in raw_network: + raise ValueError(f'{prefix} requires explicit "client" and "server" CIDRs') + bond = raw_network.get("bond", False) + if not isinstance(bond, bool): + raise ValueError(f'{prefix} "bond" must be true or false') + bond_mode = raw_network.get("bond-mode") + if bond: + if bond_mode not in BOND_MODES: + modes = ", ".join(sorted(BOND_MODES)) + raise ValueError( + f'{prefix} "bond-mode" must be present and one of: {modes}' + ) + elif bond_mode is not None: + raise ValueError(f'{prefix} "bond-mode" requires "bond: true"') + try: + client = ipaddress.ip_interface(raw_network["client"]) + server = ipaddress.ip_interface(raw_network["server"]) + except (TypeError, ValueError) as err: + raise ValueError(f"{prefix} has an invalid CIDR: {err}") from err + if client.version != 4 or server.version != 4: + raise ValueError(f"{prefix} only supports IPv4 CIDRs") + if client.network != server.network: + raise ValueError(f"{prefix} client and server must be in the same subnet") + if client.ip == server.ip: + raise ValueError(f"{prefix} client and server addresses must differ") + + for iface in (f"client{index}-b", f"server{index}-b"): + if len(iface) > 15: + raise ValueError(f"{prefix} would create an interface name longer than 15 characters") + networks.append( + TopologyNetwork( + client=str(client), + server=str(server), + bond=bond, + bond_mode=bond_mode, + ) + ) + + return TestTopology(mtu=mtu, networks=tuple(networks)) + + +class RunnerLock: + """Non-blocking process lock for the shared live-test namespaces.""" + + def __init__(self, path: str = RUNNER_LOCK_PATH) -> None: + self.path = path + self.file = None + + def __enter__(self): + self.file = open(self.path, "a+", encoding="utf-8") + try: + fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + self.file.seek(0) + holder = self.file.read().strip() + print( + "ERROR: another live test runner is already active", + file=sys.stderr, + ) + print(f"Lock file: {self.path}", file=sys.stderr) + if holder: + print(holder, file=sys.stderr) + self.file.close() + sys.exit(1) + + self.file.seek(0) + self.file.truncate() + self.file.write(f"pid: {os.getpid()}\n") + self.file.write(f"cwd: {os.getcwd()}\n") + self.file.write(f"cmd: {shlex.join(sys.argv)}\n") + self.file.flush() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if self.file is None: + return + self.file.seek(0) + self.file.truncate() + fcntl.flock(self.file, fcntl.LOCK_UN) + self.file.close() + self.file = None + + +def configure_script_env() -> None: + """Expose helper binaries to client/server scripts via environment variables.""" + cwd = os.getcwd() + candidates = [ + os.path.join(cwd, "rust", "target", "debug", "suricatasc"), + os.path.join(cwd, "rust", "target", "release", "suricatasc"), + ] + for candidate in candidates: + if os.path.isfile(candidate): + os.environ["SURICATASC"] = os.path.realpath(candidate) + return + os.environ.pop("SURICATASC", None) + + +def load_markdown_frontmatter(path: str) -> dict: + """Load YAML (---) frontmatter from a Markdown file.""" + if not os.path.isfile(path): + return {} + + with open(path, encoding="utf-8") as f: + lines = f.read().splitlines() + + if not lines or lines[0].strip() != "---": + return {} + + body = [] + for line in lines[1:]: + if line.strip() == "---": + raw = "\n".join(body) + data = yaml.safe_load(raw) or {} + if not isinstance(data, dict): + raise ValueError(f"{path}: frontmatter must be a mapping") + return data + body.append(line) + + raise ValueError(f"{path}: unterminated frontmatter") + + +def get_test_tags(test_dir: str) -> set[str]: + """Return normalized tags from a test README frontmatter.""" + readme_path = None + for candidate in ("README.md", "readme.md"): + path = os.path.join(test_dir, candidate) + if os.path.isfile(path): + readme_path = path + break + + if readme_path is None: + return set() + + frontmatter = load_markdown_frontmatter(readme_path) + tags = frontmatter.get("tags", []) + if tags is None: + return set() + if isinstance(tags, str): + tags = [tags] + if not isinstance(tags, list) or any(not isinstance(tag, str) for tag in tags): + raise ValueError( + f"{readme_path}: frontmatter 'tags' must be a string or list of strings" + ) + return {tag.strip().lower() for tag in tags if tag.strip()} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Live verification test runner.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="show Suricata stdout/stderr output", + ) + + parser.add_argument( + "--environment", + choices=list(ENVIRONMENTS), + help="only run tests for this environment", + ) + parser.add_argument( + "--tag", + action="append", + default=[], + metavar="TAG", + help="only run tests whose README.md frontmatter tags include TAG; may be specified multiple times", + ) + parser.add_argument( + "--exact", + action="store_true", + help="use supplied names as exact matches", + ) + parser.add_argument( + "--skip-tests", + metavar="PATTERNS", + help="skip tests matching a comma-separated list of patterns", + ) + parser.add_argument( + "patterns", + nargs="*", + default=[], + help="only run tests whose names match these patterns", + ) + + return parser + + +def need_root() -> None: + if os.geteuid() == 0: + return + print( + "ERROR: this script must be run as root. Use: sudo run.py ...", + file=sys.stderr, + ) + sys.exit(1) + + +def need_cmd(cmd: str) -> None: + if shutil.which(cmd) is None: + print(f"ERROR: missing command: {cmd}", file=sys.stderr) + sys.exit(1) + + +def run( + cmd: list[str], *, quiet: bool = False, capture: bool = False +) -> subprocess.CompletedProcess[str]: + kwargs: dict[str, object] = { + "check": True, + "universal_newlines": True, + } + if quiet: + kwargs["stdout"] = subprocess.DEVNULL + kwargs["stderr"] = subprocess.DEVNULL + elif capture: + kwargs["stdout"] = subprocess.PIPE + kwargs["stderr"] = subprocess.PIPE + return subprocess.run(cmd, **kwargs) + + +def run_quiet(cmd: list[str]) -> None: + subprocess.run( + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False + ) + + +def ns_exec( + ns: str, cmd: list[str], *, quiet: bool = False, capture: bool = False +) -> subprocess.CompletedProcess[str]: + return run(["ip", "netns", "exec", ns, *cmd], quiet=quiet, capture=capture) + + +def ns_run_quiet(ns: str, cmd: list[str]) -> None: + run_quiet(["ip", "netns", "exec", ns, *cmd]) + + +def netns_exists(ns: str) -> bool: + result = run(["ip", "netns", "list"], capture=True) + for line in result.stdout.splitlines(): + parts = line.split() + if parts and parts[0] == ns: + return True + return False + + +def kill_ns_processes(ns: str) -> None: + if not netns_exists(ns): + return + + result = run(["ip", "netns", "pids", ns], capture=True) + pids = [pid for pid in result.stdout.split() if pid.isdigit()] + if not pids: + return + + run_quiet(["kill", *pids]) + time.sleep(0.1) + + result = run(["ip", "netns", "pids", ns], capture=True) + pids = [pid for pid in result.stdout.split() if pid.isdigit()] + if pids: + run_quiet(["kill", "-9", *pids]) + + +def disable_offloads(ns: str, iface: str) -> None: + for feature in ("rx", "tx", "tso", "gro", "lro", "gso", "sg", "rxvlan", "txvlan"): + ns_run_quiet(ns, ["ethtool", "-K", iface, feature, "off"]) + + +def setup_namespaces() -> None: + for ns in ALL_NAMESPACES: + run(["ip", "netns", "add", ns]) + run(["ip", "-n", ns, "link", "set", "lo", "up"]) + ns_exec( + ns, ["sysctl", "-w", "net.ipv4.ping_group_range=0 2147483647"], quiet=True + ) + + +def setup_links() -> None: + run( + [ + "ip", + "link", + "add", + TMP_CLIENT_IF, + "type", + "veth", + "peer", + "name", + TMP_DUT_CLIENT_IF, + ] + ) + run( + [ + "ip", + "link", + "add", + TMP_SERVER_IF, + "type", + "veth", + "peer", + "name", + TMP_DUT_SERVER_IF, + ] + ) + + run(["ip", "link", "set", TMP_CLIENT_IF, "netns", CLIENT_NS]) + run(["ip", "link", "set", TMP_SERVER_IF, "netns", SERVER_NS]) + run(["ip", "link", "set", TMP_DUT_CLIENT_IF, "netns", DUT_NS]) + run(["ip", "link", "set", TMP_DUT_SERVER_IF, "netns", DUT_NS]) + + run(["ip", "-n", CLIENT_NS, "link", "set", TMP_CLIENT_IF, "name", CLIENT_IF]) + run(["ip", "-n", SERVER_NS, "link", "set", TMP_SERVER_IF, "name", SERVER_IF]) + run(["ip", "-n", DUT_NS, "link", "set", TMP_DUT_CLIENT_IF, "name", DUT_CLIENT_IF]) + run(["ip", "-n", DUT_NS, "link", "set", TMP_DUT_SERVER_IF, "name", DUT_SERVER_IF]) + + for ns, iface in ( + (CLIENT_NS, CLIENT_IF), + (SERVER_NS, SERVER_IF), + (DUT_NS, DUT_CLIENT_IF), + (DUT_NS, DUT_SERVER_IF), + ): + run(["ip", "-n", ns, "link", "set", iface, "mtu", MTU]) + + +def bring_up_interface(ns: str, iface: str) -> None: + disable_offloads(ns, iface) + ns_exec(ns, ["ip", "link", "set", iface, "up"]) + + +def add_address(ns: str, iface: str, cidr: str) -> None: + ns_exec(ns, ["ip", "addr", "add", cidr, "dev", iface]) + + +def replace_default_route(ns: str, via: str) -> None: + ns_exec(ns, ["ip", "route", "replace", "default", "via", via]) + + +def topology_namespaces(topology: TestTopology) -> tuple[str, ...]: + namespaces = [] + for index in range(len(topology.networks)): + namespaces.extend((f"client{index}", f"server{index}")) + namespaces.append(DUT_NS) + return tuple(namespaces) + + +def topology_root_links(topology: TestTopology) -> tuple[str, ...]: + links = [] + for index, network in enumerate(topology.networks): + members = "ab" if network.bond else "a" + for member in members: + links.extend( + ( + f"vc{index}{member}", + f"vdc{index}{member}", + f"vs{index}{member}", + f"vds{index}{member}", + ) + ) + return tuple(links) + + +def setup_topology_namespaces(topology: TestTopology) -> None: + for ns in topology_namespaces(topology): + run(["ip", "netns", "add", ns]) + run(["ip", "-n", ns, "link", "set", "lo", "up"]) + ns_exec( + ns, ["sysctl", "-w", "net.ipv4.ping_group_range=0 2147483647"], quiet=True + ) + + +def setup_topology_side( + index: int, side: str, network: TopologyNetwork, mtu: int +) -> None: + endpoint_ns = f"{side}{index}" + endpoint_if = side + dut_if = f"{side}{index}" + members = "ab" if network.bond else "a" + + for member in members: + root_endpoint = f"v{side[0]}{index}{member}" + root_dut = f"vd{side[0]}{index}{member}" + run( + [ + "ip", "link", "add", root_endpoint, "type", "veth", + "peer", "name", root_dut, + ] + ) + run(["ip", "link", "set", root_endpoint, "netns", endpoint_ns]) + run(["ip", "link", "set", root_dut, "netns", DUT_NS]) + + endpoint_member = f"{side}-{member}" if network.bond else endpoint_if + dut_member = f"{dut_if}-{member}" if network.bond else dut_if + run(["ip", "-n", endpoint_ns, "link", "set", root_endpoint, "name", endpoint_member]) + run(["ip", "-n", DUT_NS, "link", "set", root_dut, "name", dut_member]) + run(["ip", "-n", endpoint_ns, "link", "set", endpoint_member, "mtu", str(mtu)]) + run(["ip", "-n", DUT_NS, "link", "set", dut_member, "mtu", str(mtu)]) + + if network.bond: + for ns, bond_if, member_prefix in ( + (endpoint_ns, endpoint_if, side), + (DUT_NS, dut_if, dut_if), + ): + assert network.bond_mode is not None + ns_exec( + ns, + [ + "ip", + "link", + "add", + bond_if, + "type", + "bond", + "mode", + network.bond_mode, + ], + ) + ns_exec(ns, ["ip", "link", "set", bond_if, "mtu", str(mtu)]) + for member in members: + member_if = f"{member_prefix}-{member}" + ns_exec(ns, ["ip", "link", "set", member_if, "master", bond_if]) + ns_exec(ns, ["ip", "link", "set", member_if, "up"]) + + +def inline_topology_up(topology: TestTopology) -> None: + do_down(topology=topology) + setup_topology_namespaces(topology) + for index, network in enumerate(topology.networks): + setup_topology_side(index, "client", network, topology.mtu) + setup_topology_side(index, "server", network, topology.mtu) + add_address(f"client{index}", CLIENT_IF, network.client) + add_address(f"server{index}", SERVER_IF, network.server) + for ns, iface in ( + (f"client{index}", CLIENT_IF), + (f"server{index}", SERVER_IF), + (DUT_NS, f"client{index}"), + (DUT_NS, f"server{index}"), + ): + bring_up_interface(ns, iface) + + +def setup_common_topology() -> None: + do_down() + setup_namespaces() + setup_links() + + +def inline_up() -> None: + setup_common_topology() + + add_address(CLIENT_NS, CLIENT_IF, L2_CLIENT_IP) + add_address(SERVER_NS, SERVER_IF, L2_SERVER_IP) + + bring_up_interface(CLIENT_NS, CLIENT_IF) + bring_up_interface(SERVER_NS, SERVER_IF) + bring_up_interface(DUT_NS, DUT_CLIENT_IF) + bring_up_interface(DUT_NS, DUT_SERVER_IF) + +def setup_tap_bridge() -> None: + ns_exec(DUT_NS, ["ip", "link", "add", "name", DUT_BRIDGE_IF, "type", "bridge"]) + ns_exec(DUT_NS, ["ip", "link", "set", DUT_CLIENT_IF, "master", DUT_BRIDGE_IF]) + ns_exec(DUT_NS, ["ip", "link", "set", DUT_SERVER_IF, "master", DUT_BRIDGE_IF]) + ns_exec(DUT_NS, ["ip", "link", "set", DUT_BRIDGE_IF, "up"]) + + +def tap_up() -> None: + setup_common_topology() + + add_address(CLIENT_NS, CLIENT_IF, L2_CLIENT_IP) + add_address(SERVER_NS, SERVER_IF, L2_SERVER_IP) + + setup_tap_bridge() + + bring_up_interface(CLIENT_NS, CLIENT_IF) + bring_up_interface(SERVER_NS, SERVER_IF) + bring_up_interface(DUT_NS, DUT_CLIENT_IF) + bring_up_interface(DUT_NS, DUT_SERVER_IF) + +def setup_nfq_iptables() -> None: + ns_exec(DUT_NS, ["iptables", "-F"]) + ns_exec(DUT_NS, ["iptables", "-P", "FORWARD", "DROP"]) + ns_exec( + DUT_NS, + [ + "iptables", + "-A", + "FORWARD", + "-i", + DUT_CLIENT_IF, + "-o", + DUT_SERVER_IF, + "-j", + "NFQUEUE", + "--queue-num", + NFQ_QUEUE_NUM, + ], + ) + ns_exec( + DUT_NS, + [ + "iptables", + "-A", + "FORWARD", + "-i", + DUT_CLIENT_IF, + "-o", + DUT_SERVER_IF, + "-j", + "ACCEPT", + ], + ) + ns_exec( + DUT_NS, + [ + "iptables", + "-A", + "FORWARD", + "-i", + DUT_SERVER_IF, + "-o", + DUT_CLIENT_IF, + "-j", + "NFQUEUE", + "--queue-num", + NFQ_QUEUE_NUM, + ], + ) + ns_exec( + DUT_NS, + [ + "iptables", + "-A", + "FORWARD", + "-i", + DUT_SERVER_IF, + "-o", + DUT_CLIENT_IF, + "-j", + "ACCEPT", + ], + ) + + +def nfq_up() -> None: + setup_common_topology() + + add_address(CLIENT_NS, CLIENT_IF, NFQ_CLIENT_IP) + add_address(SERVER_NS, SERVER_IF, NFQ_SERVER_IP) + add_address(DUT_NS, DUT_CLIENT_IF, NFQ_DUT_CLIENT_IP) + add_address(DUT_NS, DUT_SERVER_IF, NFQ_DUT_SERVER_IP) + + bring_up_interface(CLIENT_NS, CLIENT_IF) + bring_up_interface(SERVER_NS, SERVER_IF) + bring_up_interface(DUT_NS, DUT_CLIENT_IF) + bring_up_interface(DUT_NS, DUT_SERVER_IF) + + replace_default_route(CLIENT_NS, NFQ_CLIENT_GW) + replace_default_route(SERVER_NS, NFQ_SERVER_GW) + + ns_exec(DUT_NS, ["sysctl", "-w", "net.ipv4.ip_forward=1"], quiet=True) + setup_nfq_iptables() + +def framework_namespaces(topology: TestTopology | None = None) -> tuple[str, ...]: + """Return expected and existing namespaces reserved by the live runner.""" + namespaces = set(ALL_NAMESPACES) + if topology: + namespaces.update(topology_namespaces(topology)) + + result = run(["ip", "netns", "list"], capture=True) + for line in result.stdout.splitlines(): + parts = line.split() + if parts and re.fullmatch(r"(?:client|server)\d+|dut", parts[0]): + namespaces.add(parts[0]) + return tuple(sorted(namespaces)) + + +def framework_root_links(topology: TestTopology | None = None) -> tuple[str, ...]: + """Return expected and existing root links reserved by the live runner.""" + links = set(ROOT_LINKS) + if topology: + links.update(topology_root_links(topology)) + + for link in os.listdir("/sys/class/net"): + if re.fullmatch(r"v(?:d)?[cs]\d+[ab]", link): + links.add(link) + return tuple(sorted(links)) + + +def do_down(*, topology: TestTopology | None = None) -> None: + namespaces = framework_namespaces(topology) + root_links = framework_root_links(topology) + + for ns in namespaces: + kill_ns_processes(ns) + + for link in root_links: + run_quiet(["ip", "link", "del", link]) + + for ns in namespaces: + run_quiet(["ip", "netns", "del", ns]) + +UP_FUNCS = { + "nfq": nfq_up, + "inline": inline_up, + "tap": tap_up, +} + +SURICATA_READY_MARKER = "Engine started" + + +def render_test_include(test_dir: str, output_dir: str) -> str | None: + """Render test include.yaml into the output directory, if present.""" + src = os.path.join(test_dir, "include.yaml") + if not os.path.isfile(src): + return None + + with open(src, encoding="utf-8") as f: + content = f.read() + + content = content.replace("${TESTDIR}", os.path.realpath(test_dir)) + content = content.replace("${OUTDIR}", os.path.realpath(output_dir)) + + dst = os.path.join(output_dir, "include.yaml") + with open(dst, "w", encoding="utf-8") as f: + f.write(content) + return dst + + +def get_include_args(test_include: str | None = None) -> list[str]: + """Return Suricata --include args for the selected test.""" + args = [] + if test_include: + args += ["--include", os.path.realpath(test_include)] + return args + + +def build_test_env(test_dir: str, output_dir: str) -> dict[str, str]: + """Return the standard environment exposed to test scripts and checks.""" + env = os.environ.copy() + env["SRCDIR"] = os.getcwd() + env["TZ"] = "UTC" + env["TESTDIR"] = os.path.realpath(test_dir) + env["TEST_DIR"] = env["TESTDIR"] + env["OUTDIR"] = os.path.realpath(output_dir) + env["OUTPUT_DIR"] = env["OUTDIR"] + return env + + +def get_test_args( + config: dict, test_dir: str | None = None, output_dir: str | None = None +) -> list[str]: + """Return Suricata CLI args from test.yaml.""" + raw_args = config.get("args", []) + if raw_args is None: + return [] + if not isinstance(raw_args, list): + raise ValueError('test.yaml "args" must be an array') + + substitutions = {"SRCDIR": os.getcwd()} + if test_dir: + substitutions["TESTDIR"] = os.path.realpath(test_dir) + substitutions["TEST_DIR"] = substitutions["TESTDIR"] + if output_dir: + substitutions["OUTDIR"] = os.path.realpath(output_dir) + substitutions["OUTPUT_DIR"] = substitutions["OUTDIR"] + + args: list[str] = [] + for arg in raw_args: + if not isinstance(arg, str): + raise ValueError('test.yaml "args" entries must be strings') + rendered = string.Template(arg).safe_substitute(substitutions) + args.extend(shlex.split(rendered)) + return args + + +def has_suricata_runmode_arg(args: list[str]) -> bool: + """Return true if args select a Suricata capture/runmode.""" + for arg in args: + if arg in ("--af-packet", "--pcap", "--dpdk", "-q"): + return True + if arg.startswith("--af-packet=") or arg.startswith("--pcap="): + return True + return False + + +def start_suricata( + environment: str, + script_dir: str, + test_dir: str, + output_dir: str, + config: dict, + test_include: str | None = None, +) -> subprocess.Popen[str]: + """Start Suricata in the DUT namespace and return the Popen handle.""" + cwd = os.getcwd() + suricata_bin = os.path.join(cwd, "src", "suricata") + suricata_yaml = os.path.join(cwd, "suricata.yaml") + + cmd = [ + "ip", + "netns", + "exec", + DUT_NS, + suricata_bin, + "-c", + suricata_yaml, + *get_include_args(test_include), + "-l", + output_dir, + "--set", + f"unix-command.filename={os.path.join(output_dir, 'suricata.socket')}", + "--set", + f"classification-file={os.path.join(cwd, 'etc', 'classification.config')}", + "--set", + "reference-config-file=./etc/reference.config", + "--set", + "threshold-file=./threshold.config", + ] + + if environment not in UP_FUNCS: + raise ValueError(f"start_suricata: unsupported environment '{environment}'") + + test_args = get_test_args(config, test_dir, output_dir) + if not has_suricata_runmode_arg(test_args): + raise ValueError( + 'test.yaml "args" must include Suricata runmode args ' + "(--af-packet, --pcap=..., --dpdk, or -q ...)" + ) + cmd += test_args + + if verbose: + print(f"===> Suricata command: {shlex.join(cmd)}") + + return subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + +def tee_stream(pipe, log_file, label: str) -> None: + """Read from pipe, write every line to log_file, and print if verbose. + + During shutdown, another thread may close either the pipe or the log file. + Treat that as normal teardown instead of raising noisy thread exceptions. + """ + try: + for line in pipe: + try: + log_file.write(line) + log_file.flush() + except ValueError: + return + if verbose: + print(f"===> {label}: {line}", end="") + except ValueError: + return + + +def wait_for_suricata( + proc: subprocess.Popen[str], stdout_log, timeout: float = 60 +) -> bool: + """Wait until Suricata logs that the engine has started. Returns True on success.""" + deadline = time.monotonic() + timeout + assert proc.stdout is not None + while time.monotonic() < deadline: + line = proc.stdout.readline() + if not line: + if proc.poll() is not None: + print( + "===> ERROR: Suricata exited before becoming ready", file=sys.stderr + ) + return False + continue + stdout_log.write(line) + stdout_log.flush() + if verbose: + print(f"===> suricata stdout: {line}", end="") + if SURICATA_READY_MARKER in line: + return True + print("===> ERROR: timed out waiting for Suricata to start", file=sys.stderr) + return False + + +def stop_suricata(proc: subprocess.Popen[str], timeout: float = 30) -> int: + """Send SIGTERM to Suricata and wait for it to exit.""" + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + print( + "===> WARNING: Suricata did not exit in time, sending SIGKILL", + file=sys.stderr, + ) + proc.kill() + proc.wait() + return proc.returncode + + +def write_script(script: str) -> str: + with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f: + f.write("#!/bin/bash\nset -e\n") + f.write(script) + f.flush() + script_path = f.name + os.chmod(script_path, 0o755) + return script_path + + +def terminate_process_group(pgid: int, timeout: float = 10) -> None: + """Terminate a process group and wait briefly for it to disappear.""" + try: + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + return + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return + time.sleep(0.1) + + try: + os.killpg(pgid, signal.SIGKILL) + except ProcessLookupError: + return + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return + time.sleep(0.1) + + +def run_script_logged( + script: str, cwd: str, stdout_path: str, stderr_path: str, label: str = "script" +) -> int: + """Run a script, logging stdout/stderr to files and teeing to terminal in real time.""" + script_path = write_script(script) + try: + with open(stdout_path, "w") as out_f, open(stderr_path, "w") as err_f: + proc = subprocess.Popen( + ["bash", script_path], + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + stdout_thread = threading.Thread( + target=tee_stream, + args=(proc.stdout, out_f, f"{label} stdout"), + daemon=True, + ) + stderr_thread = threading.Thread( + target=tee_stream, + args=(proc.stderr, err_f, f"{label} stderr"), + daemon=True, + ) + stdout_thread.start() + stderr_thread.start() + result = proc.wait() + terminate_process_group(proc.pid) + + for pipe in (proc.stdout, proc.stderr): + if pipe is not None: + try: + pipe.close() + except Exception: + pass + + stdout_thread.join(timeout=5) + stderr_thread.join(timeout=5) + return result + finally: + os.unlink(script_path) + + +@dataclass +class ServerScript: + """Manages a background server script with log capture and cleanup.""" + + proc: subprocess.Popen[str] + script_path: str + stdout_log: IO[str] + stderr_log: IO[str] + stdout_thread: threading.Thread + stderr_thread: threading.Thread + + def wait_for_start(self, grace_period: float = 1.0) -> bool: + """Return True if the script stays alive for a brief startup window.""" + deadline = time.monotonic() + grace_period + while time.monotonic() < deadline: + if self.proc.poll() is not None: + return False + time.sleep(0.1) + return self.proc.poll() is None + + def stop(self, timeout: float = 10) -> int: + """Stop the script, join threads, close logs, and clean up the temp file.""" + try: + os.killpg(self.proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + else: + try: + self.proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(self.proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self.proc.wait() + + # Reap the script before polling the process group. Otherwise the + # exited script can remain as a zombie and make os.killpg(pgid, 0) + # look alive until the timeout expires. + if self.proc.returncode is None: + self.proc.wait() + terminate_process_group(self.proc.pid, timeout=1) + + # Ensure the tee threads see EOF/shutdown before we close their log files. + for pipe in (self.proc.stdout, self.proc.stderr): + if pipe is not None: + try: + pipe.close() + except Exception: + pass + + self.stdout_thread.join(timeout=5) + self.stderr_thread.join(timeout=5) + self.stdout_log.close() + self.stderr_log.close() + + try: + os.unlink(self.script_path) + except FileNotFoundError: + pass + + return self.proc.returncode if self.proc.returncode is not None else 0 + + +def start_background_script( + script: str, + cwd: str, + stdout_path: str, + stderr_path: str, + label: str = "script", +) -> ServerScript: + """Start a background server script, logging stdout/stderr to files. Print if verbose.""" + script_path = write_script(script) + try: + proc = subprocess.Popen( + ["bash", script_path], + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + except Exception: + os.unlink(script_path) + raise + + stdout_log = open(stdout_path, "w") + stderr_log = open(stderr_path, "w") + stdout_thread = threading.Thread( + target=tee_stream, + args=(proc.stdout, stdout_log, f"{label} stdout"), + daemon=True, + ) + stderr_thread = threading.Thread( + target=tee_stream, + args=(proc.stderr, stderr_log, f"{label} stderr"), + daemon=True, + ) + stdout_thread.start() + stderr_thread.start() + return ServerScript( + proc=proc, + script_path=script_path, + stdout_log=stdout_log, + stderr_log=stderr_log, + stdout_thread=stdout_thread, + stderr_thread=stderr_thread, + ) + + +SuricataVersion = namedtuple("SuricataVersion", ["major", "minor", "patch"]) + + +def parse_suricata_version( + buf: object, expr: str | None = None +) -> SuricataVersion | None: + m = re.search( + r"(?:Suricata version |^)(\d+)\.?((?:\d+))?\.?((?:\d+))?.*", str(buf).strip() + ) + default_v = 0 + if expr == "equal": + default_v = None + if not m: + return None + + major = int(m.group(1)) if m.group(1) else default_v + minor = int(m.group(2)) if m.group(2) else default_v + patch = int(m.group(3)) if m.group(3) else default_v + return SuricataVersion(major=major, minor=minor, patch=patch) + + +class Version: + """Class to compare Suricata versions.""" + + def is_equal(self, a: SuricataVersion, b: SuricataVersion) -> bool: + if a.major != b.major: + return False + if a.minor is not None and b.minor is not None and a.minor != b.minor: + return False + if a.patch is not None and b.patch is not None and a.patch != b.patch: + return False + return True + + def is_gte(self, v1: SuricataVersion, v2: SuricataVersion) -> bool: + if v1.major < v2.major: + return False + if v1.major > v2.major: + return True + if v1.minor < v2.minor: + return False + if v1.minor > v2.minor: + return True + if v1.patch < v2.patch: + return False + return True + + def is_gt(self, v1: SuricataVersion, v2: SuricataVersion) -> bool: + if v1.major < v2.major: + return False + if v1.major > v2.major: + return True + if v1.minor < v2.minor: + return False + if v1.minor > v2.minor: + return True + if v1.patch < v2.patch: + return False + if v1.patch == v2.patch: + return False + return True + + def is_lt(self, v1: SuricataVersion, v2: SuricataVersion) -> bool: + if v1.major > v2.major: + return False + if v1.major < v2.major: + return True + if v1.minor < v2.minor: + return True + if v1.patch < v2.patch: + return True + return False + + +class SuricataConfig: + def __init__(self, suricata_bin: str, version: SuricataVersion) -> None: + self.suricata_bin = suricata_bin + self.version = version + self.features = set() + self.config = {} + self.load_build_info() + + def load_build_info(self) -> None: + output = subprocess.check_output([self.suricata_bin, "--build-info"]) + start_support = False + for line in output.splitlines(): + decoded = line.decode() + if decoded.startswith("Features:"): + self.features = set(decoded.split()[1:]) + if "Suricata Configuration" in decoded: + start_support = True + if start_support and "support:" in decoded: + fkey, val = decoded.split(" support:") + fkey = fkey.strip() + val = val.strip() + if val.startswith("yes"): + self.features.add(fkey) + + def load_config( + self, config_filename: str, extra_args: list[str] | None = None + ) -> None: + cmd = [self.suricata_bin, "-c", config_filename] + if extra_args: + cmd.extend(extra_args) + cmd.append("--dump-config") + output = subprocess.check_output(cmd) + self.config = {} + for line in output.decode("utf-8").split("\n"): + parts = [p.strip() for p in line.split("=", 1)] + if parts and parts[0]: + self.config[parts[0]] = parts[1] if len(parts) > 1 else "" + + def has_feature(self, feature: str) -> bool: + return feature in self.features + + +def get_suricata_config( + environment: str, + script_dir: str, + config: dict, + test_dir: str, + output_dir: str, + test_include: str | None = None, +) -> SuricataConfig: + extra_args = [ + *get_include_args(test_include), + *get_test_args(config, test_dir, output_dir), + ] + cache_key = ( + environment, + os.path.realpath(test_include) if test_include else None, + tuple(extra_args), + ) + if cache_key in suricata_config_cache: + return suricata_config_cache[cache_key] + + cwd = os.getcwd() + suricata_bin = os.path.join(cwd, "src", "suricata") + suricata_yaml = os.path.join(cwd, "suricata.yaml") + version = parse_suricata_version(subprocess.check_output([suricata_bin, "-V"])) + if version is None: + raise ValueError("failed to determine Suricata version") + + suricata_config = SuricataConfig(suricata_bin, version) + suricata_config.load_config(suricata_yaml, extra_args) + suricata_config_cache[cache_key] = suricata_config + return suricata_config + + +def is_version_compatible( + version: str, suri_version: SuricataVersion, expr: str +) -> bool: + config_version = parse_suricata_version(version, expr) + if config_version is None: + return False + version_obj = Version() + func = getattr(version_obj, f"is_{expr}") + return func(suri_version, config_version) + + +def check_required_commands(requires: dict) -> None: + check_common_required_commands(requires) + + +def check_requires( + requires: dict, suricata_config: SuricataConfig, suri_dir: str | None = None +) -> None: + check_common_requires( + requires, + suricata_config, + is_version_compatible, + suri_dir, + eval_globals=globals(), + include_script_error=True, + ) + + +def run_checks( + config: dict, output_dir: str, environment: str, script_dir: str, test_dir: str +) -> tuple[list[str], list[str]]: + """Run post-teardown checks. + + Returns (failures, warnings). + """ + failures = [] + warnings = [] + supported_checks = {"stats", "filter", "shell"} + suricata_config = None + test_include = os.path.join(output_dir, "include.yaml") + if not os.path.isfile(test_include): + test_include = None + + for i, check in enumerate(config.get("checks", []), start=1): + if "stats" in check: + result = StatsCheck(check["stats"], output_dir).run() + failures.extend(result.failures) + warnings.extend(result.warnings) + if "filter" in check or "shell" in check: + try: + if suricata_config is None: + suricata_config = get_suricata_config( + environment, script_dir, config, test_dir, output_dir, test_include + ) + if "filter" in check: + result = FilterCheck( + check["filter"], + output_dir, + suricata_config=suricata_config, + test_dir=test_dir, + require_checker=check_requires, + skip_as_warning=True, + ).run() + failures.extend(result.failures) + warnings.extend(result.warnings) + if "shell" in check: + result = ShellCheck( + check["shell"], + build_test_env(test_dir, output_dir), + output_dir, + suricata_config=suricata_config, + test_dir=test_dir, + require_checker=check_requires, + skip_as_warning=True, + use_bash=True, + ).run() + failures.extend(result.failures) + warnings.extend(result.warnings) + except Exception as err: + check_type = "filter" if "filter" in check else "shell" + failures.append(f"{check_type} check #{i} failed: {err}") + for key in check: + if key not in supported_checks: + warnings.append( + f"WARNING: unsupported check type '{key}' in check #{i}" + ) + return failures, warnings + + +def add_script_log_paths(failures: list[str], output_dir: str, name: str) -> None: + """Append the stdout/stderr log paths for a script to the failure list.""" + failures.append(f"{name} stdout: {os.path.join(output_dir, f'{name}.stdout')}") + failures.append(f"{name} stderr: {os.path.join(output_dir, f'{name}.stderr')}") + + +def log_test_step(environment: str, test_name: str, message: str) -> None: + print(f"===> [{environment}/{test_name}] {message}", flush=True) + + +def run_test( + test_name: str, + environment: str, + config: dict, + script_dir: str, + test_dir: str, + topology: TestTopology | None = None, +) -> list[str]: + """Run a single test in the given environment. Returns failure messages.""" + failures = [] + client_script = config.get("client") + if not client_script: + return ["no client script defined"] + + before_script = config.get("before") + server_script = config.get("server") + + output_dir = os.path.join(test_dir, "output") + if os.path.exists(output_dir): + shutil.rmtree(output_dir) + os.makedirs(output_dir) + + test_env = build_test_env(test_dir, output_dir) + managed_env_keys = ["SRCDIR", "TZ", "TESTDIR", "TEST_DIR", "OUTDIR", "OUTPUT_DIR"] + prev_test_env = {key: os.environ.get(key) for key in managed_env_keys} + for key in managed_env_keys: + os.environ[key] = test_env[key] + test_include = render_test_include(test_dir, output_dir) + + stdout_log = None + stderr_log = None + suricata = None + stderr_thread = None + stdout_thread = None + server: ServerScript | None = None + client_rc = -1 + server_rc = 0 + try: + log_test_step(environment, test_name, f"Setting up {environment} environment") + if topology: + inline_topology_up(topology) + else: + UP_FUNCS[environment]() + + if before_script: + log_test_step(environment, test_name, "Running before script") + before_rc = run_script_logged( + before_script, + test_dir, + os.path.join(output_dir, "before.stdout"), + os.path.join(output_dir, "before.stderr"), + label="before", + ) + if before_rc != 0: + failures.append(f"before script exited with code {before_rc}") + add_script_log_paths(failures, output_dir, "before") + return failures + + log_test_step(environment, test_name, "Starting Suricata") + + stdout_log = open(os.path.join(output_dir, "stdout"), "w") + stderr_log = open(os.path.join(output_dir, "stderr"), "w") + try: + suricata = start_suricata( + environment, script_dir, test_dir, output_dir, config, test_include + ) + except ValueError as err: + failures.append(str(err)) + return failures + + stderr_thread = threading.Thread( + target=tee_stream, + args=(suricata.stderr, stderr_log, "suricata stderr"), + daemon=True, + ) + stderr_thread.start() + + if not wait_for_suricata(suricata, stdout_log): + failures.append("Suricata did not become ready") + failures.append(f"suricata stdout: {os.path.join(output_dir, 'stdout')}") + failures.append(f"suricata stderr: {os.path.join(output_dir, 'stderr')}") + return failures + + stdout_thread = threading.Thread( + target=tee_stream, + args=(suricata.stdout, stdout_log, "suricata stdout"), + daemon=True, + ) + stdout_thread.start() + + if server_script: + log_test_step(environment, test_name, "Starting Server") + server = start_background_script( + server_script, + test_dir, + os.path.join(output_dir, "server.stdout"), + os.path.join(output_dir, "server.stderr"), + label="server", + ) + if not server.wait_for_start(): + server_rc = ( + server.proc.returncode if server.proc.returncode is not None else 1 + ) + failures.append( + f"server script exited during startup with code {server_rc}" + ) + add_script_log_paths(failures, output_dir, "server") + return failures + + log_test_step(environment, test_name, "Running Client") + client_rc = run_script_logged( + client_script, + test_dir, + os.path.join(output_dir, "client.stdout"), + os.path.join(output_dir, "client.stderr"), + label="client", + ) + if client_rc != 0: + failures.append(f"client script exited with code {client_rc}") + add_script_log_paths(failures, output_dir, "client") + + if server and server.proc.poll() is not None: + server_rc = server.proc.returncode + if server_rc != 0: + failures.append(f"server script exited with code {server_rc}") + add_script_log_paths(failures, output_dir, "server") + finally: + if server: + log_test_step(environment, test_name, "Stopping Server") + server.stop() + if suricata: + log_test_step(environment, test_name, "Stopping Suricata") + stop_suricata(suricata) + if stdout_thread: + stdout_thread.join(timeout=5) + if stderr_thread: + stderr_thread.join(timeout=5) + if stdout_log: + stdout_log.close() + if stderr_log: + stderr_log.close() + do_down(topology=topology) + for key, value in prev_test_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + return failures + + +def get_environment_requires(requires: dict, environment: str) -> dict: + """ Get requirements, taking the environment into consideration. + + For example, a test can't depend on NFQ, but NFQ is required to run tests in + the NFQ environment, so slip NFQ into the requirements as needed. + + Adding this requirement here keeps NFQ-specific build checks out of + otherwise generic test definitions. + """ + if environment != "nfq" or not isinstance(requires, dict): + return requires + + environment_requires = dict(requires) + features = list(environment_requires.get("features", [])) + if "NFQ" not in features: + features.append("NFQ") + environment_requires["features"] = features + return environment_requires + + +def check_test_requires(requires: dict, environment: str, test_dir: str) -> None: + """Validate test-level requirements before setting up namespaces.""" + requires = get_environment_requires(requires, environment) + check_required_commands(requires) + if not (set(requires) - {"command"}): + return + + cwd = os.getcwd() + suricata_bin = os.path.join(cwd, "src", "suricata") + version = parse_suricata_version(subprocess.check_output([suricata_bin, "-V"])) + if version is None: + raise ValueError("failed to determine Suricata version") + suricata_config = SuricataConfig(suricata_bin, version) + check_requires(requires, suricata_config, cwd) + + +def do_run( + *, + only_environment: str | None = None, + patterns: list[str] | None = None, + exact: bool = False, + skip_tests: str | None = None, + tags: list[str] | None = None, +) -> bool: + """Run tests. Returns True if all tests passed.""" + passed = 0 + failed = 0 + skipped = 0 + failing_tests = [] + patterns = patterns or [] + skip_patterns = [pattern for pattern in (skip_tests or "").split(",") if pattern] + selected_tags = {tag.strip().lower() for tag in (tags or []) if tag.strip()} + script_dir = os.path.dirname(os.path.realpath(__file__)) + tests_dir = os.path.join(script_dir, "tests") + for root, dirs, files in os.walk(tests_dir): + dirs.sort() + if "test.yaml" not in files: + continue + + test_name = os.path.basename(root) + relative_name = os.path.relpath(root, tests_dir) + if patterns and not any( + pattern in relative_name + if not exact + else pattern in (relative_name, test_name) + for pattern in patterns + ): + continue + if any( + pattern in relative_name + if not exact + else pattern in (relative_name, test_name) + for pattern in skip_patterns + ): + continue + if selected_tags: + try: + test_tags = get_test_tags(root) + except ValueError as err: + print(f"ERROR: {err}", file=sys.stderr) + failed += 1 + failing_tests.append(test_name) + continue + if not selected_tags.issubset(test_tags): + continue + + test_yaml = os.path.join(root, "test.yaml") + with open(test_yaml) as f: + config = yaml.safe_load(f) + + environment = config.get("environment") + if not isinstance(environment, str): + print(f"ERROR: invalid environment in {test_yaml}: {environment!r}", file=sys.stderr) + failed += 1 + failing_tests.append(test_name) + continue + if only_environment and environment != only_environment: + continue + if environment not in UP_FUNCS: + print(f"ERROR: unknown environment '{environment}' in {test_yaml}", file=sys.stderr) + failed += 1 + failing_tests.append(f"{environment}/{test_name}") + continue + + try: + topology = parse_topology(config, environment, test_yaml) + except ValueError as err: + print(f"ERROR: {err}", file=sys.stderr) + failed += 1 + failing_tests.append(f"{environment}/{test_name}") + continue + + requires = config.get("requires", {}) or {} + try: + check_test_requires(requires, environment, root) + except UnsatisfiedRequirementError as err: + skipped += 1 + log_test_step(environment, test_name, f"SKIP ⏭️: {err}") + continue + except ValueError as err: + print(f"ERROR: {test_yaml}: {err}", file=sys.stderr) + failed += 1 + failing_tests.append(f"{environment}/{test_name}") + continue + + failures = run_test(test_name, environment, config, script_dir, root, topology) + warnings = [] + if not failures: + output_dir = os.path.join(root, "output") + failures, warnings = run_checks(config, output_dir, environment, script_dir, root) + ok = not failures + log_test_step(environment, test_name, "OK ✅" if ok else "FAIL ❌") + if ok: + passed += 1 + else: + failed += 1 + failing_tests.append(f"{environment}/{test_name}") + for msg in warnings: + print(f" {msg}") + for msg in failures: + print(f" {msg}") + + print("") + print(f"PASS: {passed}") + print(f"FAIL: {failed}") + print(f"SKIP: {skipped}") + if failing_tests: + print("") + print("Failing tests:") + for test in failing_tests: + print(f"- {test}") + return failed == 0 + + +def main() -> None: + global verbose + + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(line_buffering=True) + + parser = build_parser() + args = parser.parse_args() + verbose = args.verbose + configure_script_env() + + need_root() + for cmd in ["ip", "ethtool", "sysctl", "kill", "iptables"]: + need_cmd(cmd) + with RunnerLock(): + if not do_run( + only_environment=args.environment, + patterns=args.patterns, + exact=args.exact, + skip_tests=args.skip_tests, + tags=args.tag, + ): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/run.py b/run.py index 8ec812088b..f951547818 100755 --- a/run.py +++ b/run.py @@ -25,6 +25,7 @@ from __future__ import print_function import sys + import os import os.path import subprocess @@ -35,18 +36,26 @@ import yaml import glob import re -import json import unittest from concurrent.futures import ThreadPoolExecutor from collections import namedtuple import threading -import filecmp import subprocess import yaml import traceback -import platform import signal +from lib.common import ( + FileCompareCheck, + FilterCheck, + ImpossibleRequirementError, + ShellCheck, + StatsCheck, + UnsatisfiedRequirementError, + check_requires as check_common_requires, + compare_values, +) + VALIDATE_EVE = False WIN32 = sys.platform == "win32" suricata_yaml = "suricata.yaml" if WIN32 else "./suricata.yaml" @@ -69,13 +78,6 @@ count_dict['skipped'] = 0 check_args['fail'] = 0 -COMPARISON_OPERATORS = { - "__gt": ">", - "__gte": ">=", - "__lt": "<", - "__lte": "<=", -} - # Global flag for shutdown signal shutdown_requested = False executor_instance = None @@ -140,25 +142,21 @@ def test_numeric_comparison_operators(self): self.assertFalse(compare_values("2", 1, "__gt")) def test_filter_numeric_comparison(self): - check = FilterCheck.__new__(FilterCheck) - check.config = { - "match": { - "flow.age.__gte": 2, - "flow.bytes.__lt": 100, - } - } + check = FilterCheck( + { + "match": { + "flow.age.__gte": 2, + "flow.bytes.__lt": 100, + } + }, + ".", + ) self.assertTrue(check.match({"flow": {"age": 2, "bytes": 99}})) self.assertFalse(check.match({"flow": {"age": 1, "bytes": 99}})) class TestError(Exception): pass -class UnsatisfiedRequirementError(Exception): - pass - -class ImpossibleRequirementError(Exception): - pass - class UnnecessaryRequirementError(Exception): pass @@ -355,153 +353,20 @@ def check_filter_test_version_compat(requires, test_version): raise UnnecessaryRequirementError( "test already requires min {} not needed for the check {}".format(test_version["min"], requires["min-version"])) -def check_requires(requires, suricata_config: SuricataConfig, test_dir=None): - suri_version = suricata_config.version - for key in requires: - if key == "min-version": - min_version = requires["min-version"] - if not is_version_compatible(version=min_version, - suri_version=suri_version, expr="gte"): - raise UnsatisfiedRequirementError( - "requires at least version {}".format(min_version)) - elif key == "lt-version": - if "gt-version" in requires: - if not Version().is_lt(parse_suricata_version(requires["gt-version"]), parse_suricata_version(requires["lt-version"])): - raise ImpossibleRequirementError( - "test has both lt-version {} and gt-version {}".format(requires["lt-version"], requires["gt-version"])) - lt_version = requires["lt-version"] - if not is_version_compatible(version=lt_version, - suri_version=suri_version, expr="lt"): - raise UnsatisfiedRequirementError( - "for version less than {}".format(lt_version)) - elif key == "gt-version": - if "lt-version" in requires: - if not Version().is_lt(parse_suricata_version(requires["gt-version"]), parse_suricata_version(requires["lt-version"])): - raise ImpossibleRequirementError( - "test has both lt-version {} and gt-version {}".format(requires["lt-version"], requires["gt-version"])) - gt_version = requires["gt-version"] - if not is_version_compatible(version=gt_version, - suri_version=suri_version, expr="gt"): - raise UnsatisfiedRequirementError( - "for version great than {}".format(gt_version)) - elif key == "version": - req_version = requires["version"] - if not is_version_compatible(version=req_version, - suri_version=suri_version, expr="equal"): - raise UnsatisfiedRequirementError( - "only for version {}".format(req_version)) - elif key == "features": - for feature in requires["features"]: - if not suricata_config.has_feature(feature): - raise UnsatisfiedRequirementError( - "requires feature %s" % (feature)) - elif key == "env": - for env in requires["env"]: - if not env in os.environ: - raise UnsatisfiedRequirementError( - "requires env var %s" % (env)) - elif key == "files": - for filename in requires["files"]: - if test_dir and not os.path.isabs(filename): - filename = os.path.join(test_dir, filename) - if not os.path.exists(filename): - raise UnsatisfiedRequirementError( - "requires file %s" % (filename)) - elif key == "script": - # This is run for the current directory (the Suricata - # source directory). - for script in requires["script"]: - try: - subprocess.check_call("%s" % script, shell=True) - except: - raise UnsatisfiedRequirementError( - "requires script returned false") - elif key == "pcap": - # A valid requires argument, but not verified here. - pass - elif key == "lambda": - if not eval(requires["lambda"]): - raise UnsatisfiedRequirementError(requires["lambda"]) - elif key == "os": - cur_platform = platform.system().lower() - if not cur_platform.startswith(requires["os"].lower()): - raise UnsatisfiedRequirementError(requires["os"]) - elif key == "arch": - cur_arch = platform.machine().lower() - if not cur_arch.startswith(requires["arch"].lower()): - raise UnsatisfiedRequirementError(requires["arch"]) - else: - raise Exception("unknown requires types: %s" % (key)) - - -def find_value(name, obj): - """Find the value in an object for a field specified by name. - - Example names: - event_type - alert.signature_id - smtp.rcpt_to[0] - """ - parts = name.split(".") - for part in parts: - - if part == "__len": - # Get the length of the object. Return -1 if the object is - # not a type that has a length (numbers). - try: - return len(obj) - except: - return -1 - if part in [ - "__contains", "__find", "__startswith", "__endswith", - *COMPARISON_OPERATORS.keys()]: - # Return full object, caller will handle the special match logic. - break - name = None - index = None - m = re.match(r"^(.*)\[(\d+)\]$", part) - if m: - name = m.group(1) - index = m.group(2) - else: - name = part - - if not name in obj: - return None - obj = obj[name] - - if index is not None: - try: - obj = obj[int(index)] - except: - return None - - return obj - - -def get_comparison_operator(key): - """Return the comparison operator suffix from a check key, if present.""" - suffix = key.rsplit(".", 1)[-1] - if suffix in COMPARISON_OPERATORS: - return suffix - return None - - -def compare_values(actual, expected, operator): - """Compare two numeric values using a comparison operator suffix.""" - if isinstance(actual, bool) or isinstance(expected, bool): - return False - if not isinstance(actual, (int, float)) or not isinstance(expected, (int, float)): - return False - if operator == "__gt": - return actual > expected - if operator == "__gte": - return actual >= expected - if operator == "__lt": - return actual < expected - if operator == "__lte": - return actual <= expected - raise ValueError("unknown comparison operator: {}".format(operator)) +def check_requires(requires, suricata_config: SuricataConfig, suri_dir=None): + check_common_requires( + requires, + suricata_config, + is_version_compatible, + suri_dir, + version_is_lt=lambda left, right: Version().is_lt( + parse_suricata_version(left), parse_suricata_version(right) + ), + eval_globals=globals(), + unknown_error=Exception, + unknown_message="unknown requires types: {key}", + gt_message="for version great than {version}", + ) def is_version_compatible(version, suri_version, expr): @@ -519,174 +384,14 @@ def rule_is_version_compatible(rulefile, suri_version): # default is true return True -class FileCompareCheck: - - def __init__(self, config, directory, cwd): - for key in config: - if key not in ["requires", "filename", "expected"]: - raise Exception("Unexpected key in file-compare check: {}".format(key)) - self.config = config - self.directory = directory - self.cwd = cwd - - def run(self): - if WIN32: - raise UnsatisfiedRequirementError("shell check not supported on Windows") - expected = os.path.join(self.directory, self.config["expected"]) - filename = self.config["filename"] - if self.cwd and not os.path.isabs(filename): - filename = os.path.join(self.cwd, filename) - try: - if filecmp.cmp(expected, filename): - return True - else: - raise TestError("%s %s \nFAILED: verification failed" % (expected, filename)) - except Exception as err: - raise TestError("file-compare check failed with exception: %s" % (err)) - -class ShellCheck: - - def __init__(self, config, env, suricata_config, output_dir, test_dir): - for key in config: - if key not in ["requires", "args", "expect"]: - raise Exception("Unexpected key in shell check: {}".format(key)) - self.config = config - self.env = env - self.suricata_config = suricata_config - self.cwd = output_dir - self.script_cwd = test_dir - - def run(self): - if not self.config or "args" not in self.config: - raise TestError("shell check missing args") - requires = self.config.get("requires", {}) - check_requires(requires, self.suricata_config, self.script_cwd) - - try: - if WIN32: - raise UnsatisfiedRequirementError("shell check not supported on Windows") - output = subprocess.check_output(self.config["args"], shell=True, env=self.env, cwd=self.cwd) - if "expect" in self.config: - return str(self.config["expect"]) == output.decode().strip() - return True - except subprocess.CalledProcessError as err: - raise TestError("Shell command failed: {} -> {}".format( - self.config, err.output)) - -class StatsCheck: - - def __init__(self, config, outdir): - self.config = config - self.outdir = outdir - - def run(self): - stats = None - eve_json_path = os.path.join(self.outdir, "eve.json") - with open(eve_json_path, "r") as fileobj: - for line in fileobj: - event = json.loads(line) - if event["event_type"] == "stats": - stats = event["stats"] - for key in self.config: - expected = self.config[key] - val = find_value(key, stats) - operator = get_comparison_operator(key) - if operator is not None: - if not compare_values(val, expected, operator): - raise TestError("stats.%s: expected %s %s; got %s" % ( - key, COMPARISON_OPERATORS[operator], str(expected), str(val))) - elif val != expected: - raise TestError("stats.%s: expected %s; got %s" % ( - key, str(expected), str(val))) - return True +def check_result_or_raise(result): + if result.failures: + raise TestError("\n".join(result.failures)) + return True -class FilterCheck: - def __init__(self, config, outdir, suricata_config, test_version, script_cwd=None): - for key in config: - if key not in ["count", "match", "filename", "requires"]: - raise Exception("Unexpected key in filter check: {}".format(key)) - self.config = config - self.outdir = outdir - self.suricata_config = suricata_config - self.suri_version = suricata_config.version - self.test_version = test_version - self.script_cwd = script_cwd - - def run(self): - requires = self.config.get("requires", {}) - check_filter_test_version_compat(requires, self.test_version) - check_requires(requires, self.suricata_config, self.script_cwd) - - if "filename" in self.config: - json_filename = self.config["filename"] - if not os.path.isabs(json_filename): - json_filename = os.path.join(self.outdir, json_filename) - else: - json_filename = os.path.join(self.outdir, "eve.json") - if not os.path.exists(json_filename): - raise TestError("%s does not exist" % (json_filename)) - - count = 0 - with open(json_filename, "r", encoding="utf-8") as fileobj: - for line in fileobj: - event = json.loads(line) - if self.match(event): - count += 1 - if count == self.config["count"]: - return True - if "comment" in self.config: - raise TestError("%s: expected %d, got %d" % ( - self.config["comment"], self.config["count"], count)) - raise TestError("expected %d matches; got %d for filter %s" % ( - self.config["count"], count, str(self.config))) - - def match(self, event): - for key, expected in self.config["match"].items(): - if key == "has-key": - if isinstance(expected, list): - for item in expected: - val = find_value(item, event) - if val is None: - return False - else: - val = find_value(expected, event) - if val is None: - return False - elif key == "not-has-key": - if isinstance(expected, list): - for item in expected: - val = find_value(item, event) - if val is not None: - return False - else: - val = find_value(expected, event) - if val is not None: - return False - else: - val = find_value(key, event) - if key.endswith("__find"): - if val.find(expected) < 0: - return False - elif key.endswith("__contains"): - if not expected in val: - return False - elif key.endswith("__startswith"): - if not val.startswith(expected): - return False - elif key.endswith("__endswith"): - if not val.endswith(expected): - return False - else: - operator = get_comparison_operator(key) - if operator is not None: - if not compare_values(val, expected, operator): - return False - elif val != expected: - if str(val) == str(expected): - print("Different types but same string", type(val), val, type(expected), expected) - return False - return True +def print_type_mismatch(val, expected): + print("Different types but same string", type(val), val, type(expected), expected) # wait for suricata to be ready, to send unix-socket commands def grep_start_engine(p, lines): @@ -797,7 +502,7 @@ def check_skip(self): def check_requires(self): requires = self.config.get("requires", {}) - check_requires(requires, self.suricata_config, self.directory) + check_requires(requires, self.suricata_config, self.cwd) for key in requires: if key == "min-version": self.version["min"] = requires["min-version"] @@ -1014,24 +719,45 @@ def pre_check(self): @handle_exceptions def perform_filter_checks(self, check, count, test_num, test_name): - count = FilterCheck(check, self.output, - self.suricata_config, self.version, self.directory).run() - return count + result = FilterCheck( + check, + self.output, + suricata_config=self.suricata_config, + test_dir=self.directory, + require_checker=check_requires, + test_version=self.version, + version_compat_checker=check_filter_test_version_compat, + type_mismatch_callback=print_type_mismatch, + ).run() + return check_result_or_raise(result) @handle_exceptions def perform_shell_checks(self, check, count, test_num, test_name): - count = ShellCheck(check, self.build_env(), self.suricata_config, self.output, self.directory).run() - return count + result = ShellCheck( + check, + self.build_env(), + self.output, + suricata_config=self.suricata_config, + test_dir=self.directory, + require_checker=check_requires, + windows=WIN32, + ).run() + return check_result_or_raise(result) @handle_exceptions def perform_stats_checks(self, check, count, test_num, test_name): - count = StatsCheck(check, self.output).run() - return count + result = StatsCheck(check, self.output).run() + return check_result_or_raise(result) @handle_exceptions def perform_file_compare_checks(self, check, count, test_num, test_name): - count = FileCompareCheck(check, self.directory, self.output).run() - return count + result = FileCompareCheck( + check, + self.directory, + self.output, + windows=WIN32, + ).run() + return check_result_or_raise(result) def reset_count(self, dictionary): for k in dictionary.keys(): @@ -1192,19 +918,20 @@ def check_deps(): try: cmd = "jq --version > nil" if WIN32 else "jq --version > /dev/null 2>&1" subprocess.check_call(cmd, shell=True) - except: + except Exception: print("error: jq is required") return False try: cmd = "echo suricata | xargs > nil" if WIN32 else "echo | xargs > /dev/null 2>&1" subprocess.check_call(cmd, shell=True) - except: + except Exception: print("error: xargs is required") return False return True + def run_test(dirpath, args, cwd, suricata_config): with lock: if check_args['fail'] == 1 or shutdown_requested: From 66099cf4bf2fd3d2f9de3d83fafffbf2925c2cb8 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:57:40 -0600 Subject: [PATCH 02/32] live: port AF_PACKET firewall workers namespace test --- live/tests/afp-fw/README.md | 2 + live/tests/afp-fw/client/Dockerfile | 3 + live/tests/afp-fw/empty.rules | 1 + live/tests/afp-fw/firewall1.rules | 14 ++++ live/tests/afp-fw/firewall2.rules | 14 ++++ live/tests/afp-fw/include.yaml | 26 ++++++++ live/tests/afp-fw/server/Dockerfile | 5 ++ live/tests/afp-fw/server/index.html | 0 live/tests/afp-fw/server/server.sh | 10 +++ live/tests/afp-fw/test.yaml | 99 +++++++++++++++++++++++++++++ 10 files changed, 174 insertions(+) create mode 100644 live/tests/afp-fw/README.md create mode 100644 live/tests/afp-fw/client/Dockerfile create mode 100644 live/tests/afp-fw/empty.rules create mode 100644 live/tests/afp-fw/firewall1.rules create mode 100644 live/tests/afp-fw/firewall2.rules create mode 100644 live/tests/afp-fw/include.yaml create mode 100644 live/tests/afp-fw/server/Dockerfile create mode 100644 live/tests/afp-fw/server/index.html create mode 100755 live/tests/afp-fw/server/server.sh create mode 100644 live/tests/afp-fw/test.yaml diff --git a/live/tests/afp-fw/README.md b/live/tests/afp-fw/README.md new file mode 100644 index 0000000000..d9c586bb36 --- /dev/null +++ b/live/tests/afp-fw/README.md @@ -0,0 +1,2 @@ +A port of the "afp-fw-netns-bridge" from the Suricata repo to this +testing harness. diff --git a/live/tests/afp-fw/client/Dockerfile b/live/tests/afp-fw/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/afp-fw/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/afp-fw/empty.rules b/live/tests/afp-fw/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/afp-fw/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/afp-fw/firewall1.rules b/live/tests/afp-fw/firewall1.rules new file mode 100644 index 0000000000..0008a795d0 --- /dev/null +++ b/live/tests/afp-fw/firewall1.rules @@ -0,0 +1,14 @@ +accept:packet arp:all any any -> any any (alert; sid:200;) + +# allow session setup +accept:hook tcp:all any any <> any 80 (flow:not_established; alert; sid:1021;) + +# pass rest of the flow to +accept:hook tcp:all any any <> any 80 (flow:established; alert; sid:1023;) +#accept:hook ip:all any any <> any any (alert; sid:1024;) + +# default drop + +accept:hook http1:request_started any any -> any any (alert; sid:100;) +accept:hook http1:request_line any any -> any any (http.method; content:"GET"; http.uri; content:"/"; alert; sid:101;) +accept:tx http1:request_headers any any -> any any (http.user_agent; content:"wget"; nocase; alert; sid:102;) diff --git a/live/tests/afp-fw/firewall2.rules b/live/tests/afp-fw/firewall2.rules new file mode 100644 index 0000000000..1f3359be38 --- /dev/null +++ b/live/tests/afp-fw/firewall2.rules @@ -0,0 +1,14 @@ +accept:packet arp:all any any -> any any (alert; sid:200;) + +# allow session setup +accept:hook tcp:all any any <> any 80 (flow:not_established; alert; sid:1021;) + +# pass rest of the flow to +accept:hook tcp:all any any <> any 80 (flow:established; alert; sid:1023;) +#accept:hook ip:all any any <> any any (alert; sid:1024;) + +# default drop + +accept:hook http1:request_started any any -> any any (alert; sid:100;) +accept:hook http1:request_line any any -> any any (http.method; bsize:3; urilen:>1; sid:201; alert;) +accept:tx http1:request_headers any any -> any any (http.user_agent; pcre:"/wget/i"; sid:202; alert;) diff --git a/live/tests/afp-fw/include.yaml b/live/tests/afp-fw/include.yaml new file mode 100644 index 0000000000..eca95f6634 --- /dev/null +++ b/live/tests/afp-fw/include.yaml @@ -0,0 +1,26 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: true + rule-path: ${OUTDIR} + rule-files: + - firewall.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/afp-fw/server/Dockerfile b/live/tests/afp-fw/server/Dockerfile new file mode 100644 index 0000000000..19147411d1 --- /dev/null +++ b/live/tests/afp-fw/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html \ No newline at end of file diff --git a/live/tests/afp-fw/server/index.html b/live/tests/afp-fw/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/afp-fw/server/server.sh b/live/tests/afp-fw/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/afp-fw/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/afp-fw/test.yaml b/live/tests/afp-fw/test.yaml new file mode 100644 index 0000000000..97a5d34b71 --- /dev/null +++ b/live/tests/afp-fw/test.yaml @@ -0,0 +1,99 @@ +# Port of qa/live/netns/afp-fw-netns-bridge.sh with the workers runmode. +environment: inline + +requires: + command: + - jq + - podman + +args: + - --af-packet + - --runmode workers + +before: | + cp ${TESTDIR}/firewall1.rules ${OUTDIR}/firewall.rules + + # It can take a while to build containers the first time which + # means the client or server script could start early. So we'll + # build both containers here. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Curl request should not succeed. + echo "Running curl..." + if run timeout --kill-after=1 --preserve-status 2 curl -O http://10.200.0.1/index.html; then + echo "error: curl should not have completed successfully" + errors="yes" + fi + + # Wget request should succeed. + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 2 wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # Ping should not succeed. + echo "Running ping..." + if run ping -c 10 -i 0.05 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + cp ${TESTDIR}/firewall2.rules ${OUTDIR}/firewall.rules + ${SURICATASC} -c reload-rules ${OUTDIR}/suricata.socket + + # Wget request should succeed. + if ! run timeout --kill-after=1 --preserve-status 2 wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - filter: + count: 2 + match: + alert.signature_id: 101 + + - filter: + count: 1 + match: + alert.signature_id: 102 + + - filter: + count: 1 + match: + alert.signature_id: 201 + + - filter: + count: 1 + match: + alert.signature_id: 202 + + - stats: + firewall.accepted: 25 + + # We should not have seen any pings on the server side packet capture. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 From 9e9d895a0f24c28566f3170c5f045cecc8408d8b Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:57:49 -0600 Subject: [PATCH 03/32] live: port AF_PACKET IPS workers namespace test --- live/tests/ips-drop-icmp-afp/README.md | 6 ++ .../tests/ips-drop-icmp-afp/client/Dockerfile | 3 + live/tests/ips-drop-icmp-afp/drop-icmp.rules | 1 + live/tests/ips-drop-icmp-afp/include.yaml | 20 ++++++ .../tests/ips-drop-icmp-afp/server/Dockerfile | 5 ++ .../tests/ips-drop-icmp-afp/server/index.html | 0 live/tests/ips-drop-icmp-afp/server/server.sh | 10 +++ live/tests/ips-drop-icmp-afp/test.yaml | 70 +++++++++++++++++++ 8 files changed, 115 insertions(+) create mode 100644 live/tests/ips-drop-icmp-afp/README.md create mode 100644 live/tests/ips-drop-icmp-afp/client/Dockerfile create mode 100644 live/tests/ips-drop-icmp-afp/drop-icmp.rules create mode 100644 live/tests/ips-drop-icmp-afp/include.yaml create mode 100644 live/tests/ips-drop-icmp-afp/server/Dockerfile create mode 100644 live/tests/ips-drop-icmp-afp/server/index.html create mode 100755 live/tests/ips-drop-icmp-afp/server/server.sh create mode 100644 live/tests/ips-drop-icmp-afp/test.yaml diff --git a/live/tests/ips-drop-icmp-afp/README.md b/live/tests/ips-drop-icmp-afp/README.md new file mode 100644 index 0000000000..a179744c5f --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/README.md @@ -0,0 +1,6 @@ +A port of "afp-ips-netns-bridge" (inline environment) and "nfq-ips-netns-route" +(nfq environment) from the Suricata repo to this testing harness. + +Suricata runs inline (AF_PACKET copy-mode IPS or NFQUEUE) with a single +rule that drops ICMP echo requests. HTTP traffic must still pass, ICMP +must be dropped, and no echo requests should reach the server. diff --git a/live/tests/ips-drop-icmp-afp/client/Dockerfile b/live/tests/ips-drop-icmp-afp/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/ips-drop-icmp-afp/drop-icmp.rules b/live/tests/ips-drop-icmp-afp/drop-icmp.rules new file mode 100644 index 0000000000..af3f7b14e9 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/ips-drop-icmp-afp/include.yaml b/live/tests/ips-drop-icmp-afp/include.yaml new file mode 100644 index 0000000000..ae2bb1d449 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-drop-icmp-afp/server/Dockerfile b/live/tests/ips-drop-icmp-afp/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/ips-drop-icmp-afp/server/index.html b/live/tests/ips-drop-icmp-afp/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/ips-drop-icmp-afp/server/server.sh b/live/tests/ips-drop-icmp-afp/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/ips-drop-icmp-afp/test.yaml b/live/tests/ips-drop-icmp-afp/test.yaml new file mode 100644 index 0000000000..5c2a9a00ee --- /dev/null +++ b/live/tests/ips-drop-icmp-afp/test.yaml @@ -0,0 +1,70 @@ +# Port of qa/live/netns/afp-ips-netns-bridge.sh with the workers runmode. +environment: inline + +requires: + command: + - jq + - podman + +args: + - --af-packet + - --runmode workers + +before: | + # Build both images up front. The first build can be slow and would + # otherwise race the server/client scripts. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Only ICMP is dropped, so HTTP requests should still succeed. + echo "Running curl..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -O http://10.200.0.1/index.html; then + echo "error: curl should have completed successfully" + errors="yes" + fi + + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # ICMP echo requests should be dropped, so ping should fail. + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - stats: + capture.kernel_packets.__gt: 0 + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 From 3115b8bcd0af70c08d3773d4f0a126148b037f38 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:57:49 -0600 Subject: [PATCH 04/32] live: port NFQUEUE IPS autofp namespace test --- live/tests/ips-drop-icmp-nfq/README.md | 6 ++ .../tests/ips-drop-icmp-nfq/client/Dockerfile | 3 + live/tests/ips-drop-icmp-nfq/drop-icmp.rules | 1 + live/tests/ips-drop-icmp-nfq/include.yaml | 6 ++ .../tests/ips-drop-icmp-nfq/server/Dockerfile | 5 ++ .../tests/ips-drop-icmp-nfq/server/index.html | 0 live/tests/ips-drop-icmp-nfq/server/server.sh | 10 +++ live/tests/ips-drop-icmp-nfq/test.yaml | 69 +++++++++++++++++++ 8 files changed, 100 insertions(+) create mode 100644 live/tests/ips-drop-icmp-nfq/README.md create mode 100644 live/tests/ips-drop-icmp-nfq/client/Dockerfile create mode 100644 live/tests/ips-drop-icmp-nfq/drop-icmp.rules create mode 100644 live/tests/ips-drop-icmp-nfq/include.yaml create mode 100644 live/tests/ips-drop-icmp-nfq/server/Dockerfile create mode 100644 live/tests/ips-drop-icmp-nfq/server/index.html create mode 100755 live/tests/ips-drop-icmp-nfq/server/server.sh create mode 100644 live/tests/ips-drop-icmp-nfq/test.yaml diff --git a/live/tests/ips-drop-icmp-nfq/README.md b/live/tests/ips-drop-icmp-nfq/README.md new file mode 100644 index 0000000000..a179744c5f --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/README.md @@ -0,0 +1,6 @@ +A port of "afp-ips-netns-bridge" (inline environment) and "nfq-ips-netns-route" +(nfq environment) from the Suricata repo to this testing harness. + +Suricata runs inline (AF_PACKET copy-mode IPS or NFQUEUE) with a single +rule that drops ICMP echo requests. HTTP traffic must still pass, ICMP +must be dropped, and no echo requests should reach the server. diff --git a/live/tests/ips-drop-icmp-nfq/client/Dockerfile b/live/tests/ips-drop-icmp-nfq/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/ips-drop-icmp-nfq/drop-icmp.rules b/live/tests/ips-drop-icmp-nfq/drop-icmp.rules new file mode 100644 index 0000000000..af3f7b14e9 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/ips-drop-icmp-nfq/include.yaml b/live/tests/ips-drop-icmp-nfq/include.yaml new file mode 100644 index 0000000000..ff6b818a91 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules diff --git a/live/tests/ips-drop-icmp-nfq/server/Dockerfile b/live/tests/ips-drop-icmp-nfq/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/ips-drop-icmp-nfq/server/index.html b/live/tests/ips-drop-icmp-nfq/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/ips-drop-icmp-nfq/server/server.sh b/live/tests/ips-drop-icmp-nfq/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/ips-drop-icmp-nfq/test.yaml b/live/tests/ips-drop-icmp-nfq/test.yaml new file mode 100644 index 0000000000..0c41cf0ec6 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq/test.yaml @@ -0,0 +1,69 @@ +# Port of qa/live/netns/nfq-ips-netns-route.sh with the autofp runmode. +environment: nfq + +requires: + command: + - jq + - podman + +args: + - -q 0 + - --runmode autofp + +before: | + # Build both images up front. The first build can be slow and would + # otherwise race the server/client scripts. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Only ICMP is dropped, so HTTP requests should still succeed. + echo "Running curl..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -O http://10.200.0.1/index.html; then + echo "error: curl should have completed successfully" + errors="yes" + fi + + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # ICMP echo requests should be dropped, so ping should fail. + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - stats: + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 From e21d7eb5827fa39d69a19aab6eb9220f3e27c057 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:57:49 -0600 Subject: [PATCH 05/32] live: port NFQUEUE firewall autofp namespace test --- live/tests/nfq-fw/README.md | 8 +++ live/tests/nfq-fw/client/Dockerfile | 3 + live/tests/nfq-fw/empty.rules | 1 + live/tests/nfq-fw/firewall1.rules | 12 ++++ live/tests/nfq-fw/firewall2.rules | 12 ++++ live/tests/nfq-fw/include.yaml | 12 ++++ live/tests/nfq-fw/server/Dockerfile | 5 ++ live/tests/nfq-fw/server/index.html | 0 live/tests/nfq-fw/server/server.sh | 10 +++ live/tests/nfq-fw/test.yaml | 108 ++++++++++++++++++++++++++++ 10 files changed, 171 insertions(+) create mode 100644 live/tests/nfq-fw/README.md create mode 100644 live/tests/nfq-fw/client/Dockerfile create mode 100644 live/tests/nfq-fw/empty.rules create mode 100644 live/tests/nfq-fw/firewall1.rules create mode 100644 live/tests/nfq-fw/firewall2.rules create mode 100644 live/tests/nfq-fw/include.yaml create mode 100644 live/tests/nfq-fw/server/Dockerfile create mode 100644 live/tests/nfq-fw/server/index.html create mode 100755 live/tests/nfq-fw/server/server.sh create mode 100644 live/tests/nfq-fw/test.yaml diff --git a/live/tests/nfq-fw/README.md b/live/tests/nfq-fw/README.md new file mode 100644 index 0000000000..29124b913d --- /dev/null +++ b/live/tests/nfq-fw/README.md @@ -0,0 +1,8 @@ +A port of "nfq-fw-netns-route" from the Suricata repo to this testing +harness. + +Suricata runs as a NFQUEUE router enforcing firewall rules. The default +policy is drop; accept rules allow HTTP. The curl request triggers the +request_line alert but is dropped (its user-agent is not accepted), while +the wget request is accepted. A rule reload then swaps in a second rule +set. Uses the L3 rule variants since NFQUEUE operates at layer 3. diff --git a/live/tests/nfq-fw/client/Dockerfile b/live/tests/nfq-fw/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/nfq-fw/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/nfq-fw/empty.rules b/live/tests/nfq-fw/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/nfq-fw/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/nfq-fw/firewall1.rules b/live/tests/nfq-fw/firewall1.rules new file mode 100644 index 0000000000..f33ea0990c --- /dev/null +++ b/live/tests/nfq-fw/firewall1.rules @@ -0,0 +1,12 @@ +# allow session setup +accept:hook tcp:all any any <> any 80 (flow:not_established; alert; sid:1021;) + +# pass rest of the flow to +accept:hook tcp:all any any <> any 80 (flow:established; alert; sid:1023;) +#accept:hook ip:all any any <> any any (alert; sid:1024;) + +# default drop + +accept:hook http1:request_started any any -> any any (alert; sid:100;) +accept:hook http1:request_line any any -> any any (http.method; content:"GET"; http.uri; content:"/"; alert; sid:101;) +accept:tx http1:request_headers any any -> any any (http.user_agent; content:"wget"; nocase; alert; sid:102;) diff --git a/live/tests/nfq-fw/firewall2.rules b/live/tests/nfq-fw/firewall2.rules new file mode 100644 index 0000000000..d20a74c8c7 --- /dev/null +++ b/live/tests/nfq-fw/firewall2.rules @@ -0,0 +1,12 @@ +# allow session setup +accept:hook tcp:all any any <> any 80 (flow:not_established; alert; sid:1021;) + +# pass rest of the flow to +accept:hook tcp:all any any <> any 80 (flow:established; alert; sid:1023;) +#accept:hook ip:all any any <> any any (alert; sid:1024;) + +# default drop + +accept:hook http1:request_started any any -> any any (alert; sid:100;) +accept:hook http1:request_line any any -> any any (http.method; bsize:3; urilen:>1; sid:201; alert;) +accept:tx http1:request_headers any any -> any any (http.user_agent; pcre:"/wget/i"; sid:202; alert;) diff --git a/live/tests/nfq-fw/include.yaml b/live/tests/nfq-fw/include.yaml new file mode 100644 index 0000000000..90c3c9e48a --- /dev/null +++ b/live/tests/nfq-fw/include.yaml @@ -0,0 +1,12 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: true + rule-path: ${OUTDIR} + rule-files: + - firewall.rules diff --git a/live/tests/nfq-fw/server/Dockerfile b/live/tests/nfq-fw/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/nfq-fw/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/nfq-fw/server/index.html b/live/tests/nfq-fw/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/nfq-fw/server/server.sh b/live/tests/nfq-fw/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/nfq-fw/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/nfq-fw/test.yaml b/live/tests/nfq-fw/test.yaml new file mode 100644 index 0000000000..eb8cb9c7f8 --- /dev/null +++ b/live/tests/nfq-fw/test.yaml @@ -0,0 +1,108 @@ +# NFQUEUE firewall test: default-drop with accept rules for HTTP, then a +# rule reload swaps in a second rule set. +# +# Ports qa/live/netns/nfq-fw-netns-route.sh with the autofp runmode. Uses the +# L3 firewall rule variants (no ARP rule) because NFQUEUE operates at layer 3. +environment: nfq + +requires: + command: + - jq + - podman + +args: + - -q 0 + - --runmode autofp + +before: | + cp ${TESTDIR}/firewall1.rules ${OUTDIR}/firewall.rules + + # It can take a while to build containers the first time which means + # the client or server script could start early. So build both here. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Curl request should not succeed (its user-agent is not accepted, so the + # response is dropped) but it still triggers the request_line alert. + echo "Running curl..." + if run timeout --kill-after=1 --preserve-status 5 curl -O http://10.200.0.1/index.html; then + echo "error: curl should not have completed successfully" + errors="yes" + fi + + # Wget request should succeed. + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # Ping should not succeed (default drop, ICMP is not accepted). + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + cp ${TESTDIR}/firewall2.rules ${OUTDIR}/firewall.rules + ${SURICATASC} -c reload-rules ${OUTDIR}/suricata.socket + + # Wget request should succeed. + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + # First rule set: curl + wget both hit the request_line rule (sid 101), + # only wget matches the user-agent rule (sid 102). + - filter: + count: 2 + match: + alert.signature_id: 101 + + - filter: + count: 1 + match: + alert.signature_id: 102 + + # Second rule set (after reload): wget hits sid 201 and sid 202. + - filter: + count: 1 + match: + alert.signature_id: 201 + + - filter: + count: 1 + match: + alert.signature_id: 202 + + - stats: + firewall.accepted.__gt: 0 + firewall.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 From 59c2fe8d792dd865550c980d0397c3010292c28d Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:01 -0600 Subject: [PATCH 06/32] live: add firewall ICMP test --- live/tests/firewall-icmp/README.md | 13 +++++++ live/tests/firewall-icmp/client/Dockerfile | 3 ++ live/tests/firewall-icmp/empty.rules | 1 + live/tests/firewall-icmp/firewall.rules | 1 + live/tests/firewall-icmp/firewall2.rules | 2 + live/tests/firewall-icmp/include.yaml | 12 ++++++ live/tests/firewall-icmp/test.yaml | 45 ++++++++++++++++++++++ 7 files changed, 77 insertions(+) create mode 100644 live/tests/firewall-icmp/README.md create mode 100644 live/tests/firewall-icmp/client/Dockerfile create mode 100644 live/tests/firewall-icmp/empty.rules create mode 100644 live/tests/firewall-icmp/firewall.rules create mode 100644 live/tests/firewall-icmp/firewall2.rules create mode 100644 live/tests/firewall-icmp/include.yaml create mode 100644 live/tests/firewall-icmp/test.yaml diff --git a/live/tests/firewall-icmp/README.md b/live/tests/firewall-icmp/README.md new file mode 100644 index 0000000000..2c28f7d2e7 --- /dev/null +++ b/live/tests/firewall-icmp/README.md @@ -0,0 +1,13 @@ +--- +tags: +- firewall +- icmp +--- + +A simple ICMP firewall test. + +- We first start with an empty firewall ruleset and attempt a ping + which we accept to fail. +- Then we update the firewall rules with a rule to allow ICMP and + trigger a reload. +- Then test that a ping is allowed. diff --git a/live/tests/firewall-icmp/client/Dockerfile b/live/tests/firewall-icmp/client/Dockerfile new file mode 100644 index 0000000000..78154c478c --- /dev/null +++ b/live/tests/firewall-icmp/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt update && \ + apt install -y iputils-ping diff --git a/live/tests/firewall-icmp/empty.rules b/live/tests/firewall-icmp/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/firewall-icmp/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/firewall-icmp/firewall.rules b/live/tests/firewall-icmp/firewall.rules new file mode 100644 index 0000000000..1497983561 --- /dev/null +++ b/live/tests/firewall-icmp/firewall.rules @@ -0,0 +1 @@ +# Fist firewall ruleset, just a drop all. diff --git a/live/tests/firewall-icmp/firewall2.rules b/live/tests/firewall-icmp/firewall2.rules new file mode 100644 index 0000000000..34859aa393 --- /dev/null +++ b/live/tests/firewall-icmp/firewall2.rules @@ -0,0 +1,2 @@ +# Allow ICMP. +accept:packet icmp:all any any -> any any (alert; sid:100;) diff --git a/live/tests/firewall-icmp/include.yaml b/live/tests/firewall-icmp/include.yaml new file mode 100644 index 0000000000..59d8cda94f --- /dev/null +++ b/live/tests/firewall-icmp/include.yaml @@ -0,0 +1,12 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: yes + rule-path: ${OUTDIR} + rule-files: + - firewall.rules diff --git a/live/tests/firewall-icmp/test.yaml b/live/tests/firewall-icmp/test.yaml new file mode 100644 index 0000000000..28fcef5691 --- /dev/null +++ b/live/tests/firewall-icmp/test.yaml @@ -0,0 +1,45 @@ +environment: nfq + +requires: + command: + - podman + +args: + - -q 0 + +before: | + cp firewall.rules ${OUTDIR}/firewall.rules + +client: | + id=$(podman build -q --network=host client) + + podman run --cap-add=NET_RAW --rm --network ns:/var/run/netns/client0 ${id} bash -lc ' + if ping -c 1 -W 1 10.200.0.1; then + echo "error: ping should have failed" > /dev/stderr + exit 1 + fi + ' + + cp firewall2.rules ${OUTDIR}/firewall.rules + ${SURICATASC} -c reload-rules ${OUTDIR}/suricata.socket + + podman run --cap-add=NET_RAW --rm --network ns:/var/run/netns/client0 ${id} bash -lc ' + ping -c 1 -W 1 10.200.0.1 + ' + +checks: + - stats: + decoder.icmpv4: 3 + + # I'm not sure about these 2 yet. We've tried to create a closed + # network, but I'm not sure if any unexpected packets could + # still creep in. + firewall.accepted: 2 + firewall.blocked: 1 + + - filter: + count: 2 + match: + event_type: alert + proto: ICMP + alert.action: allowed From 1bccb9e58373643228719313781f5fcc2f89b10b Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:01 -0600 Subject: [PATCH 07/32] live: add AF_PACKET firewall response-body test --- .../README.md | 13 ++++++ .../client/Dockerfile | 2 + .../empty.rules | 1 + .../firewall.rules | 8 ++++ .../include.yaml | 26 +++++++++++ .../server/Caddyfile | 6 +++ .../server/Dockerfile | 5 +++ .../server/index.txt | 1 + .../test.yaml | 43 +++++++++++++++++++ 9 files changed, 105 insertions(+) create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/README.md create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/client/Dockerfile create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/empty.rules create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/firewall.rules create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/include.yaml create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Caddyfile create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Dockerfile create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/index.txt create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/test.yaml diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/README.md b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/README.md new file mode 100644 index 0000000000..63986ded79 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/README.md @@ -0,0 +1,13 @@ +# Firewall response body no match + +Port of `ruletype-firewall-96-lt-response-body-no-match` as a live AF_PACKET +firewall test. + +The server returns the classic testmyids.org response body: + +```text +uid=0(root) gid=0(root) groups=0(root) +``` + +The firewall rules only allow response bodies containing `suricata`, so this +response should be blocked. diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/client/Dockerfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/client/Dockerfile new file mode 100644 index 0000000000..8ef35a59fb --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/client/Dockerfile @@ -0,0 +1,2 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y curl diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/empty.rules b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/firewall.rules b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/firewall.rules new file mode 100644 index 0000000000..064f5b67cf --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/firewall.rules @@ -0,0 +1,8 @@ +accept:packet arp:all any any -> any any (sid:99;) +accept:hook tcp:all any any <> any any (sid:100;) + +# Allow the complete request side. +accept:hook,alert http1: any any (sid:999;) + +# Only allow responses with "suricata" in the response body. +accept:flow,alert http1: any any (http.response_body; content:"suricata"; sid:998;) diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/include.yaml b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/include.yaml new file mode 100644 index 0000000000..eca95f6634 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/include.yaml @@ -0,0 +1,26 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: true + rule-path: ${OUTDIR} + rule-files: + - firewall.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Caddyfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Dockerfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Dockerfile new file mode 100644 index 0000000000..c18f17de69 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/index.txt b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/test.yaml b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/test.yaml new file mode 100644 index 0000000000..29792c6994 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-afp/test.yaml @@ -0,0 +1,43 @@ +environment: inline + +requires: + min-version: 9 + command: + - podman + +args: + - --af-packet + +before: | + cp ${TESTDIR}/firewall.rules ${OUTDIR}/firewall.rules + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + $(cat ${OUTDIR}/server.iid) + +client: | + cd ${OUTDIR} + + if podman run --rm --network ns:/var/run/netns/client0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/client.iid) \ + timeout --kill-after=1 --preserve-status 2 \ + curl -sS --retry-connrefused --retry 20 --retry-delay 0 \ + --output /out/response.txt http://10.200.0.1/; then + echo "error: curl should not have completed successfully" + exit 1 + fi + +checks: + - filter: + count: 0 + match: + alert.signature_id: 998 + + - stats: + app_layer.tx.http: 1 + firewall.drop_reason.default_app_policy: 1 From 6df08dbec375fc87c5e365e2f91d3d6b5c9f72a7 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:01 -0600 Subject: [PATCH 08/32] live: add NFQUEUE firewall response-body test --- .../README.md | 13 ++++++ .../client/Dockerfile | 2 + .../empty.rules | 1 + .../firewall.rules | 8 ++++ .../include.yaml | 12 ++++++ .../server/Caddyfile | 6 +++ .../server/Dockerfile | 5 +++ .../server/index.txt | 1 + .../test.yaml | 43 +++++++++++++++++++ 9 files changed, 91 insertions(+) create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/README.md create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/client/Dockerfile create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/empty.rules create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/firewall.rules create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/include.yaml create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Caddyfile create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Dockerfile create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/index.txt create mode 100644 live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/test.yaml diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/README.md b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/README.md new file mode 100644 index 0000000000..4709d93d2a --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/README.md @@ -0,0 +1,13 @@ +# Firewall response body no match + +Port of `ruletype-firewall-96-lt-response-body-no-match` as a live NFQ +firewall test. + +The server returns the classic testmyids.org response body: + +```text +uid=0(root) gid=0(root) groups=0(root) +``` + +The firewall rules only allow response bodies containing `suricata`, so this +response should be blocked. diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/client/Dockerfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/client/Dockerfile new file mode 100644 index 0000000000..8ef35a59fb --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/client/Dockerfile @@ -0,0 +1,2 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y curl diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/empty.rules b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/empty.rules new file mode 100644 index 0000000000..0832bc245c --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/empty.rules @@ -0,0 +1 @@ +# Empty ruleset for live tests that only use firewall rules. diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/firewall.rules b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/firewall.rules new file mode 100644 index 0000000000..064f5b67cf --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/firewall.rules @@ -0,0 +1,8 @@ +accept:packet arp:all any any -> any any (sid:99;) +accept:hook tcp:all any any <> any any (sid:100;) + +# Allow the complete request side. +accept:hook,alert http1: any any (sid:999;) + +# Only allow responses with "suricata" in the response body. +accept:flow,alert http1: any any (http.response_body; content:"suricata"; sid:998;) diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/include.yaml b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/include.yaml new file mode 100644 index 0000000000..90c3c9e48a --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/include.yaml @@ -0,0 +1,12 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - empty.rules + +firewall: + enabled: true + rule-path: ${OUTDIR} + rule-files: + - firewall.rules diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Caddyfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Dockerfile b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Dockerfile new file mode 100644 index 0000000000..c18f17de69 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/index.txt b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/test.yaml b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/test.yaml new file mode 100644 index 0000000000..3925d0cf5e --- /dev/null +++ b/live/tests/ruletype-firewall-96-lt-response-body-no-match-nfq/test.yaml @@ -0,0 +1,43 @@ +environment: nfq + +requires: + min-version: 9 + command: + - podman + +args: + - -q 0 + +before: | + cp ${TESTDIR}/firewall.rules ${OUTDIR}/firewall.rules + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + $(cat ${OUTDIR}/server.iid) + +client: | + cd ${OUTDIR} + + if podman run --rm --network ns:/var/run/netns/client0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/client.iid) \ + timeout --kill-after=1 --preserve-status 2 \ + curl -sS --retry-connrefused --retry 20 --retry-delay 0 \ + --output /out/response.txt http://10.200.0.1/; then + echo "error: curl should not have completed successfully" + exit 1 + fi + +checks: + - filter: + count: 0 + match: + alert.signature_id: 998 + + - stats: + app_layer.tx.http: 1 + firewall.drop_reason.default_app_policy: 1 From 71e242541020e379082764d29b3cfccc34e133bc Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:11 -0600 Subject: [PATCH 09/32] live: add AF_PACKET simple HTTP test --- live/tests/simple-http-afp/README.md | 6 ++++ live/tests/simple-http-afp/client/Dockerfile | 3 ++ live/tests/simple-http-afp/include.yaml | 20 +++++++++++ live/tests/simple-http-afp/server/Caddyfile | 6 ++++ live/tests/simple-http-afp/server/Dockerfile | 5 +++ live/tests/simple-http-afp/server/index.txt | 1 + live/tests/simple-http-afp/test.rules | 1 + live/tests/simple-http-afp/test.yaml | 35 ++++++++++++++++++++ 8 files changed, 77 insertions(+) create mode 100644 live/tests/simple-http-afp/README.md create mode 100644 live/tests/simple-http-afp/client/Dockerfile create mode 100644 live/tests/simple-http-afp/include.yaml create mode 100644 live/tests/simple-http-afp/server/Caddyfile create mode 100644 live/tests/simple-http-afp/server/Dockerfile create mode 100644 live/tests/simple-http-afp/server/index.txt create mode 100644 live/tests/simple-http-afp/test.rules create mode 100644 live/tests/simple-http-afp/test.yaml diff --git a/live/tests/simple-http-afp/README.md b/live/tests/simple-http-afp/README.md new file mode 100644 index 0000000000..9701e5e9d4 --- /dev/null +++ b/live/tests/simple-http-afp/README.md @@ -0,0 +1,6 @@ +--- +tags: +- http +--- + +A very simple AF_PACKET IPS inline test. diff --git a/live/tests/simple-http-afp/client/Dockerfile b/live/tests/simple-http-afp/client/Dockerfile new file mode 100644 index 0000000000..802c97527f --- /dev/null +++ b/live/tests/simple-http-afp/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt update && \ + apt install -y curl diff --git a/live/tests/simple-http-afp/include.yaml b/live/tests/simple-http-afp/include.yaml new file mode 100644 index 0000000000..1a652a6d7c --- /dev/null +++ b/live/tests/simple-http-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/simple-http-afp/server/Caddyfile b/live/tests/simple-http-afp/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/simple-http-afp/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/simple-http-afp/server/Dockerfile b/live/tests/simple-http-afp/server/Dockerfile new file mode 100644 index 0000000000..135a1e94b9 --- /dev/null +++ b/live/tests/simple-http-afp/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] \ No newline at end of file diff --git a/live/tests/simple-http-afp/server/index.txt b/live/tests/simple-http-afp/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/simple-http-afp/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/simple-http-afp/test.rules b/live/tests/simple-http-afp/test.rules new file mode 100644 index 0000000000..6e2b6a93bd --- /dev/null +++ b/live/tests/simple-http-afp/test.rules @@ -0,0 +1 @@ +alert ip any any -> any any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:2100498; rev:7; metadata:created_at 2010_09_23, confidence Medium, signature_severity Informational, updated_at 2019_07_26;) diff --git a/live/tests/simple-http-afp/test.yaml b/live/tests/simple-http-afp/test.yaml new file mode 100644 index 0000000000..e7153a3431 --- /dev/null +++ b/live/tests/simple-http-afp/test.yaml @@ -0,0 +1,35 @@ +# This test runs in AF_PACKET IPS mode. +environment: inline + +requires: + command: + - podman + +args: + - --af-packet + +before: | + set -x + podman build -q --network=host server > ${OUTDIR}/server.iid + podman build -q --network=host client > ${OUTDIR}/client.iid + +server: | + set -x + iid=$(cat ${OUTDIR}/server.iid) + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + ${iid} + +client: | + set -x + iid=$(cat ${OUTDIR}/client.iid) + podman run --rm --network ns:/var/run/netns/client0 ${iid} curl --fail-with-body http://10.200.0.1 + +checks: + - filter: + count: 1 + match: + alert.signature_id: 2100498 + - stats: + app_layer.tx.http: 1 From 555f75ca0400d993c28b7531e839178ac8cd29d6 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:17 -0600 Subject: [PATCH 10/32] live: add IDS simple HTTP test --- live/tests/simple-http-ids/README.md | 6 ++++ live/tests/simple-http-ids/client/Dockerfile | 3 ++ live/tests/simple-http-ids/include.yaml | 6 ++++ live/tests/simple-http-ids/server/Caddyfile | 6 ++++ live/tests/simple-http-ids/server/Dockerfile | 5 +++ live/tests/simple-http-ids/server/index.txt | 1 + live/tests/simple-http-ids/test.rules | 1 + live/tests/simple-http-ids/test.yaml | 35 ++++++++++++++++++++ 8 files changed, 63 insertions(+) create mode 100644 live/tests/simple-http-ids/README.md create mode 100644 live/tests/simple-http-ids/client/Dockerfile create mode 100644 live/tests/simple-http-ids/include.yaml create mode 100644 live/tests/simple-http-ids/server/Caddyfile create mode 100644 live/tests/simple-http-ids/server/Dockerfile create mode 100644 live/tests/simple-http-ids/server/index.txt create mode 100644 live/tests/simple-http-ids/test.rules create mode 100644 live/tests/simple-http-ids/test.yaml diff --git a/live/tests/simple-http-ids/README.md b/live/tests/simple-http-ids/README.md new file mode 100644 index 0000000000..664246da07 --- /dev/null +++ b/live/tests/simple-http-ids/README.md @@ -0,0 +1,6 @@ +--- +tags: +- http +--- + +A very simple IDS test. diff --git a/live/tests/simple-http-ids/client/Dockerfile b/live/tests/simple-http-ids/client/Dockerfile new file mode 100644 index 0000000000..802c97527f --- /dev/null +++ b/live/tests/simple-http-ids/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt update && \ + apt install -y curl diff --git a/live/tests/simple-http-ids/include.yaml b/live/tests/simple-http-ids/include.yaml new file mode 100644 index 0000000000..37e2cffa5d --- /dev/null +++ b/live/tests/simple-http-ids/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules diff --git a/live/tests/simple-http-ids/server/Caddyfile b/live/tests/simple-http-ids/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/simple-http-ids/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/simple-http-ids/server/Dockerfile b/live/tests/simple-http-ids/server/Dockerfile new file mode 100644 index 0000000000..135a1e94b9 --- /dev/null +++ b/live/tests/simple-http-ids/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] \ No newline at end of file diff --git a/live/tests/simple-http-ids/server/index.txt b/live/tests/simple-http-ids/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/simple-http-ids/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/simple-http-ids/test.rules b/live/tests/simple-http-ids/test.rules new file mode 100644 index 0000000000..6e2b6a93bd --- /dev/null +++ b/live/tests/simple-http-ids/test.rules @@ -0,0 +1 @@ +alert ip any any -> any any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:2100498; rev:7; metadata:created_at 2010_09_23, confidence Medium, signature_severity Informational, updated_at 2019_07_26;) diff --git a/live/tests/simple-http-ids/test.yaml b/live/tests/simple-http-ids/test.yaml new file mode 100644 index 0000000000..3d2cdb473e --- /dev/null +++ b/live/tests/simple-http-ids/test.yaml @@ -0,0 +1,35 @@ +# This test runs in pcap IDS mode. +environment: tap + +requires: + command: + - podman + +args: + - --pcap=br0 + +before: | + set -x + podman build -q --network=host server > ${OUTDIR}/server.iid + podman build -q --network=host client > ${OUTDIR}/client.iid + +server: | + set -x + iid=$(cat ${OUTDIR}/server.iid) + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + ${iid} + +client: | + set -x + iid=$(cat ${OUTDIR}/client.iid) + podman run --rm --network ns:/var/run/netns/client0 ${iid} curl --fail-with-body http://10.200.0.1 + +checks: + - filter: + count: 1 + match: + alert.signature_id: 2100498 + - stats: + app_layer.tx.http: 1 From 69fdd0a081b9eb918e23d1833752ff6e1a587294 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:21 -0600 Subject: [PATCH 11/32] live: add NFQUEUE simple HTTP test --- live/tests/simple-http-nfq/README.md | 6 ++++ live/tests/simple-http-nfq/client/Dockerfile | 3 ++ live/tests/simple-http-nfq/include.yaml | 6 ++++ live/tests/simple-http-nfq/server/Caddyfile | 6 ++++ live/tests/simple-http-nfq/server/Dockerfile | 5 +++ live/tests/simple-http-nfq/server/index.txt | 1 + live/tests/simple-http-nfq/test.rules | 1 + live/tests/simple-http-nfq/test.yaml | 35 ++++++++++++++++++++ 8 files changed, 63 insertions(+) create mode 100644 live/tests/simple-http-nfq/README.md create mode 100644 live/tests/simple-http-nfq/client/Dockerfile create mode 100644 live/tests/simple-http-nfq/include.yaml create mode 100644 live/tests/simple-http-nfq/server/Caddyfile create mode 100644 live/tests/simple-http-nfq/server/Dockerfile create mode 100644 live/tests/simple-http-nfq/server/index.txt create mode 100644 live/tests/simple-http-nfq/test.rules create mode 100644 live/tests/simple-http-nfq/test.yaml diff --git a/live/tests/simple-http-nfq/README.md b/live/tests/simple-http-nfq/README.md new file mode 100644 index 0000000000..ab3073593a --- /dev/null +++ b/live/tests/simple-http-nfq/README.md @@ -0,0 +1,6 @@ +--- +tags: +- http +--- + +A very simple NFQ IPS inline test. diff --git a/live/tests/simple-http-nfq/client/Dockerfile b/live/tests/simple-http-nfq/client/Dockerfile new file mode 100644 index 0000000000..802c97527f --- /dev/null +++ b/live/tests/simple-http-nfq/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt update && \ + apt install -y curl diff --git a/live/tests/simple-http-nfq/include.yaml b/live/tests/simple-http-nfq/include.yaml new file mode 100644 index 0000000000..37e2cffa5d --- /dev/null +++ b/live/tests/simple-http-nfq/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules diff --git a/live/tests/simple-http-nfq/server/Caddyfile b/live/tests/simple-http-nfq/server/Caddyfile new file mode 100644 index 0000000000..736ee0cb4a --- /dev/null +++ b/live/tests/simple-http-nfq/server/Caddyfile @@ -0,0 +1,6 @@ +:80 { + root * /srv + rewrite * /index.txt + header Content-Type text/plain + file_server +} diff --git a/live/tests/simple-http-nfq/server/Dockerfile b/live/tests/simple-http-nfq/server/Dockerfile new file mode 100644 index 0000000000..135a1e94b9 --- /dev/null +++ b/live/tests/simple-http-nfq/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt update && apt install -y caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY index.txt /srv/index.txt +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"] \ No newline at end of file diff --git a/live/tests/simple-http-nfq/server/index.txt b/live/tests/simple-http-nfq/server/index.txt new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/simple-http-nfq/server/index.txt @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/simple-http-nfq/test.rules b/live/tests/simple-http-nfq/test.rules new file mode 100644 index 0000000000..6e2b6a93bd --- /dev/null +++ b/live/tests/simple-http-nfq/test.rules @@ -0,0 +1 @@ +alert ip any any -> any any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:2100498; rev:7; metadata:created_at 2010_09_23, confidence Medium, signature_severity Informational, updated_at 2019_07_26;) diff --git a/live/tests/simple-http-nfq/test.yaml b/live/tests/simple-http-nfq/test.yaml new file mode 100644 index 0000000000..e633bbe4d7 --- /dev/null +++ b/live/tests/simple-http-nfq/test.yaml @@ -0,0 +1,35 @@ +# This test runs in NFQ mode. +environment: nfq + +requires: + command: + - podman + +args: + - -q 0 + +before: | + set -x + podman build -q --network=host server > ${OUTDIR}/server.iid + podman build -q --network=host client > ${OUTDIR}/client.iid + +server: | + set -x + iid=$(cat ${OUTDIR}/server.iid) + podman run --rm \ + --stop-timeout=0 \ + --network ns:/var/run/netns/server0 \ + ${iid} + +client: | + set -x + iid=$(cat ${OUTDIR}/client.iid) + podman run --rm --network ns:/var/run/netns/client0 ${iid} curl --fail-with-body http://10.200.0.1 + +checks: + - filter: + count: 1 + match: + alert.signature_id: 2100498 + - stats: + app_layer.tx.http: 1 From 9f2427bb7b3d8033e78fb5478b568e48a93ca2d5 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 12/32] live: add AF_PACKET IPS XDP bypass test --- live/tests/afp-ips-xdp-bypass/README.md | 11 +++ live/tests/afp-ips-xdp-bypass/client.py | 85 ++++++++++++++++++++++ live/tests/afp-ips-xdp-bypass/include.yaml | 30 ++++++++ live/tests/afp-ips-xdp-bypass/server.py | 64 ++++++++++++++++ live/tests/afp-ips-xdp-bypass/test.rules | 6 ++ live/tests/afp-ips-xdp-bypass/test.yaml | 61 ++++++++++++++++ 6 files changed, 257 insertions(+) create mode 100644 live/tests/afp-ips-xdp-bypass/README.md create mode 100644 live/tests/afp-ips-xdp-bypass/client.py create mode 100644 live/tests/afp-ips-xdp-bypass/include.yaml create mode 100644 live/tests/afp-ips-xdp-bypass/server.py create mode 100644 live/tests/afp-ips-xdp-bypass/test.rules create mode 100644 live/tests/afp-ips-xdp-bypass/test.yaml diff --git a/live/tests/afp-ips-xdp-bypass/README.md b/live/tests/afp-ips-xdp-bypass/README.md new file mode 100644 index 0000000000..8a0d47db75 --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/README.md @@ -0,0 +1,11 @@ +--- +tags: +- bypass +- xdp +--- + +# AF-PACKET IPS with XDP bypass test + +Test AF-PACKET IPS with XDP bypass/offload. Test checks that packets are +bypassed by Suricata, but uses a client and server to verify that the traffic +still flows. diff --git a/live/tests/afp-ips-xdp-bypass/client.py b/live/tests/afp-ips-xdp-bypass/client.py new file mode 100644 index 0000000000..122170d65f --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/client.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Two real TCP connections through Suricata: + +1. The *bypass* flow: start a flow with our bypass marker, then a story then + the tripwire text. This flow should now alert on the tripwire text and the + flow should be marked as bypassed. + +2. The *control* flow: just send our story followed by the tripwire text. As no + bypass was done, we should alert on the tripwire text. + +In both cases the client always checks that what was sent was echo'd back. + +The script exits non-zero if any byte fails to round-trip, which the runner +treats as a test failure. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + + +STORY = b""" +Marty the meerkat stood on the warm desert sand every morning, stretching as tall as his little legs allowed so he could watch over his family. One day, while everyone else searched for breakfast, Marty spotted a shiny blue beetle struggling on its back beside a cactus. He hurried over, flipped it gently upright, and the beetle buzzed in happy circles before flying away. Later that afternoon, when a hungry eagle swept low across the dunes, a flash of blue wings darted in front of Marty and startled the eagle just long enough for him to squeak a warning. His family dove safely into their burrow, and from that day on, Marty learned that even the smallest kindness could come back in the biggest way. +""" + + +def recv_exact(sock, n): + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError(f"connection closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_and_verify_echo(sock, payload, what): + sock.sendall(payload) + echo = recv_exact(sock, len(payload)) + if echo != payload: + raise AssertionError(f"{what}: echo mismatch ({len(echo)}/{len(payload)} bytes)") + print(f"{what}: {len(payload)} bytes echoed back OK", flush=True) + + +def bypass_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + + # First send that should trigger the bypass. + send_and_verify_echo(s, b"BYPASS", "bypass-flow trigger") + + # Wait a moment for the bypass to be applied. + time.sleep(1.0) + + # Now send the story. + send_and_verify_echo(s, STORY, "bypass-flow payload") + + # Now send the tripwire. + send_and_verify_echo(s, b"TRIPWIRE", "bypass-flow-payload") + +def control_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + send_and_verify_echo(s, STORY, "control-flow payload") + send_and_verify_echo(s, b"TRIPWIRE", "control-flow-payload") + + +def main(): + try: + bypass_flow() + control_flow() + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print("client OK", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/afp-ips-xdp-bypass/include.yaml b/live/tests/afp-ips-xdp-bypass/include.yaml new file mode 100644 index 0000000000..149edc3a16 --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/include.yaml @@ -0,0 +1,30 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +# AF_PACKET inline copy-mode (IPS) with XDP bypass on both capture interfaces. +# When a flow is bypassed, Suricata installs it in the XDP flow map; the XDP +# program then forwards that flow's packets between client0 and server0 in the +# kernel, so they stop reaching Suricata's userspace while the endpoints keep +# talking. This is the af-packet equivalent of the NFQ capture bypass in +# ../bypass-nfq. +af-packet: + - interface: client0 + cluster-id: 91 + copy-mode: ips + copy-iface: server0 + bypass: yes + xdp-mode: soft + xdp-filter-file: ${OUTDIR}/xdp_filter.bpf + - interface: server0 + cluster-id: 92 + copy-mode: ips + copy-iface: client0 + bypass: yes + xdp-mode: soft + xdp-filter-file: ${OUTDIR}/xdp_filter.bpf + - interface: default + cluster-type: cluster_flow diff --git a/live/tests/afp-ips-xdp-bypass/server.py b/live/tests/afp-ips-xdp-bypass/server.py new file mode 100644 index 0000000000..f12e20956a --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/server.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Tiny TCP echo + byte-counting server for the live bypass test. + +For every connection it echoes back every byte it receives and counts the +total. When a connection closes it appends a JSON line describing how many +bytes that connection delivered to ``$OUT`` (argv[1]). The client uses the +echo to prove that every byte it sent survived the trip through Suricata +(i.e. that bypassing the flow did not break end-to-end delivery), and the +JSON file lets a check assert the server actually received the full payload. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + # Echo everything straight back. + sock.sendall(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/afp-ips-xdp-bypass/test.rules b/live/tests/afp-ips-xdp-bypass/test.rules new file mode 100644 index 0000000000..a26e5cd40d --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/test.rules @@ -0,0 +1,6 @@ +# Flow to be passed. "BYPASS" will be sent before "TRIPWIRE". +alert tcp any any -> any 7000 (flow:to_server; content:"BYPASS"; bypass; flowbits:set,bypassed; sid:1; rev:1;) + +# Alert when we see "TRIPWIRE". The idea is that we'll see this on flows +# that were not bypassed. +alert tcp any any -> any 7000 (flow:to_server; content:"TRIPWIRE"; sid:2; rev:1;) diff --git a/live/tests/afp-ips-xdp-bypass/test.yaml b/live/tests/afp-ips-xdp-bypass/test.yaml new file mode 100644 index 0000000000..dde4a64fc6 --- /dev/null +++ b/live/tests/afp-ips-xdp-bypass/test.yaml @@ -0,0 +1,61 @@ +environment: inline + +requires: + command: + - python3 + features: + - XDP + files: + - ebpf/xdp_filter.bpf + +args: + - --af-packet + +before: | + set -e + # include.yaml can only expand ${OUTDIR}/${TESTDIR}; stage the XDP object there. + cp ${SRCDIR}/ebpf/xdp_filter.bpf ${OUTDIR}/xdp_filter.bpf + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + # We should see sid 1 on the BYPASS flow. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + metadata.flowbits[0]: "bypassed" + + # SID 2 should match once on the non-bypassed flow. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + + # We should have one flow on port 7000 marked as bypassed by capture + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: capture + + # We should have one flow on port 7000 marked as closed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: closed + not-has-key: flow.bypass + + # One flow should be marked as bypassed in the stats. + - stats: + flow.end.state.capture_bypassed: 1 From 444a0ced42693f6c26c8145dd2ddb6cf39dd4318 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 13/32] live: add NFQUEUE IPS capture bypass test --- live/tests/nfq-ips-bypass/README.md | 18 ++++++ live/tests/nfq-ips-bypass/client.py | 85 ++++++++++++++++++++++++++ live/tests/nfq-ips-bypass/include.yaml | 12 ++++ live/tests/nfq-ips-bypass/server.py | 64 +++++++++++++++++++ live/tests/nfq-ips-bypass/test.rules | 6 ++ live/tests/nfq-ips-bypass/test.yaml | 83 +++++++++++++++++++++++++ 6 files changed, 268 insertions(+) create mode 100644 live/tests/nfq-ips-bypass/README.md create mode 100644 live/tests/nfq-ips-bypass/client.py create mode 100644 live/tests/nfq-ips-bypass/include.yaml create mode 100644 live/tests/nfq-ips-bypass/server.py create mode 100644 live/tests/nfq-ips-bypass/test.rules create mode 100644 live/tests/nfq-ips-bypass/test.yaml diff --git a/live/tests/nfq-ips-bypass/README.md b/live/tests/nfq-ips-bypass/README.md new file mode 100644 index 0000000000..7045efc2ff --- /dev/null +++ b/live/tests/nfq-ips-bypass/README.md @@ -0,0 +1,18 @@ +--- +tags: +- bypass +- nfq +--- + +# NFQ IPS with capture bypass test + +Test NFQ IPS mode with capture (offload) bypass. The test checks that packets +are bypassed by Suricata, but uses a client and server to verify that the +traffic still flows. + +When a flow is bypassed, Suricata ORs `nfq.bypass-mark` into the NFQUEUE verdict. +The test's `before` script installs a conntrack/CONNMARK ruleset in the DUT +namespace that saves that mark onto the connection and then accepts any +subsequent marked packet without queueing it. Suricata therefore stops seeing +the flow entirely while the endpoints keep talking. This is the NFQ equivalent +of the af-packet XDP capture bypass in `../afp-ips-xdp-bypass`. diff --git a/live/tests/nfq-ips-bypass/client.py b/live/tests/nfq-ips-bypass/client.py new file mode 100644 index 0000000000..122170d65f --- /dev/null +++ b/live/tests/nfq-ips-bypass/client.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Two real TCP connections through Suricata: + +1. The *bypass* flow: start a flow with our bypass marker, then a story then + the tripwire text. This flow should now alert on the tripwire text and the + flow should be marked as bypassed. + +2. The *control* flow: just send our story followed by the tripwire text. As no + bypass was done, we should alert on the tripwire text. + +In both cases the client always checks that what was sent was echo'd back. + +The script exits non-zero if any byte fails to round-trip, which the runner +treats as a test failure. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + + +STORY = b""" +Marty the meerkat stood on the warm desert sand every morning, stretching as tall as his little legs allowed so he could watch over his family. One day, while everyone else searched for breakfast, Marty spotted a shiny blue beetle struggling on its back beside a cactus. He hurried over, flipped it gently upright, and the beetle buzzed in happy circles before flying away. Later that afternoon, when a hungry eagle swept low across the dunes, a flash of blue wings darted in front of Marty and startled the eagle just long enough for him to squeak a warning. His family dove safely into their burrow, and from that day on, Marty learned that even the smallest kindness could come back in the biggest way. +""" + + +def recv_exact(sock, n): + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError(f"connection closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_and_verify_echo(sock, payload, what): + sock.sendall(payload) + echo = recv_exact(sock, len(payload)) + if echo != payload: + raise AssertionError(f"{what}: echo mismatch ({len(echo)}/{len(payload)} bytes)") + print(f"{what}: {len(payload)} bytes echoed back OK", flush=True) + + +def bypass_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + + # First send that should trigger the bypass. + send_and_verify_echo(s, b"BYPASS", "bypass-flow trigger") + + # Wait a moment for the bypass to be applied. + time.sleep(1.0) + + # Now send the story. + send_and_verify_echo(s, STORY, "bypass-flow payload") + + # Now send the tripwire. + send_and_verify_echo(s, b"TRIPWIRE", "bypass-flow-payload") + +def control_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + send_and_verify_echo(s, STORY, "control-flow payload") + send_and_verify_echo(s, b"TRIPWIRE", "control-flow-payload") + + +def main(): + try: + bypass_flow() + control_flow() + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print("client OK", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/nfq-ips-bypass/include.yaml b/live/tests/nfq-ips-bypass/include.yaml new file mode 100644 index 0000000000..523812b3f5 --- /dev/null +++ b/live/tests/nfq-ips-bypass/include.yaml @@ -0,0 +1,12 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +# NFQ IPS with capture (offload) bypass. Unlike af-packet, the kernel forwarding +# path is driven by an iptables conntrack/CONNMARK ruleset (installed by the +# test's `before` script) rather than an XDP program; Suricata only ORs the +# bypass mark into the verdict via nfq.bypass-mark. This is the NFQ equivalent of +# the af-packet XDP capture bypass in ../afp-ips-xdp-bypass. diff --git a/live/tests/nfq-ips-bypass/server.py b/live/tests/nfq-ips-bypass/server.py new file mode 100644 index 0000000000..f12e20956a --- /dev/null +++ b/live/tests/nfq-ips-bypass/server.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Tiny TCP echo + byte-counting server for the live bypass test. + +For every connection it echoes back every byte it receives and counts the +total. When a connection closes it appends a JSON line describing how many +bytes that connection delivered to ``$OUT`` (argv[1]). The client uses the +echo to prove that every byte it sent survived the trip through Suricata +(i.e. that bypassing the flow did not break end-to-end delivery), and the +JSON file lets a check assert the server actually received the full payload. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + # Echo everything straight back. + sock.sendall(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/nfq-ips-bypass/test.rules b/live/tests/nfq-ips-bypass/test.rules new file mode 100644 index 0000000000..a26e5cd40d --- /dev/null +++ b/live/tests/nfq-ips-bypass/test.rules @@ -0,0 +1,6 @@ +# Flow to be passed. "BYPASS" will be sent before "TRIPWIRE". +alert tcp any any -> any 7000 (flow:to_server; content:"BYPASS"; bypass; flowbits:set,bypassed; sid:1; rev:1;) + +# Alert when we see "TRIPWIRE". The idea is that we'll see this on flows +# that were not bypassed. +alert tcp any any -> any 7000 (flow:to_server; content:"TRIPWIRE"; sid:2; rev:1;) diff --git a/live/tests/nfq-ips-bypass/test.yaml b/live/tests/nfq-ips-bypass/test.yaml new file mode 100644 index 0000000000..1f4286a5ff --- /dev/null +++ b/live/tests/nfq-ips-bypass/test.yaml @@ -0,0 +1,83 @@ +environment: nfq + +requires: + command: + - python3 + +# Enable NFQ capture bypass: Suricata ORs this mark into the verdict of a +# bypassed flow's packets so the kernel can offload the rest of the flow. +args: + - -q 0 + - --set nfq.bypass-mark=1 + - --set nfq.bypass-mask=1 + +# Replace the runner's plain NFQUEUE ruleset with a conntrack/CONNMARK ruleset +# so the bypass mark persists across packets and marked flows skip the queue. +# This is the NFQ analogue of staging the XDP object in afp-ips-xdp-bypass: it is +# what actually forwards a bypassed flow's packets in the kernel, past Suricata. +before: | + set -e + dut() { ip netns exec dut "$@"; } + + dut iptables -F + dut iptables -t mangle -F + dut iptables -P FORWARD DROP + + # Restore the connection's saved mark onto each packet as it enters FORWARD. + dut iptables -A FORWARD -j CONNMARK --restore-mark + # A packet whose flow has already been bypassed carries the mark: accept it + # immediately so it never reaches Suricata. + dut iptables -A FORWARD -m mark --mark 0x1/0x1 -j ACCEPT + # Everything else is queued to Suricata for inspection. + dut iptables -A FORWARD -i client0 -o server0 -j NFQUEUE --queue-num 0 + dut iptables -A FORWARD -i server0 -o client0 -j NFQUEUE --queue-num 0 + + # After Suricata accepts a bypassed packet (with the mark set on the verdict), + # save that packet mark onto the conntrack entry so future packets inherit it. + dut iptables -t mangle -A POSTROUTING -j CONNMARK --save-mark + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + # We should see sid 1 on the BYPASS flow, with the bypass flowbit set. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + metadata.flowbits[0]: "bypassed" + + # SID 2 should match once, on the non-bypassed (control) flow only. On the + # bypassed flow the TRIPWIRE payload is offloaded to the kernel and never + # reaches Suricata, so it must not match there. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + + # We should have one flow on port 7000 reported capture-bypassed (offloaded + # to the kernel), not local-bypassed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: capture + + # The control flow on port 7000 should be reported closed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: closed + + # One flow should be marked as capture-bypassed in the stats. + - stats: + flow.end.state.capture_bypassed: 1 From 55d0c31366fa51d84354ee487a8ec5fca4edcc4d Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 14/32] live: add AF_PACKET IPS local bypass test --- live/tests/ips-local-bypass-afp/README.md | 11 +++ live/tests/ips-local-bypass-afp/client.py | 85 ++++++++++++++++++++ live/tests/ips-local-bypass-afp/include.yaml | 20 +++++ live/tests/ips-local-bypass-afp/server.py | 64 +++++++++++++++ live/tests/ips-local-bypass-afp/test.rules | 6 ++ live/tests/ips-local-bypass-afp/test.yaml | 61 ++++++++++++++ 6 files changed, 247 insertions(+) create mode 100644 live/tests/ips-local-bypass-afp/README.md create mode 100644 live/tests/ips-local-bypass-afp/client.py create mode 100644 live/tests/ips-local-bypass-afp/include.yaml create mode 100644 live/tests/ips-local-bypass-afp/server.py create mode 100644 live/tests/ips-local-bypass-afp/test.rules create mode 100644 live/tests/ips-local-bypass-afp/test.yaml diff --git a/live/tests/ips-local-bypass-afp/README.md b/live/tests/ips-local-bypass-afp/README.md new file mode 100644 index 0000000000..890c766fd5 --- /dev/null +++ b/live/tests/ips-local-bypass-afp/README.md @@ -0,0 +1,11 @@ +--- +tags: +- bypass +- local +--- + +# IPS with local bypass test + +Test Suricata's own local bypass in AF_PACKET IPS mode. +The test checks that a flow matched by a `bypass` rule is bypassed by Suricata, +while a client and server verify that traffic still flows end to end. diff --git a/live/tests/ips-local-bypass-afp/client.py b/live/tests/ips-local-bypass-afp/client.py new file mode 100644 index 0000000000..122170d65f --- /dev/null +++ b/live/tests/ips-local-bypass-afp/client.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Two real TCP connections through Suricata: + +1. The *bypass* flow: start a flow with our bypass marker, then a story then + the tripwire text. This flow should now alert on the tripwire text and the + flow should be marked as bypassed. + +2. The *control* flow: just send our story followed by the tripwire text. As no + bypass was done, we should alert on the tripwire text. + +In both cases the client always checks that what was sent was echo'd back. + +The script exits non-zero if any byte fails to round-trip, which the runner +treats as a test failure. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + + +STORY = b""" +Marty the meerkat stood on the warm desert sand every morning, stretching as tall as his little legs allowed so he could watch over his family. One day, while everyone else searched for breakfast, Marty spotted a shiny blue beetle struggling on its back beside a cactus. He hurried over, flipped it gently upright, and the beetle buzzed in happy circles before flying away. Later that afternoon, when a hungry eagle swept low across the dunes, a flash of blue wings darted in front of Marty and startled the eagle just long enough for him to squeak a warning. His family dove safely into their burrow, and from that day on, Marty learned that even the smallest kindness could come back in the biggest way. +""" + + +def recv_exact(sock, n): + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError(f"connection closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_and_verify_echo(sock, payload, what): + sock.sendall(payload) + echo = recv_exact(sock, len(payload)) + if echo != payload: + raise AssertionError(f"{what}: echo mismatch ({len(echo)}/{len(payload)} bytes)") + print(f"{what}: {len(payload)} bytes echoed back OK", flush=True) + + +def bypass_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + + # First send that should trigger the bypass. + send_and_verify_echo(s, b"BYPASS", "bypass-flow trigger") + + # Wait a moment for the bypass to be applied. + time.sleep(1.0) + + # Now send the story. + send_and_verify_echo(s, STORY, "bypass-flow payload") + + # Now send the tripwire. + send_and_verify_echo(s, b"TRIPWIRE", "bypass-flow-payload") + +def control_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + send_and_verify_echo(s, STORY, "control-flow payload") + send_and_verify_echo(s, b"TRIPWIRE", "control-flow-payload") + + +def main(): + try: + bypass_flow() + control_flow() + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print("client OK", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/ips-local-bypass-afp/include.yaml b/live/tests/ips-local-bypass-afp/include.yaml new file mode 100644 index 0000000000..1a652a6d7c --- /dev/null +++ b/live/tests/ips-local-bypass-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-local-bypass-afp/server.py b/live/tests/ips-local-bypass-afp/server.py new file mode 100644 index 0000000000..f12e20956a --- /dev/null +++ b/live/tests/ips-local-bypass-afp/server.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Tiny TCP echo + byte-counting server for the live bypass test. + +For every connection it echoes back every byte it receives and counts the +total. When a connection closes it appends a JSON line describing how many +bytes that connection delivered to ``$OUT`` (argv[1]). The client uses the +echo to prove that every byte it sent survived the trip through Suricata +(i.e. that bypassing the flow did not break end-to-end delivery), and the +JSON file lets a check assert the server actually received the full payload. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + # Echo everything straight back. + sock.sendall(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/ips-local-bypass-afp/test.rules b/live/tests/ips-local-bypass-afp/test.rules new file mode 100644 index 0000000000..a26e5cd40d --- /dev/null +++ b/live/tests/ips-local-bypass-afp/test.rules @@ -0,0 +1,6 @@ +# Flow to be passed. "BYPASS" will be sent before "TRIPWIRE". +alert tcp any any -> any 7000 (flow:to_server; content:"BYPASS"; bypass; flowbits:set,bypassed; sid:1; rev:1;) + +# Alert when we see "TRIPWIRE". The idea is that we'll see this on flows +# that were not bypassed. +alert tcp any any -> any 7000 (flow:to_server; content:"TRIPWIRE"; sid:2; rev:1;) diff --git a/live/tests/ips-local-bypass-afp/test.yaml b/live/tests/ips-local-bypass-afp/test.yaml new file mode 100644 index 0000000000..12ccbc4abd --- /dev/null +++ b/live/tests/ips-local-bypass-afp/test.yaml @@ -0,0 +1,61 @@ +environment: inline + +requires: + command: + - python3 + +args: + - --af-packet + +# No `before` script and no capture-bypass configuration: this test uses plain +# inline IPS copy-mode. When the `bypass` rule keyword fires, Suricata has no +# capture-bypass callback to hand the flow off to, so it uses its own local +# bypass instead. Unlike capture bypass, the packets keep arriving in +# Suricata's userspace; they just skip detection. + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + # We should see sid 1 on the BYPASS flow, with the bypass flowbit set. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + metadata.flowbits[0]: "bypassed" + + # SID 2 should match once, on the non-bypassed (control) flow only. On the + # bypassed flow the TRIPWIRE payload still reaches Suricata, but the flow is + # locally bypassed so it skips detection and must not match there. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + + # We should have one flow on port 7000 reported local-bypassed (handled by + # Suricata itself), not capture-bypassed (offloaded to the kernel/NIC). + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: local + + # The control flow on port 7000 should be reported closed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: closed + not-has-key: flow.bypass + + # One flow should be marked as local-bypassed in the stats. + - stats: + flow.end.state.local_bypassed: 1 From 220b1aa436d7d56d98e40e31d0dbaaa4d2bf8f4a Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 15/32] live: add NFQUEUE IPS local bypass test --- live/tests/ips-local-bypass-nfq/README.md | 11 +++ live/tests/ips-local-bypass-nfq/client.py | 85 ++++++++++++++++++++ live/tests/ips-local-bypass-nfq/include.yaml | 6 ++ live/tests/ips-local-bypass-nfq/server.py | 64 +++++++++++++++ live/tests/ips-local-bypass-nfq/test.rules | 6 ++ live/tests/ips-local-bypass-nfq/test.yaml | 61 ++++++++++++++ 6 files changed, 233 insertions(+) create mode 100644 live/tests/ips-local-bypass-nfq/README.md create mode 100644 live/tests/ips-local-bypass-nfq/client.py create mode 100644 live/tests/ips-local-bypass-nfq/include.yaml create mode 100644 live/tests/ips-local-bypass-nfq/server.py create mode 100644 live/tests/ips-local-bypass-nfq/test.rules create mode 100644 live/tests/ips-local-bypass-nfq/test.yaml diff --git a/live/tests/ips-local-bypass-nfq/README.md b/live/tests/ips-local-bypass-nfq/README.md new file mode 100644 index 0000000000..3cfb844b46 --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/README.md @@ -0,0 +1,11 @@ +--- +tags: +- bypass +- local +--- + +# IPS with local bypass test + +Test Suricata's own local bypass in NFQ IPS mode. +The test checks that a flow matched by a `bypass` rule is bypassed by Suricata, +while a client and server verify that traffic still flows end to end. diff --git a/live/tests/ips-local-bypass-nfq/client.py b/live/tests/ips-local-bypass-nfq/client.py new file mode 100644 index 0000000000..122170d65f --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/client.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Two real TCP connections through Suricata: + +1. The *bypass* flow: start a flow with our bypass marker, then a story then + the tripwire text. This flow should now alert on the tripwire text and the + flow should be marked as bypassed. + +2. The *control* flow: just send our story followed by the tripwire text. As no + bypass was done, we should alert on the tripwire text. + +In both cases the client always checks that what was sent was echo'd back. + +The script exits non-zero if any byte fails to round-trip, which the runner +treats as a test failure. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + + +STORY = b""" +Marty the meerkat stood on the warm desert sand every morning, stretching as tall as his little legs allowed so he could watch over his family. One day, while everyone else searched for breakfast, Marty spotted a shiny blue beetle struggling on its back beside a cactus. He hurried over, flipped it gently upright, and the beetle buzzed in happy circles before flying away. Later that afternoon, when a hungry eagle swept low across the dunes, a flash of blue wings darted in front of Marty and startled the eagle just long enough for him to squeak a warning. His family dove safely into their burrow, and from that day on, Marty learned that even the smallest kindness could come back in the biggest way. +""" + + +def recv_exact(sock, n): + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise EOFError(f"connection closed after {len(buf)}/{n} bytes") + buf.extend(chunk) + return bytes(buf) + + +def send_and_verify_echo(sock, payload, what): + sock.sendall(payload) + echo = recv_exact(sock, len(payload)) + if echo != payload: + raise AssertionError(f"{what}: echo mismatch ({len(echo)}/{len(payload)} bytes)") + print(f"{what}: {len(payload)} bytes echoed back OK", flush=True) + + +def bypass_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + + # First send that should trigger the bypass. + send_and_verify_echo(s, b"BYPASS", "bypass-flow trigger") + + # Wait a moment for the bypass to be applied. + time.sleep(1.0) + + # Now send the story. + send_and_verify_echo(s, STORY, "bypass-flow payload") + + # Now send the tripwire. + send_and_verify_echo(s, b"TRIPWIRE", "bypass-flow-payload") + +def control_flow(): + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + send_and_verify_echo(s, STORY, "control-flow payload") + send_and_verify_echo(s, b"TRIPWIRE", "control-flow-payload") + + +def main(): + try: + bypass_flow() + control_flow() + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print("client OK", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/ips-local-bypass-nfq/include.yaml b/live/tests/ips-local-bypass-nfq/include.yaml new file mode 100644 index 0000000000..37e2cffa5d --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules diff --git a/live/tests/ips-local-bypass-nfq/server.py b/live/tests/ips-local-bypass-nfq/server.py new file mode 100644 index 0000000000..f12e20956a --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/server.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Tiny TCP echo + byte-counting server for the live bypass test. + +For every connection it echoes back every byte it receives and counts the +total. When a connection closes it appends a JSON line describing how many +bytes that connection delivered to ``$OUT`` (argv[1]). The client uses the +echo to prove that every byte it sent survived the trip through Suricata +(i.e. that bypassing the flow did not break end-to-end delivery), and the +JSON file lets a check assert the server actually received the full payload. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + # Echo everything straight back. + sock.sendall(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/ips-local-bypass-nfq/test.rules b/live/tests/ips-local-bypass-nfq/test.rules new file mode 100644 index 0000000000..a26e5cd40d --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/test.rules @@ -0,0 +1,6 @@ +# Flow to be passed. "BYPASS" will be sent before "TRIPWIRE". +alert tcp any any -> any 7000 (flow:to_server; content:"BYPASS"; bypass; flowbits:set,bypassed; sid:1; rev:1;) + +# Alert when we see "TRIPWIRE". The idea is that we'll see this on flows +# that were not bypassed. +alert tcp any any -> any 7000 (flow:to_server; content:"TRIPWIRE"; sid:2; rev:1;) diff --git a/live/tests/ips-local-bypass-nfq/test.yaml b/live/tests/ips-local-bypass-nfq/test.yaml new file mode 100644 index 0000000000..141e3f3be6 --- /dev/null +++ b/live/tests/ips-local-bypass-nfq/test.yaml @@ -0,0 +1,61 @@ +environment: nfq + +requires: + command: + - python3 + +args: + - -q 0 + +# No `before` script and no capture-bypass configuration: this test uses the +# plain NFQUEUE ruleset. When the `bypass` rule keyword fires, Suricata has no +# capture-bypass callback to hand the flow off to, so it uses its own local +# bypass instead. Unlike capture bypass, the packets keep arriving in +# Suricata's userspace; they just skip detection. + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + # We should see sid 1 on the BYPASS flow, with the bypass flowbit set. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + metadata.flowbits[0]: "bypassed" + + # SID 2 should match once, on the non-bypassed (control) flow only. On the + # bypassed flow the TRIPWIRE payload still reaches Suricata, but the flow is + # locally bypassed so it skips detection and must not match there. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + + # We should have one flow on port 7000 reported local-bypassed (handled by + # Suricata itself), not capture-bypassed (offloaded to the kernel/NIC). + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: local + + # The control flow on port 7000 should be reported closed. + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: closed + not-has-key: flow.bypass + + # One flow should be marked as local-bypassed in the stats. + - stats: + flow.end.state.local_bypassed: 1 From 3c49a6873c8fb1f5997e985b39985e8eee4d06f9 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 16/32] live: add XDP bypass shutdown demo test --- .../README.md | 8 +++ .../client.py | 33 +++++++++++ .../include.yaml | 21 +++++++ .../server.py | 59 +++++++++++++++++++ .../test.rules | 1 + .../test.yaml | 38 ++++++++++++ 6 files changed, 160 insertions(+) create mode 100644 live/tests/demo-afp-xdp-bypass-stats-shutdown/README.md create mode 100644 live/tests/demo-afp-xdp-bypass-stats-shutdown/client.py create mode 100644 live/tests/demo-afp-xdp-bypass-stats-shutdown/include.yaml create mode 100644 live/tests/demo-afp-xdp-bypass-stats-shutdown/server.py create mode 100644 live/tests/demo-afp-xdp-bypass-stats-shutdown/test.rules create mode 100644 live/tests/demo-afp-xdp-bypass-stats-shutdown/test.yaml diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/README.md b/live/tests/demo-afp-xdp-bypass-stats-shutdown/README.md new file mode 100644 index 0000000000..5b08f70cc5 --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/README.md @@ -0,0 +1,8 @@ +--- +tags: +- bypass +- xdp +--- + +Demo test based on the test in +https://github.com/OISF/suricata-verify/pull/3194. diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/client.py b/live/tests/demo-afp-xdp-bypass-stats-shutdown/client.py new file mode 100644 index 0000000000..deb10358db --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/client.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Client driver for the live bypass test. + +Open one TCP connection and send a fixed TLS-like payload 97 times. +""" + +import socket +import sys +import time + +HOST = "10.200.0.1" +PORT = 7000 + +PAYLOAD = b"\x17\x03\x03" + b"\x00" * 100 +COUNT = 97 + + +def main(): + try: + with socket.create_connection((HOST, PORT), timeout=10) as s: + s.settimeout(10) + for _ in range(COUNT): + s.sendall(PAYLOAD) + time.sleep(0.01) + except Exception as err: # noqa: BLE001 - surface any failure to the runner + print(f"ERROR: {err}", file=sys.stderr, flush=True) + return 1 + print(f"client OK: sent {len(PAYLOAD) * COUNT} bytes", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/include.yaml b/live/tests/demo-afp-xdp-bypass-stats-shutdown/include.yaml new file mode 100644 index 0000000000..b9a4e5a0c2 --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/include.yaml @@ -0,0 +1,21 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +# AF_PACKET inline copy-mode (IPS) with XDP bypass on both capture interfaces. +# When a flow is bypassed, Suricata installs it in the XDP flow map; the XDP +# program then forwards that flow's packets between client0 and server0 in the +# kernel, so they stop reaching Suricata's userspace while the endpoints keep +# talking. This is the af-packet equivalent of the NFQ capture bypass in +# ../bypass-nfq. +af-packet: + - interface: br0 + cluster-id: 91 + bypass: yes + xdp-mode: soft + xdp-filter-file: ${OUTDIR}/xdp_filter.bpf + - interface: default + cluster-type: cluster_flow diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/server.py b/live/tests/demo-afp-xdp-bypass-stats-shutdown/server.py new file mode 100644 index 0000000000..8a1d1ee8a7 --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/server.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Tiny TCP sink for the live bypass test. + +For every connection, consume all bytes until the peer closes. When a +connection closes, append a JSON line with the byte count to ``$OUT`` +(argv[1]) if provided. +""" + +import json +import socket +import sys +import threading + +HOST = "0.0.0.0" +PORT = 7000 + +out_path = sys.argv[1] if len(sys.argv) > 1 else None +out_lock = threading.Lock() +conn_no = 0 + + +def record(conn_id, nbytes): + if not out_path: + return + with out_lock: + with open(out_path, "a", encoding="utf-8") as f: + f.write(json.dumps({"conn": conn_id, "bytes": nbytes}) + "\n") + + +def handle(sock, conn_id): + total = 0 + try: + while True: + data = sock.recv(65536) + if not data: + break + total += len(data) + finally: + sock.close() + record(conn_id, total) + print(f"conn {conn_id}: received {total} bytes", flush=True) + + +def main(): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((HOST, PORT)) + srv.listen(16) + print(f"listening on {HOST}:{PORT}", flush=True) + global conn_no + while True: + client, _ = srv.accept() + conn_no += 1 + t = threading.Thread(target=handle, args=(client, conn_no), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.rules b/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.rules new file mode 100644 index 0000000000..895aa615ce --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.rules @@ -0,0 +1 @@ +alert ip4 any any -> any any (msg:"Dropping all IPv4 traffic"; bypass; sid:1; rev:1;) diff --git a/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.yaml b/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.yaml new file mode 100644 index 0000000000..014d6d4aa7 --- /dev/null +++ b/live/tests/demo-afp-xdp-bypass-stats-shutdown/test.yaml @@ -0,0 +1,38 @@ +environment: tap + +requires: + command: + - python3 + features: + - XDP + files: + - ebpf/xdp_filter.bpf + +args: + - --af-packet + +before: | + set -e + # include.yaml can only expand ${OUTDIR}/${TESTDIR}; stage the XDP object there. + cp ${SRCDIR}/ebpf/xdp_filter.bpf ${OUTDIR}/xdp_filter.bpf + +server: | + exec ip netns exec server0 python3 ${TESTDIR}/server.py ${OUTDIR}/server.json + +client: | + exec ip netns exec client0 python3 ${TESTDIR}/client.py + +checks: + + - filter: + count: 1 + match: + event_type: flow + dest_port: 7000 + flow.state: bypassed + flow.bypass: capture + + - stats: + flow.end.state.capture_bypassed: 1 + flow_bypassed.local_capture_pkts.__gt: 0 + flow_bypassed.local_capture_bytes.__gt: 0 From 37cdae217e73ae293d6434d7d7f6624bffec64bf Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 17/32] live: add tcpreplay XDP bypass VLAN demo test --- .../demo-replay-xdp-bypass-vlan/README.md | 14 +++++++++ .../demo-replay-xdp-bypass-vlan/include.yaml | 19 ++++++++++++ .../demo-replay-xdp-bypass-vlan/input.pcap | Bin 0 -> 12124 bytes .../demo-replay-xdp-bypass-vlan/test.rules | 1 + .../demo-replay-xdp-bypass-vlan/test.yaml | 28 ++++++++++++++++++ 5 files changed, 62 insertions(+) create mode 100644 live/tests/demo-replay-xdp-bypass-vlan/README.md create mode 100644 live/tests/demo-replay-xdp-bypass-vlan/include.yaml create mode 100644 live/tests/demo-replay-xdp-bypass-vlan/input.pcap create mode 100644 live/tests/demo-replay-xdp-bypass-vlan/test.rules create mode 100644 live/tests/demo-replay-xdp-bypass-vlan/test.yaml diff --git a/live/tests/demo-replay-xdp-bypass-vlan/README.md b/live/tests/demo-replay-xdp-bypass-vlan/README.md new file mode 100644 index 0000000000..ddebab6a36 --- /dev/null +++ b/live/tests/demo-replay-xdp-bypass-vlan/README.md @@ -0,0 +1,14 @@ +--- +tags: +- bypass +- replay +- xdp +--- + +Demo live IDS replay test based on the test in +https://github.com/OISF/suricata-verify/pull/3204. + +The pcap contains one VLAN-tagged TCP flow with 100 packets. The client script +replays it from the client namespace onto the IDS bridge path with `tcpreplay`. + +Ticket: https://redmine.openinfosecfoundation.org/issues/8699 diff --git a/live/tests/demo-replay-xdp-bypass-vlan/include.yaml b/live/tests/demo-replay-xdp-bypass-vlan/include.yaml new file mode 100644 index 0000000000..8c823d7fb6 --- /dev/null +++ b/live/tests/demo-replay-xdp-bypass-vlan/include.yaml @@ -0,0 +1,19 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +flow-timeouts: + tcp: + bypassed: 1 + +af-packet: + - interface: br0 + cluster-id: 92 + bypass: yes + xdp-mode: soft + xdp-filter-file: ${OUTDIR}/xdp_filter.bpf + - interface: default + cluster-type: cluster_flow diff --git a/live/tests/demo-replay-xdp-bypass-vlan/input.pcap b/live/tests/demo-replay-xdp-bypass-vlan/input.pcap new file mode 100644 index 0000000000000000000000000000000000000000..0a273ad39be71387510905a191d51126d28f6f50 GIT binary patch literal 12124 zcmciIe@N7490%~{ocF`?I`90IQHGR(uIsupf1WyYOG&VHZUs)(Di|`GMQckG=9=PY zf>!(CFxuQ4#Tr|i$^xZmMcJfHrsEpLX3SR1Ku3WLjl@0A^V#fR6Dx>Dc7SiFsu zFqS5MT4bpnw(~#PP0ZxWGo5AZu?dkDj7VXnJXj=E{vLJ`PZ4RRv%WmWtRp<_ZZI~5 zJ+T^+{1ag(E!?NOJZ&`S;z_BHG&AXBU3@>q`k%VCe{?nf(m_&E3M=dv&PTt9Be-fh zZ1=(=ILcV$X5lWp0Pd+7oI7%}Jc7c#_0gNf7KGc%V#2)!7rT3<`*}!u*Bpa7-;uqB zyWk$3yUT%hn-he)U{(VQZ?LT((MDc zB^~J=Gu-)?>DnB63h5p-+|?Yw2> zBY8d!&8eqzhhEWew@de%;C`nL>26oA#}~40qN9oja-_f1|KV$ZkJE!-O( zg8SofoZIPE?xS?>Ufy@0-3O$*6Wn{JknRJ@?V)qeJ_QOrdaRjDY)8vD*t(YPh?lyB6HFOOb9~IYGOYLBp1_zjZ6| zZkJiQZQ%B=#<^W)!|nf&1}^3mb061m*9f=&1h^k~k?tDh&ZToFXKT2JrMn2+V*#Xl z*l_zobnd^3@ou+YxP80=5LeE(r8u|SuiX3S+?Td!xI3h~9^40?MY{P&7}_23k{p~FZwpV@?V&6`B$ z?&{ERPf2$QxT}sM-FzGe?sebLxnKAc?~bn!?sezDz5FwrJHA4>gLLi{Uun3{NOvW; zulFI{XAHMzlFt3h0N$OjOt?MM;Qr`5&YiGKx!E=(W;Qscc z(zzQ(HQeLU?E&}3pOJ1pF9+_m7wFu%fyC;dIL6LL?lA0&OPFlE6{M`-fPg2K|{sxhfHunGk literal 0 HcmV?d00001 diff --git a/live/tests/demo-replay-xdp-bypass-vlan/test.rules b/live/tests/demo-replay-xdp-bypass-vlan/test.rules new file mode 100644 index 0000000000..d380e1d3ad --- /dev/null +++ b/live/tests/demo-replay-xdp-bypass-vlan/test.rules @@ -0,0 +1 @@ +alert tcp any any -> any any (bypass; sid:1000004; rev:1;) diff --git a/live/tests/demo-replay-xdp-bypass-vlan/test.yaml b/live/tests/demo-replay-xdp-bypass-vlan/test.yaml new file mode 100644 index 0000000000..0be9a23ea9 --- /dev/null +++ b/live/tests/demo-replay-xdp-bypass-vlan/test.yaml @@ -0,0 +1,28 @@ +environment: tap + +requires: + min-version: 9 + os: linux + command: + - tcpreplay + features: + - XDP + files: + - ebpf/xdp_filter.bpf + +args: + - --af-packet + +before: | + set -e + # include.yaml can only expand ${OUTDIR}/${TESTDIR}; stage the XDP object there. + cp ${SRCDIR}/ebpf/xdp_filter.bpf ${OUTDIR}/xdp_filter.bpf + +client: | + set -e + ip netns exec dut tcpreplay -q -i br0 ${TESTDIR}/input.pcap + sleep 2 + +checks: + - stats: + capture.kernel_packets.__gte: 100 From 332050869a5b7d778c34acefac53ce8c944d8cbb Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 18/32] live: port AF_PACKET IPS autofp namespace test --- live/tests/ips-drop-icmp-afp-autofp/README.md | 6 ++ .../client/Dockerfile | 3 + .../ips-drop-icmp-afp-autofp/drop-icmp.rules | 1 + .../ips-drop-icmp-afp-autofp/include.yaml | 20 ++++++ .../server/Dockerfile | 5 ++ .../server/index.html | 0 .../ips-drop-icmp-afp-autofp/server/server.sh | 10 +++ live/tests/ips-drop-icmp-afp-autofp/test.yaml | 70 +++++++++++++++++++ 8 files changed, 115 insertions(+) create mode 100644 live/tests/ips-drop-icmp-afp-autofp/README.md create mode 100644 live/tests/ips-drop-icmp-afp-autofp/client/Dockerfile create mode 100644 live/tests/ips-drop-icmp-afp-autofp/drop-icmp.rules create mode 100644 live/tests/ips-drop-icmp-afp-autofp/include.yaml create mode 100644 live/tests/ips-drop-icmp-afp-autofp/server/Dockerfile create mode 100644 live/tests/ips-drop-icmp-afp-autofp/server/index.html create mode 100755 live/tests/ips-drop-icmp-afp-autofp/server/server.sh create mode 100644 live/tests/ips-drop-icmp-afp-autofp/test.yaml diff --git a/live/tests/ips-drop-icmp-afp-autofp/README.md b/live/tests/ips-drop-icmp-afp-autofp/README.md new file mode 100644 index 0000000000..2e6d3dd61b --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/README.md @@ -0,0 +1,6 @@ +A copy of the `ips-drop-icmp` AF_PACKET IPS test that runs Suricata with +`--runmode autofp`. + +Suricata runs inline with AF_PACKET copy-mode IPS and a single rule that drops +ICMP echo requests. HTTP traffic must still pass, ICMP must be dropped, and no +echo requests should reach the server. diff --git a/live/tests/ips-drop-icmp-afp-autofp/client/Dockerfile b/live/tests/ips-drop-icmp-afp-autofp/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/ips-drop-icmp-afp-autofp/drop-icmp.rules b/live/tests/ips-drop-icmp-afp-autofp/drop-icmp.rules new file mode 100644 index 0000000000..af3f7b14e9 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/ips-drop-icmp-afp-autofp/include.yaml b/live/tests/ips-drop-icmp-afp-autofp/include.yaml new file mode 100644 index 0000000000..ae2bb1d449 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-drop-icmp-afp-autofp/server/Dockerfile b/live/tests/ips-drop-icmp-afp-autofp/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/ips-drop-icmp-afp-autofp/server/index.html b/live/tests/ips-drop-icmp-afp-autofp/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/ips-drop-icmp-afp-autofp/server/server.sh b/live/tests/ips-drop-icmp-afp-autofp/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/ips-drop-icmp-afp-autofp/test.yaml b/live/tests/ips-drop-icmp-afp-autofp/test.yaml new file mode 100644 index 0000000000..a6b85fb771 --- /dev/null +++ b/live/tests/ips-drop-icmp-afp-autofp/test.yaml @@ -0,0 +1,70 @@ +# Port of qa/live/netns/afp-ips-netns-bridge.sh with the autofp runmode. +environment: inline + +requires: + command: + - jq + - podman + +args: + - --af-packet + - --runmode autofp + +before: | + # Build both images up front. The first build can be slow and would + # otherwise race the server/client scripts. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Only ICMP is dropped, so HTTP requests should still succeed. + echo "Running curl..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -O http://10.200.0.1/index.html; then + echo "error: curl should have completed successfully" + errors="yes" + fi + + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # ICMP echo requests should be dropped, so ping should fail. + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - stats: + capture.kernel_packets.__gt: 0 + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 From b1e95bc1352dd9ad26d9636efa03494a0698b366 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 19/32] live: port NFQUEUE IPS workers namespace test --- .../tests/ips-drop-icmp-nfq-workers/README.md | 6 ++ .../client/Dockerfile | 3 + .../ips-drop-icmp-nfq-workers/drop-icmp.rules | 1 + .../ips-drop-icmp-nfq-workers/include.yaml | 6 ++ .../server/Dockerfile | 5 ++ .../server/index.html | 0 .../server/server.sh | 10 +++ .../tests/ips-drop-icmp-nfq-workers/test.yaml | 69 +++++++++++++++++++ 8 files changed, 100 insertions(+) create mode 100644 live/tests/ips-drop-icmp-nfq-workers/README.md create mode 100644 live/tests/ips-drop-icmp-nfq-workers/client/Dockerfile create mode 100644 live/tests/ips-drop-icmp-nfq-workers/drop-icmp.rules create mode 100644 live/tests/ips-drop-icmp-nfq-workers/include.yaml create mode 100644 live/tests/ips-drop-icmp-nfq-workers/server/Dockerfile create mode 100644 live/tests/ips-drop-icmp-nfq-workers/server/index.html create mode 100755 live/tests/ips-drop-icmp-nfq-workers/server/server.sh create mode 100644 live/tests/ips-drop-icmp-nfq-workers/test.yaml diff --git a/live/tests/ips-drop-icmp-nfq-workers/README.md b/live/tests/ips-drop-icmp-nfq-workers/README.md new file mode 100644 index 0000000000..0565b21e53 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/README.md @@ -0,0 +1,6 @@ +A copy of the `ips-drop-icmp` NFQ IPS test that runs Suricata with +`--runmode workers`. + +Suricata runs inline with NFQUEUE and a single rule that drops ICMP echo +requests. HTTP traffic must still pass, ICMP must be dropped, and no echo +requests should reach the server. diff --git a/live/tests/ips-drop-icmp-nfq-workers/client/Dockerfile b/live/tests/ips-drop-icmp-nfq-workers/client/Dockerfile new file mode 100644 index 0000000000..e7f07fa8ed --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/client/Dockerfile @@ -0,0 +1,3 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install curl wget iputils-ping diff --git a/live/tests/ips-drop-icmp-nfq-workers/drop-icmp.rules b/live/tests/ips-drop-icmp-nfq-workers/drop-icmp.rules new file mode 100644 index 0000000000..af3f7b14e9 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/ips-drop-icmp-nfq-workers/include.yaml b/live/tests/ips-drop-icmp-nfq-workers/include.yaml new file mode 100644 index 0000000000..ff6b818a91 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/include.yaml @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules diff --git a/live/tests/ips-drop-icmp-nfq-workers/server/Dockerfile b/live/tests/ips-drop-icmp-nfq-workers/server/Dockerfile new file mode 100644 index 0000000000..e0561d17c5 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/server/Dockerfile @@ -0,0 +1,5 @@ +FROM ubuntu:24.04 +RUN apt-get -y update && \ + apt-get -y install caddy tshark +COPY /server.sh /server.sh +COPY /index.html /srv/www/index.html diff --git a/live/tests/ips-drop-icmp-nfq-workers/server/index.html b/live/tests/ips-drop-icmp-nfq-workers/server/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/live/tests/ips-drop-icmp-nfq-workers/server/server.sh b/live/tests/ips-drop-icmp-nfq-workers/server/server.sh new file mode 100755 index 0000000000..30ce5e9f92 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/server/server.sh @@ -0,0 +1,10 @@ +#! /bin/bash + +set -e +set -x + +echo "Starting tshark..." +tshark -i server -f icmp -T json > /out/tshark-server.json & + +echo "Starting caddy..." +cd /srv/www && exec caddy file-server browse diff --git a/live/tests/ips-drop-icmp-nfq-workers/test.yaml b/live/tests/ips-drop-icmp-nfq-workers/test.yaml new file mode 100644 index 0000000000..329cc288d4 --- /dev/null +++ b/live/tests/ips-drop-icmp-nfq-workers/test.yaml @@ -0,0 +1,69 @@ +# Port of qa/live/netns/nfq-ips-netns-route.sh with the workers runmode. +environment: nfq + +requires: + command: + - jq + - podman + +args: + - -q 0 + - --runmode workers + +before: | + # Build both images up front. The first build can be slow and would + # otherwise race the server/client scripts. + podman build --iidfile=${OUTDIR}/server.iid --network=host server + podman build --iidfile=${OUTDIR}/client.iid --network=host client + +server: | + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/server0 \ + -v ${OUTDIR}:/out:rw \ + $(cat ${OUTDIR}/server.iid) \ + /server.sh + +client: | + errors="no" + + iid=$(cat ${OUTDIR}/client.iid) + run() { + echo "Running: $@" + podman run --rm --cap-add=NET_RAW --network ns:/var/run/netns/client0 ${iid} "$@" + } + + # Only ICMP is dropped, so HTTP requests should still succeed. + echo "Running curl..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -O http://10.200.0.1/index.html; then + echo "error: curl should have completed successfully" + errors="yes" + fi + + echo "Running wget..." + if ! run timeout --kill-after=1 --preserve-status 5 \ + wget http://10.200.0.1/index.html; then + echo "error: wget should have completed successfully" + errors="yes" + fi + + # ICMP echo requests should be dropped, so ping should fail. + echo "Running ping..." + if run ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + - stats: + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + # We should not have seen any ICMP echo requests on the server. + - shell: + args: | + pings=$(jq -c '.[]' ./tshark-server.json|jq 'select(._source.layers.icmp."icmp.type"=="8")'|wc -l) + test "${pings}" -eq 0 From 13e70a2e18cf78a55791062d9589d6196be24aac Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 20/32] live: port single-network AF_PACKET bond test --- live/tests/afp-ips-bond/README.md | 7 ++ live/tests/afp-ips-bond/drop-icmp.rules | 1 + live/tests/afp-ips-bond/include.yaml | 35 ++++++++++ live/tests/afp-ips-bond/test.yaml | 85 +++++++++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 live/tests/afp-ips-bond/README.md create mode 100644 live/tests/afp-ips-bond/drop-icmp.rules create mode 100644 live/tests/afp-ips-bond/include.yaml create mode 100644 live/tests/afp-ips-bond/test.yaml diff --git a/live/tests/afp-ips-bond/README.md b/live/tests/afp-ips-bond/README.md new file mode 100644 index 0000000000..9056ef2307 --- /dev/null +++ b/live/tests/afp-ips-bond/README.md @@ -0,0 +1,7 @@ +# AF_PACKET IPS with one bonded network + +Ports `qa/live/netns/afp-ips-netns-bond-bridge.sh` into the live test +framework using the framework's `10.200.0.0/24` address scheme. One +workers-mode Suricata process forwards an inline network over two-member +balance-rr bonds at MTU 9000. HTTP must pass while fragmented ICMP echo +requests must be dropped before they reach the server. diff --git a/live/tests/afp-ips-bond/drop-icmp.rules b/live/tests/afp-ips-bond/drop-icmp.rules new file mode 100644 index 0000000000..54280bc8c8 --- /dev/null +++ b/live/tests/afp-ips-bond/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (msg:"Drop ICMP"; sid:1; rev:1;) diff --git a/live/tests/afp-ips-bond/include.yaml b/live/tests/afp-ips-bond/include.yaml new file mode 100644 index 0000000000..b9fd95b3e9 --- /dev/null +++ b/live/tests/afp-ips-bond/include.yaml @@ -0,0 +1,35 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - drop: + flows: all + alerts: true + - http + - stats: + totals: yes + threads: no + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: 2 + cluster-type: cluster_flow diff --git a/live/tests/afp-ips-bond/test.yaml b/live/tests/afp-ips-bond/test.yaml new file mode 100644 index 0000000000..acc1f70c5d --- /dev/null +++ b/live/tests/afp-ips-bond/test.yaml @@ -0,0 +1,85 @@ +# Port of qa/live/netns/afp-ips-netns-bond-bridge.sh. +environment: inline + +topology: + mtu: 9000 + networks: + - client: 10.200.0.2/24 + server: 10.200.0.1/24 + bond: true + bond-mode: balance-rr + +requires: + command: + - curl + - grep + - hping3 + - jq + - python3 + - tcpdump + - wc + - wget + +args: + - --af-packet + - --runmode workers + +server: | + cleanup() { + kill "${http:-}" "${capture:-}" 2>/dev/null || true + wait 2>/dev/null || true + } + trap cleanup EXIT INT TERM + + ip netns exec server0 tcpdump -U -n -i server \ + -w ${OUTDIR}/server0.pcap icmp & + capture=$! + ip netns exec server0 python3 -m http.server 8000 --bind 10.200.0.1 & + http=$! + wait + +client: | + errors=no + + echo "Checking HTTP with curl" + if ! ip netns exec client0 \ + curl --retry 10 --retry-connrefused --retry-delay 1 -fsS \ + http://10.200.0.1:8000/ -o /dev/null; then + echo "error: curl should have succeeded" + errors=yes + fi + + echo "Checking HTTP with wget" + if ! ip netns exec client0 wget -qO /dev/null http://10.200.0.1:8000/; then + echo "error: wget should have succeeded" + errors=yes + fi + + echo "Sending fragmented ICMP" + hping_output=$(ip netns exec client0 hping3 -c 10 -1 -f -d 15000 \ + 10.200.0.1 2>&1) || true + echo "${hping_output}" + if ! grep -Eq '0 packets received|100% packet loss' <<<"${hping_output}"; then + echo "error: fragmented ICMP should have been dropped" + errors=yes + fi + + test "${errors}" = no + +checks: + - stats: + capture.kernel_packets.__gt: 0 + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + - shell: + args: | + http=$(jq -c 'select(.event_type == "http" and .src_ip == "10.200.0.2")' eve.json | wc -l) + drops=$(jq -c 'select(.event_type == "drop" and .src_ip == "10.200.0.2")' eve.json | wc -l) + test "${http}" -ge 2 + test "${drops}" -ge 20 + + - shell: + args: | + echoes=$(tcpdump -nn -r server0.pcap 'icmp[0] = 8' 2>/dev/null | wc -l) + test "${echoes}" -eq 0 From 205aeb57bfc39040ea0e6f0ac285ece13dc0a2e7 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:30 -0600 Subject: [PATCH 21/32] live: port two-network AF_PACKET bond test --- .../tests/afp-ips-bond-two-networks/README.md | 7 ++ .../afp-ips-bond-two-networks/drop-icmp.rules | 1 + .../afp-ips-bond-two-networks/include.yaml | 45 ++++++++ .../tests/afp-ips-bond-two-networks/test.yaml | 107 ++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 live/tests/afp-ips-bond-two-networks/README.md create mode 100644 live/tests/afp-ips-bond-two-networks/drop-icmp.rules create mode 100644 live/tests/afp-ips-bond-two-networks/include.yaml create mode 100644 live/tests/afp-ips-bond-two-networks/test.yaml diff --git a/live/tests/afp-ips-bond-two-networks/README.md b/live/tests/afp-ips-bond-two-networks/README.md new file mode 100644 index 0000000000..8db5784f08 --- /dev/null +++ b/live/tests/afp-ips-bond-two-networks/README.md @@ -0,0 +1,7 @@ +# AF_PACKET IPS with two bonded networks + +Ports `qa/live/netns/afp-ips-netns-bond-bridge2.sh` into the live test +framework. One workers-mode Suricata process forwards two independent inline +networks over balance-rr bonds at MTU 9000. HTTP must pass on both networks, +while fragmented and ordinary ICMP echo requests must be dropped before they +reach either server. diff --git a/live/tests/afp-ips-bond-two-networks/drop-icmp.rules b/live/tests/afp-ips-bond-two-networks/drop-icmp.rules new file mode 100644 index 0000000000..54280bc8c8 --- /dev/null +++ b/live/tests/afp-ips-bond-two-networks/drop-icmp.rules @@ -0,0 +1 @@ +drop icmp any any -> any any (msg:"Drop ICMP"; sid:1; rev:1;) diff --git a/live/tests/afp-ips-bond-two-networks/include.yaml b/live/tests/afp-ips-bond-two-networks/include.yaml new file mode 100644 index 0000000000..65de078f1a --- /dev/null +++ b/live/tests/afp-ips-bond-two-networks/include.yaml @@ -0,0 +1,45 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - drop-icmp.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - drop: + flows: all + alerts: true + - http + - stats: + totals: yes + threads: no + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: client1 + cluster-id: 82 + threads: 4 + copy-mode: ips + copy-iface: server1 + - interface: server1 + cluster-id: 83 + threads: 4 + copy-mode: ips + copy-iface: client1 + - interface: default + defrag: false + threads: 2 + cluster-type: cluster_flow diff --git a/live/tests/afp-ips-bond-two-networks/test.yaml b/live/tests/afp-ips-bond-two-networks/test.yaml new file mode 100644 index 0000000000..568b7666fd --- /dev/null +++ b/live/tests/afp-ips-bond-two-networks/test.yaml @@ -0,0 +1,107 @@ +# Port of qa/live/netns/afp-ips-netns-bond-bridge2.sh. +environment: inline + +topology: + mtu: 9000 + networks: + - client: 10.200.0.2/24 + server: 10.200.0.1/24 + bond: true + bond-mode: balance-rr + - client: 10.200.1.2/24 + server: 10.200.1.1/24 + bond: true + bond-mode: balance-rr + +requires: + command: + - curl + - grep + - hping3 + - jq + - ping + - python3 + - tcpdump + - wc + - wget + +args: + - --af-packet + - --runmode=workers + +server: | + cleanup() { + kill "${http0:-}" "${http1:-}" "${capture0:-}" "${capture1:-}" 2>/dev/null || true + wait 2>/dev/null || true + } + trap cleanup EXIT INT TERM + + ip netns exec server0 tcpdump -U -n -i server -w ${OUTDIR}/server0.pcap icmp & + capture0=$! + ip netns exec server1 tcpdump -U -n -i server -w ${OUTDIR}/server1.pcap icmp & + capture1=$! + ip netns exec server0 python3 -m http.server 8000 --bind 10.200.0.1 & + http0=$! + ip netns exec server1 python3 -m http.server 8000 --bind 10.200.1.1 & + http1=$! + wait + +client: | + errors=no + + for endpoint in 10.200.0.1 10.200.1.1; do + echo "Checking HTTP on ${endpoint}" + if ! ip netns exec client$([ "${endpoint}" = 10.200.0.1 ] && echo 0 || echo 1) \ + curl --retry 10 --retry-connrefused --retry-delay 1 -fsS \ + http://${endpoint}:8000/ -o /dev/null; then + echo "error: HTTP should have succeeded on ${endpoint}" + errors=yes + fi + done + + echo "Checking HTTP with wget on network 0" + if ! ip netns exec client0 wget -qO /dev/null http://10.200.0.1:8000/; then + echo "error: wget should have succeeded on network 0" + errors=yes + fi + + echo "Sending fragmented ICMP on network 0" + hping_output=$(ip netns exec client0 hping3 -c 10 -1 -f -d 15000 \ + 10.200.0.1 2>&1) || true + echo "${hping_output}" + if ! grep -Eq '0 packets received|100% packet loss' <<<"${hping_output}"; then + echo "error: fragmented ICMP should have been dropped" + errors=yes + fi + + echo "Sending ordinary ICMP on network 1" + if ip netns exec client1 ping -c 10 -i 0.2 -W 1 10.200.1.1; then + echo "error: ordinary ICMP should have been dropped" + errors=yes + fi + + test "${errors}" = no + +checks: + - stats: + capture.kernel_packets.__gt: 0 + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 + + - shell: + args: | + http0=$(jq -c 'select(.event_type == "http" and .src_ip == "10.200.0.2")' eve.json | wc -l) + http1=$(jq -c 'select(.event_type == "http" and .src_ip == "10.200.1.2")' eve.json | wc -l) + hping_drops=$(jq -c 'select(.event_type == "drop" and .src_ip == "10.200.0.2")' eve.json | wc -l) + ping_drops=$(jq -c 'select(.event_type == "drop" and .src_ip == "10.200.1.2")' eve.json | wc -l) + test "${http0}" -ge 1 + test "${http1}" -ge 1 + test "${hping_drops}" -ge 20 + test "${ping_drops}" -ge 10 + + - shell: + args: | + server0_echo=$(tcpdump -nn -r server0.pcap 'icmp[0] = 8' 2>/dev/null | wc -l) + server1_echo=$(tcpdump -nn -r server1.pcap 'icmp[0] = 8' 2>/dev/null | wc -l) + test "${server0_echo}" -eq 0 + test "${server1_echo}" -eq 0 From 8aea5772d706547e751feeac53ebf72acc8dbc3b Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:37 -0600 Subject: [PATCH 22/32] live: port AF_PACKET argument 2 autofp test --- live/tests/afp-ids-tpacket2-autofp/README.md | 15 +++ live/tests/afp-ids-tpacket2-autofp/client.sh | 95 +++++++++++++++++++ live/tests/afp-ids-tpacket2-autofp/icmp.rules | 3 + .../tests/afp-ids-tpacket2-autofp/icmp2.rules | 1 + .../afp-ids-tpacket2-autofp/include.yaml | 18 ++++ live/tests/afp-ids-tpacket2-autofp/test.yaml | 38 ++++++++ 6 files changed, 170 insertions(+) create mode 100644 live/tests/afp-ids-tpacket2-autofp/README.md create mode 100644 live/tests/afp-ids-tpacket2-autofp/client.sh create mode 100644 live/tests/afp-ids-tpacket2-autofp/icmp.rules create mode 100644 live/tests/afp-ids-tpacket2-autofp/icmp2.rules create mode 100644 live/tests/afp-ids-tpacket2-autofp/include.yaml create mode 100644 live/tests/afp-ids-tpacket2-autofp/test.yaml diff --git a/live/tests/afp-ids-tpacket2-autofp/README.md b/live/tests/afp-ids-tpacket2-autofp/README.md new file mode 100644 index 0000000000..6393b157d7 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-afp +--- + +# AF_PACKET IDS tpacket argument 2 autofp test + +Ports `qa/live/afp-ids.sh 2 autofp` from the Suricata repository. The legacy +script maps argument `2` to `af-packet.1.tpacket-v3=true`; this port preserves +that behavior while replacing default-gateway traffic with deterministic ICMP +traffic on the live framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. diff --git a/live/tests/afp-ids-tpacket2-autofp/client.sh b/live/tests/afp-ids-tpacket2-autofp/client.sh new file mode 100644 index 0000000000..3c37939902 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/client.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = AF_PACKET_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/afp-ids-tpacket2-autofp/icmp.rules b/live/tests/afp-ids-tpacket2-autofp/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/afp-ids-tpacket2-autofp/icmp2.rules b/live/tests/afp-ids-tpacket2-autofp/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/afp-ids-tpacket2-autofp/include.yaml b/live/tests/afp-ids-tpacket2-autofp/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/afp-ids-tpacket2-autofp/test.yaml b/live/tests/afp-ids-tpacket2-autofp/test.yaml new file mode 100644 index 0000000000..038da04c3e --- /dev/null +++ b/live/tests/afp-ids-tpacket2-autofp/test.yaml @@ -0,0 +1,38 @@ +# Port of qa/live/afp-ids.sh 2 autofp. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --af-packet=br0 + - --runmode autofp + - --set af-packet.1.bpf-filter=icmp + - --set af-packet.1.tpacket-v3=true + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=autofp exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 From a86c823773a97921e6ff99f0f9163b8f31005c51 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:37 -0600 Subject: [PATCH 23/32] live: port AF_PACKET argument 2 workers test --- live/tests/afp-ids-tpacket2-workers/README.md | 15 +++ live/tests/afp-ids-tpacket2-workers/client.sh | 95 +++++++++++++++++++ .../tests/afp-ids-tpacket2-workers/icmp.rules | 3 + .../afp-ids-tpacket2-workers/icmp2.rules | 1 + .../afp-ids-tpacket2-workers/include.yaml | 18 ++++ live/tests/afp-ids-tpacket2-workers/test.yaml | 38 ++++++++ 6 files changed, 170 insertions(+) create mode 100644 live/tests/afp-ids-tpacket2-workers/README.md create mode 100644 live/tests/afp-ids-tpacket2-workers/client.sh create mode 100644 live/tests/afp-ids-tpacket2-workers/icmp.rules create mode 100644 live/tests/afp-ids-tpacket2-workers/icmp2.rules create mode 100644 live/tests/afp-ids-tpacket2-workers/include.yaml create mode 100644 live/tests/afp-ids-tpacket2-workers/test.yaml diff --git a/live/tests/afp-ids-tpacket2-workers/README.md b/live/tests/afp-ids-tpacket2-workers/README.md new file mode 100644 index 0000000000..c43c5e2950 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-afp +--- + +# AF_PACKET IDS tpacket argument 2 workers test + +Ports `qa/live/afp-ids.sh 2 workers` from the Suricata repository. The legacy +script maps argument `2` to `af-packet.1.tpacket-v3=true`; this port preserves +that behavior while replacing default-gateway traffic with deterministic ICMP +traffic on the live framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. diff --git a/live/tests/afp-ids-tpacket2-workers/client.sh b/live/tests/afp-ids-tpacket2-workers/client.sh new file mode 100644 index 0000000000..3c37939902 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/client.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = AF_PACKET_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/afp-ids-tpacket2-workers/icmp.rules b/live/tests/afp-ids-tpacket2-workers/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/afp-ids-tpacket2-workers/icmp2.rules b/live/tests/afp-ids-tpacket2-workers/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/afp-ids-tpacket2-workers/include.yaml b/live/tests/afp-ids-tpacket2-workers/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/afp-ids-tpacket2-workers/test.yaml b/live/tests/afp-ids-tpacket2-workers/test.yaml new file mode 100644 index 0000000000..16d325040f --- /dev/null +++ b/live/tests/afp-ids-tpacket2-workers/test.yaml @@ -0,0 +1,38 @@ +# Port of qa/live/afp-ids.sh 2 workers. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --af-packet=br0 + - --runmode workers + - --set af-packet.1.bpf-filter=icmp + - --set af-packet.1.tpacket-v3=true + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=workers exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 From 9285f0d8b456844474e7641072b223231474af48 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:38 -0600 Subject: [PATCH 24/32] live: port AF_PACKET argument 3 autofp test --- live/tests/afp-ids-tpacket3-autofp/README.md | 15 +++ live/tests/afp-ids-tpacket3-autofp/client.sh | 95 +++++++++++++++++++ live/tests/afp-ids-tpacket3-autofp/icmp.rules | 3 + .../tests/afp-ids-tpacket3-autofp/icmp2.rules | 1 + .../afp-ids-tpacket3-autofp/include.yaml | 18 ++++ live/tests/afp-ids-tpacket3-autofp/test.yaml | 38 ++++++++ 6 files changed, 170 insertions(+) create mode 100644 live/tests/afp-ids-tpacket3-autofp/README.md create mode 100644 live/tests/afp-ids-tpacket3-autofp/client.sh create mode 100644 live/tests/afp-ids-tpacket3-autofp/icmp.rules create mode 100644 live/tests/afp-ids-tpacket3-autofp/icmp2.rules create mode 100644 live/tests/afp-ids-tpacket3-autofp/include.yaml create mode 100644 live/tests/afp-ids-tpacket3-autofp/test.yaml diff --git a/live/tests/afp-ids-tpacket3-autofp/README.md b/live/tests/afp-ids-tpacket3-autofp/README.md new file mode 100644 index 0000000000..f8d6a90e60 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-afp +--- + +# AF_PACKET IDS tpacket argument 3 autofp test + +Ports `qa/live/afp-ids.sh 3 autofp` from the Suricata repository. The legacy +script maps argument `3` to `af-packet.1.tpacket-v3=false`; this port preserves +that behavior while replacing default-gateway traffic with deterministic ICMP +traffic on the live framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. diff --git a/live/tests/afp-ids-tpacket3-autofp/client.sh b/live/tests/afp-ids-tpacket3-autofp/client.sh new file mode 100644 index 0000000000..3c37939902 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/client.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = AF_PACKET_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/afp-ids-tpacket3-autofp/icmp.rules b/live/tests/afp-ids-tpacket3-autofp/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/afp-ids-tpacket3-autofp/icmp2.rules b/live/tests/afp-ids-tpacket3-autofp/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/afp-ids-tpacket3-autofp/include.yaml b/live/tests/afp-ids-tpacket3-autofp/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/afp-ids-tpacket3-autofp/test.yaml b/live/tests/afp-ids-tpacket3-autofp/test.yaml new file mode 100644 index 0000000000..c66d114d91 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-autofp/test.yaml @@ -0,0 +1,38 @@ +# Port of qa/live/afp-ids.sh 3 autofp. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --af-packet=br0 + - --runmode autofp + - --set af-packet.1.bpf-filter=icmp + - --set af-packet.1.tpacket-v3=false + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=autofp exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 From 1b53836fed16617fa717f727781c1981778d5c9f Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:38 -0600 Subject: [PATCH 25/32] live: port AF_PACKET argument 3 workers test --- live/tests/afp-ids-tpacket3-workers/README.md | 15 +++ live/tests/afp-ids-tpacket3-workers/client.sh | 95 +++++++++++++++++++ .../tests/afp-ids-tpacket3-workers/icmp.rules | 3 + .../afp-ids-tpacket3-workers/icmp2.rules | 1 + .../afp-ids-tpacket3-workers/include.yaml | 18 ++++ live/tests/afp-ids-tpacket3-workers/test.yaml | 38 ++++++++ 6 files changed, 170 insertions(+) create mode 100644 live/tests/afp-ids-tpacket3-workers/README.md create mode 100644 live/tests/afp-ids-tpacket3-workers/client.sh create mode 100644 live/tests/afp-ids-tpacket3-workers/icmp.rules create mode 100644 live/tests/afp-ids-tpacket3-workers/icmp2.rules create mode 100644 live/tests/afp-ids-tpacket3-workers/include.yaml create mode 100644 live/tests/afp-ids-tpacket3-workers/test.yaml diff --git a/live/tests/afp-ids-tpacket3-workers/README.md b/live/tests/afp-ids-tpacket3-workers/README.md new file mode 100644 index 0000000000..44420578c8 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-afp +--- + +# AF_PACKET IDS tpacket argument 3 workers test + +Ports `qa/live/afp-ids.sh 3 workers` from the Suricata repository. The legacy +script maps argument `3` to `af-packet.1.tpacket-v3=false`; this port preserves +that behavior while replacing default-gateway traffic with deterministic ICMP +traffic on the live framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. diff --git a/live/tests/afp-ids-tpacket3-workers/client.sh b/live/tests/afp-ids-tpacket3-workers/client.sh new file mode 100644 index 0000000000..3c37939902 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/client.sh @@ -0,0 +1,95 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = AF_PACKET_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/afp-ids-tpacket3-workers/icmp.rules b/live/tests/afp-ids-tpacket3-workers/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/afp-ids-tpacket3-workers/icmp2.rules b/live/tests/afp-ids-tpacket3-workers/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/afp-ids-tpacket3-workers/include.yaml b/live/tests/afp-ids-tpacket3-workers/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/afp-ids-tpacket3-workers/test.yaml b/live/tests/afp-ids-tpacket3-workers/test.yaml new file mode 100644 index 0000000000..2203754e48 --- /dev/null +++ b/live/tests/afp-ids-tpacket3-workers/test.yaml @@ -0,0 +1,38 @@ +# Port of qa/live/afp-ids.sh 3 workers. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --af-packet=br0 + - --runmode workers + - --set af-packet.1.bpf-filter=icmp + - --set af-packet.1.tpacket-v3=false + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=workers exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 From 255e80b5dbc88c6f4d2e36a6dc30a45081497931 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:38 -0600 Subject: [PATCH 26/32] live: port libpcap IDS autofp test --- live/tests/pcap-ids-autofp/README.md | 15 ++++ live/tests/pcap-ids-autofp/client.sh | 105 ++++++++++++++++++++++++ live/tests/pcap-ids-autofp/icmp.rules | 3 + live/tests/pcap-ids-autofp/icmp2.rules | 1 + live/tests/pcap-ids-autofp/include.yaml | 18 ++++ live/tests/pcap-ids-autofp/test.yaml | 37 +++++++++ 6 files changed, 179 insertions(+) create mode 100644 live/tests/pcap-ids-autofp/README.md create mode 100644 live/tests/pcap-ids-autofp/client.sh create mode 100644 live/tests/pcap-ids-autofp/icmp.rules create mode 100644 live/tests/pcap-ids-autofp/icmp2.rules create mode 100644 live/tests/pcap-ids-autofp/include.yaml create mode 100644 live/tests/pcap-ids-autofp/test.yaml diff --git a/live/tests/pcap-ids-autofp/README.md b/live/tests/pcap-ids-autofp/README.md new file mode 100644 index 0000000000..12ba69f7f7 --- /dev/null +++ b/live/tests/pcap-ids-autofp/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-pcap +--- + +# Libpcap IDS autofp test + +Ports `qa/live/pcap.sh autofp` from the Suricata repository. It replaces +host-default-gateway traffic with deterministic ICMP traffic on the live +framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, IPv4 and IPv6 datasets, malformed dataset +input, rule reload, interface and runmode socket commands, and hostbit +management. diff --git a/live/tests/pcap-ids-autofp/client.sh b/live/tests/pcap-ids-autofp/client.sh new file mode 100644 index 0000000000..38797e5afa --- /dev/null +++ b/live/tests/pcap-ids-autofp/client.sh @@ -0,0 +1,105 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "dataset-add ipv6-list ip 192.168.1.1") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "data added" +json=$(sc "dataset-lookup ipv6-list ip ::ffff:c0a8:0101") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "item found in set" +json=$(sc "dataset-add ipv6-list ip ::ffff:c0a8:0z0z") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "failed to add data" + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = PCAP_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/pcap-ids-autofp/icmp.rules b/live/tests/pcap-ids-autofp/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/pcap-ids-autofp/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/pcap-ids-autofp/icmp2.rules b/live/tests/pcap-ids-autofp/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/pcap-ids-autofp/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/pcap-ids-autofp/include.yaml b/live/tests/pcap-ids-autofp/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/pcap-ids-autofp/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/pcap-ids-autofp/test.yaml b/live/tests/pcap-ids-autofp/test.yaml new file mode 100644 index 0000000000..05bc33e304 --- /dev/null +++ b/live/tests/pcap-ids-autofp/test.yaml @@ -0,0 +1,37 @@ +# Port of qa/live/pcap.sh autofp. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --pcap=br0 + - --runmode autofp + - --set pcap.bpf-filter=icmp + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=autofp exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 From 3e291316fe83558c29a03226e8f0227212a58462 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:38 -0600 Subject: [PATCH 27/32] live: port libpcap IDS single-mode test --- live/tests/pcap-ids-single/README.md | 15 ++++ live/tests/pcap-ids-single/client.sh | 105 ++++++++++++++++++++++++ live/tests/pcap-ids-single/icmp.rules | 3 + live/tests/pcap-ids-single/icmp2.rules | 1 + live/tests/pcap-ids-single/include.yaml | 18 ++++ live/tests/pcap-ids-single/test.yaml | 37 +++++++++ 6 files changed, 179 insertions(+) create mode 100644 live/tests/pcap-ids-single/README.md create mode 100644 live/tests/pcap-ids-single/client.sh create mode 100644 live/tests/pcap-ids-single/icmp.rules create mode 100644 live/tests/pcap-ids-single/icmp2.rules create mode 100644 live/tests/pcap-ids-single/include.yaml create mode 100644 live/tests/pcap-ids-single/test.yaml diff --git a/live/tests/pcap-ids-single/README.md b/live/tests/pcap-ids-single/README.md new file mode 100644 index 0000000000..d11ba9bc35 --- /dev/null +++ b/live/tests/pcap-ids-single/README.md @@ -0,0 +1,15 @@ +--- +tags: +- legacy-afpdpdk +- legacy-pcap +--- + +# Libpcap IDS single runmode test + +Ports `qa/live/pcap.sh single` from the Suricata repository. It replaces +host-default-gateway traffic with deterministic ICMP traffic on the live +framework's `10.200.0.0/24` IDS bridge. + +The test covers packet capture, IPv4 and IPv6 datasets, malformed dataset +input, rule reload, interface and runmode socket commands, and hostbit +management. diff --git a/live/tests/pcap-ids-single/client.sh b/live/tests/pcap-ids-single/client.sh new file mode 100644 index 0000000000..38797e5afa --- /dev/null +++ b/live/tests/pcap-ids-single/client.sh @@ -0,0 +1,105 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +json=$(sc "dataset-add ipv6-list ip 192.168.1.1") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "data added" +json=$(sc "dataset-lookup ipv6-list ip ::ffff:c0a8:0101") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "item found in set" +json=$(sc "dataset-add ipv6-list ip ::ffff:c0a8:0z0z") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "failed to add data" + +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = PCAP_DEV +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/pcap-ids-single/icmp.rules b/live/tests/pcap-ids-single/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/pcap-ids-single/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/pcap-ids-single/icmp2.rules b/live/tests/pcap-ids-single/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/pcap-ids-single/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/pcap-ids-single/include.yaml b/live/tests/pcap-ids-single/include.yaml new file mode 100644 index 0000000000..ba4d603fee --- /dev/null +++ b/live/tests/pcap-ids-single/include.yaml @@ -0,0 +1,18 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/pcap-ids-single/test.yaml b/live/tests/pcap-ids-single/test.yaml new file mode 100644 index 0000000000..3a50e2ceb8 --- /dev/null +++ b/live/tests/pcap-ids-single/test.yaml @@ -0,0 +1,37 @@ +# Port of qa/live/pcap.sh single. +environment: tap + +requires: + command: + - jq + - ping + +args: + - --pcap=br0 + - --runmode single + - --set pcap.bpf-filter=icmp + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_RUNMODE=single exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 From cfb53a8c94926878ae19316121b73b449414a8ce Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:38 -0600 Subject: [PATCH 28/32] live: port libpcap multi-tenant autofp test --- live/tests/pcap-multi-tenant-autofp/README.md | 12 ++++++++ live/tests/pcap-multi-tenant-autofp/client.sh | 29 +++++++++++++++++++ .../pcap-multi-tenant-autofp/include.yaml | 29 +++++++++++++++++++ .../pcap-multi-tenant-autofp/suricata.rules | 1 + .../pcap-multi-tenant-autofp/tenant-1.yaml.in | 6 ++++ live/tests/pcap-multi-tenant-autofp/test.yaml | 23 +++++++++++++++ 6 files changed, 100 insertions(+) create mode 100644 live/tests/pcap-multi-tenant-autofp/README.md create mode 100644 live/tests/pcap-multi-tenant-autofp/client.sh create mode 100644 live/tests/pcap-multi-tenant-autofp/include.yaml create mode 100644 live/tests/pcap-multi-tenant-autofp/suricata.rules create mode 100644 live/tests/pcap-multi-tenant-autofp/tenant-1.yaml.in create mode 100644 live/tests/pcap-multi-tenant-autofp/test.yaml diff --git a/live/tests/pcap-multi-tenant-autofp/README.md b/live/tests/pcap-multi-tenant-autofp/README.md new file mode 100644 index 0000000000..c01162bf5c --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/README.md @@ -0,0 +1,12 @@ +--- +tags: +- legacy-afpdpdk +- legacy-pcap +--- + +# Libpcap multi-tenant autofp test + +Ports `qa/live/multi-tenant.sh autofp` from the Suricata repository. It runs +libpcap IDS capture on the framework's bridge while exercising tenant +registration, tenant reload, tenant removal, and the expected failure when +removing an unknown tenant through the command socket. diff --git a/live/tests/pcap-multi-tenant-autofp/client.sh b/live/tests/pcap-multi-tenant-autofp/client.sh new file mode 100644 index 0000000000..6ead190aef --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/client.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +tenant="${OUTDIR}/tenant-1.yaml" + +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(timeout --kill-after=60 30 "${SURICATASC}" -c "${command}" "${socket}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +ip netns exec client0 ping -c 1 -W 1 10.200.0.1 + +# Match the legacy test's startup delay. The engine-ready message can precede +# multi-tenant management becoming reliable under load. +sleep 15 + +expect_return "register-tenant 2 ${tenant}" +expect_return "reload-tenants" +expect_return "register-tenant 3 ${tenant}" +expect_return "reload-tenants" +expect_return "unregister-tenant 2" +expect_return "unregister-tenant 3" +expect_return "unregister-tenant 5" NOK +expect_return "reload-tenants" diff --git a/live/tests/pcap-multi-tenant-autofp/include.yaml b/live/tests/pcap-multi-tenant-autofp/include.yaml new file mode 100644 index 0000000000..be308c2f2c --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/include.yaml @@ -0,0 +1,29 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - suricata.rules + +multi-detect: + enabled: yes + selector: vlan + loaders: 3 + tenants: + - id: 1 + yaml: ${OUTDIR}/tenant-1.yaml + mappings: + - vlan-id: 1000 + tenant-id: 1 + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 diff --git a/live/tests/pcap-multi-tenant-autofp/suricata.rules b/live/tests/pcap-multi-tenant-autofp/suricata.rules new file mode 100644 index 0000000000..c0f94ab545 --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/suricata.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; sid:1;) diff --git a/live/tests/pcap-multi-tenant-autofp/tenant-1.yaml.in b/live/tests/pcap-multi-tenant-autofp/tenant-1.yaml.in new file mode 100644 index 0000000000..f04c41f98e --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/tenant-1.yaml.in @@ -0,0 +1,6 @@ +%YAML 1.1 +--- + +default-rule-path: @RULEDIR@ +rule-files: + - suricata.rules diff --git a/live/tests/pcap-multi-tenant-autofp/test.yaml b/live/tests/pcap-multi-tenant-autofp/test.yaml new file mode 100644 index 0000000000..1741bf421e --- /dev/null +++ b/live/tests/pcap-multi-tenant-autofp/test.yaml @@ -0,0 +1,23 @@ +# Port of qa/live/multi-tenant.sh autofp. +environment: tap + +requires: + command: + - jq + - ping + - timeout + +args: + - --pcap=br0 + - --runmode autofp + - --set pcap.bpf-filter=icmp + +before: | + sed "s|@RULEDIR@|${TESTDIR}|" ${TESTDIR}/tenant-1.yaml.in > ${OUTDIR}/tenant-1.yaml + +client: | + exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.kernel_packets.__gt: 0 From 50b2b3aa04c8cc8d859881f5b61c624c32457b76 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:38 -0600 Subject: [PATCH 29/32] live: add DPDK IDS AF_PACKET PMD test --- live/tests/dpdk-ids-afpacket-pmd/README.md | 16 +++ live/tests/dpdk-ids-afpacket-pmd/client.sh | 113 ++++++++++++++++++ live/tests/dpdk-ids-afpacket-pmd/icmp.rules | 3 + live/tests/dpdk-ids-afpacket-pmd/icmp2.rules | 1 + live/tests/dpdk-ids-afpacket-pmd/include.yaml | 47 ++++++++ live/tests/dpdk-ids-afpacket-pmd/test.yaml | 38 ++++++ 6 files changed, 218 insertions(+) create mode 100644 live/tests/dpdk-ids-afpacket-pmd/README.md create mode 100644 live/tests/dpdk-ids-afpacket-pmd/client.sh create mode 100644 live/tests/dpdk-ids-afpacket-pmd/icmp.rules create mode 100644 live/tests/dpdk-ids-afpacket-pmd/icmp2.rules create mode 100644 live/tests/dpdk-ids-afpacket-pmd/include.yaml create mode 100644 live/tests/dpdk-ids-afpacket-pmd/test.yaml diff --git a/live/tests/dpdk-ids-afpacket-pmd/README.md b/live/tests/dpdk-ids-afpacket-pmd/README.md new file mode 100644 index 0000000000..e701ffbaa2 --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/README.md @@ -0,0 +1,16 @@ +--- +tags: +- dpdk +- dpdk-ids +--- + +# DPDK IDS using the AF_PACKET virtual PMD + +Converts the `afp-ids-tpacket2-workers` live test to DPDK IDS. The live runner +creates its normal tap bridge in the DUT namespace. DPDK's AF_PACKET virtual +PMD attaches to `br0`, allowing Suricata to observe traffic forwarded by the +Linux bridge without owning physical PCI devices. + +The test covers packet capture, datasets, rule reload, interface and runmode +socket commands, and hostbit management. This exercises Suricata's DPDK receive +path, but not a VFIO-bound hardware NIC. diff --git a/live/tests/dpdk-ids-afpacket-pmd/client.sh b/live/tests/dpdk-ids-afpacket-pmd/client.sh new file mode 100644 index 0000000000..060861dc3e --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/client.sh @@ -0,0 +1,113 @@ +#!/bin/bash +set -euo pipefail + +socket="${OUTDIR}/suricata.socket" +eve="${OUTDIR}/eve.json" +target=10.200.0.1 + +# Drive traffic through the IDS bridge while exercising Suricata's Unix socket +# API. Alerts are asynchronous, so helpers below poll instead of relying on +# fixed sleeps. + +# Run one command through suricatasc and return its JSON response. +sc() { + "${SURICATASC}" -c "$1" "${socket}" +} + +# Require a socket command to return the expected status (OK by default). +expect_return() { + local command="$1" + local expected="${2:-OK}" + local json + json=$(sc "${command}") + echo "${json}" + test "$(jq -r '.return' <<<"${json}")" = "${expected}" +} + +# Some socket commands can briefly return NOK while a reload is completing. +wait_for_ok() { + local command="$1" + local json + for _ in $(seq 1 40); do + json=$(sc "${command}") + if [ "$(jq -r '.return' <<<"${json}")" = OK ]; then + echo "${json}" + return 0 + fi + sleep 0.25 + done + echo "${json}" + echo "error: command did not return OK: ${command}" + return 1 +} + +# Wait until eve.json contains at least the requested number of alerts for SID. +wait_for_alerts() { + local sid="$1" + local expected="$2" + local count=0 + for _ in $(seq 1 40); do + count=$(jq -c "select(.event_type == \"alert\" and .alert.signature_id == ${sid})" \ + "${eve}" 2>/dev/null | wc -l) + if [ "${count}" -ge "${expected}" ]; then + return 0 + fi + sleep 0.25 + done + echo "error: expected at least ${expected} alerts for sid ${sid}, got ${count}" + return 1 +} + +ping_once() { + ip netns exec client0 ping -c 1 -W 1 "${target}" +} + +# The initial rules alert on ICMP and add its destination to ipv4-list. Clear +# the dataset between pings to verify both packet inspection and dataset API +# handling; the final checks expect two alerts from each initial rule. +ping_once +wait_for_alerts 222 1 +expect_return "dataset-clear ipv4-list ipv4" +ping_once +wait_for_alerts 222 2 + +# Discover the DPDK virtual interface and confirm it has captured traffic. +json=$(sc "iface-list") +echo "${json}" +iface=$(jq -r '.message.ifaces[0]' <<<"${json}") +sleep 1 # Allow the interface counters to be published. +json=$(sc "iface-stat ${iface}") +echo "${json}" +test "$(jq -r '.message.pkts > 0' <<<"${json}")" = true + +# Replace the ruleset with a hostbit-dependent rule and reload it live. +cp "${TESTDIR}/icmp2.rules" "${OUTDIR}/suricata.rules" +expect_return "reload-rules" + +# Smoke-test the remaining management commands and verify DPDK/workers are the +# active capture and running modes. Commands without assertions still fail the +# script if suricatasc itself cannot execute them. +sc "iface-bypassed-stat" +json=$(sc "capture-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_CAPTURE_MODE}" +json=$(wait_for_ok "dump-counters") +test "$(jq -r '.message.uptime >= 0' <<<"${json}")" = true +sc "memcap-list" +json=$(sc "running-mode") +echo "${json}" +test "$(jq -r '.message' <<<"${json}")" = "${EXPECTED_RUNMODE}" +sc "version" +json=$(sc "uptime") +echo "${json}" +test "$(jq -r '.message >= 0' <<<"${json}")" = true + +# Set the hostbit required by the reloaded rule, then prove that another ICMP +# packet alerts before removing the hostbit again. +expect_return "add-hostbit ${target} test 60" +ping_once +wait_for_alerts 2 1 +json=$(sc "list-hostbit ${target}") +echo "${json}" +test "$(jq -r '.message.hostbits[0].name' <<<"${json}")" = test +expect_return "remove-hostbit ${target} test" diff --git a/live/tests/dpdk-ids-afpacket-pmd/icmp.rules b/live/tests/dpdk-ids-afpacket-pmd/icmp.rules new file mode 100644 index 0000000000..a938596a81 --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/icmp.rules @@ -0,0 +1,3 @@ +alert icmp any any -> any any (itype:8; sid:1;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv4-list,type ipv4; sid:222;) +alert icmp any any -> any any (itype:8; ip.dst; dataset:set,ipv6-list,type ipv6; sid:226;) diff --git a/live/tests/dpdk-ids-afpacket-pmd/icmp2.rules b/live/tests/dpdk-ids-afpacket-pmd/icmp2.rules new file mode 100644 index 0000000000..a60be4dbd1 --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/icmp2.rules @@ -0,0 +1 @@ +alert icmp any any -> any any (itype:8; hostbits:isset,test,dst; sid:2;) diff --git a/live/tests/dpdk-ids-afpacket-pmd/include.yaml b/live/tests/dpdk-ids-afpacket-pmd/include.yaml new file mode 100644 index 0000000000..4f63bc5f20 --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/include.yaml @@ -0,0 +1,47 @@ +%YAML 1.1 +--- + +default-rule-path: ${OUTDIR} +rule-files: + - suricata.rules + +outputs: + - eve-log: + enabled: yes + filetype: regular + filename: eve.json + types: + - alert + - stats: + totals: yes + threads: no + interval: 1 + +dpdk: + eal-params: + proc-type: primary + vdev: "net_af_packet0,iface=br0" + no-huge: + no-pci: + m: 256 + + interfaces: + - interface: net_af_packet0 + threads: 1 + multicast: false + mempool-size: auto + mempool-cache-size: auto + rx-descriptors: 16 + tx-descriptors: 16 + copy-mode: none + copy-iface: none + +# DPDK requires CPU affinity. +threading: + set-cpu-affinity: yes + cpu-affinity: + - management-cpu-set: + cpu: [ 0 ] + - worker-cpu-set: + cpu: [ "all" ] + mode: "exclusive" diff --git a/live/tests/dpdk-ids-afpacket-pmd/test.yaml b/live/tests/dpdk-ids-afpacket-pmd/test.yaml new file mode 100644 index 0000000000..169c9e80fb --- /dev/null +++ b/live/tests/dpdk-ids-afpacket-pmd/test.yaml @@ -0,0 +1,38 @@ +# DPDK IDS conversion of afp-ids-tpacket2-workers. +environment: tap + +requires: + command: + - jq + - ping + features: + - DPDK + +args: + - --dpdk + - --runmode workers + +before: | + cp ${TESTDIR}/icmp.rules ${OUTDIR}/suricata.rules + +client: | + EXPECTED_CAPTURE_MODE=DPDK EXPECTED_RUNMODE=workers exec bash ${TESTDIR}/client.sh + +checks: + - stats: + capture.packets.__gt: 0 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 1 + - filter: + count: 2 + match: + event_type: alert + alert.signature_id: 222 + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 From e7152800d881086e2cafedb3734c1c04f6c06cc2 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:38 -0600 Subject: [PATCH 30/32] live: add AF_PACKET IPS overlapping alert and drop test --- live/tests/ips-alert-drop-icmp-afp/README.md | 17 ++++++ .../ips-alert-drop-icmp-afp/include.yaml | 20 +++++++ live/tests/ips-alert-drop-icmp-afp/test.rules | 2 + live/tests/ips-alert-drop-icmp-afp/test.yaml | 55 +++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 live/tests/ips-alert-drop-icmp-afp/README.md create mode 100644 live/tests/ips-alert-drop-icmp-afp/include.yaml create mode 100644 live/tests/ips-alert-drop-icmp-afp/test.rules create mode 100644 live/tests/ips-alert-drop-icmp-afp/test.yaml diff --git a/live/tests/ips-alert-drop-icmp-afp/README.md b/live/tests/ips-alert-drop-icmp-afp/README.md new file mode 100644 index 0000000000..bcc4e97c52 --- /dev/null +++ b/live/tests/ips-alert-drop-icmp-afp/README.md @@ -0,0 +1,17 @@ +# AF_PACKET IPS with overlapping alert and drop rules + +Suricata runs inline in AF_PACKET copy-mode IPS with two rules that +have identical match conditions (ICMP echo request), but different +actions and rule headers: + +- sid 1: a generic alert rule matching any source and destination +- sid 2: a drop rule limited to echo requests from the client + (10.200.0.2) to the server (10.200.0.1) + +Pings from the client to the server must fail as they are dropped by +sid 2, while pings from the server to the client only match the alert +rule and must succeed. + +The checks verify that both rules alerted with their expected actions +("allowed" for sid 1, "blocked" for sid 2, including on the same +dropped packets), and that the IPS blocked the dropped pings. diff --git a/live/tests/ips-alert-drop-icmp-afp/include.yaml b/live/tests/ips-alert-drop-icmp-afp/include.yaml new file mode 100644 index 0000000000..1a652a6d7c --- /dev/null +++ b/live/tests/ips-alert-drop-icmp-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-alert-drop-icmp-afp/test.rules b/live/tests/ips-alert-drop-icmp-afp/test.rules new file mode 100644 index 0000000000..6dccb112a0 --- /dev/null +++ b/live/tests/ips-alert-drop-icmp-afp/test.rules @@ -0,0 +1,2 @@ +alert icmp any any -> any any (msg:"ICMP echo request"; itype:8; sid:1;) +drop icmp 10.200.0.2 any -> 10.200.0.1 any (msg:"ICMP echo request to server"; itype:8; sid:2;) diff --git a/live/tests/ips-alert-drop-icmp-afp/test.yaml b/live/tests/ips-alert-drop-icmp-afp/test.yaml new file mode 100644 index 0000000000..6f45afebbd --- /dev/null +++ b/live/tests/ips-alert-drop-icmp-afp/test.yaml @@ -0,0 +1,55 @@ +environment: inline + +requires: + command: + - ping + +args: + - --af-packet + - --runmode workers + +client: | + errors="no" + + # Pings to the server match both the generic alert rule and the + # specific drop rule, so they must fail. + echo "Pinging server from client (should be dropped)..." + if ip netns exec client0 ping -c 10 -i 0.2 -W 1 10.200.0.1; then + echo "error: ping to server should have failed" + errors="yes" + fi + + # Pings from the server to the client only match the generic alert + # rule, so they must succeed. + echo "Pinging client from server (should pass)..." + if ! ip netns exec server0 ping -c 5 -i 0.2 -W 1 10.200.0.2; then + echo "error: ping to client should have succeeded" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + # The generic alert rule matches every echo request in both + # directions: 10 client->server plus 5 server->client. It alerts on + # the dropped packets too, but its own action stays "allowed". + - filter: + count: 15 + match: + event_type: alert + alert.signature_id: 1 + alert.action: allowed + + # The drop rule only matches the 10 echo requests to the server. + - filter: + count: 10 + match: + event_type: alert + alert.signature_id: 2 + alert.action: blocked + + - stats: + ips.accepted.__gt: 0 + ips.blocked.__gte: 10 From dad709db80f5fea24f7416134084bd1430dfb2d1 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Fri, 21 Aug 2026 15:58:38 -0600 Subject: [PATCH 31/32] live: add AF_PACKET IPS alert and drop HTTP test --- live/tests/ips-alert-drop-http-afp/README.md | 23 +++++++ .../ips-alert-drop-http-afp/include.yaml | 20 ++++++ live/tests/ips-alert-drop-http-afp/test.rules | 2 + live/tests/ips-alert-drop-http-afp/test.yaml | 67 +++++++++++++++++++ .../ips-alert-drop-http-afp/www/ids.html | 1 + .../ips-alert-drop-http-afp/www/index.html | 1 + 6 files changed, 114 insertions(+) create mode 100644 live/tests/ips-alert-drop-http-afp/README.md create mode 100644 live/tests/ips-alert-drop-http-afp/include.yaml create mode 100644 live/tests/ips-alert-drop-http-afp/test.rules create mode 100644 live/tests/ips-alert-drop-http-afp/test.yaml create mode 100644 live/tests/ips-alert-drop-http-afp/www/ids.html create mode 100644 live/tests/ips-alert-drop-http-afp/www/index.html diff --git a/live/tests/ips-alert-drop-http-afp/README.md b/live/tests/ips-alert-drop-http-afp/README.md new file mode 100644 index 0000000000..57ad416c4a --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/README.md @@ -0,0 +1,23 @@ +--- +tags: +- http +--- + +# AF_PACKET IPS with overlapping alert and drop rules over HTTP + +Suricata runs inline in AF_PACKET copy-mode IPS with two copies of the +classic testmyids.org "GPL ATTACK_RESPONSE id check returned root" +rule with identical match conditions: sid 1 is a generic alert rule +matching any source and destination, while sid 2 is a drop rule +limited to traffic to the client (10.200.0.2), such as the server +responses. + +The client fetches a benign page which must succeed, then the +testmyids page whose response contains +`uid=0(root) gid=0(root) groups=0(root)`. That response matches both +rules and is dropped, so the fetch must fail. + +The checks verify that both rules alerted with their expected actions +("allowed" for sid 1, "blocked" for sid 2, on the same dropped +response), that the benign transaction completed normally, and that +the IPS blocked packets. diff --git a/live/tests/ips-alert-drop-http-afp/include.yaml b/live/tests/ips-alert-drop-http-afp/include.yaml new file mode 100644 index 0000000000..1a652a6d7c --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/include.yaml @@ -0,0 +1,20 @@ +%YAML 1.1 +--- + +default-rule-path: ${TESTDIR} +rule-files: + - test.rules + +af-packet: + - interface: client0 + cluster-id: 80 + copy-mode: ips + copy-iface: server0 + - interface: server0 + cluster-id: 81 + copy-mode: ips + copy-iface: client0 + - interface: default + defrag: false + threads: auto + cluster-type: cluster_flow diff --git a/live/tests/ips-alert-drop-http-afp/test.rules b/live/tests/ips-alert-drop-http-afp/test.rules new file mode 100644 index 0000000000..89775e0f27 --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/test.rules @@ -0,0 +1,2 @@ +alert ip any any -> any any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:1;) +drop ip any any -> 10.200.0.2 any (msg:"GPL ATTACK_RESPONSE id check returned root"; content:"uid=0|28|root|29|"; classtype:bad-unknown; sid:2;) diff --git a/live/tests/ips-alert-drop-http-afp/test.yaml b/live/tests/ips-alert-drop-http-afp/test.yaml new file mode 100644 index 0000000000..8ba997f375 --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/test.yaml @@ -0,0 +1,67 @@ +environment: inline + +requires: + command: + - curl + - python3 + +args: + - --af-packet + - --runmode workers + +server: | + exec ip netns exec server0 python3 -m http.server --directory ${TESTDIR}/www 80 + +client: | + errors="no" + + # The benign page does not match any rule and must pass. + echo "Fetching benign page..." + if ! ip netns exec client0 timeout --kill-after=1 --preserve-status 5 \ + curl -fsS --retry 3 --retry-connrefused --retry-delay 1 \ + -o /dev/null http://10.200.0.1/index.html; then + echo "error: fetching the benign page should have succeeded" + errors="yes" + fi + + # The testmyids page matches both rules and the response is dropped, + # so curl must fail. + echo "Fetching testmyids page..." + if ip netns exec client0 timeout --kill-after=1 --preserve-status 5 \ + curl -fsS -o /dev/null http://10.200.0.1/ids.html; then + echo "error: fetching the testmyids page should have failed" + errors="yes" + fi + + if [ "${errors}" = "yes" ]; then + exit 1 + fi + +checks: + # Both rules match the testmyids response, but the alert rule's own + # action stays "allowed" even though the packet was dropped. + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 1 + alert.action: allowed + + - filter: + count: 1 + match: + event_type: alert + alert.signature_id: 2 + alert.action: blocked + + # The benign request completes as a normal HTTP transaction. + - filter: + count: 1 + match: + event_type: http + http.url: "/index.html" + http.status: 200 + + - stats: + ips.accepted.__gt: 0 + ips.blocked.__gte: 1 diff --git a/live/tests/ips-alert-drop-http-afp/www/ids.html b/live/tests/ips-alert-drop-http-afp/www/ids.html new file mode 100644 index 0000000000..0e70fba574 --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/www/ids.html @@ -0,0 +1 @@ +uid=0(root) gid=0(root) groups=0(root) diff --git a/live/tests/ips-alert-drop-http-afp/www/index.html b/live/tests/ips-alert-drop-http-afp/www/index.html new file mode 100644 index 0000000000..af5626b4a1 --- /dev/null +++ b/live/tests/ips-alert-drop-http-afp/www/index.html @@ -0,0 +1 @@ +Hello, world! From eafd1163582594d241ef8cd33167f2c498c2c037 Mon Sep 17 00:00:00 2001 From: Jason Ish Date: Wed, 26 Aug 2026 17:35:57 -0600 Subject: [PATCH 32/32] live: secure runner lock creation --- live/run.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/live/run.py b/live/run.py index f291fe22e5..68d24fd0a8 100755 --- a/live/run.py +++ b/live/run.py @@ -13,6 +13,7 @@ import re import shlex import signal +import stat import string import shutil import subprocess @@ -78,7 +79,8 @@ verbose = False suricata_config_cache = {} -RUNNER_LOCK_PATH = "/tmp/suricata-verify-live.lock" +RUNNER_RUNTIME_DIR = "/run/suricata-verify-live" +RUNNER_LOCK_PATH = os.path.join(RUNNER_RUNTIME_DIR, "runner.lock") BOND_MODES = { @@ -190,8 +192,70 @@ def __init__(self, path: str = RUNNER_LOCK_PATH) -> None: self.path = path self.file = None + def _open(self): + runtime_dir, filename = os.path.split(self.path) + if not runtime_dir or not filename: + raise RuntimeError(f"invalid runner lock path: {self.path}") + + try: + os.mkdir(runtime_dir, mode=0o700) + except FileExistsError: + pass + + dir_flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | os.O_NOFOLLOW + try: + dir_fd = os.open(runtime_dir, dir_flags) + except OSError as err: + raise RuntimeError( + f"unable to safely open runner runtime directory {runtime_dir}: {err}" + ) from err + + lock_fd = None + try: + dir_stat = os.fstat(dir_fd) + if dir_stat.st_uid != os.geteuid(): + raise RuntimeError( + f"runner runtime directory is not owned by uid {os.geteuid()}: " + f"{runtime_dir}" + ) + if stat.S_IMODE(dir_stat.st_mode) != 0o700: + raise RuntimeError( + f"runner runtime directory permissions are not 0700: {runtime_dir}" + ) + + lock_flags = os.O_RDWR | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW + try: + lock_fd = os.open(filename, lock_flags, 0o600, dir_fd=dir_fd) + except OSError as err: + raise RuntimeError( + f"unable to safely open runner lock {self.path}: {err}" + ) from err + finally: + os.close(dir_fd) + + try: + lock_stat = os.fstat(lock_fd) + if not stat.S_ISREG(lock_stat.st_mode): + raise RuntimeError(f"runner lock is not a regular file: {self.path}") + if lock_stat.st_uid != os.geteuid(): + raise RuntimeError( + f"runner lock is not owned by uid {os.geteuid()}: {self.path}" + ) + if stat.S_IMODE(lock_stat.st_mode) != 0o600: + raise RuntimeError( + f"runner lock permissions are not 0600: {self.path}" + ) + if lock_stat.st_nlink != 1: + raise RuntimeError( + f"runner lock has an unexpected link count: {self.path}" + ) + return os.fdopen(lock_fd, "r+", encoding="utf-8") + except Exception: + os.close(lock_fd) + raise + def __enter__(self): - self.file = open(self.path, "a+", encoding="utf-8") + self.file = self._open() try: fcntl.flock(self.file, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: @@ -206,6 +270,10 @@ def __enter__(self): print(holder, file=sys.stderr) self.file.close() sys.exit(1) + except Exception: + self.file.close() + self.file = None + raise self.file.seek(0) self.file.truncate()