はじめに
第4回 では、制御プレーン(NETCONF)とデータプレーン(XDP)の役割の違いと、Rust/Go/Python の3層アーキテクチャの設計思想を解説しました。
今回はその「最速の筋肉」である Rust × eBPF/XDP の実装に踏み込みます。リポジトリの ztna-tetragon-maf/xdp-ebpf/main.rs をベースに、カーネル内でパケットを判定する仕組みを解説します。
※本記事の内容は個人の研究成果であり、所属組織の業務や公式見解とは一切関係ありません。
C言語から Rust/Aya への移行 ― なぜ書き直したか
最初の eBPF プログラムは C言語で書きました。
IPS(Intrusion Prevention System)機能の最初のバージョンは、AI支援(LLM によるコード生成)を活用しながら、AIエージェントオーケストレーションを含む形で着手から約3人日で動くところまで持っていきました。eBPF の C言語実装は情報が豊富で、libbpf や bpftool も整備されており、LLM との相性もよく、スピード優先で動かすには合理的な選択です。
ただし、開発を続ける中でいくつかの課題が出てきました。
C言語 eBPF の課題
① メモリ安全性:
カーネル内で動作するため、バッファオーバーフローや
ヌルポインタ参照は即カーネルパニックにつながる。
eBPF Verifier がある程度防いでくれるが、
C言語ではコンパイル時の保証がない。
② パケットヘッダ解析の煩雑さ:
Ethernet → IP → TCP/UDP のオフセット計算を
手動で管理する必要があり、ミスが起きやすい。
③ 型の共有:
Go コントロールプレーンと BPF Map の構造体を
C言語と Go で二重に定義・同期する必要がある。
Rust/Aya を選んだ理由
Aya は Rust でeBPF プログラムを書くためのフレームワークです。Rust の eBPF ライブラリには libbpf-rs という選択肢もありますが、libbpf-rs は C言語の libbpf をバインディングしているため C言語ランタイムへの依存が残ります。一方 Aya は純粋な Rust 実装で C言語ランタイムに依存しません。no_std 環境との親和性が高く、Rust の型システムをフル活用できる点が決め手でした。最終的に Aya で書き直した理由はさらに3つあります。
-
コンパイル時のメモリ安全性: Rust の借用チェッカーがカーネル内コードのメモリ安全性をコンパイル時に保証します。
unsafeブロックは必要な箇所に限定でき、リスクを局所化できます。 -
network-typesクレート: Ethernet/IP/TCP/UDP のヘッダ構造体が整備されており、オフセット計算をライブラリに任せられます。EthHdr::LENのような定数が使えるため、手動計算のミスがなくなりました。 -
#[no_std]環境への対応: カーネル内は標準ライブラリが使えないno_std環境ですが、Aya はこれを自然に扱えます。C言語と同様のパフォーマンスを維持しながら Rust の型システムの恩恵を受けられます。
IPS(初期版)から ZTNA(現行版)への移行で、コードの信頼性と保守性が大きく向上しました。
XDP プログラムの基本構造
まずプロジェクトの依存関係を確認します。
# ztna-tetragon-maf/xdp-ebpf/Cargo.toml
[dependencies]
aya-ebpf = { git = "https://github.com/aya-rs/aya", branch = "main" }
aya-log-ebpf = { git = "https://github.com/aya-rs/aya", branch = "main" }
network-types = "0.0.5" # Ethernet/IP/TCP/UDP ヘッダ構造体
#![no_std] と #![no_main] が必須です。カーネル内プログラムのため、標準ライブラリもエントリポイントも使いません。
#![no_std]
#![no_main]
use aya_ebpf::{
bindings::xdp_action,
helpers::{bpf_ktime_get_ns, bpf_redirect},
macros::{map, xdp},
maps::HashMap,
programs::XdpContext,
};
use network_types::{eth::EthHdr, ip::Ipv4Hdr, tcp::TcpHdr, udp::UdpHdr};
// エントリポイント: NICがパケットを受信するたびに呼ばれる
#[xdp]
pub fn xdp_filter(ctx: XdpContext) -> u32 {
match try_xdp_filter(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
#[xdp] マクロがこの関数を XDP プログラムとして登録します。戻り値は XDP_DROP・XDP_PASS・XDP_REDIRECT などの定数です。
BPF Map ― カーネルとユーザー空間をつなぐ共有メモリ
BPF Map はカーネル内の XDP プログラムと、ユーザー空間(Go コントロールプレーン)が共有するデータ構造です。Go から「この IP をブロックせよ」と書き込むと、Rust の XDP プログラムがそれを読んで DROP します。
// 5つの BPF Map を定義
#[map]
static mut STATS_MAP: HashMap<FlowKey, Stats> =
HashMap::with_max_entries(16384, 0); // フロー統計
#[map]
static mut DROP_LIST: HashMap<FlowKey, u32> =
HashMap::with_max_entries(16384, 0); // ブロックリスト(Go が書き込む)
#[map]
static mut QOS_MAP: HashMap<u32, QosConfig> =
HashMap::with_max_entries(16384, 0); // QoS 設定
#[map]
static mut AUTH_IPS: HashMap<u32, AuthInfo> =
HashMap::with_max_entries(4096, 0); // 認証済み IP(ZTNA)
#[map]
static mut CONFIG_MAP: HashMap<u32, u64> =
HashMap::with_max_entries(16, 0); // マジックナンバー等の設定値
#[map] マクロが Map をカーネルに登録します。Go 側の cilium/ebpf ライブラリが同じ Map に名前でアクセスします(coll.Maps["DROP_LIST"])。この仕組みが「Python/MAF の判断 → Go の REST API → BPF Map 更新 → Rust が即座に DROP」という連携を可能にしています。
構造体の共有
BPF Map のキーと値は、Go と Rust で同じメモリレイアウトを持つ必要があります。
// Rust 側(xdp-ebpf/main.rs)
#[repr(C)] // C言語互換のメモリレイアウトを保証
#[derive(Clone, Copy)]
pub struct FlowKey {
pub ip: u32,
pub port: u16,
pub protocol: u8,
pub pad: u8, // アライメント調整
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Stats {
pub packets: u64,
pub bytes: u64,
pub dropped_packets: u64,
pub syn_packets: u64,
pub rst_packets: u64,
pub ack_packets: u64,
pub last_ts: u64,
pub flow_start_ns: u64,
pub user_id: u32,
pub policy_status: u32,
pub l7_proto_label: u32,
pub pkt_min: u32,
pub pkt_max: u32,
pub _pad: u32,
}
// Go 側(go-control-plane/main.go)
type FlowKey struct {
Ip uint32
Port uint16
Protocol uint8
Pad uint8
}
type IpStats struct {
Packets uint64 `json:"packets"`
Bytes uint64 `json:"bytes"`
DroppedPackets uint64 `json:"dropped_packets"`
// ... 同じフィールド順
}
#[repr(C)] が両言語間のレイアウト互換を保証します。フィールドの順序・型・パディングが一致していないと、Map の読み書きで値が化けます。
特に AUTH_IPS と CONFIG_MAP は次回の Go コントロールプレーンが MAF からの指示を受けて書き換える「エンドポイント」になります。MAF Admin Agent が /drop/block や /auth/revoke を叩くと、Go がこれらの Map を更新し、Rust の XDP プログラムが次のパケット到着時に即座に反映します。
パケット処理の流れ
try_xdp_filter がパケット1つごとに呼ばれます。L2 → L3 → L4 と順にヘッダを剥いていき、BPF Map を参照してアクションを決める流れを図で示します。
Step 1: パケットの安全な読み出し
eBPF Verifier はカーネル内プログラムの安全性を検証します。パケットの境界チェックを省略するとVerifier に弾かれます。
// 境界チェック付きのポインタ取得ヘルパー
#[inline(always)]
unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();
// パケット末尾を超えないことを確認してからポインタを返す
if start + offset + len > end { return Err(()); }
Ok((start + offset) as *const T)
}
ctx.data() と ctx.data_end() でパケットの先頭と末尾を取得し、アクセスする範囲が収まっているか確認します。これを怠ると Verifier に拒否されます。
Step 2: Ethernet → IP → TCP/UDP のパース
fn try_xdp_filter(ctx: XdpContext) -> Result<u32, ()> {
let pkt_sz = (ctx.data_end() - ctx.data()) as u32;
// Ethernet ヘッダを読む
let eth = unsafe { ptr_at::<EthHdr>(&ctx, 0)? };
// IPv4 以外は素通し(ARP等)
if u16::from_be(unsafe { (*eth).ether_type as u16 }) != 0x0800 {
return Ok(xdp_action::XDP_PASS);
}
// IP ヘッダを読む
let iph = unsafe { ptr_at::<Ipv4Hdr>(&ctx, EthHdr::LEN)? };
let src_addr = u32::from_be(unsafe { (*iph).src_addr });
let protocol = unsafe { (*iph).proto as u8 };
let l4_offset = EthHdr::LEN + Ipv4Hdr::LEN;
let mut fk = FlowKey { ip: src_addr, port: 0, protocol, pad: 0 };
// TCP / UDP でポート番号を取得
match protocol {
6 => { // TCP
let tcp = unsafe { ptr_at::<TcpHdr>(&ctx, l4_offset)? };
fk.port = u16::from_be(unsafe { (*tcp).dest });
// SYN/RST/ACK フラグを記録(統計・自律防御ループに使用)
}
17 => { // UDP
let udp = unsafe { ptr_at::<UdpHdr>(&ctx, l4_offset)? };
fk.port = u16::from_be(unsafe { (*udp).dest });
}
_ => {}
}
network-types クレートの EthHdr::LEN・Ipv4Hdr::LEN が定数としてオフセット計算を担います。C言語での sizeof(struct ethhdr) の手動計算が不要になりました。
Step 3: DROP_LIST の判定
// Go から書き込まれた DROP_LIST を確認
unsafe {
if let Some(drop) = DROP_LIST.get(&fk) {
if *drop == 1 {
update_stats(&fk, pkt_sz, is_syn, is_rst, is_ack,
user_id, current_prio, true);
return Ok(xdp_action::XDP_DROP); // ラインレートで DROP
}
}
}
MAF Admin Agent が POST /drop/block?ip=10.0.1.30 を叩くと、Go が DROP_LIST に書き込みます。次のパケットが届いた瞬間、この判定で XDP_DROP が返ります。
ZTNA の核心:マジックナンバー認証
IPS版(初期版)との最大の違いが ZTNA(Zero Trust Network Access) です。IPS版はブロックリストに基づく「拒否リスト型」でしたが、ZTNA版は「デフォルト全拒否・認証済みのみ通過」という設計です。
// 未認証の場合、すべてのトラフィックを DROP(ICMP 含む)
if !is_authed {
update_stats(&fk, pkt_sz, is_syn, is_rst, is_ack,
user_id, current_prio, true);
return Ok(xdp_action::XDP_DROP);
}
認証は UDP ポート 8888 へのマジックナンバー送信で行います。
// UDP 8888: マジックナンバー認証
if fk.port == 8888 {
if let Ok(tag_ptr) = unsafe {
ptr_at::<u64>(&ctx, l4_offset + UdpHdr::LEN)
} {
let tag = u64::from_be(unsafe { *tag_ptr });
let magic_key = 0u32;
let expected = unsafe {
CONFIG_MAP.get(&magic_key).unwrap_or(&0)
};
if *expected != 0 && *expected != u64::MAX
&& tag == *expected
{
let now = unsafe { bpf_ktime_get_ns() };
let info = AuthInfo {
expiry: now + DEFAULT_AUTH_DURATION, // 300秒
priority: 2,
user_id: src_addr,
};
unsafe {
let _ = AUTH_IPS.insert(&src_addr, &info, 0);
// 認証後はマジックナンバーを番兵値で上書き(再利用防止)
let _ = CONFIG_MAP.insert(&magic_key, &u64::MAX, 0);
}
return Ok(xdp_action::XDP_PASS);
}
}
}
マジックナンバーを一度使うと u64::MAX(番兵値)で上書きされ、再利用できません。Go の /auth/lock API でチケット発行を恒久ロックすることもできます。
QoS:トークンバケットアルゴリズム
認証済みでもブロックリストにない IP に対して、帯域制御を行います。
#[inline(always)]
fn apply_qos(src_ip: u32, pkt_sz: u32, priority: u32) -> bool {
let now = unsafe { bpf_ktime_get_ns() };
unsafe {
if let Some(config) = QOS_MAP.get_ptr_mut(&src_ip) {
let elapsed = now.saturating_sub((*config).last_updated);
// 優先度2以上はトークン補充を10倍速に
let multiplier = if priority >= 2 { 10 } else { 1 };
let refill = (elapsed * (*config).limit_bytes_per_sec
* multiplier) / 1_000_000_000;
(*config).tokens = core::cmp::min(
MAX_TOKENS,
(*config).tokens + refill
);
(*config).last_updated = now;
if (*config).tokens >= pkt_sz as u64 {
(*config).tokens -= pkt_sz as u64;
return true; // 通過
}
return false; // トークン不足 → DROP
}
}
true // QoS 設定なし → 通過
}
トークンバケットアルゴリズムは帯域制御の古典的手法です。時間経過に比例してトークン(バイト数)が補充され、パケットを送るたびにトークンを消費します。トークンが足りなければ DROP します。認証済み優先ユーザー(priority >= 2)は補充速度が10倍になります。
IPS版(初期版)との比較
C言語から Rust/Aya で書き直し、さらに IPS から ZTNA に進化する過程で何が変わったか:
| 項目 | IPS版(初期・C言語) | IPS Rust版 | ZTNA版(現行・Rust) |
|---|---|---|---|
| 言語 | C | Rust/Aya | Rust/Aya |
| 開発期間 | 約3人日 | 約1人日 | 追加実装 |
| デフォルト | PASS(拒否リスト型) | PASS(拒否リスト型) | DROP(ゼロトラスト型) |
| 認証 | なし | なし | マジックナンバー + 有効期限 |
| BPF Map | STATS/DROP/QOS | STATS/DROP/QOS | + AUTH_IPS + CONFIG_MAP |
| リダイレクト | XDP_PASS のみ | XDP_PASS のみ | VPP へ XDP_REDIRECT |
| メモリ安全性 | Verifier のみ | コンパイル時保証 | コンパイル時保証 |
最も大きな設計変更は 「デフォルト PASS → デフォルト DROP」 です。IPS は悪いものを弾く「拒否リスト型」でしたが、ZTNA は認証されていないものはすべて通さない「許可リスト型」です。ゼロトラストの思想がそのままカーネルレベルで実装されています。
「許可されていないものはすべて DROP」 ― この一行がゼロトラストの本質です。シグネチャもルールも不要で、認証という事実だけがトラフィックの通過を許可します。
パニックハンドラ
#![no_std] 環境では、Rust のパニック機構が使えません。eBPF プログラムにはパニックハンドラの実装が必須です。
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
カーネル内でパニックが起きると無限ループになります。実際には eBPF Verifier がパニックに至るコードを事前に弾くため、このハンドラが実行されることはほぼありません。
まとめと次回予告
今回のポイントをまとめます。
- LLM オーケストレーションを含む IPS 初期版を AI 支援で約3人日で開発したが、メモリ安全性・保守性の観点から Rust/Aya で書き直した
-
#[map]マクロで BPF Map を定義し、Go コントロールプレーンと構造体を共有する -
ptr_atヘルパーで境界チェックを行い eBPF Verifier の要件を満たす -
network-typesクレートでヘッダ解析の手動計算を排除 - IPS(拒否リスト型)から ZTNA(ゼロトラスト・デフォルト DROP)への設計進化
次回(第6回)は Go × BPF Map の実装を解説します。Rust で定義した BPF Map を Go からどう操作するか、cilium/ebpf ライブラリの使い方、3秒ごとの自律防御ループ、そして REST API で MAF エージェントからの命令を受け付ける仕組みを扱います。
参考リンク
- リポジトリ: hidemi-k/maf-ebpf-sase
- 第1回: MAF rc5 入門
- 第2回: マルチレイヤ障害診断
- 第3回: NETCONF × RAG
- 第4回: eBPF × MAF で作る秒速の自律防御
- Aya(Rust eBPF フレームワーク): aya-rs.dev
- network-types クレート: crates.io/crates/network-types
- cilium/ebpf(Go ライブラリ): github.com/cilium/ebpf