深入理解 netfilter 和 iptables

Netfilter (配合 iptables)使得用戶空間應用程序可以註冊內核網絡棧在處理數據包時應用的處理規則,實現高效的網絡轉發和過濾。很多常見的主機防火牆程序以及 Kubernetes 的 Service 轉發都是通過 iptables 來實現的。

關於 netfilter 的介紹文章大部分只描述了抽象的概念,實際上其內核代碼的基本實現不算複雜,本文主要參考 Linux 內核 2.6 版本代碼(早期版本較爲簡單),與最新的 5.x 版本在實現上可能有較大差異,但基本設計變化不大,不影響理解其原理。

Netfilter 的設計與實現

netfilter 的定義是一個工作在 Linux 內核的網絡數據包處理框架,爲了徹底理解 netfilter 的工作方式,我們首先需要對數據包在 Linux 內核中的處理路徑建立基本認識。

數據包的內核之旅

數據包在內核中的處理路徑,也就是處理網絡數據包的內核代碼調用鏈,大體上也可按 TCP/IP 模型分爲多個層級,以接收一個 IPv4 的 tcp 數據包爲例:

在物理 - 網絡設備層,網卡通過 DMA 將接收到的數據包寫入內存中的 ring buffer,經過一系列中斷和調度後,操作系統內核調用 __skb_dequeue 將數據包加入對應設備的處理隊列中,並轉換成 sk_buffer 類型(即 socket buffer - 將在整個內核調用棧中持續作爲參數傳遞的基礎數據結構,下文指稱的數據包都可以認爲是 sk_buffer),最後調用 netif_receive_skb 函數按協議類型對數據包進行分類,並跳轉到對應的處理函數。如下圖所示:

network-path

假設該數據包爲 IP 協議包,對應的接收包處理函數 ip_rcv 將被調用,數據包處理進入網絡(IP)層。ip_rcv 檢查數據包的 IP 首部並丟棄出錯的包,必要時還會聚合被分片的 IP 包。然後執行 ip_rcv_finish 函數,對數據包進行路由查詢並決定是將數據包交付本機還是轉發其他主機。假設數據包的目的地址是本主機,接着執行的 dst_input 函數將調用 ip_local_deliver 函數。ip_local_deliver 函數中將根據 IP 首部中的協議號判斷載荷數據的協議類型,最後調用對應類型的包處理函數。本例中將調用 TCP 協議對應的 tcp_v4_rcv 函數,之後數據包處理進入傳輸層。

tcp_v4_rcv 函數同樣讀取數據包的 TCP 首部並計算校驗和,然後在數據包對應的 TCP control buffer 中維護一些必要狀態包括 TCP 序列號以及 SACK 號等。該函數下一步將調用 __tcp_v4_lookup 查詢數據包對應的 socket,如果沒找到或 socket 的連接狀態處於 TCP_TIME_WAIT,數據包將被丟棄。如果 socket 處於未加鎖狀態,數據包將通過調用 tcp_prequeue 函數進入 prequeue 隊列,之後數據包將可被用戶態的用戶程序所處理。傳輸層的處理流程超出本文討論範圍,實際上還要複雜很多。

netfilter hooks

接下來我們正式進入主題。netfilter 的首要組成部分是 netfilter hooks。

hook 觸發點

對於不同的協議(IPv4、IPv6 或 ARP 等),Linux 內核網絡棧會在該協議棧數據包處理路徑上的預設位置觸發對應的 hook。在不同協議處理流程中的觸發點位置以及對應的 hook 名稱(藍色矩形外部的黑體字)如下,本文僅重點關注 IPv4 協議:

netfilter-flow

所謂的 hook 實質上是代碼中的枚舉對象(值爲從 0 開始遞增的整型):

enum nf_inet_hooks { NF_INET_PRE_ROUTING, NF_INET_LOCAL_IN, NF_INET_FORWARD, NF_INET_LOCAL_OUT, NF_INET_POST_ROUTING, NF_INET_NUMHOOKS };

每個 hook 在內核網絡棧中對應特定的觸發點位置,以 IPv4 協議棧爲例,有以下 netfilter hooks 定義:

netfilter-hooks-stack

NF_INET_PRE_ROUTING: 這個 hook 在 IPv4 協議棧的 ip_rcv 函數或 IPv6 協議棧的 ipv6_rcv 函數中執行。所有接收數據包到達的第一個 hook 觸發點(實際上新版本 Linux 增加了 INGRESS hook 作爲最早觸發點),在進行路由判斷之前執行。

NF_INET_LOCAL_IN: 這個 hook 在 IPv4 協議棧的 ip_local_deliver() 函數或 IPv6 協議棧的 ip6_input() 函數中執行。經過路由判斷後,所有目標地址是本機的接收數據包到達此 hook 觸發點。

NF_INET_FORWARD: 這個 hook 在 IPv4 協議棧的 ip_forward() 函數或 IPv6 協議棧的 ip6_forward() 函數中執行。經過路由判斷後,所有目標地址不是本機的接收數據包到達此 hook 觸發點。

NF_INET_LOCAL_OUT: 這個 hook 在 IPv4 協議棧的 __ip_local_out() 函數或 IPv6 協議棧的 __ip6_local_out() 函數中執行。所有本機產生的準備發出的數據包,在進入網絡棧後首先到達此 hook 觸發點。

NF_INET_POST_ROUTING: 這個 hook 在 IPv4 協議棧的 ip_output() 函數或 IPv6 協議棧的 ip6_finish_output2() 函數中執行。本機產生的準備發出的數據包或者轉發的數據包,在經過路由判斷之後, 將到達此 hook 觸發點。

NF_HOOK 宏和 netfilter 向量

所有的觸發點位置統一調用 NF_HOOK 這個宏來觸發 hook:

static inline int NF_HOOK(uint8_t pf, unsigned int hook, struct sk_buff *skb, struct net_device *in, struct net_device *out, int (*okfn)(struct sk_buff *)) { return NF_HOOK_THRESH(pf, hook, skb, in, out, okfn, INT_MIN); }

NF-HOOK 接收的參數如下:

NF-HOOK 的返回值是以下具有特定含義的 netfilter 向量之一:

迴歸到源碼,IPv4 內核網絡棧會在以下代碼模塊中調用 NF_HOOK():

NF_HOOK

實際調用方式以 net/ipv4/ip_forward.c[1] 對數據包進行轉發的源碼爲例,在 ip_forward 函數結尾部分的第 115 行以 NF_INET_FORWARDhook 作爲入參調用了 NF_HOOK 宏,並將網絡棧接下來的處理函數 ip_forward_finish 作爲 okfn 參數傳入:

int ip_forward(struct sk_buff *skb) { .....(省略部分代碼) if (rt->rt_flags&RTCF_DOREDIRECT && !opt->srr && !skb_sec_path(skb))  ip_rt_send_redirect(skb);  skb->priority = rt_tos2priority(iph->tos);  return NF_HOOK(NFPROTO_IPV4, NF_INET_FORWARD, skb, skb->dev,         rt->dst.dev, ip_forward_finish); .....(省略部分代碼) }

回調函數與優先級

netfilter 的另一組成部分是 hook 的回調函數。內核網絡棧既使用 hook 來代表特定觸發位置,也使用 hook (的整數值)作爲數據索引來訪問觸發點對應的回調函數。

內核的其他模塊可以通過 netfilter 提供的 api 向指定的 hook 註冊回調函數,同一 hook 可以註冊多個回調函數,通過註冊時指定的 priority 參數可指定回調函數在執行時的優先級。

註冊 hook 的回調函數時,首先需要定義一個 nf_hook_ops 結構(或由多個該結構組成的數組),其定義如下:

struct nf_hook_ops { struct list_head list;  /* User fills in from here down. */ nf_hookfn *hook; struct module *owner; u_int8_t pf; unsigned int hooknum; /* Hooks are ordered in ascending priority. */  int priority; };

在定義中有 3 個重要成員:

定義結構體後可通過 int nf_register_hook(struct nf_hook_ops *reg) 或 int nf_register_hooks(struct nf_hook_ops *reg, unsigned int n); 分別註冊一個或多個回調函數。同一 netfilter hook 下所有的 nf_hook_ops 註冊後以 priority 爲順序組成一個鏈表結構,註冊過程會根據 priority 從鏈表中找到合適的位置,然後執行鏈表插入操作。

在執行 NF-HOOK 宏觸發指定的 hook 時,將調用 nf_iterate 函數迭代這個 hook 對應的 nf_hook_ops 鏈表,並依次調用每一個 nf_hook_ops 的註冊函數成員 hookfn。示意圖如下:

netfilter-hookfn1

這種鏈式調用回調函數的工作方式,也讓 netfilter hook 被稱爲 Chain,下文的 iptables 介紹中尤其體現了這一關聯。

每個回調函數也必須返回一個 netfilter 向量;如果該向量爲 NF_ACCEPT,nf_iterate 將會繼續調用下一個 nf_hook_ops 的回調函數,直到所有回調函數調用完畢後返回 NF_ACCEPT;如果該向量爲 NF_DROP,將中斷遍歷並直接返回 NF_DROP;** 如果該向量爲 **NF_REPEAT**,將重新執行該回調函數**。nf_iterate 的返回值也將作爲 NF-HOOK 的返回值,網絡棧將根據該向量值判斷是否繼續執行處理函數。示意圖如下:

netfilter-hookfn2

netfilter hook 的回調函數機制具有以下特性:

iptables

基於內核 netfilter 提供的 hook 回調函數機制,netfilter 作者 Rusty Russell 還開發了 iptables,實現在用戶空間管理應用於數據包的自定義規則。

iptbles 分爲兩部分:

內核空間模塊

xt_table 的初始化

在內核網絡棧中,iptables 通過 xt_table 結構對衆多的數據包處理規則進行有序管理,一個 xt_table 對應一個規則表,對應的用戶空間概念爲 table。不同的規則表有以下特徵:

基於規則的最終目的,iptables 默認初始化了 4 個不同的規則表,分別是 raw、 filter、nat 和 mangle。下文以 filter 爲例介紹 xt_table 的初始化和調用過程。

filter table 的定義如下:

#define FILTER_VALID_HOOKS ((1 << NF_INET_LOCAL_IN) | \               (1 << NF_INET_FORWARD) | \               (1 << NF_INET_LOCAL_OUT)) static const struct xt_table packet_filter = {   .name = "filter",   .valid_hooks = FILTER_VALID_HOOKS,   .me = THIS_MODULE,   .af = NFPROTO_IPV4,   .priority = NF_IP_PRI_FILTER,  }; (net/ipv4/netfilter/iptable_filter.c)

在 iptable_filter.c[2] 模塊的初始化函數 iptable_filter_init **** 中,調用 xt_hook_link 對 xt_table 結構 packet_filter 執行如下初始化過程:

通過 .valid_hooks 屬性迭代 xt_table 將生效的每一個 hook,對於 filter 來說是 NF_INET_LOCAL_IN,NF_INET_FORWARD 和 NF_INET_LOCAL_OUT 這 3 個 hook。

對每一個 hook,使用 xt_table 的 priority 屬性向 hook 註冊一個回調函數。

不同 table 的 priority 值如下:

enum nf_ip_hook_priorities { NF_IP_PRI_RAW = -300, NF_IP_PRI_MANGLE = -150, NF_IP_PRI_NAT_DST = -100, NF_IP_PRI_FILTER = 0, NF_IP_PRI_SECURITY = 50, NF_IP_PRI_NAT_SRC = 100, };

當數據包到達某一 hook 觸發點時,會依次執行不同 table 在該 hook 上註冊的所有回調函數,這些回調函數總是根據上文的 priority 值以固定的相對順序執行:

tables-priority

ipt_do_table()

filter 註冊的 hook 回調函數 iptable_filter_hook[3] 將對 xt_table 結構執行公共的規則檢查函數 ipt_do_table[4]。ipt_do_table 接收 skb、hook 和 xt_table 作爲參數,對 skb 執行後兩個參數所確定的規則集,返回 netfilter 向量作爲回調函數的返回值。

在深入規則執行過程前,需要先了解規則集如何在內存中表示。每一條規則由 3 部分組成:

ipt_entry 結構體定義如下:

struct ipt_entry { struct ipt_ip ip; unsigned int nfcache;  /* ipt_entry + matches 在內存中的大小*/ u_int16_t target_offset; /* ipt_entry + matches + target 在內存中的大小 */ u_int16_t next_offset;  /* 跳轉後指向前一規則 */ unsigned int comefrom; /* 數據包計數器 */ struct xt_counters counters; /* 長度爲0數組的特殊用法,作爲 match 的內存地址 */ unsigned char elems[0]; };

ipt_do_table 首先根據 hook 類型以及 xt_table.private.entries 屬性跳轉到對應的規則集內存區域,執行如下過程:

ipt_do_table

首先檢查數據包的 IP 首部與第一條規則 ipt_entry 的 .ipt_ip 屬性是否一致,如不匹配根據 next_offset 屬性跳轉到下一條規則。

若 IP 首部匹配 ,則開始依次檢查該規則所定義的所有 ipt_entry_match 對象,與對象關聯的匹配函數將被調用,根據調用返回值有返回到回調函數(以及是否丟棄數據包)、跳轉到下一規則或繼續檢查等結果。

所有檢查通過後讀取 ipt_entry_target,根據其屬性返回 netfilter 向量到回調函數、繼續下一規則或跳轉到指定內存地址的其他規則,非標準 ipt_entry_target 還會調用被綁定的函數,但只能返回向量值不能跳轉其他規則。

靈活性和更新時延

以上數據結構與執行方式爲 iptables 提供了強大的擴展能力,我們可以靈活地自定義每條規則的匹配條件並根據結果執行不同行爲,甚至還能在額外的規則集之間棧式跳轉。

由於每條規則長度不等、內部結構複雜,且同一規則集位於連續的內存空間,iptables 使用全量替換的方式來更新規則,這使得我們能夠從用戶空間以原子操作來添加 / 刪除規則,但非增量式的規則更新會在規則數量級較大時帶來嚴重的性能問題:假如在一個大規模 Kubernetes 集羣中使用 iptables 方式實現 Service,當 service 數量較多時,哪怕更新一個 service 也會整體修改 iptables 規則表。全量提交的過程會 kernel lock 進行保護,因此會有很大的更新時延。

用戶空間的 tables、chains 和 rules

用戶空間的 iptables 命令行可以讀取指定表的數據並渲染到終端,添加新的規則(實際上是替換整個 table 的規則表)等。

iptables 主要操作以下幾種對象:

基於上文介紹的代碼調用過程流程,chain 和 rule 按如下示意圖執行:

iptables-chains

對於 iptables 具體的用法和指令本文不做詳細介紹。

conntrack

僅僅通過 3、4 層的首部信息對數據包進行過濾是不夠的,有時候還需要進一步考慮連接的狀態。netfilter 通過另一內置模塊 conntrack 進行連接跟蹤(connection tracking),以提供根據連接過濾、地址轉換(NAT)等更進階的網絡過濾功能。由於需要對連接狀態進行判斷,conntrack 在整體機制相同的基礎上,又針對協議特點有單獨的實現。

傑哥的 IT 之旅 點擊領取「傑哥原創的 6 份 PDF 手冊」回覆「JGNB」即可獲取

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9hlFZ73liZBsne4SiH9PrA