diff options
Diffstat (limited to 'include')
| -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 |
6 files changed, 391 insertions, 0 deletions
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); |