AF_PACKET raw socketによるL2フレームキャプチャの実装
NICから実際にEthernetフレームを送受信するraw socketキャプチャを実装し、実機上のARPトラフィックを観測するプログラムを実装します。
raw socketをクラスとして設計する
NICとの間でパケットを送受信するには、OSのソケットfd(ファイルディスクリプタ)というリソースを保持し続ける必要があります。これは関数ではなく、リソースの所有権を型として表現するクラスにするのが妥当な場面です。
// l2-arp/include/l2arp/capture.hpp
enum class CaptureError {
InterfaceNotFound,
SocketCreateFailed,
BindFailed,
ReceiveFailed,
SendFailed,
};
class RawSocket {
public:
static std::expected<RawSocket, CaptureError> open(std::string_view interface_name);
RawSocket(const RawSocket&) = delete;
RawSocket& operator=(const RawSocket&) = delete;
RawSocket(RawSocket&&) noexcept;
RawSocket& operator=(RawSocket&&) noexcept;
~RawSocket();
std::expected<std::span<std::byte>, CaptureError> receive(std::span<std::byte> buffer);
std::expected<void, CaptureError> send(std::span<const std::byte> frame);
private:
RawSocket(int fd, int interface_index);
int fd_;
int interface_index_;
};コピーを= deleteで禁止し、ムーブだけを許可しているのは、fdの所有者を常に1つに保つためです。コピーを許すと2つのRawSocketが同じfdを指してしまい、片方が破棄されたときにclose()し、もう片方がそのfdを使おうとする、あるいは両方が同じfdをclose()してしまうといった問題が起きます。ムーブコンストラクタ・ムーブ代入演算子の実装では、移動元のfdを-1にリセットすることで、デストラクタが誤って二重にclose()しないようにしています。
生成をopen()という静的メンバ関数(ファクトリ)に切り出しているのは、socket()・if_nametoindex()・bind()のいずれもが失敗しうるためです。コンストラクタは失敗を表現できない(例外を投げるか、不完全なオブジェクトを作るしかない)ので、失敗しうる初期化処理はstd::expectedを返せる静的関数に外出しし、コンストラクタ自体は「必ず成功する」ことが分かっている単純な代入だけにしています。
// l2-arp/src/capture.cpp(抜粋)
std::expected<RawSocket, CaptureError> RawSocket::open(std::string_view interface_name) {
int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
if (fd == -1) {
return std::unexpected(CaptureError::SocketCreateFailed);
}
unsigned int index = if_nametoindex(std::string(interface_name).c_str());
if (index == 0) {
close(fd);
return std::unexpected(CaptureError::InterfaceNotFound);
}
sockaddr_ll addr{};
addr.sll_family = AF_PACKET;
addr.sll_protocol = htons(ETH_P_ALL);
addr.sll_ifindex = static_cast<int>(index);
if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == -1) {
close(fd);
return std::unexpected(CaptureError::BindFailed);
}
return RawSocket(fd, static_cast<int>(index));
}AF_PACKET + SOCK_RAWのソケットは、Ethernetヘッダを含むフレーム全体をそのまま扱えます。bind()で対象インターフェースに紐付けることで、そのNICを通過するフレームだけを受信できるようになります。このソケットの作成にはroot権限(またはCAP_NET_RAW)が必要です。
receive()は呼び出し側が確保したバッファに書き込み、実際に読めたバイト数分のstd::spanだけを返します。アロケーションが発生しないのは、これまでのspanベースの設計と一貫しています。
// l2-arp/src/capture.cpp
std::expected<std::span<std::byte>, CaptureError> RawSocket::receive(std::span<std::byte> buffer) {
ssize_t received = recv(fd_, buffer.data(), buffer.size(), 0);
if (received < 0) {
return std::unexpected(CaptureError::ReceiveFailed);
}
return buffer.subspan(0, static_cast<std::size_t>(received));
}Ethernet解析・ARP解析と繋いで実際に受信する
RawSocket::receive()で読んだバイト列は、そのままparse_ethernet_frame()に渡せます。EtherTypeがARPであれば、そのペイロードをさらに、ARPパケットの解析とバイト列の組み立てで実装したparse_arp_packet()に渡すことで、L2からARPまでの解析パイプラインがつながります。
// l2-arp/apps/arp_sniff.cpp(抜粋)
auto socket = l2arp::RawSocket::open(argv[1]);
std::array<std::byte, 2048> buffer{};
while (true) {
auto received = socket->receive(buffer);
if (!received) continue;
auto frame = l2arp::parse_ethernet_frame(*received);
if (!frame || frame->ether_type != l2arp::EtherType::Arp) continue;
auto arp = l2arp::parse_arp_packet(frame->payload);
if (!arp) continue;
std::cout << std::format(
"ARP {} sender={} ({}) target={}\n",
arp->operation == l2arp::ArpOperation::Request ? "request" : "reply",
l2arp::to_string(arp->sender_ip),
l2arp::to_string(arp->sender_mac),
l2arp::to_string(arp->target_ip)
);
}表示するのはsender_ip・sender_mac・target_ipのみです。target_macはRequestでは常に全ゼロ(まだ分からないから聞いている)、Replyでは元々質問した側自身のMACなので、いずれの場合も表示する情報としての価値がありません。ARPが運ぶ新しい情報は常に「senderのIP↔MAC対応」だけです。
実機での動作確認
実機(Ubuntu、GCC 15)でsudo権限付きでこのプログラムを実行し、同一LAN上の別ホストに向けてpingを打つと、ARP requestとreplyが実際に流れてくることを確認できました。合成したバイト列でのユニットテストとは別に、実ネットワーク上のフレームを実装が正しく解釈できることを確認できたことになります。
参考リンク
- Linux packet(7) man page ——
AF_PACKETソケットの仕様(sockaddr_llの各フィールドを含む)です - cppreference: Move constructors —— コピー禁止・ムーブ許可によるリソース所有権の設計を確認する際に参照します
- cppreference: std::expected —— 失敗しうる初期化を静的ファクトリ関数として表現する際の戻り値型です