diff options
| author | srdusr <[email protected]> | 2025-07-01 00:40:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-07-01 00:40:00 +0200 |
| commit | 407249eb5d654b5a951c43bc1722fd397d5d922c (patch) | |
| tree | c367bf95c3855152d477a7eed5e709b3094a427c | |
| parent | 9046e6a10fd2d987cf6f6dc601ed3a75d286793f (diff) | |
| download | packeteer-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.txt | 4 | ||||
| -rw-r--r-- | PLAN.md | 39 | ||||
| -rw-r--r-- | include/packeteer/l7/ftp.hpp | 94 | ||||
| -rw-r--r-- | include/packeteer/l7/smtp.hpp | 92 | ||||
| -rw-r--r-- | include/packeteer/l7/tftp.hpp | 116 | ||||
| -rw-r--r-- | include/packeteer/net/igmp.hpp | 69 | ||||
| -rw-r--r-- | include/packeteer/net/ipv4.hpp | 1 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 19 | ||||
| -rw-r--r-- | tests/test_ftp.cpp | 51 | ||||
| -rw-r--r-- | tests/test_igmp.cpp | 51 | ||||
| -rw-r--r-- | tests/test_smtp.cpp | 51 | ||||
| -rw-r--r-- | tests/test_summarize.cpp | 22 | ||||
| -rw-r--r-- | tests/test_tftp.cpp | 85 |
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 @@ -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)"); +} |