← Back to README | Back to kernel module design
The tcpstats module supports named filter profiles created via sysctl:
sysctl dev.tcpstats.profiles.cdn_clients="local_port=443 exclude=listen,timewait"The filter string is parsed in kernel space at sysctl write time. This is a deliberate architectural choice over a userspace helper.
| Approach | Pros | Cons |
|---|---|---|
| Kernel parser (chosen) | Direct sysctl from shell, works in /etc/sysctl.conf, Ansible sysctl: module, no helper binary needed |
Parser complexity in kernel, must be hardened |
| Userspace helper | Parser in safe language (Rust), easier testing | Requires helper binary installed, can't use sysctl.conf directly, two-step workflow |
| Binary-only ioctl | No parsing at all, struct passed directly | Unusable from shell, requires custom tool for every operation |
The kernel parser runs only on the sysctl write path — a low-frequency administrative operation (profile creation/deletion). It is never invoked on the hot data path (socket iteration, record emission). The parser processes at most 512 bytes of input at most 16 times (max profiles), so even an inefficient parser has negligible performance impact.
For programmatic use (the Rust tcpstats-reader tool), the binary
tcpstats_filter struct is available via the TCPSTATS_SET_FILTER ioctl,
bypassing the string parser entirely.
filter_string = { directive } ;
directive = key_value | flag ;
key_value = key , "=" , value ;
flag = "ipv4_only" | "ipv6_only" ;
key = "local_port" | "remote_port" | "exclude"
| "include_state" | "local_addr" | "remote_addr"
| "format" | "fields" ;
value = list_value | single_value ;
list_value = single_value , { "," , single_value } ;
single_value = port_number | state_name | field_group
| addr_cidr | format_name ;
port_number = nonzero_digit , { digit } ; (* 1-65535, no leading zeros *)
nonzero_digit = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" ;
digit = "0" | nonzero_digit ;
state_name = "listen" | "syn_sent" | "syn_received" | "established"
| "close_wait" | "fin_wait_1" | "fin_wait_2" | "closing"
| "last_ack" | "time_wait" | "closed" ;
field_group = "identity" | "state" | "congestion" | "rtt" | "sequences"
| "counters" | "timers" | "buffers" | "ecn" | "names"
| "all" | "default" ;
addr_cidr = ipv4_cidr | ipv6_cidr ;
ipv4_cidr = ipv4_addr , [ "/" , prefix_len_v4 ] ;
ipv6_cidr = ipv6_addr , [ "/" , prefix_len_v6 ] ;
ipv4_addr = octet , "." , octet , "." , octet , "." , octet ;
octet = digit , [ digit , [ digit ] ] ; (* 0-255 *)
ipv6_addr = (* RFC 5952 canonical form, see section 6 *) ;
prefix_len_v4 = digit , [ digit ] ; (* 0-32 *)
prefix_len_v6 = digit , [ digit , [ digit ] ] ; (* 0-128 *)
format_name = "compact" | "full" ;- Whitespace: Directives are separated by one or more ASCII space characters (0x20) or horizontal tabs (0x09). Leading and trailing whitespace is stripped.
- Case: All keywords are case-insensitive. Internally normalized to lowercase during tokenization. Address strings preserve case (hex digits).
- Quoting: No quoting mechanism. Values must not contain spaces,
=, or,. - Comments: Not supported (this is a kernel sysctl value, not a config file).
- Maximum length: 512 bytes total (including NUL terminator).
- Maximum directives: 16 per filter string.
- No port ranges: Port values are explicit numbers only (no
1000-2000). Operators list ports individually;TSF_MAX_PORTS=8per direction is sufficient.
# CDN client-facing connections
"local_port=443 exclude=listen,timewait"
# Upstream origin fetches with CIDR source filter
"remote_port=443,80 local_addr=10.0.1.0/24 exclude=listen,timewait"
# IPv6-only monitoring
"ipv6_only local_port=443,8443 exclude=listen,timewait,closewait"
# Database connections (PostgreSQL + MySQL)
"remote_port=5432,3306 fields=state,congestion,rtt,buffers"
# Dual-stack microservice mesh
"local_port=8080,8443 remote_port=8080,8443 exclude=listen,timewait format=full"
# Full-format debugging on a specific subnet
"local_addr=192.168.1.0/24 format=full fields=all"
# Include only ESTABLISHED state (positive match)
"include_state=established"| Directive | Syntax | Struct field(s) | Flag set | Notes |
|---|---|---|---|---|
local_port |
local_port=443 or local_port=443,8443 |
local_ports[] |
TSF_LOCAL_PORT_MATCH |
Max 8 ports, network byte order, no duplicates |
remote_port |
remote_port=80,443 |
remote_ports[] |
TSF_REMOTE_PORT_MATCH |
Max 8 ports, network byte order, no duplicates |
exclude |
exclude=listen,timewait |
exclude_states |
TSF_EXCLUDE_* per state |
Sets per-state exclude flags; clears corresponding bits in state_mask |
include_state |
include_state=established |
state_mask |
TSF_STATE_INCLUDE_MODE |
Positive match — only listed states are included. Mutually exclusive with exclude. |
local_addr |
local_addr=10.0.0.1 or local_addr=10.0.0.0/24 |
local_addr_v4 + local_mask_v4 or local_addr_v6 + local_prefix_v6 |
TSF_LOCAL_ADDR_MATCH |
CIDR or exact match. AF detected from format. |
remote_addr |
remote_addr=fe80::/10 |
remote_addr_v6 + remote_prefix_v6 |
TSF_REMOTE_ADDR_MATCH |
CIDR or exact match |
format |
format=compact or format=full |
format |
— | Selects record format (128 or 320 bytes) |
fields |
fields=state,rtt,congestion |
field_mask |
— | Comma-separated field group names, OR'd into bitmask |
ipv4_only |
ipv4_only (bare flag) |
— | TSF_IPV4_ONLY |
No value accepted. Mutually exclusive with ipv6_only. |
ipv6_only |
ipv6_only (bare flag) |
— | TSF_IPV6_ONLY |
No value accepted. Mutually exclusive with ipv4_only. |
| Filter name | FreeBSD constant | Value | Description |
|---|---|---|---|
closed |
TCPS_CLOSED |
0 | Connection closed |
listen |
TCPS_LISTEN |
1 | Listening for connections |
syn_sent |
TCPS_SYN_SENT |
2 | SYN sent, awaiting SYN-ACK |
syn_received |
TCPS_SYN_RECEIVED |
3 | SYN received, sent SYN-ACK |
established |
TCPS_ESTABLISHED |
4 | Connection established |
close_wait |
TCPS_CLOSE_WAIT |
5 | Remote closed, awaiting local close |
fin_wait_1 |
TCPS_FIN_WAIT_1 |
6 | Local closed, FIN sent |
closing |
TCPS_CLOSING |
7 | Both sides closing simultaneously |
last_ack |
TCPS_LAST_ACK |
8 | Awaiting final ACK |
fin_wait_2 |
TCPS_FIN_WAIT_2 |
9 | FIN acknowledged, awaiting remote FIN |
time_wait |
TCPS_TIME_WAIT |
10 | 2MSL timer running |
| Field group | Bitmask | Struct fields included |
|---|---|---|
identity |
TSR_FIELDS_IDENTITY (0x001) |
af, addresses, ports (always included) |
state |
TSR_FIELDS_STATE (0x002) |
t_state, flags_tcp |
congestion |
TSR_FIELDS_CONGESTION (0x004) |
cwnd, ssthresh, snd_wnd, rcv_wnd, maxseg |
rtt |
TSR_FIELDS_RTT (0x008) |
rtt, rttvar, rto, rttmin |
sequences |
TSR_FIELDS_SEQUENCES (0x010) |
snd_nxt, snd_una, snd_max, rcv_nxt, rcv_adv |
counters |
TSR_FIELDS_COUNTERS (0x020) |
rexmitpack, ooopack, zerowin, dupacks, numsacks |
timers |
TSR_FIELDS_TIMERS (0x040) |
tt_rexmt, tt_persist, tt_keep, tt_2msl, tt_delack, rcvtime |
buffers |
TSR_FIELDS_BUFFERS (0x080) |
snd_buf_cc, snd_buf_hiwat, rcv_buf_cc, rcv_buf_hiwat |
ecn |
TSR_FIELDS_ECN (0x100) |
ecn, delivered_ce, received_ce |
names |
TSR_FIELDS_NAMES (0x200) |
cc algo name, tcp stack name |
all |
TSR_FIELDS_ALL (0x3FF) |
All of the above |
default |
TSR_FIELDS_DEFAULT (0x08F) |
identity + state + congestion + rtt + buffers |
# Client-facing HTTPS (50K connections)
sysctl dev.tcpstats.profiles.clients="local_port=443 exclude=listen,timewait"
# Upstream origin fetches (5K connections)
sysctl dev.tcpstats.profiles.upstream="remote_port=443,80 exclude=listen,timewait"
# Health checks (low frequency, full detail)
sysctl dev.tcpstats.profiles.health="local_port=80,8080 exclude=timewait format=full"# All HTTP/HTTPS traffic
sysctl dev.tcpstats.profiles.web="local_port=80,443 exclude=listen,timewait,closewait"
# Identify slow clients (need RTT + buffers)
sysctl dev.tcpstats.profiles.slow="local_port=443 include_state=established fields=identity,state,rtt,buffers,congestion"# PostgreSQL connections from app tier
sysctl dev.tcpstats.profiles.pg="local_port=5432 local_addr=10.0.1.0/24 exclude=listen"
# MySQL replication
sysctl dev.tcpstats.profiles.repl="remote_port=3306 include_state=established fields=state,congestion,rtt,sequences"# Service-to-service on mesh ports
sysctl dev.tcpstats.profiles.mesh="local_port=8080,8443 remote_port=8080,8443 exclude=listen,timewait"# Traffic on the public interface only
sysctl dev.tcpstats.profiles.public="local_addr=203.0.113.10 exclude=listen,timewait"
# Traffic on the management VLAN
sysctl dev.tcpstats.profiles.mgmt="local_addr=10.255.0.0/16 format=full"# IPv6-only monitoring to track v6 adoption
sysctl dev.tcpstats.profiles.v6="ipv6_only local_port=443 exclude=listen,timewait"
# IPv4 legacy traffic
sysctl dev.tcpstats.profiles.v4="ipv4_only local_port=443 exclude=listen,timewait"This is the updated filter struct incorporating CIDR support, expanded state filtering, and forward-compatibility versioning. See kernel-module.md section 12.3 for the in-context definition.
/*
* Socket filter — configurable via ioctl or sysctl-created named profiles.
*
* All conditions are ANDed. Empty/zero fields mean "match any".
* Port arrays use network byte order. A port value of 0 means "unused slot".
*
* Version 2: adds CIDR masks, include_state mode, expanded exclude flags.
*/
#define TSF_VERSION 2
#define TSF_MAX_PORTS 8
struct tcpstats_filter {
/* Version for forward compatibility */
uint32_t version; /* Must be TSF_VERSION */
/* State filter */
uint16_t state_mask; /* Bitmask of (1 << TCPS_*); 0xFFFF = all */
uint16_t _pad0;
uint32_t flags;
/* --- Exclude flags (one per TCP state) --- */
#define TSF_EXCLUDE_CLOSED 0x00000001
#define TSF_EXCLUDE_LISTEN 0x00000002
#define TSF_EXCLUDE_SYN_SENT 0x00000004
#define TSF_EXCLUDE_SYN_RCVD 0x00000008
#define TSF_EXCLUDE_ESTABLISHED 0x00000010
#define TSF_EXCLUDE_CLOSE_WAIT 0x00000020
#define TSF_EXCLUDE_FIN_WAIT_1 0x00000040
#define TSF_EXCLUDE_CLOSING 0x00000080
#define TSF_EXCLUDE_LAST_ACK 0x00000100
#define TSF_EXCLUDE_FIN_WAIT_2 0x00000200
#define TSF_EXCLUDE_TIME_WAIT 0x00000400
/* --- Mode flags --- */
#define TSF_STATE_INCLUDE_MODE 0x00001000 /* include_state= used (exclusive with exclude=) */
#define TSF_LOCAL_PORT_MATCH 0x00002000 /* Filter on local ports */
#define TSF_REMOTE_PORT_MATCH 0x00004000 /* Filter on remote ports */
#define TSF_LOCAL_ADDR_MATCH 0x00008000 /* Filter on local address (CIDR) */
#define TSF_REMOTE_ADDR_MATCH 0x00010000 /* Filter on remote address (CIDR) */
#define TSF_IPV4_ONLY 0x00020000
#define TSF_IPV6_ONLY 0x00040000
/* Port filters — match if socket port is ANY of the listed ports */
uint16_t local_ports[TSF_MAX_PORTS]; /* Network byte order; 0 = unused */
uint16_t remote_ports[TSF_MAX_PORTS]; /* Network byte order; 0 = unused */
/* IPv4 address filters with CIDR mask */
struct in_addr local_addr_v4; /* Match if non-zero */
struct in_addr local_mask_v4; /* Netmask (e.g., 0xFFFFFF00 for /24) */
struct in_addr remote_addr_v4;
struct in_addr remote_mask_v4;
/* IPv6 address filters with prefix length */
struct in6_addr local_addr_v6; /* Match if non-zero */
uint8_t local_prefix_v6; /* Prefix length (0-128); 0 = exact match */
uint8_t _pad1[3];
struct in6_addr remote_addr_v6;
uint8_t remote_prefix_v6;
uint8_t _pad2[3];
/* Field mask (which field groups to populate) */
uint32_t field_mask;
/* Record format selection */
uint32_t format; /* 0 = compact (default), 1 = full */
#define TSF_FORMAT_COMPACT 0
#define TSF_FORMAT_FULL 1
/* Spare for future expansion */
uint32_t _spare[4];
};
/* Compile-time validation */
_Static_assert(sizeof(struct tcpstats_filter) <= 256,
"tcpstats_filter exceeds maximum profile size");| Field group | Size (bytes) | Purpose |
|---|---|---|
| Version + state + flags | 12 | Filter mode and state bitmask |
| Port arrays (2 x 8 x uint16) | 32 | Local and remote port lists |
| IPv4 addresses + masks | 16 | CIDR-aware v4 address filtering |
| IPv6 addresses + prefixes | 40 | CIDR-aware v6 address filtering |
| Field mask + format | 8 | Output field selection |
| Spare | 16 | Future expansion without ABI break |
| Total | ~128 | Fits in 2 cache lines |
-
TSF_MAX_PORTS=8: No port ranges means operators list ports explicitly. Real-world deployments rarely monitor more than 4-5 distinct ports per profile. 8 provides comfortable headroom. -
versionfield: Allows the kernel to reject filters from newer userspace tools gracefully (returnENOTSUP) rather than misinterpreting fields. Old tools sending version=0 (uninitialized) are handled by treating version 0 as version 1 (original layout without CIDR). -
_spare[4]: 16 bytes of reserved space. Future additions (e.g., UID filtering, NUMA domain filter, connection age threshold) can use these without changing the struct size, preserving ABI compatibility within version 2. -
Separate IPv4 mask vs IPv6 prefix length: IPv4 uses a precomputed netmask (
0xFFFFFF00for /24) for single-instruction AND+CMP in the match callback. IPv6 uses a prefix length because precomputing a 128-bit mask offers no advantage over byte-wise comparison with length.
The parser uses a two-pass design to ensure atomicity — the filter struct is never left in a partially-modified state on error:
Pass 1: Tokenize
- Split input into directive tokens on whitespace
- Validate directive count <= 16
- For each token, identify key and value (split on '=')
- Check for unknown keys, duplicate directives, flags with values
- NO struct modification in this pass
Pass 2: Parse and populate
- Allocate temporary tcpstats_filter on stack, bzero it
- For each validated token, call the appropriate tsf_parse_*() function
- Each parse function validates its value and populates the temp struct
- After all directives: run tsf_validate_filter() for cross-directive checks
- On success: copy temp struct to output
- On failure: output struct unchanged, error reported
/*
* Parse a filter string into a tcpstats_filter struct.
*
* Returns 0 on success, errno on failure.
* On failure, errbuf contains a human-readable error message and
* the output filter struct is unchanged.
*
* The input string is copied and modified in place (tokenized with strsep).
* The original sysctl buffer is not modified.
*/
#define TSF_PARSE_MAXLEN 512
#define TSF_PARSE_MAXDIRECTIVES 16
#define TSF_ERRBUF_SIZE 128
int
tsf_parse_filter_string(const char *input, size_t len,
struct tcpstats_filter *out, char *errbuf, size_t errbuflen)
{
struct tcpstats_filter tmp;
char buf[TSF_PARSE_MAXLEN];
char *tokens[TSF_PARSE_MAXDIRECTIVES];
int ntokens, error;
/* --- Input validation --- */
if (len == 0 || input[0] == '\0') {
/* Empty string = reset to default (all states, no filter) */
bzero(out, sizeof(*out));
out->version = TSF_VERSION;
out->state_mask = 0xFFFF;
out->field_mask = TSR_FIELDS_DEFAULT;
return (0);
}
if (len > TSF_PARSE_MAXLEN - 1) {
snprintf(errbuf, errbuflen, "filter string too long (%zu > %d)",
len, TSF_PARSE_MAXLEN - 1);
return (ENAMETOOLONG);
}
/* Validate: no non-printable characters */
for (size_t i = 0; i < len; i++) {
if (input[i] != '\0' && (input[i] < 0x20 || input[i] > 0x7E)) {
snprintf(errbuf, errbuflen,
"non-printable character 0x%02x at offset %zu",
(unsigned char)input[i], i);
return (EINVAL);
}
}
/* Work on a mutable copy */
strlcpy(buf, input, sizeof(buf));
/* --- Pass 1: Tokenize --- */
ntokens = 0;
char *p = buf;
char *tok;
while ((tok = strsep(&p, " \t")) != NULL) {
if (tok[0] == '\0')
continue; /* Skip consecutive whitespace */
if (ntokens >= TSF_PARSE_MAXDIRECTIVES) {
snprintf(errbuf, errbuflen,
"too many directives (%d > %d)",
ntokens + 1, TSF_PARSE_MAXDIRECTIVES);
return (EINVAL);
}
tokens[ntokens++] = tok;
}
if (ntokens == 0) {
/* Whitespace-only string = same as empty */
bzero(out, sizeof(*out));
out->version = TSF_VERSION;
out->state_mask = 0xFFFF;
out->field_mask = TSR_FIELDS_DEFAULT;
return (0);
}
/* --- Pass 2: Parse into temporary struct --- */
bzero(&tmp, sizeof(tmp));
tmp.version = TSF_VERSION;
tmp.state_mask = 0xFFFF;
tmp.field_mask = TSR_FIELDS_DEFAULT;
for (int i = 0; i < ntokens; i++) {
error = tsf_parse_directive(tokens[i], &tmp, errbuf, errbuflen);
if (error != 0)
return (error);
}
/* --- Cross-directive validation --- */
error = tsf_validate_filter(&tmp, errbuf, errbuflen);
if (error != 0)
return (error);
/* --- Success: atomic copy to output --- */
bcopy(&tmp, out, sizeof(*out));
return (0);
}/*
* Parse a single directive token ("key=value" or bare "flag").
* Modifies the filter struct in place.
*/
static int
tsf_parse_directive(char *token, struct tcpstats_filter *f,
char *errbuf, size_t errbuflen)
{
char *key, *value;
/* Split on '=' */
key = token;
value = strchr(token, '=');
if (value != NULL) {
*value = '\0';
value++;
if (*value == '\0') {
snprintf(errbuf, errbuflen,
"directive '%s' has empty value", key);
return (EINVAL);
}
}
/* Normalize key to lowercase (case-insensitive) */
for (char *c = key; *c != '\0'; c++)
*c = tolower((unsigned char)*c);
/* --- Flags (no value) --- */
if (strcmp(key, "ipv4_only") == 0) {
if (value != NULL) {
snprintf(errbuf, errbuflen,
"'ipv4_only' is a flag and does not accept a value");
return (EINVAL);
}
f->flags |= TSF_IPV4_ONLY;
return (0);
}
if (strcmp(key, "ipv6_only") == 0) {
if (value != NULL) {
snprintf(errbuf, errbuflen,
"'ipv6_only' is a flag and does not accept a value");
return (EINVAL);
}
f->flags |= TSF_IPV6_ONLY;
return (0);
}
/* --- Key=value directives (value required) --- */
if (value == NULL) {
snprintf(errbuf, errbuflen,
"unknown flag '%s' (did you mean '%s=...'?)", key, key);
return (EINVAL);
}
if (strcmp(key, "local_port") == 0)
return tsf_parse_port_list(value, f->local_ports, TSF_MAX_PORTS,
&f->flags, TSF_LOCAL_PORT_MATCH, errbuf, errbuflen);
if (strcmp(key, "remote_port") == 0)
return tsf_parse_port_list(value, f->remote_ports, TSF_MAX_PORTS,
&f->flags, TSF_REMOTE_PORT_MATCH, errbuf, errbuflen);
if (strcmp(key, "exclude") == 0)
return tsf_parse_exclude_list(value, f, errbuf, errbuflen);
if (strcmp(key, "include_state") == 0)
return tsf_parse_include_state_list(value, f, errbuf, errbuflen);
if (strcmp(key, "local_addr") == 0)
return tsf_parse_addr(value, f, TSF_LOCAL_ADDR_MATCH,
errbuf, errbuflen);
if (strcmp(key, "remote_addr") == 0)
return tsf_parse_addr(value, f, TSF_REMOTE_ADDR_MATCH,
errbuf, errbuflen);
if (strcmp(key, "format") == 0)
return tsf_parse_format(value, f, errbuf, errbuflen);
if (strcmp(key, "fields") == 0)
return tsf_parse_field_list(value, f, errbuf, errbuflen);
snprintf(errbuf, errbuflen, "unknown directive '%s'", key);
return (EINVAL);
}| Function | Input | Output | Security-critical? |
|---|---|---|---|
tsf_parse_filter_string() |
Raw sysctl string | Populated tcpstats_filter |
Yes — entry point from untrusted input |
tsf_parse_directive() |
Single token | Dispatches to specific parsers | Yes — validates key names |
tsf_parse_port_number() |
Port string (e.g., "443") |
uint16_t in network byte order |
Yes — integer overflow, octal injection |
tsf_parse_port_list() |
Comma-separated ports | Port array + flag | Yes — array bounds |
tsf_parse_ipv4_addr() |
Dotted-decimal + optional /prefix |
in_addr + in_addr mask |
Yes — format validation |
tsf_parse_ipv6_addr() |
RFC 5952 string + optional /prefix |
in6_addr + prefix length |
Yes — complex format, :: compression |
tsf_parse_addr() |
Address string (auto-detect AF) | Dispatches to v4 or v6 parser | Yes — AF detection |
tsf_parse_state_list() |
Comma-separated state names | State bitmask | Moderate — bounded enum |
tsf_parse_exclude_list() |
Comma-separated state names | Exclude flags + state_mask | Moderate |
tsf_parse_include_state_list() |
Comma-separated state names | state_mask + include mode flag | Moderate |
tsf_parse_field_list() |
Comma-separated field groups | field_mask bitmask |
Moderate — bounded enum |
tsf_parse_format() |
"compact" or "full" |
format field |
Low — two valid values |
tsf_validate_filter() |
Complete tcpstats_filter |
0 or EINVAL | Yes — conflict detection |
FreeBSD's kernel does not export inet_pton() — it is a libc function
not available in kernel space. The module must implement its own IPv6 address
parser.
The parser handles the following IPv6 address forms:
| Form | Example | Description |
|---|---|---|
| Full | 2001:0db8:0000:0000:0000:0000:0000:0001 |
8 groups of 4 hex digits |
| Compressed | 2001:db8::1 |
:: replaces consecutive zero groups |
| Leading zero omission | 2001:db8:0:0:0:0:0:1 |
Leading zeros within groups omitted |
| Loopback | ::1 |
:: at start |
| All zeros | :: |
Unspecified address |
| IPv4-mapped | ::ffff:192.168.1.1 |
Last 32 bits as dotted-decimal |
| Link-local | fe80::1 |
Common in practice |
| With CIDR | fe80::/10 |
Prefix length appended |
/*
* Parse an IPv6 address string into a struct in6_addr.
* Handles :: compression and mixed IPv4 notation.
*
* Returns 0 on success, EINVAL on invalid format.
* If prefix_out is non-NULL and the string contains "/N",
* the prefix length is stored there (0-128).
*/
static int
tsf_parse_ipv6_addr(const char *str, struct in6_addr *addr,
uint8_t *prefix_out, char *errbuf, size_t errbuflen)
{
uint16_t groups[8];
int ngroups_before = 0; /* Groups before :: */
int ngroups_after = 0; /* Groups after :: */
int double_colon = -1; /* Position of :: (-1 = not seen) */
const char *p = str;
const char *slash;
char addrbuf[48]; /* Max IPv6 string: 45 chars + NUL */
int total_groups;
bzero(groups, sizeof(groups));
bzero(addr, sizeof(*addr));
/* Separate address from prefix length */
slash = strchr(str, '/');
if (slash != NULL) {
size_t addrlen = slash - str;
if (addrlen >= sizeof(addrbuf)) {
snprintf(errbuf, errbuflen,
"IPv6 address too long before '/'");
return (EINVAL);
}
strlcpy(addrbuf, str, addrlen + 1);
p = addrbuf;
}
/* Parse groups with :: detection */
int gi = 0; /* Current group index */
int seen_digits;
uint32_t val;
while (*p != '\0' && gi < 8) {
/* Check for :: */
if (p[0] == ':' && p[1] == ':') {
if (double_colon >= 0) {
snprintf(errbuf, errbuflen,
"multiple '::' in IPv6 address");
return (EINVAL);
}
double_colon = gi;
ngroups_before = gi;
p += 2;
if (*p == '\0')
break;
continue;
}
/* Skip single colon separator (not at start) */
if (*p == ':') {
if (gi == 0 && double_colon < 0) {
snprintf(errbuf, errbuflen,
"IPv6 address starts with single ':'");
return (EINVAL);
}
p++;
}
/* Parse hex group (1-4 hex digits) */
seen_digits = 0;
val = 0;
while (*p != '\0' && *p != ':' && *p != '/' && seen_digits < 4) {
char c = tolower((unsigned char)*p);
if (c >= '0' && c <= '9')
val = (val << 4) | (c - '0');
else if (c >= 'a' && c <= 'f')
val = (val << 4) | (c - 'a' + 10);
else {
/* Check for IPv4-mapped notation */
if (c == '.' && gi >= 6) {
/* Reparse last group + remaining as IPv4 */
/* (implementation deferred to helper) */
return tsf_parse_ipv6_v4mapped(
p - seen_digits, addr, gi,
groups, ngroups_before, double_colon,
errbuf, errbuflen);
}
snprintf(errbuf, errbuflen,
"invalid character '%c' in IPv6 group", *p);
return (EINVAL);
}
seen_digits++;
p++;
}
if (seen_digits == 0) {
snprintf(errbuf, errbuflen, "empty group in IPv6 address");
return (EINVAL);
}
if (val > 0xFFFF) {
snprintf(errbuf, errbuflen,
"IPv6 group value 0x%x exceeds 0xFFFF", val);
return (EINVAL);
}
groups[gi++] = (uint16_t)val;
}
/* Determine total groups and expand :: */
if (double_colon >= 0) {
ngroups_after = gi - ngroups_before;
total_groups = ngroups_before + ngroups_after;
if (total_groups > 7) {
snprintf(errbuf, errbuflen,
"too many groups (%d) with '::' in IPv6 address",
total_groups);
return (EINVAL);
}
/* Shift after-groups to the end of the 8-group array */
int zero_fill = 8 - total_groups;
for (int i = ngroups_after - 1; i >= 0; i--)
groups[ngroups_before + zero_fill + i] =
groups[ngroups_before + i];
for (int i = 0; i < zero_fill; i++)
groups[ngroups_before + i] = 0;
} else {
if (gi != 8) {
snprintf(errbuf, errbuflen,
"IPv6 address has %d groups (expected 8, or use '::')", gi);
return (EINVAL);
}
}
/* Convert groups to in6_addr (network byte order) */
for (int i = 0; i < 8; i++) {
addr->s6_addr[i * 2] = (groups[i] >> 8) & 0xFF;
addr->s6_addr[i * 2 + 1] = groups[i] & 0xFF;
}
/* Parse prefix length if present */
if (prefix_out != NULL && slash != NULL) {
const char *pstr = slash + 1; /* Points into original str */
return tsf_parse_prefix_length(pstr, 128, prefix_out,
errbuf, errbuflen);
} else if (prefix_out != NULL) {
*prefix_out = 128; /* No prefix = exact match (/128) */
}
return (0);
}| Property | Implementation |
|---|---|
| Bounded iteration | Outer loop: max 8 groups. Inner hex loop: max 4 digits. Total iterations bounded at 8 * 4 = 32. |
| No buffer overflows | groups[8] array indexed by gi < 8 check. addrbuf[48] bounded by addrlen < sizeof(addrbuf). |
| Strict character validation | Only [0-9a-fA-F:./] accepted. Any other character → immediate EINVAL. |
Single :: enforcement |
double_colon >= 0 check prevents multiple :: sequences. |
| Group count validation | With ::: total groups <= 7. Without ::: exactly 8 groups. |
| No dependency on libc | Pure kernel implementation — no inet_pton(), no sscanf(). |
When a prefix length is specified, the parser validates that host bits are zero in the network address:
/*
* Validate that host bits are zero in a CIDR address.
* For example, fe80::1/10 is rejected (host bits set).
* fe80::/10 is accepted.
*/
static int
tsf_validate_v6_cidr(const struct in6_addr *addr, uint8_t prefix,
char *errbuf, size_t errbuflen)
{
if (prefix == 128)
return (0); /* Exact match, no host bits */
if (prefix == 0)
return (0); /* Match all, any address valid */
int full_bytes = prefix / 8;
int remainder_bits = prefix % 8;
/* Check partial byte */
if (remainder_bits > 0) {
uint8_t mask = (uint8_t)(0xFF << (8 - remainder_bits));
if ((addr->s6_addr[full_bytes] & ~mask) != 0) {
snprintf(errbuf, errbuflen,
"host bits set in IPv6 CIDR (prefix /%u)", prefix);
return (EINVAL);
}
full_bytes++;
}
/* Check remaining bytes (must all be zero) */
for (int i = full_bytes; i < 16; i++) {
if (addr->s6_addr[i] != 0) {
snprintf(errbuf, errbuflen,
"host bits set in IPv6 CIDR (prefix /%u)", prefix);
return (EINVAL);
}
}
return (0);
}Port number parsing is security-critical because it converts untrusted
string input into a uint16_t used in the SMR match callback. Any
parsing bug here could cause incorrect filtering (security bypass) or
integer overflow (undefined behavior in kernel).
/*
* Parse a single port number string to uint16_t in network byte order.
*
* Rejects: leading zeros, negative numbers, overflow >65535, port 0,
* trailing non-digits, empty strings.
*
* Returns 0 on success with *port_out set, EINVAL on failure.
*/
static int
tsf_parse_port_number(const char *str, uint16_t *port_out,
char *errbuf, size_t errbuflen)
{
unsigned long val;
size_t len;
if (str == NULL || str[0] == '\0') {
snprintf(errbuf, errbuflen, "empty port number");
return (EINVAL);
}
len = strnlen(str, 6); /* Max 5 digits + check for 6th */
/* Reject leading zeros (octal confusion) */
if (len > 1 && str[0] == '0') {
snprintf(errbuf, errbuflen,
"port '%s' has leading zero (octal not supported)", str);
return (EINVAL);
}
/* Reject non-digit characters before calling strtoul */
for (size_t i = 0; i < len && str[i] != '\0'; i++) {
if (str[i] < '0' || str[i] > '9') {
snprintf(errbuf, errbuflen,
"port '%s' contains non-digit character '%c'",
str, str[i]);
return (EINVAL);
}
}
/* Reject if more than 5 digits (> 65535 guaranteed) */
if (len > 5) {
snprintf(errbuf, errbuflen,
"port '%s' too many digits (max 65535)", str);
return (EINVAL);
}
/* Safe to call strtoul — input is validated digits only */
val = strtoul(str, NULL, 10);
/* Range check */
if (val == 0) {
snprintf(errbuf, errbuflen, "port 0 is not valid");
return (EINVAL);
}
if (val > 65535) {
snprintf(errbuf, errbuflen,
"port %lu exceeds maximum 65535", val);
return (EINVAL);
}
*port_out = htons((uint16_t)val);
return (0);
}static int
tsf_parse_port_list(char *value, uint16_t *ports, int maxports,
uint32_t *flags, uint32_t flag_bit,
char *errbuf, size_t errbuflen)
{
char *tok, *p;
int count = 0;
uint16_t port;
int error;
if (*flags & flag_bit) {
snprintf(errbuf, errbuflen, "duplicate port directive");
return (EINVAL);
}
p = value;
while ((tok = strsep(&p, ",")) != NULL) {
if (tok[0] == '\0')
continue; /* Skip empty tokens (e.g., "443,,80") */
if (count >= maxports) {
snprintf(errbuf, errbuflen,
"too many ports (max %d per direction)", maxports);
return (EINVAL);
}
error = tsf_parse_port_number(tok, &port, errbuf, errbuflen);
if (error != 0)
return (error);
/* Duplicate detection */
for (int i = 0; i < count; i++) {
if (ports[i] == port) {
snprintf(errbuf, errbuflen,
"duplicate port %u", ntohs(port));
return (EINVAL);
}
}
ports[count++] = port;
}
if (count == 0) {
snprintf(errbuf, errbuflen, "empty port list");
return (EINVAL);
}
*flags |= flag_bit;
return (0);
}| Input | Rejection reason | Check |
|---|---|---|
"" |
Empty string | str[0] == '\0' |
"0" |
Port 0 not valid | val == 0 |
"00" |
Leading zero | len > 1 && str[0] == '0' |
"08080" |
Leading zero | Same check |
"65536" |
Exceeds maximum | val > 65535 |
"99999" |
Exceeds maximum | Same (99999 > 65535) |
"100000" |
Too many digits | len > 5 |
"-1" |
Non-digit character | str[i] < '0' |
"443abc" |
Non-digit character | str[i] > '9' |
"0x1BB" |
Non-digit character | str[1] == 'x' |
" 443" |
Non-digit character (space) | str[0] < '0' |
"443 " |
Non-digit character (space) | Trailing space check |
| Input | Error | errno | Message |
|---|---|---|---|
| String > 511 bytes | Too long | ENAMETOOLONG |
"filter string too long (N > 511)" |
| Contains byte 0x01-0x1F (except 0x09) | Non-printable | EINVAL |
"non-printable character 0xNN at offset N" |
| Contains byte 0x7F-0xFF | Non-printable | EINVAL |
Same |
| > 16 directives | Too many | EINVAL |
"too many directives (N > 16)" |
| Empty string | Reset (not error) | 0 | Filter reset to defaults |
| Whitespace-only | Reset (not error) | 0 | Filter reset to defaults |
| Input | Error | errno | Message |
|---|---|---|---|
foobar=123 |
Unknown key | EINVAL |
"unknown directive 'foobar'" |
local_port (no =value) |
Missing value | EINVAL |
"unknown flag 'local_port' (did you mean 'local_port=...'?)" |
local_port=443 local_port=80 |
Duplicate directive | EINVAL |
"duplicate port directive" |
ipv4_only=true |
Flag with value | EINVAL |
"'ipv4_only' is a flag and does not accept a value" |
local_port= |
Empty value | EINVAL |
"directive 'local_port' has empty value" |
=443 |
Empty key | EINVAL |
"unknown directive ''" |
| Input | Error | errno | Message |
|---|---|---|---|
local_port=0 |
Port 0 invalid | EINVAL |
"port 0 is not valid" |
local_port=65536 |
Exceeds max | EINVAL |
"port 65536 exceeds maximum 65535" |
local_port=08080 |
Leading zero | EINVAL |
"port '08080' has leading zero (octal not supported)" |
local_port=abc |
Non-digit | EINVAL |
"port 'abc' contains non-digit character 'a'" |
local_port=-1 |
Non-digit (dash) | EINVAL |
"port '-1' contains non-digit character '-'" |
local_port=443,443 |
Duplicate | EINVAL |
"duplicate port 443" |
local_port=1,2,3,4,5,6,7,8,9 |
Too many (>8) | EINVAL |
"too many ports (max 8 per direction)" |
local_port=,, |
Empty list | EINVAL |
"empty port list" |
local_port=100000 |
Too many digits | EINVAL |
"port '100000' too many digits (max 65535)" |
| Input | Error | errno | Message |
|---|---|---|---|
exclude=foobar |
Unknown state | EINVAL |
"unknown state name 'foobar'" |
exclude=listen,listen |
Duplicate state | EINVAL |
"duplicate state 'listen' in exclude list" |
exclude=listen include_state=established |
Conflict | EINVAL |
"'exclude' and 'include_state' are mutually exclusive" |
include_state= |
Empty list | EINVAL |
"directive 'include_state' has empty value" |
| Input | Error | errno | Message |
|---|---|---|---|
local_addr=999.1.2.3 |
Invalid octet | EINVAL |
"IPv4 octet 999 exceeds 255" |
local_addr=10.0.0.0/33 |
Prefix too long | EINVAL |
"IPv4 prefix length 33 exceeds maximum 32" |
local_addr=10.0.0.1/24 |
Host bits set | EINVAL |
"host bits set in IPv4 CIDR (prefix /24)" |
local_addr=10.0.0 |
Missing octets | EINVAL |
"IPv4 address has 3 octets (expected 4)" |
local_addr=10.0.0.0.1 |
Extra octets | EINVAL |
"trailing characters after IPv4 address" |
remote_addr=fe80::1/10 |
Host bits set (v6) | EINVAL |
"host bits set in IPv6 CIDR (prefix /10)" |
remote_addr=fe80::/129 |
Prefix too long (v6) | EINVAL |
"IPv6 prefix length 129 exceeds maximum 128" |
remote_addr=gggg::1 |
Invalid hex | EINVAL |
"invalid character 'g' in IPv6 group" |
remote_addr=2001:db8::1::2 |
Multiple :: |
EINVAL |
"multiple '::' in IPv6 address" |
local_addr=10.0.0.1 ipv6_only |
AF conflict | EINVAL |
"IPv4 address conflicts with ipv6_only flag" |
local_addr=fe80::1 ipv4_only |
AF conflict | EINVAL |
"IPv6 address conflicts with ipv4_only flag" |
| Input | Error | errno | Message |
|---|---|---|---|
ipv4_only ipv6_only |
Mutual exclusion | EINVAL |
"'ipv4_only' and 'ipv6_only' are mutually exclusive" |
exclude=listen include_state=established |
Mutual exclusion | EINVAL |
"'exclude' and 'include_state' are mutually exclusive" |
format=compact format=full |
Duplicate directive | EINVAL |
"duplicate 'format' directive" |
fields=all fields=rtt |
Duplicate directive | EINVAL |
"duplicate 'fields' directive" |
Errors from the filter parser are reported through three channels to ensure operators can always diagnose configuration failures:
| Channel | Audience | Persistence | Example |
|---|---|---|---|
| errno return | Programmatic (sysctl write returns errno) | Per-call | sysctl: dev.tcpstats.profiles.foo: Invalid argument |
dev.tcpstats.last_error sysctl |
Interactive operator | Until next write | "port '08080' has leading zero (octal not supported)" |
dmesg via log(LOG_NOTICE) |
System audit trail | Persistent (kernel log) | tcpstats: filter parse error: port '08080' has leading zero |
Every tsf_parse_* function takes errbuf and errbuflen parameters.
On error, the function populates errbuf with a human-readable message
using snprintf() (never sprintf()), then returns an appropriate errno.
The top-level sysctl handler copies the errbuf to the dev.tcpstats.last_error
sysctl and logs it to dmesg:
static int
tcpstats_profile_handler(SYSCTL_HANDLER_ARGS)
{
char input[TSF_PARSE_MAXLEN];
char errbuf[TSF_ERRBUF_SIZE];
struct tcpstats_filter filter;
int error;
/* ... read input from sysctl handler ... */
errbuf[0] = '\0';
error = tsf_parse_filter_string(input, strlen(input),
&filter, errbuf, sizeof(errbuf));
if (error != 0) {
/* Channel 2: store in last_error sysctl */
strlcpy(tcpstats_last_error, errbuf, sizeof(tcpstats_last_error));
/* Channel 3: log to dmesg */
log(LOG_NOTICE, "tcpstats: filter parse error: %s\n", errbuf);
/* Channel 1: return errno to caller */
return (error);
}
/* Success — clear last error */
tcpstats_last_error[0] = '\0';
/* ... create device with parsed filter ... */
return (0);
}| errno | Meaning | When used |
|---|---|---|
EINVAL |
Parse error (bad syntax, unknown key, invalid value) | Most parse failures |
ENAMETOOLONG |
Filter string exceeds 512-byte limit | Input length check |
ENOSPC |
Maximum 16 profiles already exist | Profile creation limit |
EEXIST |
Profile name already exists | Duplicate profile name |
ENOTSUP |
Filter struct version too new for this kernel | Version mismatch |
# Attempt to create a profile with a typo
$ sysctl dev.tcpstats.profiles.web="local_port=443 exlude=listen"
sysctl: dev.tcpstats.profiles.web: Invalid argument
# Check what went wrong
$ sysctl dev.tcpstats.last_error
dev.tcpstats.last_error: unknown directive 'exlude'
# Also visible in dmesg
$ dmesg | tail -1
tcpstats: filter parse error: unknown directive 'exlude'
# Fix the typo
$ sysctl dev.tcpstats.profiles.web="local_port=443 exclude=listen"
dev.tcpstats.profiles.web: local_port=443 exclude=listen
# Verify the device was created
$ ls -la /dev/tcpstats/web
crw-r----- 1 root network 0x... /dev/tcpstats/webEvery item in this checklist addresses a specific attack surface of the kernel string parser. The parser runs with kernel privileges on input provided by a privileged user (root or sysctl access), but defense in depth requires hardening against malformed input regardless of caller privilege.
| Control | Implementation | Rationale |
|---|---|---|
| Maximum string length | len > TSF_PARSE_MAXLEN - 1 → ENAMETOOLONG |
Prevents unbounded kernel stack/heap usage |
| Maximum directive count | ntokens >= TSF_PARSE_MAXDIRECTIVES → EINVAL |
Prevents O(N^2) in duplicate detection |
| Maximum port array size | count >= TSF_MAX_PORTS → EINVAL |
Prevents array overflow in port list |
| Maximum port digit count | strnlen(str, 6) > 5 → EINVAL |
Pre-validates before strtoul() |
| Maximum IPv6 groups | gi < 8 loop bound |
Prevents buffer overflow in groups[8] |
| Maximum hex digits per group | seen_digits < 4 loop bound |
Prevents integer overflow in group value |
| Maximum prefix length | 32 (IPv4) or 128 (IPv6) | Prevents out-of-range shift/mask |
| Control | Implementation | Rationale |
|---|---|---|
No sprintf() |
All formatting uses snprintf(errbuf, errbuflen, ...) |
Prevents buffer overflow in error messages |
No strcpy() |
All copies use strlcpy() |
Bounded copy with NUL termination |
No strlen() on untrusted input |
Use strnlen(input, maxlen) |
Prevents scanning past buffer end |
| Mutable copy of input | strlcpy(buf, input, sizeof(buf)) before tokenization |
Protects original sysctl buffer from modification |
| Non-printable rejection | Character range check 0x20-0x7E |
Prevents control characters in kernel strings |
| Control | Implementation | Rationale |
|---|---|---|
| Leading zero rejection | len > 1 && str[0] == '0' |
Prevents octal interpretation by strtoul() |
| Pre-validated digits | Character-by-character [0-9] check before strtoul() |
Prevents sign character injection (-, +) |
| Digit count limit | Max 5 digits checked before conversion | strtoul() cannot overflow unsigned long with 5 digits |
| Range check after conversion | val > 65535 |
Defense in depth after digit count limit |
| Network byte order storage | htons((uint16_t)val) |
Prevents byte order bugs in match callback |
| Control | Implementation | Rationale |
|---|---|---|
bzero on temp struct |
bzero(&tmp, sizeof(tmp)) before population |
No uninitialized fields in output |
| Atomic output update | bcopy(&tmp, out, sizeof(*out)) only on success |
No partial state on error |
| Stack-allocated buffers | char buf[512], char errbuf[128] |
No heap allocation in parser (no leak path) |
_Static_assert on struct sizes |
Compile-time validation | Catches struct layout drift |
Bounded for loops |
All loops have explicit upper bounds (no while(1)) |
Guaranteed termination |
| Control | Implementation | Rationale |
|---|---|---|
| All errors logged to dmesg | log(LOG_NOTICE, ...) on every parse failure |
Administrator audit trail |
| Success logged | log(LOG_NOTICE, "profile '%s' created") |
Track profile lifecycle |
| Deletion logged | log(LOG_NOTICE, "profile '%s' deleted") |
Track profile lifecycle |
| Error stored in sysctl | dev.tcpstats.last_error |
Interactive debugging |
The filter parser is designed to compile as both kernel module code and a userspace test program. This enables comprehensive testing — including fuzzing — without loading a kernel module.
kmod/tcpstats/
tcp_statsdev_filter.c # Parser implementation (dual-compile)
tcp_statsdev_filter.h # Parser API + struct definition
tcp_statsdev.c # Module code (includes parser)
test/
test_filter_parse.c # Userspace test harness
Makefile # Builds userspace test
/* tcp_statsdev_filter.c */
#ifdef _KERNEL
#include <sys/param.h>
#include <sys/systm.h>
#include <sys/libkern.h>
#include <netinet/in.h>
#else
/* Userspace shims */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <errno.h>
/* Shim kernel functions */
#define log(level, fmt, ...) fprintf(stderr, fmt, ##__VA_ARGS__)
#define strlcpy(dst, src, len) snprintf(dst, len, "%s", src)
/* bzero is available on BSD but not all platforms */
#ifndef bzero
#define bzero(ptr, len) memset(ptr, 0, len)
#endif
#ifndef bcopy
#define bcopy(src, dst, len) memcpy(dst, src, len)
#endif
#endif /* _KERNEL */The test harness exercises every entry in the rejection tables from section 8, plus positive test cases for all valid grammar productions:
/* test/test_filter_parse.c */
#include "../tcp_statsdev_filter.h"
struct test_case {
const char *name;
const char *input;
int expected_error; /* 0 = success, EINVAL, ENAMETOOLONG, etc. */
const char *expected_errmsg; /* Substring match in errbuf, or NULL */
};
static const struct test_case cases[] = {
/* --- Positive cases --- */
{"empty string resets",
"", 0, NULL},
{"single port",
"local_port=443", 0, NULL},
{"multiple ports",
"local_port=443,8443,8080", 0, NULL},
{"exclude states",
"exclude=listen,timewait", 0, NULL},
{"include states",
"include_state=established", 0, NULL},
{"ipv4 exact address",
"local_addr=10.0.0.1", 0, NULL},
{"ipv4 cidr",
"local_addr=10.0.0.0/24", 0, NULL},
{"ipv6 loopback",
"local_addr=::1", 0, NULL},
{"ipv6 cidr",
"remote_addr=fe80::/10", 0, NULL},
{"full combo",
"local_port=443 exclude=listen,timewait ipv4_only format=full", 0, NULL},
{"case insensitive",
"LOCAL_PORT=443 EXCLUDE=LISTEN", 0, NULL},
{"ipv4_only flag",
"ipv4_only", 0, NULL},
/* --- Structural rejections --- */
{"non-printable char",
"local_port=443\x01", EINVAL, "non-printable"},
{"too many directives",
/* 17 directives */
"a=1 b=2 c=3 d=4 e=5 f=6 g=7 h=8 i=9 j=10 k=11 l=12 m=13 n=14 o=15 p=16 q=17",
EINVAL, "too many directives"},
/* --- Port rejections --- */
{"port zero",
"local_port=0", EINVAL, "port 0"},
{"port overflow",
"local_port=65536", EINVAL, "exceeds maximum"},
{"port leading zero",
"local_port=0443", EINVAL, "leading zero"},
{"port non-digit",
"local_port=abc", EINVAL, "non-digit"},
{"port duplicate",
"local_port=443,443", EINVAL, "duplicate port"},
{"port too many",
"local_port=1,2,3,4,5,6,7,8,9", EINVAL, "too many ports"},
/* --- State rejections --- */
{"unknown state",
"exclude=foobar", EINVAL, "unknown state"},
/* --- Conflict rejections --- */
{"ipv4_only + ipv6_only",
"ipv4_only ipv6_only", EINVAL, "mutually exclusive"},
{"exclude + include_state",
"exclude=listen include_state=established", EINVAL, "mutually exclusive"},
/* --- Address rejections --- */
{"ipv4 host bits",
"local_addr=10.0.0.1/24", EINVAL, "host bits set"},
{"ipv6 host bits",
"remote_addr=fe80::1/10", EINVAL, "host bits set"},
{"ipv6 multiple ::",
"remote_addr=2001::1::2", EINVAL, "multiple '::'"},
{NULL, NULL, 0, NULL} /* Sentinel */
};
int main(void)
{
struct tcpstats_filter filter;
char errbuf[TSF_ERRBUF_SIZE];
int pass = 0, fail = 0;
for (const struct test_case *tc = cases; tc->name != NULL; tc++) {
bzero(&filter, sizeof(filter));
errbuf[0] = '\0';
int err = tsf_parse_filter_string(tc->input,
tc->input ? strlen(tc->input) : 0,
&filter, errbuf, sizeof(errbuf));
int ok = 1;
if (err != tc->expected_error) {
printf("FAIL: %s: expected errno %d, got %d\n",
tc->name, tc->expected_error, err);
ok = 0;
}
if (tc->expected_errmsg != NULL && err != 0) {
if (strstr(errbuf, tc->expected_errmsg) == NULL) {
printf("FAIL: %s: expected '%s' in errbuf, got '%s'\n",
tc->name, tc->expected_errmsg, errbuf);
ok = 0;
}
}
if (ok) {
pass++;
} else {
fail++;
}
}
printf("\n%d passed, %d failed, %d total\n",
pass, fail, pass + fail);
return (fail > 0) ? 1 : 0;
}The dual-compilation design enables fuzzing with standard userspace fuzzers:
/* test/fuzz_filter_parse.c — AFL/libFuzzer harness */
#include "../tcp_statsdev_filter.h"
#ifdef __AFL_FUZZ_TESTCASE_LEN
/* AFL persistent mode */
__AFL_FUZZ_INIT();
int main(void)
{
__AFL_INIT();
unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
while (__AFL_LOOP(1000)) {
int len = __AFL_FUZZ_TESTCASE_LEN;
if (len > 0 && len < TSF_PARSE_MAXLEN) {
char input[TSF_PARSE_MAXLEN];
memcpy(input, buf, len);
input[len] = '\0';
struct tcpstats_filter filter;
char errbuf[TSF_ERRBUF_SIZE];
tsf_parse_filter_string(input, len,
&filter, errbuf, sizeof(errbuf));
}
}
return 0;
}
#else
/* libFuzzer entry point */
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
{
if (size == 0 || size >= TSF_PARSE_MAXLEN)
return 0;
char input[TSF_PARSE_MAXLEN];
memcpy(input, data, size);
input[size] = '\0';
struct tcpstats_filter filter;
char errbuf[TSF_ERRBUF_SIZE];
tsf_parse_filter_string(input, size,
&filter, errbuf, sizeof(errbuf));
return 0;
}
#endifBuild and run:
# With AFL
afl-gcc -o fuzz_filter test/fuzz_filter_parse.c tcp_statsdev_filter.c
afl-fuzz -i seeds/ -o findings/ -- ./fuzz_filter
# With libFuzzer (clang)
clang -fsanitize=fuzzer,address -o fuzz_filter \
test/fuzz_filter_parse.c tcp_statsdev_filter.c
./fuzz_filter -max_len=512 corpus/When an operator writes to dev.tcpstats.profiles.<name>, the sysctl
handler:
- Validates the profile name (alphanumeric + underscore, max 32 chars)
- Checks the profile count limit (max 16)
- Calls
tsf_parse_filter_string()to parse the filter - Allocates a profile struct and stores the parsed filter
- Calls
make_dev_credf()to create/dev/tcpstats/<name> - Stores the original filter string for readback
#define TSF_MAX_PROFILES 16
#define TSF_PROFILE_NAME_MAX 32
struct tcpstats_profile {
char name[TSF_PROFILE_NAME_MAX];
char filter_str[TSF_PARSE_MAXLEN]; /* Original string */
struct tcpstats_filter filter; /* Parsed filter */
struct cdev *dev;
SLIST_ENTRY(tcpstats_profile) link;
};
static SLIST_HEAD(, tcpstats_profile) tcpstats_profiles =
SLIST_HEAD_INITIALIZER(tcpstats_profiles);
static int tcpstats_nprofiles;
static struct sx tcpstats_profile_lock;
/*
* Validate profile name: [a-z0-9_]+, max 32 chars.
* No uppercase (normalize on input), no special chars.
*/
static int
tsf_validate_profile_name(const char *name, char *errbuf, size_t errbuflen)
{
size_t len = strnlen(name, TSF_PROFILE_NAME_MAX + 1);
if (len == 0) {
snprintf(errbuf, errbuflen, "empty profile name");
return (EINVAL);
}
if (len > TSF_PROFILE_NAME_MAX) {
snprintf(errbuf, errbuflen,
"profile name too long (%zu > %d)", len, TSF_PROFILE_NAME_MAX);
return (ENAMETOOLONG);
}
for (size_t i = 0; i < len; i++) {
char c = name[i];
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_')) {
snprintf(errbuf, errbuflen,
"invalid character '%c' in profile name "
"(allowed: a-z, 0-9, _)", c);
return (EINVAL);
}
}
return (0);
}Writing an empty string to a profile sysctl deletes the profile:
# Delete a profile
sysctl dev.tcpstats.profiles.web=""The handler calls destroy_dev() which waits for all open file
descriptors to close before destroying the device. This ensures no
in-flight reads are interrupted.
Reading a profile sysctl returns the original filter string:
$ sysctl dev.tcpstats.profiles.web
dev.tcpstats.profiles.web: local_port=80,443 exclude=listen,timewaitEach profile device stores its filter in the si_drv1 pointer of the
struct cdev, accessible from the open/read handlers:
static int
tcpstats_profile_create(const char *name, const char *filter_str,
struct tcpstats_filter *filter, char *errbuf, size_t errbuflen)
{
struct tcpstats_profile *prof;
sx_xlock(&tcpstats_profile_lock);
if (tcpstats_nprofiles >= TSF_MAX_PROFILES) {
sx_xunlock(&tcpstats_profile_lock);
snprintf(errbuf, errbuflen,
"maximum profiles reached (%d)", TSF_MAX_PROFILES);
return (ENOSPC);
}
/* Check for duplicate */
SLIST_FOREACH(prof, &tcpstats_profiles, link) {
if (strcmp(prof->name, name) == 0) {
sx_xunlock(&tcpstats_profile_lock);
snprintf(errbuf, errbuflen,
"profile '%s' already exists", name);
return (EEXIST);
}
}
prof = malloc(sizeof(*prof), M_TCPSTATS, M_WAITOK | M_ZERO);
strlcpy(prof->name, name, sizeof(prof->name));
strlcpy(prof->filter_str, filter_str, sizeof(prof->filter_str));
bcopy(filter, &prof->filter, sizeof(prof->filter));
/* Create /dev/tcpstats/<name> */
prof->dev = make_dev_credf(MAKEDEV_ETERNAL_KLD,
&tcpstats_profile_cdevsw, 0, NULL,
UID_ROOT, GID_NETWORK, 0440, "tcpstats/%s", name);
if (prof->dev == NULL) {
free(prof, M_TCPSTATS);
sx_xunlock(&tcpstats_profile_lock);
snprintf(errbuf, errbuflen,
"failed to create device for profile '%s'", name);
return (ENXIO);
}
/* Store filter as device context for open() */
prof->dev->si_drv1 = prof;
SLIST_INSERT_HEAD(&tcpstats_profiles, prof, link);
tcpstats_nprofiles++;
sx_xunlock(&tcpstats_profile_lock);
log(LOG_NOTICE, "tcpstats: profile '%s' created: %s\n",
name, filter_str);
return (0);
}The open() handler for profile devices copies the profile's filter
into the per-fd softc:
static int
tcpstats_profile_open(struct cdev *dev, int oflags, int devtype,
struct thread *td)
{
struct tcpstats_profile *prof = dev->si_drv1;
struct tcpstats_softc *sc;
if (__predict_false(oflags & FWRITE))
return (EPERM);
sc = malloc(sizeof(*sc), M_TCPSTATS, M_WAITOK | M_ZERO);
sc->sc_cred = crhold(td->td_ucred);
/* Copy the profile's pre-parsed filter */
bcopy(&prof->filter, &sc->sc_filter, sizeof(sc->sc_filter));
sc->sc_full = (prof->filter.format == TSF_FORMAT_FULL);
devfs_set_cdevpriv(sc, tcpstats_dtor);
return (0);
}The tcpstats_match() function is updated to support CIDR-masked address
comparison. This function runs in the SMR section with no locks held
and can only read immutable fields of the inpcb.
/*
* SMR-safe match function with CIDR address support.
* Called with NO locks held — reads only immutable inpcb fields.
*
* Returns true if this socket should be included.
* Returns false to skip (no lock acquired, minimal cache impact).
*/
static bool
tcpstats_match(const struct inpcb *inp, void *ctx)
{
const struct tcpstats_filter *f = ctx;
/* IP version filter — cheapest check first */
if (__predict_false(f->flags & TSF_IPV4_ONLY)) {
if (!(inp->inp_vflag & INP_IPV4))
return (false);
}
if (__predict_false(f->flags & TSF_IPV6_ONLY)) {
if (!(inp->inp_vflag & INP_IPV6))
return (false);
}
/* Local port filter */
if (__predict_false(f->flags & TSF_LOCAL_PORT_MATCH)) {
uint16_t lport = inp->inp_inc.inc_lport;
bool found = false;
for (int i = 0; i < TSF_MAX_PORTS && f->local_ports[i] != 0; i++) {
if (__predict_true(lport == f->local_ports[i])) {
found = true;
break;
}
}
if (__predict_false(!found))
return (false);
}
/* Remote port filter */
if (__predict_false(f->flags & TSF_REMOTE_PORT_MATCH)) {
uint16_t fport = inp->inp_inc.inc_fport;
bool found = false;
for (int i = 0; i < TSF_MAX_PORTS && f->remote_ports[i] != 0; i++) {
if (__predict_true(fport == f->remote_ports[i])) {
found = true;
break;
}
}
if (__predict_false(!found))
return (false);
}
/* Local address filter with CIDR mask */
if (__predict_false(f->flags & TSF_LOCAL_ADDR_MATCH)) {
if (inp->inp_vflag & INP_IPV4) {
/* IPv4: single AND + CMP — no performance impact */
if (f->local_addr_v4.s_addr != INADDR_ANY) {
if ((inp->inp_inc.inc_laddr.s_addr & f->local_mask_v4.s_addr)
!= (f->local_addr_v4.s_addr & f->local_mask_v4.s_addr))
return (false);
}
} else if (inp->inp_vflag & INP_IPV6) {
if (!IN6_IS_ADDR_UNSPECIFIED(&f->local_addr_v6)) {
if (!tsf_match_v6_prefix(
&inp->inp_inc.inc6_laddr,
&f->local_addr_v6, f->local_prefix_v6))
return (false);
}
}
}
/* Remote address filter with CIDR mask */
if (__predict_false(f->flags & TSF_REMOTE_ADDR_MATCH)) {
if (inp->inp_vflag & INP_IPV4) {
if (f->remote_addr_v4.s_addr != INADDR_ANY) {
if ((inp->inp_inc.inc_faddr.s_addr & f->remote_mask_v4.s_addr)
!= (f->remote_addr_v4.s_addr & f->remote_mask_v4.s_addr))
return (false);
}
} else if (inp->inp_vflag & INP_IPV6) {
if (!IN6_IS_ADDR_UNSPECIFIED(&f->remote_addr_v6)) {
if (!tsf_match_v6_prefix(
&inp->inp_inc.inc6_faddr,
&f->remote_addr_v6, f->remote_prefix_v6))
return (false);
}
}
}
return (true);
}/*
* Compare two IPv6 addresses under a prefix length.
* Returns true if they match within the prefix.
*
* This runs in the SMR section — must be fast and branch-free
* on the common path.
*/
static __always_inline bool
tsf_match_v6_prefix(const struct in6_addr *a, const struct in6_addr *b,
uint8_t prefix)
{
int full_bytes, remainder_bits;
if (prefix == 128) {
/* Exact match — compare all 16 bytes */
return (memcmp(a, b, 16) == 0);
}
if (prefix == 0) {
/* Match all */
return (true);
}
full_bytes = prefix / 8;
remainder_bits = prefix % 8;
/* Compare full bytes */
if (full_bytes > 0 && memcmp(a, b, full_bytes) != 0)
return (false);
/* Compare partial byte */
if (remainder_bits > 0) {
uint8_t mask = (uint8_t)(0xFF << (8 - remainder_bits));
if ((a->s6_addr[full_bytes] & mask) !=
(b->s6_addr[full_bytes] & mask))
return (false);
}
return (true);
}State filtering remains after lock acquisition because tp->t_state
is mutable. The updated logic handles both exclude and include_state
modes:
/* In tcpstats_read(), after inp_next() acquires the read lock: */
struct tcpcb *tp = intotcpcb(inp);
int state = tp->t_state;
if (f->flags & TSF_STATE_INCLUDE_MODE) {
/* Positive match: only include listed states */
if (!(f->state_mask & (1 << state)))
continue;
} else {
/* Negative match: exclude listed states */
if (!(f->state_mask & (1 << state)))
continue;
/* Legacy exclude flags (also modify state_mask during parse) */
/* Already handled by state_mask — exclude flags clear bits */
}| Operation | IPv4 | IPv6 |
|---|---|---|
| Exact match | 1 CMP (32-bit) | memcmp(16) — 2 x 64-bit CMP on amd64 |
| CIDR match | 1 AND + 1 CMP (32-bit) | memcmp(N) + 1 byte mask + 1 CMP |
| Cost per socket | ~1 ns | ~2-3 ns |
| Added latency for 100K sockets | ~0.1 ms (IPv4) | ~0.2-0.3 ms (IPv6) |
The CIDR matching cost is negligible compared to the ~5 ns per-socket
baseline of the SMR match callback. The IPv4 path uses a precomputed
32-bit netmask for single-instruction AND+CMP. The IPv6 path uses
byte-wise comparison which compiles to efficient memcmp on amd64.
- Kernel module design: 05-kernel-module.md
- Section 12.3:
tcpstats_filterstruct definition (updated to match this document) - Section 12.4:
tcpstats_match()implementation (updated for CIDR) - Section 12.5: Named filter profiles via sysctl
- Section 12.7: Filter string syntax (references this document)
- Section 12.10: Filter safety (references validation tables in this document)
- Section 12.3:
- Kernel module implementation plan: ../../archive/kernel-module-impl-plan.md