srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-07-01 00:40:00 +0200
committersrdusr <[email protected]>2025-07-01 00:40:00 +0200
commit407249eb5d654b5a951c43bc1722fd397d5d922c (patch)
treec367bf95c3855152d477a7eed5e709b3094a427c
parent9046e6a10fd2d987cf6f6dc601ed3a75d286793f (diff)
downloadpacketeer-407249eb5d654b5a951c43bc1722fd397d5d922c.tar.gz
packeteer-407249eb5d654b5a951c43bc1722fd397d5d922c.zip
Add FTP, SMTP, TFTP, and IGMP; fix an IGMPv3 group-address misparse
FTP and SMTP share HTTP's line-based response-code-or-command shape but keep their own command vocabularies in separate files rather than sharing a parser. FTP passwords are shown as-is, not redacted - FTP sends them in the clear regardless, matching Wireshark's own behavior. TFTP is a small binary opcode protocol (RFC 1350) instead. IGMP sits directly on IP like ICMP, so it's dispatched by protocol number rather than through the port-keyed L7Registry the other three use. Live-verified: FTP/SMTP against minimal real TCP servers written for this (nothing installed locally), a full command/response exchange decoded correctly in both directions. TFTP against a real atftpd server and atftp client - the RRQ decoded correctly even though the transfer itself didn't complete (an atftpd sandbox issue, not this code). IGMP against real multicast traffic on wlp1s0, including a genuine query from the actual router. That live IGMP traffic caught a real bug before it shipped further: parse_igmp() read bytes[4:8] as a group address for every message type, but IGMPv3 reports use those bytes for Reserved+RecordCount instead - a real V3 report showed "group=0.0.0.1" (0 reserved, 1 record, misread as an IP). Fixed by only populating group for the types where it's genuinely an address; re-verified against the same live traffic, and a regression test locks in the exact pattern.
-rw-r--r--CMakeLists.txt4
-rw-r--r--PLAN.md39
-rw-r--r--include/packeteer/l7/ftp.hpp94
-rw-r--r--include/packeteer/l7/smtp.hpp92
-rw-r--r--include/packeteer/l7/tftp.hpp116
-rw-r--r--include/packeteer/net/igmp.hpp69
-rw-r--r--include/packeteer/net/ipv4.hpp1
-rw-r--r--include/packeteer/summarize.hpp19
-rw-r--r--tests/test_ftp.cpp51
-rw-r--r--tests/test_igmp.cpp51
-rw-r--r--tests/test_smtp.cpp51
-rw-r--r--tests/test_summarize.cpp22
-rw-r--r--tests/test_tftp.cpp85
13 files changed, 694 insertions, 0 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 0f313c9..bcf68b1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -98,12 +98,16 @@ add_executable(packeteer_tests
tests/test_byteio.cpp
tests/test_net.cpp
tests/test_arp.cpp
+ tests/test_igmp.cpp
tests/test_ipv6.cpp
tests/test_dns.cpp
tests/test_mdns.cpp
tests/test_ssh.cpp
tests/test_ntp.cpp
tests/test_dhcp.cpp
+ tests/test_ftp.cpp
+ tests/test_smtp.cpp
+ tests/test_tftp.cpp
tests/test_http.cpp
tests/test_tls.cpp
tests/test_pcapng.cpp
diff --git a/PLAN.md b/PLAN.md
index a9a8da6..3fa8c1d 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -461,3 +461,42 @@ None currently open.
correctly unwrapped. Both virtual interfaces and the dummy/8021q
kernel modules they pulled in were torn down afterward, restoring
the machine to its prior state.
+- FTP (l7/ftp.hpp), SMTP (l7/smtp.hpp), TFTP (l7/tftp.hpp), and IGMP
+ (net/igmp.hpp), pushing further toward broad real-world coverage.
+ FTP's control channel and SMTP share the same line-based
+ response-code-or-command shape as HTTP (SMTP's own RFC predates and
+ clearly borrowed from FTP's), but were kept as separate files with
+ their own command vocabularies rather than sharing a parser - the
+ overlap is real but shallower than DNS/mDNS's identical wire format,
+ not worth coupling two otherwise-independent protocols over. FTP
+ passwords (PASS) are shown as-is, not redacted: FTP sends them in the
+ clear regardless, so this reflects what's genuinely on the wire, the
+ same reasoning Wireshark itself uses. TFTP is a small binary
+ protocol instead (opcode + a shape that depends on it), decoded via
+ RFC 1350; OACK is recognized by opcode but its options aren't parsed.
+ IGMP sits directly on IP (protocol 2) like ICMP, so it's dispatched
+ by protocol number in summarize_transport_and_above() rather than
+ through the port-keyed L7Registry the other four use.
+ Live-verified: FTP and SMTP against minimal real TCP servers written
+ for this (no vsftpd/postfix installed on this machine) speaking
+ genuine line protocol over real loopback TCP segments - both
+ directions of a full USER/PASS/QUIT and EHLO/MAIL/RCPT/QUIT exchange
+ decoded correctly. TFTP against a real atftpd server and atftp
+ client - the client's actual RRQ packet decoded as "TFTP RRQ
+ testfile.txt (octet)" (the transfer itself didn't complete, an
+ atftpd sandbox/config issue unrelated to the dissector, but the
+ request itself is what needed verifying). IGMP against real
+ multicast traffic on wlp1s0: joining 239.255.255.250 from Python
+ produced genuine IGMPv3 Membership Reports, and a real
+ group-specific query later arrived from the actual router
+ (192.168.1.1) - "IGMP Membership Query group=239.255.255.250".
+ That same live traffic caught a real bug before it shipped further:
+ the first parse_igmp() read bytes[4:8] as a group address for every
+ message type, but IGMPv3 reports put Reserved+RecordCount there
+ instead - a real V3 report showed "group=0.0.0.1" (literally
+ "0 reserved, 1 group record" misread as an IP). Fixed by only
+ populating IgmpMessage::group (now optional) for the types where
+ those bytes genuinely are an address (query/v1/v2 report/leave);
+ re-verified against the same live traffic afterward, confirmed
+ clean, and a regression test locks in the exact byte pattern that
+ triggered it.
diff --git a/include/packeteer/l7/ftp.hpp b/include/packeteer/l7/ftp.hpp
new file mode 100644
index 0000000..c8f9edc
--- /dev/null
+++ b/include/packeteer/l7/ftp.hpp
@@ -0,0 +1,94 @@
+#pragma once
+
+#include <cctype>
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
+
+#include "packeteer/l7/dissector.hpp"
+
+// RFC 959 FTP control channel (not the separate data connection, which
+// has no fixed port and carries the actual file/listing bytes rather
+// than commands). Line-based like HTTP: a response is a 3-digit code
+// plus text; a command is a known keyword plus an optional argument.
+// Same single-segment, best-effort scope as HTTP/DNS. Passwords sent
+// via PASS are shown as-is, not redacted - FTP itself sends them in
+// the clear, so this reflects what's genuinely on the wire rather than
+// adding any exposure a real capture wouldn't already have.
+namespace packeteer::net {
+
+inline constexpr std::uint16_t kFtpPort = 21;
+
+struct FtpMessage {
+ bool is_response;
+ std::string command_or_code; // response: 3-digit code; command: the keyword
+ std::string argument;
+};
+
+inline std::optional<FtpMessage> parse_ftp(std::span<const unsigned char> payload) {
+ std::string_view text(reinterpret_cast<const char*>(payload.data()), payload.size());
+
+ std::size_t line_end = text.find("\r\n");
+ if (line_end == std::string_view::npos) {
+ line_end = text.find('\n');
+ if (line_end == std::string_view::npos) return std::nullopt;
+ }
+ std::string_view line = text.substr(0, line_end);
+ if (line.empty()) return std::nullopt;
+
+ if (line.size() >= 3 && std::isdigit(static_cast<unsigned char>(line[0])) &&
+ std::isdigit(static_cast<unsigned char>(line[1])) &&
+ std::isdigit(static_cast<unsigned char>(line[2]))) {
+ FtpMessage msg;
+ msg.is_response = true;
+ msg.command_or_code = std::string(line.substr(0, 3));
+ std::size_t arg_start = 3;
+ while (arg_start < line.size() && (line[arg_start] == ' ' || line[arg_start] == '-')) {
+ ++arg_start;
+ }
+ msg.argument = std::string(line.substr(arg_start));
+ return msg;
+ }
+
+ static constexpr std::string_view kCommands[] = {
+ "USER", "PASS", "ACCT", "CWD", "CDUP", "SMNT", "QUIT", "REIN", "PORT", "PASV",
+ "TYPE", "STRU", "MODE", "RETR", "STOR", "STOU", "APPE", "ALLO", "REST", "RNFR",
+ "RNTO", "ABOR", "DELE", "RMD", "MKD", "PWD", "LIST", "NLST", "SITE", "SYST",
+ "STAT", "HELP", "NOOP", "EPSV", "EPRT", "FEAT",
+ };
+ std::size_t sp = line.find(' ');
+ std::string_view word = (sp == std::string_view::npos) ? line : line.substr(0, sp);
+
+ bool known = false;
+ for (auto cmd : kCommands) {
+ if (word == cmd) {
+ known = true;
+ break;
+ }
+ }
+ if (!known) return std::nullopt;
+
+ FtpMessage msg;
+ msg.is_response = false;
+ msg.command_or_code = std::string(word);
+ msg.argument = (sp == std::string_view::npos) ? "" : std::string(line.substr(sp + 1));
+ return msg;
+}
+
+class FtpDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kFtpPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_ftp(payload);
+ if (!msg) return std::nullopt;
+
+ std::string out = "FTP " + msg->command_or_code;
+ if (!msg->argument.empty()) out += " " + msg->argument;
+ return out;
+ }
+};
+
+} // namespace packeteer::net
diff --git a/include/packeteer/l7/smtp.hpp b/include/packeteer/l7/smtp.hpp
new file mode 100644
index 0000000..fc0b49c
--- /dev/null
+++ b/include/packeteer/l7/smtp.hpp
@@ -0,0 +1,92 @@
+#pragma once
+
+#include <cctype>
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
+
+#include "packeteer/l7/dissector.hpp"
+
+// RFC 5321 SMTP. Same line-based response-code-or-command shape as
+// FTP's control channel (RFC 959 predates and clearly influenced SMTP
+// here), but a distinct command vocabulary - kept as its own file
+// rather than sharing code with ftp.hpp, matching how DNS and mDNS
+// stayed separate dissector *registrations* even where mDNS reuses
+// DNS's actual parser: the wire-format overlap here is real but
+// shallower than DNS/mDNS's identical format, not worth coupling two
+// otherwise-independent protocols over.
+namespace packeteer::net {
+
+inline constexpr std::uint16_t kSmtpPort = 25;
+
+struct SmtpMessage {
+ bool is_response;
+ std::string command_or_code;
+ std::string argument;
+};
+
+inline std::optional<SmtpMessage> parse_smtp(std::span<const unsigned char> payload) {
+ std::string_view text(reinterpret_cast<const char*>(payload.data()), payload.size());
+
+ std::size_t line_end = text.find("\r\n");
+ if (line_end == std::string_view::npos) {
+ line_end = text.find('\n');
+ if (line_end == std::string_view::npos) return std::nullopt;
+ }
+ std::string_view line = text.substr(0, line_end);
+ if (line.empty()) return std::nullopt;
+
+ if (line.size() >= 3 && std::isdigit(static_cast<unsigned char>(line[0])) &&
+ std::isdigit(static_cast<unsigned char>(line[1])) &&
+ std::isdigit(static_cast<unsigned char>(line[2]))) {
+ SmtpMessage msg;
+ msg.is_response = true;
+ msg.command_or_code = std::string(line.substr(0, 3));
+ std::size_t arg_start = 3;
+ while (arg_start < line.size() && (line[arg_start] == ' ' || line[arg_start] == '-')) {
+ ++arg_start;
+ }
+ msg.argument = std::string(line.substr(arg_start));
+ return msg;
+ }
+
+ static constexpr std::string_view kCommands[] = {
+ "HELO", "EHLO", "MAIL", "RCPT", "DATA", "RSET",
+ "VRFY", "EXPN", "HELP", "NOOP", "QUIT", "STARTTLS",
+ };
+ std::size_t sp = line.find(' ');
+ std::string_view word = (sp == std::string_view::npos) ? line : line.substr(0, sp);
+
+ bool known = false;
+ for (auto cmd : kCommands) {
+ if (word == cmd) {
+ known = true;
+ break;
+ }
+ }
+ if (!known) return std::nullopt;
+
+ SmtpMessage msg;
+ msg.is_response = false;
+ msg.command_or_code = std::string(word);
+ msg.argument = (sp == std::string_view::npos) ? "" : std::string(line.substr(sp + 1));
+ return msg;
+}
+
+class SmtpDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kSmtpPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_smtp(payload);
+ if (!msg) return std::nullopt;
+
+ std::string out = "SMTP " + msg->command_or_code;
+ if (!msg->argument.empty()) out += " " + msg->argument;
+ return out;
+ }
+};
+
+} // namespace packeteer::net
diff --git a/include/packeteer/l7/tftp.hpp b/include/packeteer/l7/tftp.hpp
new file mode 100644
index 0000000..b8620f7
--- /dev/null
+++ b/include/packeteer/l7/tftp.hpp
@@ -0,0 +1,116 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <utility>
+
+#include "packeteer/byteio.hpp"
+#include "packeteer/l7/dissector.hpp"
+
+// RFC 1350 TFTP. Unlike FTP/SMTP/HTTP, this is a small binary
+// protocol, not line-based: a 2-byte opcode followed by a shape that
+// depends on it (two null-terminated strings for RRQ/WRQ, a 2-byte
+// block number for DATA/ACK, a 2-byte error code + string for ERROR).
+// OACK (RFC 2347) is recognized by opcode but not decoded further --
+// its option/value pairs aren't common enough to be worth the parsing
+// for a one-line summary.
+namespace packeteer::net {
+
+inline constexpr std::uint16_t kTftpPort = 69;
+
+inline constexpr std::uint16_t kTftpRrq = 1;
+inline constexpr std::uint16_t kTftpWrq = 2;
+inline constexpr std::uint16_t kTftpData = 3;
+inline constexpr std::uint16_t kTftpAck = 4;
+inline constexpr std::uint16_t kTftpError = 5;
+inline constexpr std::uint16_t kTftpOack = 6;
+
+struct TftpMessage {
+ std::uint16_t opcode;
+ std::optional<std::string> filename; // RRQ/WRQ
+ std::optional<std::string> mode; // RRQ/WRQ
+ std::optional<std::uint16_t> block; // DATA/ACK
+ std::optional<std::uint16_t> error_code; // ERROR
+ std::optional<std::string> error_message; // ERROR
+};
+
+namespace detail {
+
+// Reads a null-terminated string starting at `start`; returns the
+// string and the offset just past its terminator, or nullopt if no
+// terminator is found before the end of the buffer.
+inline std::optional<std::pair<std::string, std::size_t>> read_cstr(
+ std::span<const unsigned char> bytes, std::size_t start) {
+ std::size_t i = start;
+ while (i < bytes.size() && bytes[i] != 0) ++i;
+ if (i >= bytes.size()) return std::nullopt;
+ return std::make_pair(std::string(reinterpret_cast<const char*>(bytes.data() + start), i - start),
+ i + 1);
+}
+
+} // namespace detail
+
+inline std::optional<TftpMessage> parse_tftp(std::span<const unsigned char> bytes) {
+ if (bytes.size() < 2) return std::nullopt;
+ std::uint16_t opcode = read_be16(bytes, 0);
+ if (opcode < kTftpRrq || opcode > kTftpOack) return std::nullopt;
+
+ TftpMessage msg{};
+ msg.opcode = opcode;
+
+ if (opcode == kTftpRrq || opcode == kTftpWrq) {
+ auto filename = detail::read_cstr(bytes, 2);
+ if (!filename) return msg; // opcode decoded; filename truncated
+ msg.filename = filename->first;
+ if (auto mode = detail::read_cstr(bytes, filename->second)) msg.mode = mode->first;
+ return msg;
+ }
+ if (opcode == kTftpData || opcode == kTftpAck) {
+ if (bytes.size() >= 4) msg.block = read_be16(bytes, 2);
+ return msg;
+ }
+ if (opcode == kTftpError) {
+ if (bytes.size() >= 4) {
+ msg.error_code = read_be16(bytes, 2);
+ if (auto message = detail::read_cstr(bytes, 4)) msg.error_message = message->first;
+ }
+ return msg;
+ }
+ return msg; // OACK: opcode reported, options not decoded
+}
+
+inline std::string tftp_opcode_name(std::uint16_t opcode) {
+ switch (opcode) {
+ case kTftpRrq: return "RRQ";
+ case kTftpWrq: return "WRQ";
+ case kTftpData: return "DATA";
+ case kTftpAck: return "ACK";
+ case kTftpError: return "ERROR";
+ case kTftpOack: return "OACK";
+ default: return "opcode=" + std::to_string(opcode);
+ }
+}
+
+class TftpDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kTftpPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_tftp(payload);
+ if (!msg) return std::nullopt;
+
+ std::string out = "TFTP " + tftp_opcode_name(msg->opcode);
+ if (msg->filename) out += " " + *msg->filename;
+ if (msg->mode) out += " (" + *msg->mode + ")";
+ if (msg->block) out += " block=" + std::to_string(*msg->block);
+ if (msg->error_code) {
+ out += " code=" + std::to_string(*msg->error_code);
+ if (msg->error_message) out += " " + *msg->error_message;
+ }
+ return out;
+ }
+};
+
+} // namespace packeteer::net
diff --git a/include/packeteer/net/igmp.hpp b/include/packeteer/net/igmp.hpp
new file mode 100644
index 0000000..6ab4878
--- /dev/null
+++ b/include/packeteer/net/igmp.hpp
@@ -0,0 +1,69 @@
+#pragma once
+
+#include <algorithm>
+#include <cstdint>
+#include <cstdio>
+#include <optional>
+#include <span>
+#include <string>
+
+#include "packeteer/net/ipv4.hpp"
+
+// RFC 2236 (IGMPv2) / RFC 3376 (IGMPv3). Like ICMP, IGMP sits directly
+// on IP (protocol 2) rather than over TCP/UDP, so it's dispatched by
+// IP protocol number in summarize_transport_and_above() rather than
+// through the port-keyed L7Registry - the same reasoning as ICMP.
+// IGMPv1/v2's fixed 8-byte header (type + max-resp-time + checksum +
+// group address) is decoded fully. IGMPv3 membership reports use a
+// different, variable-length group-record format; only the message
+// type is reported for those; decoding each record isn't worth it for
+// a one-line summary.
+namespace packeteer::net {
+
+inline constexpr std::uint8_t kIgmpMembershipQuery = 0x11;
+inline constexpr std::uint8_t kIgmpV1MembershipReport = 0x12;
+inline constexpr std::uint8_t kIgmpV2MembershipReport = 0x16;
+inline constexpr std::uint8_t kIgmpLeaveGroup = 0x17;
+inline constexpr std::uint8_t kIgmpV3MembershipReport = 0x22;
+
+struct IgmpMessage {
+ std::uint8_t type;
+ // Only set for the types where bytes[4:8] genuinely is a group
+ // address (query/v1/v2 report/leave) - IGMPv3 reports put a
+ // different field there entirely (reserved + record count), so
+ // reading it as an address there would print a garbage IP rather
+ // than the actual group(s), which live inside the group records
+ // this dissector doesn't decode.
+ std::optional<Ipv4Address> group;
+};
+
+inline std::optional<IgmpMessage> parse_igmp(std::span<const unsigned char> bytes) {
+ if (bytes.size() < 8) return std::nullopt;
+
+ IgmpMessage msg{};
+ msg.type = bytes[0];
+ if (msg.type == kIgmpMembershipQuery || msg.type == kIgmpV1MembershipReport ||
+ msg.type == kIgmpV2MembershipReport || msg.type == kIgmpLeaveGroup) {
+ Ipv4Address group{};
+ std::copy_n(bytes.begin() + 4, 4, group.bytes.begin());
+ msg.group = group;
+ }
+ return msg;
+}
+
+inline std::string igmp_type_name(std::uint8_t type) {
+ switch (type) {
+ case kIgmpMembershipQuery: return "Membership Query";
+ case kIgmpV1MembershipReport: return "V1 Membership Report";
+ case kIgmpV2MembershipReport: return "V2 Membership Report";
+ case kIgmpLeaveGroup: return "Leave Group";
+ case kIgmpV3MembershipReport: return "V3 Membership Report";
+ default: {
+ char buf[16];
+ std::snprintf(buf, sizeof(buf), "type=0x%02x", type);
+ return buf;
+ }
+ }
+}
+
+} // namespace packeteer::net
diff --git a/include/packeteer/net/ipv4.hpp b/include/packeteer/net/ipv4.hpp
index ee77c17..c6cb656 100644
--- a/include/packeteer/net/ipv4.hpp
+++ b/include/packeteer/net/ipv4.hpp
@@ -11,6 +11,7 @@
namespace packeteer::net {
inline constexpr std::uint8_t kProtoIcmp = 1;
+inline constexpr std::uint8_t kProtoIgmp = 2;
inline constexpr std::uint8_t kProtoTcp = 6;
inline constexpr std::uint8_t kProtoUdp = 17;
diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp
index 302b380..66b2e18 100644
--- a/include/packeteer/summarize.hpp
+++ b/include/packeteer/summarize.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include <array>
#include <cstdio>
#include <optional>
#include <span>
@@ -11,14 +12,18 @@
#include "packeteer/l7/dhcp.hpp"
#include "packeteer/l7/dissector.hpp"
#include "packeteer/l7/dns.hpp"
+#include "packeteer/l7/ftp.hpp"
#include "packeteer/l7/http.hpp"
#include "packeteer/l7/mdns.hpp"
#include "packeteer/l7/ntp.hpp"
+#include "packeteer/l7/smtp.hpp"
#include "packeteer/l7/ssh.hpp"
+#include "packeteer/l7/tftp.hpp"
#include "packeteer/l7/tls.hpp"
#include "packeteer/net/arp.hpp"
#include "packeteer/net/ethernet.hpp"
#include "packeteer/net/icmp.hpp"
+#include "packeteer/net/igmp.hpp"
#include "packeteer/net/ipv4.hpp"
#include "packeteer/net/ipv6.hpp"
#include "packeteer/net/tcp.hpp"
@@ -101,6 +106,9 @@ inline const net::L7Registry& l7_registry() {
static const net::SshDissector ssh_dissector;
static const net::NtpDissector ntp_dissector;
static const net::DhcpDissector dhcp_dissector;
+ static const net::FtpDissector ftp_dissector;
+ static const net::SmtpDissector smtp_dissector;
+ static const net::TftpDissector tftp_dissector;
static const net::L7Registry registry = [] {
net::L7Registry r;
r.add(&dns_dissector);
@@ -110,6 +118,9 @@ inline const net::L7Registry& l7_registry() {
r.add(&ssh_dissector);
r.add(&ntp_dissector);
r.add(&dhcp_dissector);
+ r.add(&ftp_dissector);
+ r.add(&smtp_dissector);
+ r.add(&tftp_dissector);
return r;
}();
return registry;
@@ -174,6 +185,14 @@ inline std::string summarize_transport_and_above(const IpInfo& info) {
" seq=" + std::to_string(*icmp->sequence);
}
}
+ } else if (info.proto == net::kProtoIgmp) {
+ if (auto igmp = net::parse_igmp(info.payload)) {
+ out += " | IGMP " + net::igmp_type_name(igmp->type);
+ static constexpr std::array<unsigned char, 4> kZero{0, 0, 0, 0};
+ if (igmp->group && igmp->group->bytes != kZero) {
+ out += " group=" + ipv4_to_string(*igmp->group);
+ }
+ }
} else if (info.proto == net::kNextHeaderIcmpv6) {
if (auto icmp = net::parse_icmpv6(info.payload)) {
out += " | ICMPv6 " + net::icmpv6_type_name(icmp->type);
diff --git a/tests/test_ftp.cpp b/tests/test_ftp.cpp
new file mode 100644
index 0000000..0015f42
--- /dev/null
+++ b/tests/test_ftp.cpp
@@ -0,0 +1,51 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "packeteer/l7/ftp.hpp"
+
+using namespace packeteer::net;
+
+namespace {
+
+std::vector<unsigned char> to_bytes(const std::string& s) {
+ return std::vector<unsigned char>(s.begin(), s.end());
+}
+
+} // namespace
+
+TEST_CASE("parse_ftp decodes a greeting response") {
+ auto msg = parse_ftp(to_bytes("220 (vsFTPd 3.0.5)\r\n"));
+ REQUIRE(msg.has_value());
+ CHECK(msg->is_response);
+ CHECK(msg->command_or_code == "220");
+ CHECK(msg->argument == "(vsFTPd 3.0.5)");
+}
+
+TEST_CASE("parse_ftp decodes a USER command") {
+ auto msg = parse_ftp(to_bytes("USER anonymous\r\n"));
+ REQUIRE(msg.has_value());
+ CHECK_FALSE(msg->is_response);
+ CHECK(msg->command_or_code == "USER");
+ CHECK(msg->argument == "anonymous");
+}
+
+TEST_CASE("parse_ftp decodes a command with no argument") {
+ auto msg = parse_ftp(to_bytes("PASV\r\n"));
+ REQUIRE(msg.has_value());
+ CHECK(msg->command_or_code == "PASV");
+ CHECK(msg->argument.empty());
+}
+
+TEST_CASE("parse_ftp rejects an unrecognized command word") {
+ CHECK_FALSE(parse_ftp(to_bytes("BOGUS foo\r\n")).has_value());
+}
+
+TEST_CASE("FtpDissector claims port 21 and formats command and argument together") {
+ FtpDissector dissector;
+ CHECK(dissector.port() == kFtpPort);
+
+ auto summary = dissector.summarize(to_bytes("RETR file.txt\r\n"));
+ REQUIRE(summary.has_value());
+ CHECK(*summary == "FTP RETR file.txt");
+}
diff --git a/tests/test_igmp.cpp b/tests/test_igmp.cpp
new file mode 100644
index 0000000..b87d88d
--- /dev/null
+++ b/tests/test_igmp.cpp
@@ -0,0 +1,51 @@
+#include <doctest/doctest.h>
+
+#include <array>
+#include <vector>
+
+#include "packeteer/net/igmp.hpp"
+
+using namespace packeteer::net;
+
+TEST_CASE("parse_igmp decodes a general membership query with an all-zero group") {
+ std::vector<unsigned char> bytes = {0x11, 0x64, 0x00, 0x00, 0, 0, 0, 0};
+ auto igmp = parse_igmp(bytes);
+ REQUIRE(igmp.has_value());
+ CHECK(igmp->type == kIgmpMembershipQuery);
+ REQUIRE(igmp->group.has_value());
+ CHECK(igmp->group->bytes == std::array<unsigned char, 4>{0, 0, 0, 0});
+}
+
+TEST_CASE("parse_igmp decodes a v2 membership report with a real group address") {
+ std::vector<unsigned char> bytes = {0x16, 0x00, 0x00, 0x00, 239, 255, 255, 250};
+ auto igmp = parse_igmp(bytes);
+ REQUIRE(igmp.has_value());
+ CHECK(igmp->type == kIgmpV2MembershipReport);
+ REQUIRE(igmp->group.has_value());
+ CHECK(igmp->group->bytes == std::array<unsigned char, 4>{239, 255, 255, 250});
+}
+
+TEST_CASE("parse_igmp rejects a payload shorter than the fixed header") {
+ std::vector<unsigned char> bytes(4, 0);
+ CHECK_FALSE(parse_igmp(bytes).has_value());
+}
+
+TEST_CASE("parse_igmp leaves group unset for a v3 report, whose bytes[4:8] aren't an address") {
+ // Real capture from this exact bug: bytes[4:8] here are Reserved
+ // (2 bytes, zero) + Number of Group Records (2 bytes, = 1) --
+ // reading that as an IPv4 address produced the nonsense
+ // "group=0.0.0.1" before this was fixed. IGMPv3's actual group
+ // address(es) live inside the group records that follow, which
+ // this dissector doesn't decode (see the header comment).
+ std::vector<unsigned char> bytes = {0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01};
+ auto igmp = parse_igmp(bytes);
+ REQUIRE(igmp.has_value());
+ CHECK(igmp->type == kIgmpV3MembershipReport);
+ CHECK_FALSE(igmp->group.has_value());
+}
+
+TEST_CASE("igmp_type_name names known types and falls back to hex for unknown ones") {
+ CHECK(igmp_type_name(kIgmpMembershipQuery) == "Membership Query");
+ CHECK(igmp_type_name(kIgmpLeaveGroup) == "Leave Group");
+ CHECK(igmp_type_name(0x99) == "type=0x99");
+}
diff --git a/tests/test_smtp.cpp b/tests/test_smtp.cpp
new file mode 100644
index 0000000..610ed4e
--- /dev/null
+++ b/tests/test_smtp.cpp
@@ -0,0 +1,51 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "packeteer/l7/smtp.hpp"
+
+using namespace packeteer::net;
+
+namespace {
+
+std::vector<unsigned char> to_bytes(const std::string& s) {
+ return std::vector<unsigned char>(s.begin(), s.end());
+}
+
+} // namespace
+
+TEST_CASE("parse_smtp decodes a greeting response") {
+ auto msg = parse_smtp(to_bytes("220 mail.example.com ESMTP\r\n"));
+ REQUIRE(msg.has_value());
+ CHECK(msg->is_response);
+ CHECK(msg->command_or_code == "220");
+ CHECK(msg->argument == "mail.example.com ESMTP");
+}
+
+TEST_CASE("parse_smtp decodes a MAIL FROM command") {
+ auto msg = parse_smtp(to_bytes("MAIL FROM:<[email protected]>\r\n"));
+ REQUIRE(msg.has_value());
+ CHECK_FALSE(msg->is_response);
+ CHECK(msg->command_or_code == "MAIL");
+ CHECK(msg->argument == "FROM:<[email protected]>");
+}
+
+TEST_CASE("parse_smtp decodes a multi-line response's first line, dash included") {
+ auto msg = parse_smtp(to_bytes("250-mail.example.com Hello\r\n250-PIPELINING\r\n"));
+ REQUIRE(msg.has_value());
+ CHECK(msg->command_or_code == "250");
+ CHECK(msg->argument == "mail.example.com Hello");
+}
+
+TEST_CASE("parse_smtp rejects an unrecognized command word") {
+ CHECK_FALSE(parse_smtp(to_bytes("BOGUS foo\r\n")).has_value());
+}
+
+TEST_CASE("SmtpDissector claims port 25") {
+ SmtpDissector dissector;
+ CHECK(dissector.port() == kSmtpPort);
+
+ auto summary = dissector.summarize(to_bytes("EHLO client.example.com\r\n"));
+ REQUIRE(summary.has_value());
+ CHECK(*summary == "SMTP EHLO client.example.com");
+}
diff --git a/tests/test_summarize.cpp b/tests/test_summarize.cpp
index d7b223a..9eef454 100644
--- a/tests/test_summarize.cpp
+++ b/tests/test_summarize.cpp
@@ -186,6 +186,28 @@ TEST_CASE("summarize_packet decodes an ARP request end to end") {
"ARP who-has 10.0.0.2 tell 10.0.0.1 (aa:bb:cc:dd:ee:ff)");
}
+TEST_CASE("summarize_packet decodes IGMP directly on IP (not through a TCP/UDP port)") {
+ std::vector<unsigned char> igmp = {0x16, 0x00, 0x00, 0x00, 239, 255, 255, 250}; // v2 report
+
+ std::vector<unsigned char> ip(20, 0);
+ ip[0] = 0x45;
+ ip[8] = 1;
+ ip[9] = packeteer::net::kProtoIgmp;
+ ip[12] = 10; ip[13] = 0; ip[14] = 0; ip[15] = 1;
+ ip[16] = 239; ip[17] = 255; ip[18] = 255; ip[19] = 250;
+
+ std::vector<unsigned char> eth = {
+ 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x08, 0x00,
+ };
+
+ std::vector<unsigned char> frame = eth;
+ frame.insert(frame.end(), ip.begin(), ip.end());
+ frame.insert(frame.end(), igmp.begin(), igmp.end());
+
+ auto line = packeteer::summarize_packet(frame, DLT_EN10MB);
+ CHECK(line.find("IGMP V2 Membership Report group=239.255.255.250") != std::string::npos);
+}
+
TEST_CASE("summarize_packet unwraps a VLAN tag to reach the real ARP payload underneath") {
std::vector<unsigned char> bytes = {
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
diff --git a/tests/test_tftp.cpp b/tests/test_tftp.cpp
new file mode 100644
index 0000000..2955537
--- /dev/null
+++ b/tests/test_tftp.cpp
@@ -0,0 +1,85 @@
+#include <doctest/doctest.h>
+
+#include <vector>
+
+#include "packeteer/l7/tftp.hpp"
+
+using namespace packeteer::net;
+
+namespace {
+
+std::vector<unsigned char> build_rrq(const std::string& filename, const std::string& mode) {
+ std::vector<unsigned char> bytes = {0x00, 0x01}; // opcode RRQ
+ bytes.insert(bytes.end(), filename.begin(), filename.end());
+ bytes.push_back(0);
+ bytes.insert(bytes.end(), mode.begin(), mode.end());
+ bytes.push_back(0);
+ return bytes;
+}
+
+} // namespace
+
+TEST_CASE("parse_tftp decodes an RRQ with filename and mode") {
+ auto msg = parse_tftp(build_rrq("boot.img", "octet"));
+ REQUIRE(msg.has_value());
+ CHECK(msg->opcode == kTftpRrq);
+ REQUIRE(msg->filename.has_value());
+ CHECK(*msg->filename == "boot.img");
+ REQUIRE(msg->mode.has_value());
+ CHECK(*msg->mode == "octet");
+}
+
+TEST_CASE("parse_tftp decodes a DATA block number") {
+ std::vector<unsigned char> bytes = {0x00, 0x03, 0x00, 0x07, 'h', 'i'};
+ auto msg = parse_tftp(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->opcode == kTftpData);
+ REQUIRE(msg->block.has_value());
+ CHECK(*msg->block == 7);
+}
+
+TEST_CASE("parse_tftp decodes an ACK block number") {
+ std::vector<unsigned char> bytes = {0x00, 0x04, 0x00, 0x07};
+ auto msg = parse_tftp(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->opcode == kTftpAck);
+ REQUIRE(msg->block.has_value());
+ CHECK(*msg->block == 7);
+}
+
+TEST_CASE("parse_tftp decodes an ERROR code and message") {
+ std::vector<unsigned char> bytes = {0x00, 0x05, 0x00, 0x01};
+ std::string message = "File not found";
+ bytes.insert(bytes.end(), message.begin(), message.end());
+ bytes.push_back(0);
+
+ auto msg = parse_tftp(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->opcode == kTftpError);
+ REQUIRE(msg->error_code.has_value());
+ CHECK(*msg->error_code == 1);
+ REQUIRE(msg->error_message.has_value());
+ CHECK(*msg->error_message == "File not found");
+}
+
+TEST_CASE("parse_tftp rejects an opcode outside the known range") {
+ std::vector<unsigned char> bytes = {0x00, 0x99};
+ CHECK_FALSE(parse_tftp(bytes).has_value());
+}
+
+TEST_CASE("parse_tftp handles an RRQ with a truncated filename gracefully") {
+ std::vector<unsigned char> bytes = {0x00, 0x01, 'a', 'b'}; // no null terminator
+ auto msg = parse_tftp(bytes);
+ REQUIRE(msg.has_value());
+ CHECK(msg->opcode == kTftpRrq);
+ CHECK_FALSE(msg->filename.has_value());
+}
+
+TEST_CASE("TftpDissector claims port 69 and formats an RRQ") {
+ TftpDissector dissector;
+ CHECK(dissector.port() == kTftpPort);
+
+ auto summary = dissector.summarize(build_rrq("boot.img", "octet"));
+ REQUIRE(summary.has_value());
+ CHECK(*summary == "TFTP RRQ boot.img (octet)");
+}