mirror of
https://github.com/eunomia-bpf/bpf-developer-tutorial.git
synced 2026-10-29 03:34:44 +08:00
docs: improve DNS allowlist tutorial prose
This commit is contained in:
@@ -1,36 +1,36 @@
|
||||
# eBPF Tutorial: Building a DNS-Derived IP Allowlist with cgroup BPF
|
||||
|
||||
Ever tried to restrict a service so it can only connect to `api.example.com`? You might reach for an IP allowlist, but DNS answers change constantly and TTLs expire. By the time your application calls `connect()`, the kernel only sees an IP address—it has no idea which domain produced it. What's missing is a short-lived link between the DNS lookup and the subsequent connection.
|
||||
Suppose you want a containerized service to communicate only with `api.example.com`. The obvious approach is an IP allowlist, but DNS answers change constantly and TTLs expire. By the time your application calls `connect()`, the kernel sees only a raw IP address with no trace of the domain that produced it. The missing piece is a short-lived correlation between the DNS lookup and the subsequent TCP connection.
|
||||
|
||||
This tutorial builds that link. We'll watch DNS traffic for a specific domain, learn IP addresses only from valid query-response pairs, and allow connections until the TTL expires. It's a minimal but complete policy tool that shows how cgroup BPF can coordinate across multiple kernel hooks.
|
||||
This tutorial builds that correlation. We watch DNS traffic for a specific domain, extract IP addresses only from valid query-response pairs, and permit connections until the TTL expires. The result is a minimal but complete policy tool that demonstrates how cgroup BPF programs can share state across multiple kernel hooks.
|
||||
|
||||
> Complete source code: <https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/55-dns-egress>
|
||||
|
||||
## Understanding the Problem: DNS and Connect Are Disconnected
|
||||
## The Problem: DNS and Connect Live in Different Worlds
|
||||
|
||||
When your application resolves `api.example.com`, the DNS response contains an IP address and a TTL. But this information lives only in userspace—the resolver library caches it, your application calls `connect()` with the IP, and the kernel has no idea where that IP came from. This creates a fundamental security gap.
|
||||
When your application resolves `api.example.com`, the DNS response contains an IP address and a TTL. But this information lives only in userspace: the resolver library caches it, your application calls `connect()` with the IP, and the kernel has no idea where that IP came from. This disconnect creates a fundamental security gap.
|
||||
|
||||
Consider a container that should only talk to your backend API. Traditional firewalls can block destination IPs, but they can't enforce "only connect to IPs that came from resolving api.example.com within the last 60 seconds." The kernel-level policy and the application-level DNS resolution operate in complete isolation.
|
||||
Consider a container that should only talk to your backend API. Traditional firewalls can block destination IPs, but they cannot enforce a policy like "only connect to IPs that came from resolving api.example.com within the last 60 seconds." Kernel-level networking and application-level DNS resolution operate in complete isolation from each other.
|
||||
|
||||
cgroup BPF bridges this gap. By attaching programs to packet hooks and connect hooks on the same cgroup, we can observe DNS traffic and later enforce connection policy based on what we learned. The key insight is that cgroup BPF programs share state through maps, creating a trust chain from DNS query through response to eventual connection.
|
||||
cgroup BPF bridges this gap. By attaching programs to both packet hooks and socket-address hooks on the same cgroup, we can observe DNS traffic and later enforce connection policy based on what we learned. The key insight: cgroup BPF programs share state through maps, enabling a trust chain from DNS query through response to eventual connection.
|
||||
|
||||
## How the Pieces Fit Together
|
||||
## Architecture Overview
|
||||
|
||||
eBPF lets us run verified programs at multiple kernel hooks and share state between them through maps. cgroup BPF makes these hooks follow a workload: packet programs can inspect traffic entering and leaving a cgroup, while socket-address programs can accept or reject connections before they're established.
|
||||
eBPF lets us run verified programs at multiple kernel hooks and share state between them through maps. cgroup BPF ties these hooks to a workload: packet programs inspect traffic entering and leaving a cgroup, while socket-address programs can accept or reject connections before they are established.
|
||||
|
||||
Our tool attaches three programs to the same cgroup:
|
||||
|
||||
1. **`cgroup_skb/egress`** watches outgoing DNS queries and records which ones we're expecting answers for
|
||||
2. **`cgroup_skb/ingress`** validates incoming DNS responses and learns IP addresses only from matching replies
|
||||
3. **`cgroup/connect4`** makes the final decision—allowing connections only to IPs learned from valid DNS responses, and only while their TTL is still valid
|
||||
1. **`cgroup_skb/egress`** watches outgoing DNS queries and records which responses we expect
|
||||
2. **`cgroup_skb/ingress`** validates incoming DNS responses and extracts IP addresses only from replies that match a pending query
|
||||
3. **`cgroup/connect4`** makes the final decision, allowing connections only to IPs learned from valid DNS responses and only while their TTL remains valid
|
||||
|
||||
Let's walk through a successful exchange. Your application sends an A query for `lab.test`. The egress hook saves a correlation key containing the resolver address, client address, client UDP port, and DNS transaction ID. This record lives for five seconds. When the reply arrives, the ingress hook reconstructs the same key, verifies the response, and extracts the IP address along with its TTL. A later `connect()` to that address succeeds—but only while the TTL is valid.
|
||||
Here is how a successful resolution flows through the system. Your application sends an A query for `lab.test`. The egress hook saves a correlation key containing four fields: resolver address, client address, client UDP port, and DNS transaction ID. This record expires after five seconds. When the reply arrives, the ingress hook reconstructs the same key, verifies the response, and extracts the IP address along with its TTL. A subsequent `connect()` to that address succeeds, but only while the TTL is valid.
|
||||
|
||||
The query record is the trust boundary here. An unsolicited response has no pending key to match. A response with the wrong transaction ID looks for a different key and finds nothing. Neither can pollute the allowlist. And even if an entry lingers in the LRU map, the connect hook re-checks expiration time, so addresses naturally stop working when their DNS time runs out.
|
||||
The pending-query record establishes the trust boundary. An unsolicited response finds no matching key in the map. A response with the wrong transaction ID looks for a different key and finds nothing. Neither can pollute the allowlist. Even if an entry lingers in the LRU map, the connect hook re-checks the expiration timestamp, so addresses naturally stop working when their DNS TTL runs out.
|
||||
|
||||
## The Data Structures
|
||||
## Data Structures
|
||||
|
||||
Before diving into code, let's understand the data structures that make this work. The shared header defines DNS protocol layouts and the events we report to userspace:
|
||||
Before looking at the code, let's examine the data structures that enable this design. The shared header defines DNS protocol layouts and the events we report to userspace:
|
||||
|
||||
```c
|
||||
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
|
||||
@@ -81,13 +81,13 @@ struct dns_egress_event {
|
||||
#endif /* __DNS_EGRESS_H */
|
||||
```
|
||||
|
||||
The protocol structs use the `packed` attribute because they describe bytes on the wire—no padding allowed. The `dns_header` maps directly to the 12-byte DNS header that starts every query and response. The `dns_question` struct follows the question name (which uses length-prefixed labels). The `dns_a_answer` expects the common compressed format where the name pointer is `0xc00c`, pointing back to the question section.
|
||||
The protocol structs use the `packed` attribute because they map directly to bytes on the wire, with no padding allowed. `dns_header` corresponds to the 12-byte DNS header that starts every query and response. `dns_question` follows the question name, which uses length-prefixed labels. `dns_a_answer` expects the common compressed format where the name pointer is `0xc00c`, pointing back to the question section.
|
||||
|
||||
Each event we send to userspace carries both the DNS TTL and the absolute expiration timestamp. User space prints the human-readable TTL, while the BPF side uses the monotonic timestamp for its decisions. This separation keeps the kernel logic clean—no time format conversions in BPF code.
|
||||
Each event sent to userspace carries both the DNS TTL in seconds and the absolute expiration timestamp in nanoseconds. Userspace prints the human-readable TTL for logging, while the BPF programs use the monotonic timestamp for decisions. This separation keeps kernel logic simple: no time format conversions in BPF code.
|
||||
|
||||
## The BPF Programs: Complete Implementation
|
||||
## BPF Programs: Complete Implementation
|
||||
|
||||
Here's the complete kernel-side implementation. It's longer than some of our examples, but each piece has a clear job. Let's look at it in full, then walk through the key sections:
|
||||
Here is the complete kernel-side implementation. It is longer than some of our examples, but each piece has a well-defined responsibility:
|
||||
|
||||
```c
|
||||
// SPDX-License-Identifier: GPL-2.0
|
||||
@@ -381,43 +381,45 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx)
|
||||
}
|
||||
```
|
||||
|
||||
### Understanding the Map Design
|
||||
### Map Design
|
||||
|
||||
The program uses three BPF maps, each serving a specific purpose in the trust chain. The `pending_queries` map is an LRU hash that holds correlation state for DNS queries. The key combines four fields—server IP, client IP, client port, and transaction ID—that uniquely identify a query/response pair. The value stores only the expiration timestamp because that's all we need to validate timing. LRU eviction ensures memory stays bounded even under DNS flood conditions.
|
||||
The program uses three BPF maps, each serving a distinct role in the trust chain.
|
||||
|
||||
The `allowed_ips` map is also an LRU hash, but keyed simply by IPv4 address. The value contains the expiration timestamp, the original TTL in seconds (for logging), padding for alignment, and a flag to track whether we've already reported expiration. This flag prevents duplicate "expired" events when multiple threads race on a stale entry.
|
||||
**`pending_queries`** is an LRU hash that holds correlation state for DNS queries. The key combines four fields—server IP, client IP, client port, and transaction ID—that together uniquely identify a query-response pair. The value stores only the expiration timestamp, which is all we need for timing validation. LRU eviction keeps memory bounded even under heavy DNS traffic.
|
||||
|
||||
The `events` ring buffer sends notifications to userspace. At 256KB, it can hold thousands of events without blocking the kernel path. Ring buffers are the modern replacement for perf buffers—they're more efficient and provide better ordering guarantees.
|
||||
**`allowed_ips`** is also an LRU hash, keyed simply by IPv4 address. The value contains the expiration timestamp, the original TTL in seconds (for logging), padding for alignment, and a flag indicating whether we have already reported expiration. This flag prevents duplicate "expired" events when multiple threads race on a stale entry.
|
||||
|
||||
**`events`** is a 256 KB ring buffer for sending notifications to userspace. At this size, it can hold thousands of events without blocking the kernel path. Ring buffers are the modern replacement for perf buffers: more efficient and with better ordering guarantees.
|
||||
|
||||
### The Egress Path: Recording Pending Queries
|
||||
|
||||
When a packet leaves the cgroup, `record_dns_query` runs. The function first validates the transport layer through `parse_query_transport`. This function loads the IP header, checks for IPv4 with UDP protocol, rejects fragmented packets (which would require reassembly we don't implement), and verifies the destination matches our configured resolver. If all checks pass, it calculates where DNS data begins and populates the correlation key.
|
||||
When a packet leaves the cgroup, `record_dns_query` executes. The function first validates the transport layer through `parse_query_transport`, which loads the IP header, confirms IPv4 with UDP protocol, rejects fragmented packets (which would require reassembly logic we don't implement), and verifies the destination matches our configured resolver. If all checks pass, it calculates where DNS data begins and populates the correlation key.
|
||||
|
||||
The `parse_dns_query` function then validates the DNS layer. It checks that the flags indicate a standard query (not a response), that exactly one question exists, and that the question name matches our configured domain. The question type and class must be A (address) and IN (internet). Only after all validation passes does it extract the transaction ID and insert into `pending_queries`.
|
||||
Next, `parse_dns_query` validates the DNS layer. It checks that the flags indicate a standard query (not a response), that exactly one question exists, and that the question name matches our configured domain. The question type and class must be A (address) and IN (internet). Only after all validation passes does it extract the transaction ID and insert the record into `pending_queries`.
|
||||
|
||||
Notice that `record_dns_query` always returns 1. This tells the kernel to continue processing the packet normally—we're observing, not blocking. DNS queries flow through unchanged.
|
||||
Note that `record_dns_query` always returns 1, telling the kernel to continue processing the packet normally. We are observing, not blocking. DNS queries flow through unchanged.
|
||||
|
||||
### The Ingress Path: Learning from Responses
|
||||
|
||||
The ingress program `learn_dns_answer` reverses the perspective. Now we're looking at packets arriving from the resolver, so `parse_response_transport` checks that the source (not destination) matches the resolver IP and port. The correlation key gets populated with the same fields, but from the response's viewpoint.
|
||||
The ingress program `learn_dns_answer` reverses the perspective. Now we examine packets arriving from the resolver, so `parse_response_transport` checks that the *source* (not destination) matches the resolver IP and port. The correlation key gets populated with the same fields, but from the response's viewpoint.
|
||||
|
||||
The critical security check happens in `pending_query_is_live`. This function looks up the correlation key in `pending_queries`. If no entry exists—meaning we never saw a matching query—the response is rejected. If an entry exists but has expired, we delete it and reject the response. Only responses that match a live pending query proceed.
|
||||
|
||||
After confirming we have a legitimate response, `parse_response_question` validates the DNS header. It verifies this is a successful response (flags indicate "response" and "no error"), contains exactly one question matching our domain, and has at least one answer. `parse_direct_a_answer` then extracts the first A record, requiring the common `0xc00c` compressed name format, correct type and class, and a sane TTL between 1 and 86400 seconds.
|
||||
After confirming we have a legitimate response, `parse_response_question` validates the DNS header. It checks that flags indicate a successful response with no errors, that exactly one question matches our domain, and that at least one answer exists. Then `parse_direct_a_answer` extracts the first A record, requiring the common `0xc00c` compressed name format, correct type and class, and a TTL between 1 and 86400 seconds.
|
||||
|
||||
When validation passes, the pending query gets deleted (it's been consumed), and the IP address gets added to `allowed_ips` with an expiration based on the DNS TTL. The `emit_event` call sends a `DNS_LEARNED` notification to userspace.
|
||||
When validation passes, the pending query is deleted (it has been consumed), and the IP address is added to `allowed_ips` with an expiration based on the DNS TTL. An `emit_event` call sends a `DNS_LEARNED` notification to userspace.
|
||||
|
||||
### The Connect Path: Enforcing Policy
|
||||
|
||||
The `enforce_dns_policy` function attaches to `cgroup/connect4`, which runs before every IPv4 TCP connect. The function first applies filtering: if we're targeting a specific process and this isn't it, allow the connection. If it's not TCP or not the protected port, allow the connection. These early returns minimize overhead for irrelevant traffic.
|
||||
`enforce_dns_policy` attaches to `cgroup/connect4`, running before every IPv4 TCP connection attempt. The function first applies filtering: if we are targeting a specific process and this is not it, allow the connection. If it is not TCP or not the protected port, allow it. These early returns minimize overhead for irrelevant traffic.
|
||||
|
||||
For connections that need policy enforcement, we look up the destination IP in `allowed_ips`. If found and not expired, we emit `DNS_ALLOWED` and return 1 (allow). If found but expired, we use an atomic compare-and-swap on `expired_reported` to emit exactly one `DNS_EXPIRED` event even under concurrent access. BPF atomic compare-and-exchange arrived in Linux 5.12, which sets this tool's minimum kernel version.
|
||||
For connections requiring policy enforcement, we look up the destination IP in `allowed_ips`. If found and not expired, we emit `DNS_ALLOWED` and return 1 (allow). If found but expired, we use an atomic compare-and-swap on `expired_reported` to emit exactly one `DNS_EXPIRED` event even under concurrent access. This atomic operation arrived in Linux 5.12, which sets the tool's minimum kernel version.
|
||||
|
||||
If the IP isn't in the map or is expired, we emit `DNS_DENIED` and return 0. The kernel translates return value 0 into `EPERM`, and the application's connect() fails immediately.
|
||||
If the IP is not in the map or is expired, we emit `DNS_DENIED` and return 0. The kernel translates a return value of 0 into `EPERM`, and the application's `connect()` fails immediately.
|
||||
|
||||
## The User-Space Program
|
||||
## User-Space Program
|
||||
|
||||
The user-space side configures the read-only BPF data, attaches all three programs to a cgroup, and processes ring-buffer events. It also includes a self-test demo mode that exercises the complete trust chain.
|
||||
The user-space program configures the read-only BPF data, attaches all three programs to a cgroup, and processes ring-buffer events. It also includes a self-test demo mode that exercises the complete trust chain.
|
||||
|
||||
```c
|
||||
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
|
||||
@@ -1061,29 +1063,29 @@ cleanup:
|
||||
}
|
||||
```
|
||||
|
||||
### Understanding the User-Space Control Flow
|
||||
### User-Space Control Flow
|
||||
|
||||
The user-space program follows a clear initialization sequence. First, `parse_options` handles command-line arguments, validating inputs and setting defaults. Demo mode automatically configures loopback addresses and non-standard ports to avoid conflicts with real DNS and web traffic.
|
||||
The user-space program follows a clear initialization sequence. First, `parse_options` handles command-line arguments, validates inputs, and sets defaults. Demo mode automatically configures loopback addresses and non-standard ports to avoid conflicts with real DNS and web traffic.
|
||||
|
||||
The `encode_qname` function converts a domain name like `lab.test` into DNS label format: `\x03lab\x04test\x00`. Each label starts with a length byte followed by the label content. The final zero byte terminates the name. This encoding happens once at startup and gets written into the BPF skeleton's `rodata` section, where the verifier treats it as a constant.
|
||||
The `encode_qname` function converts a human-readable domain name like `lab.test` into DNS wire format: `\x03lab\x04test\x00`. Each label starts with a length byte followed by the label content, terminated by a zero byte. This encoding happens once at startup and is written into the BPF skeleton's `rodata` section, where the verifier treats it as a constant.
|
||||
|
||||
The `prepare_runtime` function ties everything together. It opens the target cgroup directory, opens the BPF skeleton, configures all the `rodata` values (resolver IP, ports, domain name), loads the BPF programs, and attaches each program to the cgroup. The three separate links allow independent attachment and detachment. Finally, it creates a ring buffer consumer that calls `handle_event` for each notification from the kernel.
|
||||
`prepare_runtime` ties everything together. It opens the target cgroup directory, opens the BPF skeleton, configures all `rodata` values (resolver IP, ports, domain name), loads the BPF programs, and attaches each program to the cgroup. The three separate links allow independent attachment and detachment. Finally, it creates a ring buffer consumer that calls `handle_event` for each kernel notification.
|
||||
|
||||
Normal mode enters `poll_policy_events`, which loops on the ring buffer until the duration expires or a signal arrives. Each event gets printed with the IP address, PID, TTL, and event type. Demo mode instead runs `run_demo`, which exercises the complete trust chain with synthetic DNS traffic and TCP connections.
|
||||
In normal mode, `poll_policy_events` loops on the ring buffer until the duration expires or a signal arrives. Each event is printed with its IP address, PID, TTL, and type. Demo mode runs `run_demo` instead, exercising the complete trust chain with synthetic DNS traffic and TCP connections.
|
||||
|
||||
### The Demo Mode: Proving the Security Properties
|
||||
### Demo Mode: Validating Security Properties
|
||||
|
||||
Demo mode serves as both a functional test and a demonstration of the security model. It runs entirely on loopback, using non-standard ports (15353 for DNS, 19090 for TCP) to avoid interfering with real services.
|
||||
Demo mode functions as both an integration test and a demonstration of the security model. It runs entirely on loopback using non-standard ports (15353 for DNS, 19090 for TCP) to avoid interfering with real services.
|
||||
|
||||
The test sequence starts by verifying that connections are blocked before any DNS traffic. Then it sends an unsolicited DNS response—a response that arrives without a preceding query. The BPF program should reject this because there's no matching entry in `pending_queries`. The demo verifies the connection is still blocked.
|
||||
The test sequence begins by verifying that connections are blocked before any DNS traffic occurs. It then sends an unsolicited DNS response—one that arrives without a preceding query. The BPF program rejects this because `pending_queries` contains no matching entry. The test confirms the connection remains blocked.
|
||||
|
||||
Next, it sends a legitimate DNS query and receives a response with the wrong transaction ID. The BPF program should also reject this because the transaction ID is part of the correlation key. Again, the demo verifies the connection remains blocked.
|
||||
Next, it sends a legitimate DNS query but responds with the wrong transaction ID. The BPF program rejects this too, because the transaction ID is part of the correlation key. Again, the test confirms the connection stays blocked.
|
||||
|
||||
Finally, it sends a response with the correct transaction ID and a 1-second TTL. Now the connection should succeed. After waiting 1.3 seconds (longer than the TTL), the demo verifies the connection is blocked again.
|
||||
Finally, it sends a response with the correct transaction ID and a 1-second TTL. Now the connection succeeds. After waiting 1.3 seconds (longer than the TTL), the test confirms the connection is blocked again.
|
||||
|
||||
This sequence proves that the tool correctly implements query-response correlation, rejects spoofing attempts, honors TTLs, and properly expires allowlist entries.
|
||||
|
||||
## Compilation and Execution
|
||||
## Building and Running
|
||||
|
||||
Build the example:
|
||||
|
||||
@@ -1092,7 +1094,7 @@ cd src/55-dns-egress
|
||||
make
|
||||
```
|
||||
|
||||
Attach it to a service cgroup and watch one domain through its resolver:
|
||||
Attach to a service cgroup and monitor a specific domain:
|
||||
|
||||
```bash
|
||||
sudo ./dns_egress \
|
||||
@@ -1102,7 +1104,7 @@ sudo ./dns_egress \
|
||||
--port 443
|
||||
```
|
||||
|
||||
The cgroup must contain the workload whose DNS packets and connections should share the policy state. TCP 443 and DNS 53 are defaults; `--dns-port` selects another resolver port, and `--duration` adds a time limit. The built-in demo needs no external DNS server:
|
||||
The specified cgroup must contain the workload whose DNS packets and connections should share policy state. TCP port 443 and DNS port 53 are the defaults; `--dns-port` selects an alternate resolver port, and `--duration` sets a time limit. The built-in demo requires no external DNS server:
|
||||
|
||||
```bash
|
||||
sudo ./dns_egress --demo
|
||||
@@ -1126,7 +1128,7 @@ event=denied pid=1246 ip=127.0.0.1 ttl=1
|
||||
demo step=expired-answer result=blocked
|
||||
```
|
||||
|
||||
The three early `denied` events show that merely receiving DNS-shaped traffic or seeing the right domain name isn't enough. `learned` appears only for the correlated response, `allowed` covers its live TTL, and `expired` is immediately followed by a denied connect.
|
||||
The first three `denied` events show that merely receiving DNS-shaped traffic or seeing the correct domain name is not enough. `learned` appears only for a properly correlated response, `allowed` covers its live TTL window, and once the TTL expires the next connect is immediately denied.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -1136,17 +1138,19 @@ The three early `denied` events show that merely receiving DNS-shaped traffic or
|
||||
| Kernel config | `CONFIG_BPF`, `CONFIG_BPF_SYSCALL`, `CONFIG_BPF_JIT`, `CONFIG_CGROUP_BPF`, `CONFIG_DEBUG_INFO_BTF`, `CONFIG_INET` |
|
||||
| cgroup | cgroup v2, with workload placed below the attached directory |
|
||||
| Privileges | Root, or equivalent BPF and network capabilities |
|
||||
| Architecture | x86-64 tested; no special network hardware required |
|
||||
| Architecture | Tested on x86-64; no special network hardware required |
|
||||
|
||||
## What This Example Doesn't Cover
|
||||
## Scope and Limitations
|
||||
|
||||
The tool deliberately implements one exact domain, one resolver, one protected TCP port, IPv4 UDP DNS, and the first direct A answer. It understands the common `0xc00c` compressed owner name. CNAME chains, alternate answer layouts, TCP DNS, IPv6, DoH, and DoT would need additional parsers or observation points. This compact scope keeps the important property visible: an IP enters the allowlist through a recent matching query and leaves through DNS time.
|
||||
This tool deliberately implements a narrow scope: one exact domain, one resolver, one protected TCP port, IPv4 UDP DNS, and the first direct A answer. It recognizes the common `0xc00c` compressed owner name format. Supporting CNAME chains, alternate answer layouts, TCP DNS, IPv6, DoH, or DoT would require additional parsers or observation points.
|
||||
|
||||
This compact scope keeps the central property visible: an IP enters the allowlist through a recent matching DNS query and exits when the DNS TTL expires.
|
||||
|
||||
## Summary
|
||||
|
||||
This example turns observed DNS results into a time-bounded connect policy. The egress and ingress hooks establish a trustworthy query-response relation, the TTL controls address lifetime, and the connect hook enforces the result for the protected port.
|
||||
This example transforms observed DNS results into a time-bounded connection policy. The egress and ingress hooks establish a trustworthy query-response correlation, the TTL controls address lifetime, and the connect hook enforces the policy for the protected port.
|
||||
|
||||
> If you'd like to dive deeper into eBPF, check out our tutorial repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or visit our website at <https://eunomia.dev/tutorials/>.
|
||||
> For more eBPF tutorials, visit our repository at <https://github.com/eunomia-bpf/bpf-developer-tutorial> or our website at <https://eunomia.dev/tutorials/>.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
# eBPF 实战教程:用 cgroup BPF 构建基于 DNS 的 IP 允许列表
|
||||
|
||||
假设你想让一个服务只能连接 `api.example.com`,其他地址一律拒绝。静态 IP 白名单听起来可行,但 DNS 应答会变化,TTL 也会过期。到了 `connect()` 阶段,内核只看到一个 IP 地址,根本不知道它来自哪个域名。这里缺少的是一条短期关联,把应用的 DNS 查询和随后的连接联系起来。
|
||||
假设你希望一个容器化服务只能访问 `api.example.com`,拒绝所有其他连接。最直接的方案是 IP 白名单,但 DNS 应答会变、TTL 会过期。到了 `connect()` 阶段,内核只看到一个裸 IP,完全不知道它源自哪个域名。缺少的是一条短期关联——把 DNS 查询和随后的 TCP 连接串起来。
|
||||
|
||||
本课就来构建这条关联。我们会观察特定域名的 DNS 流量,只从有效的查询响应对中学习 IP 地址,并在 TTL 有效期间允许连接。这是一个最小但完整的策略工具,展示了 cgroup BPF 如何在多个内核 hook 之间协调工作。
|
||||
本教程就来构建这条关联。我们监控特定域名的 DNS 流量,只从有效的查询与响应对中提取 IP 地址,并在 TTL 有效期内允许连接。最终是一个简洁但完整的策略工具,展示 cgroup BPF 程序如何跨多个内核 hook 共享状态。
|
||||
|
||||
> 完整源代码:<https://github.com/eunomia-bpf/bpf-developer-tutorial/tree/main/src/55-dns-egress>
|
||||
|
||||
## 理解问题:DNS 和 Connect 相互隔离
|
||||
## 问题的本质:DNS 和 connect 相互隔离
|
||||
|
||||
当你的应用解析 `api.example.com` 时,DNS 响应包含一个 IP 地址和一个 TTL。但这些信息只存在于用户空间——解析器库缓存它,应用用这个 IP 调用 `connect()`,而内核完全不知道这个 IP 是怎么来的。这就形成了一个根本性的安全缺口。
|
||||
当应用解析 `api.example.com` 时,DNS 响应包含 IP 地址和 TTL。但这些信息只存在于用户空间:解析器库把它缓存起来,应用拿着这个 IP 调用 `connect()`,而内核完全不知道这个 IP 从何而来。这种割裂造成了根本性的安全缺口。
|
||||
|
||||
考虑一个只应该访问你后端 API 的容器。传统防火墙可以阻止目标 IP,但无法执行"只允许连接最近 60 秒内从 api.example.com 解析出来的 IP"这样的策略。内核级策略和应用级 DNS 解析完全隔离运行。
|
||||
考虑一个只应该访问后端 API 的容器。传统防火墙可以按目标 IP 过滤,却无法执行“只允许连接最近 60 秒内从 api.example.com 解析出来的 IP”这类策略。内核网络栈和应用层 DNS 解析彼此孤立,互不感知。
|
||||
|
||||
cgroup BPF 弥合了这个缺口。通过在同一个 cgroup 上挂载 packet hook 和 connect hook,我们可以观察 DNS 流量,然后根据观察到的结果执行连接策略。关键洞察是:cgroup BPF 程序通过 map 共享状态,从 DNS 查询到响应再到最终连接形成一条信任链。
|
||||
cgroup BPF 弥合了这个缺口。在同一个 cgroup 上同时挂载 packet hook 和 socket-address hook,就能先观察 DNS 流量,再据此执行连接策略。关键洞察是:cgroup BPF 程序通过 map 共享状态,从而把 DNS 查询、响应和最终连接串成一条信任链。
|
||||
|
||||
## 整体架构
|
||||
|
||||
eBPF 可以在多个内核 hook 上运行经过验证的程序,并通过 map 在它们之间共享状态。cgroup BPF 让这些 hook 跟随工作负载:packet program 可以观察 cgroup 的出入流量,socket-address program 则能在连接建立之前接受或拒绝请求。
|
||||
eBPF 允许在多个内核 hook 上运行经过验证的程序,并通过 map 在它们之间共享状态。cgroup BPF 让这些 hook 跟随工作负载:packet 程序检查进出 cgroup 的流量,socket-address 程序则能在连接建立之前决定放行或拦截。
|
||||
|
||||
我们的工具在同一个 cgroup 上挂载三个程序:
|
||||
本工具在同一个 cgroup 上挂载三个程序:
|
||||
|
||||
1. **`cgroup_skb/egress`** 监控出站 DNS 查询,记录我们正在等待响应的请求
|
||||
2. **`cgroup_skb/ingress`** 验证入站 DNS 响应,只从匹配的回复中学习 IP 地址
|
||||
3. **`cgroup/connect4`** 做最终决策——只允许连接到从有效 DNS 响应中学到的 IP,且仅在 TTL 有效期内
|
||||
1. **`cgroup_skb/egress`**:监控出站 DNS 查询,记录哪些响应是我们期待的
|
||||
2. **`cgroup_skb/ingress`**:验证入站 DNS 响应,只从匹配待处理查询的回复中提取 IP 地址
|
||||
3. **`cgroup/connect4`**:做最终裁决——只允许连接到从有效 DNS 响应中学到的 IP,且仅在 TTL 有效期内
|
||||
|
||||
先跟着一次成功的解析走一遍。应用发送 `lab.test` 的 A 查询,egress hook 保存一个关联 key,包含解析器地址、客户端地址、客户端 UDP 端口和 DNS 事务 ID。这份记录有效期 5 秒。响应到达后,ingress hook 重建同一个 key,验证响应内容,并提取 IP 地址和 TTL。之后发往这个地址的 `connect()` 可以成功——但只在 TTL 有效期内。
|
||||
下面跟着一次成功的解析走完整个流程。应用发送 `lab.test` 的 A 查询,egress hook 保存一个关联 key,包含四个字段:解析器地址、客户端地址、客户端 UDP 端口和 DNS 事务 ID。这条记录 5 秒后过期。响应到达后,ingress hook 用相同字段重建 key,验证响应内容,并提取 IP 地址和 TTL。之后发往这个地址的 `connect()` 可以成功——但只在 TTL 有效期内。
|
||||
|
||||
待匹配查询就是这里的信任边界。未经请求的响应没有待匹配的 key。事务 ID 错误的响应会查找另一个 key 而找不到。两者都无法污染允许列表。即使条目还留在 LRU map 中,connect hook 也会重新检查过期时间,地址会随 DNS 时间自然失效。
|
||||
待处理查询记录就是信任边界。主动推送的伪造响应在 map 中找不到匹配的键;事务 ID 错误的响应会查找另一个键而落空。两者都无法污染允许列表。即使条目还留在 LRU map 中,connect hook 也会重新检查过期时间戳,所以地址会随 DNS TTL 自然失效。
|
||||
|
||||
## 数据结构详解
|
||||
## 数据结构
|
||||
|
||||
在深入代码之前,先理解让这一切工作的数据结构。共享头文件定义了 DNS 协议布局和我们报告给用户空间的事件:
|
||||
先看让这一切运转的数据结构。共享头文件定义了 DNS 协议布局和上报给用户空间的事件:
|
||||
|
||||
```c
|
||||
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
|
||||
@@ -81,13 +81,13 @@ struct dns_egress_event {
|
||||
#endif /* __DNS_EGRESS_H */
|
||||
```
|
||||
|
||||
协议结构使用 `packed` 属性,因为它们直接描述线上字节——不允许任何填充。`dns_header` 直接映射到每个查询和响应开头的 12 字节 DNS 头部。`dns_question` 结构跟在问题名(使用长度前缀标签)后面。`dns_a_answer` 期望常见的压缩格式,其中 name 指针是 `0xc00c`,指回问题部分。
|
||||
协议结构使用 `packed` 属性,因为它们直接映射网络报文字节,不允许填充。`dns_header` 对应每个查询和响应开头的 12 字节 DNS 头。`dns_question` 跟在问题名(使用长度前缀标签)后面。`dns_a_answer` 期望常见的压缩格式,其中 name 指针是 `0xc00c`,指回问题部分。
|
||||
|
||||
我们发送给用户空间的每个事件同时携带 DNS TTL 和绝对过期时间戳。用户空间打印人类可读的 TTL,而 BPF 侧使用单调时间戳做决策。这种分离保持了内核逻辑的简洁——BPF 代码中不需要时间格式转换。
|
||||
发给用户空间的每个事件同时携带以秒为单位的 DNS TTL 和以纳秒为单位的绝对过期时间戳。用户空间打印人类可读的 TTL 用于日志,而 BPF 程序用单调时间戳做决策。这种分离让内核逻辑保持简洁——BPF 代码中无需时间格式转换。
|
||||
|
||||
## BPF 程序:完整实现
|
||||
|
||||
下面是完整的内核态实现。虽然比某些例子长,但每个部分都有明确的职责。让我们先看完整代码,然后逐步分析关键部分:
|
||||
下面是完整的内核态实现。虽然比某些例子长,但每个部分都有明确的职责:
|
||||
|
||||
```c
|
||||
// SPDX-License-Identifier: GPL-2.0
|
||||
@@ -381,43 +381,45 @@ int enforce_dns_policy(struct bpf_sock_addr *ctx)
|
||||
}
|
||||
```
|
||||
|
||||
### 理解 Map 设计
|
||||
### Map 设计
|
||||
|
||||
程序使用三个 BPF map,每个在信任链中都有特定用途。`pending_queries` map 是一个 LRU hash,保存 DNS 查询的关联状态。key 组合了四个字段——服务器 IP、客户端 IP、客户端端口和事务 ID——它们唯一标识一个查询/响应对。value 只存储过期时间戳,因为这就是我们验证时序所需的全部。LRU 淘汰确保即使在 DNS 洪水条件下内存也保持有界。
|
||||
程序使用三个 BPF map,各自在信任链中承担不同角色。
|
||||
|
||||
`allowed_ips` map 也是 LRU hash,但只用 IPv4 地址作为 key。value 包含过期时间戳、以秒为单位的原始 TTL(用于日志)、对齐填充,以及一个标记是否已报告过期的 flag。这个 flag 防止多个线程在竞争访问过期条目时产生重复的 "expired" 事件。
|
||||
**`pending_queries`** 是一个 LRU hash,保存 DNS 查询的关联状态。键由四个字段组成——服务器 IP、客户端 IP、客户端端口和事务 ID——它们共同唯一标识一个查询与响应对。值只存储过期时间戳,这就是时序验证所需的全部信息。LRU 淘汰机制确保即使 DNS 流量很大,内存占用也保持有界。
|
||||
|
||||
`events` ring buffer 向用户空间发送通知。256KB 的大小可以容纳数千个事件而不会阻塞内核路径。Ring buffer 是 perf buffer 的现代替代品——它们更高效,提供更好的顺序保证。
|
||||
**`allowed_ips`** 也是 LRU hash,但只用 IPv4 地址作为键。值包含过期时间戳、以秒为单位的原始 TTL(用于日志)、对齐填充,以及一个标记是否已上报过期的标志位。这个标志位可以防止多线程竞争访问过期条目时产生重复的 `expired` 事件。
|
||||
|
||||
**`events`** 是一个 256 KB 的 ring buffer,用于向用户空间发送通知。这个容量可以容纳数千个事件而不会阻塞内核路径。ring buffer 是 perf buffer 的现代替代方案:效率更高,顺序保证也更清晰。
|
||||
|
||||
### 出站路径:记录待处理查询
|
||||
|
||||
当报文离开 cgroup 时,`record_dns_query` 运行。函数首先通过 `parse_query_transport` 验证传输层。这个函数加载 IP 头,检查是否为使用 UDP 协议的 IPv4,拒绝分片报文(需要我们没有实现的重组),并验证目标是否匹配配置的解析器。如果所有检查通过,它计算 DNS 数据的起始位置并填充关联 key。
|
||||
当报文离开 cgroup 时,`record_dns_query` 开始执行。函数首先通过 `parse_query_transport` 验证传输层:加载 IP 头,确认这是 IPv4 UDP 报文,拒绝分片报文(需要我们没有实现的重组逻辑),并验证目标是否匹配配置的解析器。所有检查通过后,计算 DNS 数据的起始位置并填充关联键。
|
||||
|
||||
然后 `parse_dns_query` 函数验证 DNS 层。它检查 flags 是否表示标准查询(不是响应),是否恰好有一个问题,以及问题名是否匹配我们配置的域名。问题类型和类必须是 A(地址)和 IN(互联网)。只有所有验证都通过后,它才提取事务 ID 并插入 `pending_queries`。
|
||||
接着 `parse_dns_query` 验证 DNS 层。它检查 flags 是否表示标准查询(而非响应)、是否恰好有一个问题、以及问题名是否匹配配置的域名。问题类型和类必须是 A(地址)和 IN(互联网)。只有所有验证都通过后,才提取事务 ID 并将记录插入 `pending_queries`。
|
||||
|
||||
注意 `record_dns_query` 始终返回 1。这告诉内核继续正常处理报文——我们是在观察,不是阻断。DNS 查询原样流过。
|
||||
注意 `record_dns_query` 始终返回 1,告诉内核继续正常处理报文。我们是在观察,不是阻断。DNS 查询原样流过。
|
||||
|
||||
### 入站路径:从响应中学习
|
||||
|
||||
入站程序 `learn_dns_answer` 反转了视角。现在我们看的是从解析器到达的报文,所以 `parse_response_transport` 检查源(不是目标)是否匹配解析器 IP 和端口。关联 key 用相同的字段填充,但从响应的视角来看。
|
||||
入站程序 `learn_dns_answer` 反转了视角。现在我们检查的是从解析器到达的报文,所以 `parse_response_transport` 检查*源*(而非目标)是否匹配解析器 IP 和端口。关联键使用相同的字段填充,但改从响应的视角取值。
|
||||
|
||||
关键的安全检查发生在 `pending_query_is_live`。这个函数在 `pending_queries` 中查找关联 key。如果没有条目存在——意味着我们从未看到匹配的查询——响应被拒绝。如果条目存在但已过期,我们删除它并拒绝响应。只有匹配活跃待处理查询的响应才能继续。
|
||||
关键的安全检查发生在 `pending_query_is_live`。这个函数在 `pending_queries` 中查找关联键。如果没有条目——说明我们从未看到匹配的查询——响应被拒绝。如果条目存在但已过期,删除它并拒绝响应。只有匹配活跃待处理查询的响应才能继续。
|
||||
|
||||
确认我们有合法响应后,`parse_response_question` 验证 DNS 头。它验证这是成功响应(flags 表示"响应"且"无错误"),恰好包含一个匹配我们域名的问题,且至少有一个应答。`parse_direct_a_answer` 然后提取第一个 A 记录,要求常见的 `0xc00c` 压缩 name 格式、正确的 type 和 class,以及 1 到 86400 秒之间的合理 TTL。
|
||||
确认响应合法后,`parse_response_question` 验证 DNS 头。它检查 flags 是否表示成功响应且无错误、是否恰好包含一个匹配我们域名的问题、以及是否至少有一个应答。然后 `parse_direct_a_answer` 提取第一个 A 记录,要求常见的 `0xc00c` 压缩 name 格式、正确的 type 和 class,以及 1 到 86400 秒之间的 TTL。
|
||||
|
||||
验证通过后,待处理查询被删除(已被消费),IP 地址以基于 DNS TTL 的过期时间添加到 `allowed_ips`。`emit_event` 调用向用户空间发送 `DNS_LEARNED` 通知。
|
||||
|
||||
### 连接路径:执行策略
|
||||
|
||||
`enforce_dns_policy` 函数挂载到 `cgroup/connect4`,在每个 IPv4 TCP 连接之前运行。函数首先应用过滤:如果我们针对特定进程且这不是它,允许连接。如果不是 TCP 或不是受保护端口,允许连接。这些提前返回最小化了无关流量的开销。
|
||||
`enforce_dns_policy` 挂载到 `cgroup/connect4`,在每个 IPv4 TCP 连接之前执行。函数首先应用过滤:如果我们针对特定进程且这不是它,放行;如果不是 TCP 或不是受保护端口,放行。这些提前返回将无关流量的开销降到最低。
|
||||
|
||||
对于需要策略执行的连接,我们在 `allowed_ips` 中查找目标 IP。如果找到且未过期,我们发出 `DNS_ALLOWED` 并返回 1(允许)。如果找到但已过期,我们在 `expired_reported` 上使用原子 compare-and-swap,即使在并发访问下也只发出一个 `DNS_EXPIRED` 事件。BPF atomic compare-and-exchange 在 Linux 5.12 引入,这也确定了工具的最低内核版本。
|
||||
对于需要策略执行的连接,我们在 `allowed_ips` 中查找目标 IP。如果找到且未过期,发出 `DNS_ALLOWED` 并返回 1(允许)。如果找到但已过期,使用原子 compare-and-swap 操作 `expired_reported`,确保即使并发访问也只发出一个 `DNS_EXPIRED` 事件。这个原子操作在 Linux 5.12 引入,也确定了本工具的最低内核版本。
|
||||
|
||||
如果 IP 不在 map 中或已过期,我们发出 `DNS_DENIED` 并返回 0。内核将返回值 0 转换为 `EPERM`,应用的 connect() 立即失败。
|
||||
如果 IP 不在 map 中或已过期,发出 `DNS_DENIED` 并返回 0。内核将返回值 0 转换为 `EPERM`,应用的 `connect()` 立即失败。
|
||||
|
||||
## 用户态程序
|
||||
|
||||
用户态程序配置只读 BPF 数据,把三个程序附加到 cgroup,并处理 ring buffer 事件。它还包含一个自测的 demo 模式,验证完整的信任链。
|
||||
用户态程序配置 BPF 只读数据区,把三个程序挂载到 cgroup,并处理 ring buffer 事件。它还包含一个自测的 demo 模式,用于验证完整的信任链。
|
||||
|
||||
```c
|
||||
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
|
||||
@@ -1061,27 +1063,27 @@ cleanup:
|
||||
}
|
||||
```
|
||||
|
||||
### 理解用户态控制流程
|
||||
### 用户态控制流程
|
||||
|
||||
用户态程序遵循清晰的初始化序列。首先,`parse_options` 处理命令行参数,验证输入并设置默认值。Demo 模式自动配置 loopback 地址和非标准端口,以避免与真实 DNS 和 Web 流量冲突。
|
||||
用户态程序遵循清晰的初始化序列。首先 `parse_options` 处理命令行参数,验证输入并设置默认值。Demo 模式自动配置 loopback 地址和非标准端口,以避免与真实 DNS 和 Web 流量冲突。
|
||||
|
||||
`encode_qname` 函数将域名如 `lab.test` 转换为 DNS label 格式:`\x03lab\x04test\x00`。每个 label 以长度字节开头,后跟 label 内容。最后的零字节终止名称。这个编码在启动时只发生一次,并写入 BPF skeleton 的 `rodata` 部分,验证器将其视为常量。
|
||||
`encode_qname` 函数将人类可读的域名如 `lab.test` 转换为 DNS 报文格式:`\x03lab\x04test\x00`。每个标签以长度字节开头,后跟标签内容,以零字节结尾。这个编码在启动时只执行一次,写入 BPF skeleton 的 `rodata` 部分,验证器将其视为常量。
|
||||
|
||||
`prepare_runtime` 函数把一切串联起来。它打开目标 cgroup 目录,打开 BPF skeleton,配置所有 `rodata` 值(解析器 IP、端口、域名),加载 BPF 程序,并将每个程序附加到 cgroup。三个独立的 link 允许独立的附加和分离。最后,它创建一个 ring buffer 消费者,为每个内核通知调用 `handle_event`。
|
||||
`prepare_runtime` 把一切串联起来。它打开目标 cgroup 目录、打开 BPF skeleton、配置所有 `rodata` 值(解析器 IP、端口、域名)、加载 BPF 程序,并将每个程序挂载到 cgroup。三个独立的 link 允许独立地挂载和卸载。最后,它创建一个 ring buffer 消费者,为每个内核通知调用 `handle_event`。
|
||||
|
||||
普通模式进入 `poll_policy_events`,在 ring buffer 上循环直到持续时间结束或收到信号。每个事件都会打印 IP 地址、PID、TTL 和事件类型。Demo 模式则运行 `run_demo`,用合成的 DNS 流量和 TCP 连接验证完整的信任链。
|
||||
在普通模式下,`poll_policy_events` 在 ring buffer 上循环,直到持续时间结束或收到信号。每个事件都会打印其 IP 地址、PID、TTL 和类型。Demo 模式则运行 `run_demo`,用合成的 DNS 流量和 TCP 连接验证完整的信任链。
|
||||
|
||||
### Demo 模式:证明安全属性
|
||||
### Demo 模式:验证安全属性
|
||||
|
||||
Demo 模式既是功能测试,也是安全模型的演示。它完全在 loopback 上运行,使用非标准端口(DNS 15353,TCP 19090)以避免干扰真实服务。
|
||||
Demo 模式既是集成测试,也是安全模型的演示。它完全在 loopback 上运行,使用非标准端口(DNS 15353,TCP 19090)以避免干扰真实服务。
|
||||
|
||||
测试序列首先验证在任何 DNS 流量之前连接被阻止。然后发送一个未经请求的 DNS 响应——一个没有前置查询就到达的响应。BPF 程序应该拒绝它,因为 `pending_queries` 中没有匹配的条目。Demo 验证连接仍然被阻止。
|
||||
测试序列首先验证在任何 DNS 流量之前连接被阻止。然后发送一个未经请求的 DNS 响应——一个没有前置查询就到达的响应。BPF 程序拒绝它,因为 `pending_queries` 中没有匹配的条目。测试确认连接仍然被阻止。
|
||||
|
||||
接下来,它发送一个合法的 DNS 查询并收到一个事务 ID 错误的响应。BPF 程序也应该拒绝它,因为事务 ID 是关联 key 的一部分。同样,demo 验证连接保持阻止状态。
|
||||
接下来,发送一个合法的 DNS 查询,但响应使用错误的事务 ID。BPF 程序同样拒绝它,因为事务 ID 是关联键的一部分。测试再次确认连接仍处于阻断状态。
|
||||
|
||||
最后,它发送一个事务 ID 正确、TTL 为 1 秒的响应。现在连接应该成功。等待 1.3 秒(超过 TTL)后,demo 验证连接再次被阻止。
|
||||
最后,发送一个事务 ID 正确、TTL 为 1 秒的响应。现在连接成功了。等待 1.3 秒(超过 TTL)后,测试确认连接再次被阻止。
|
||||
|
||||
这个序列证明了工具正确实现了查询响应关联、拒绝欺骗尝试、遵守 TTL,并正确使允许列表条目过期。
|
||||
这个序列证明了工具正确实现了查询-响应关联、拒绝欺骗尝试、遵守 TTL,并正确使允许列表条目过期。
|
||||
|
||||
## 编译和运行
|
||||
|
||||
@@ -1092,7 +1094,7 @@ cd src/55-dns-egress
|
||||
make
|
||||
```
|
||||
|
||||
将它挂到服务 cgroup,通过解析器观察一个域名:
|
||||
挂载到服务 cgroup 并监控特定域名:
|
||||
|
||||
```bash
|
||||
sudo ./dns_egress \
|
||||
@@ -1102,7 +1104,7 @@ sudo ./dns_egress \
|
||||
--port 443
|
||||
```
|
||||
|
||||
cgroup 需要包含目标工作负载,让它的 DNS 报文和连接共享同一份策略状态。TCP 443 和 DNS 53 是默认端口;`--dns-port` 选择其他解析器端口,`--duration` 设置时间上限。内置 demo 不需要外部 DNS 服务器:
|
||||
指定的 cgroup 必须包含目标工作负载,让它的 DNS 报文和连接共享策略状态。TCP 443 和 DNS 53 是默认端口;`--dns-port` 选择其他解析器端口,`--duration` 设置时间上限。内置 demo 不需要外部 DNS 服务器:
|
||||
|
||||
```bash
|
||||
sudo ./dns_egress --demo
|
||||
@@ -1126,7 +1128,7 @@ event=denied pid=1246 ip=127.0.0.1 ttl=1
|
||||
demo step=expired-answer result=blocked
|
||||
```
|
||||
|
||||
前三个 `denied` 说明仅仅收到 DNS 格式的流量或看到正确的域名还不够。只有关联正确的响应才会产生 `learned`,`allowed` 覆盖它的有效 TTL,`expired` 之后的连接立刻回到 denied。
|
||||
前三个 `denied` 事件说明,仅仅收到 DNS 格式的流量或看到正确的域名是不够的。只有正确关联的响应才产生 `learned`,`allowed` 覆盖其有效 TTL 窗口,TTL 一过期,下一次连接立即被拒绝。
|
||||
|
||||
## 环境要求
|
||||
|
||||
@@ -1135,18 +1137,20 @@ demo step=expired-answer result=blocked
|
||||
| 内核 | Linux 5.12+(BPF atomic compare-and-exchange) |
|
||||
| 内核配置 | `CONFIG_BPF`、`CONFIG_BPF_SYSCALL`、`CONFIG_BPF_JIT`、`CONFIG_CGROUP_BPF`、`CONFIG_DEBUG_INFO_BTF`、`CONFIG_INET` |
|
||||
| cgroup | cgroup v2,工作负载位于挂载目录之下 |
|
||||
| 权限 | root,或等价的 BPF 与网络 capability |
|
||||
| 权限 | root 权限,或等价的 BPF 与网络能力 |
|
||||
| 架构 | x86-64 已测试;不需要特殊网卡 |
|
||||
|
||||
## 本例未涵盖的内容
|
||||
## 范围与限制
|
||||
|
||||
工具刻意只实现一个精确域名、一个解析器、一个受保护的 TCP 端口、IPv4 UDP DNS 和第一个直接 A 应答。它识别常见的 `0xc00c` 压缩 owner name。CNAME 链、其他 answer 布局、TCP DNS、IPv6、DoH 和 DoT 需要额外的解析器或新的观察点。这个紧凑范围保留了重要属性的可见性:IP 通过最近一次匹配查询进入允许列表,再通过 DNS 时间退出。
|
||||
本工具刻意实现了一个紧凑的范围:一个精确域名、一个解析器、一个受保护的 TCP 端口、IPv4 UDP DNS 和第一个直接 A 应答。它识别常见的 `0xc00c` 压缩 owner name 格式。支持 CNAME 链、其他应答布局、TCP DNS、IPv6、DoH 或 DoT 需要额外的解析器或新的观察点。
|
||||
|
||||
这个紧凑范围保留了核心属性的可见性:IP 通过最近一次匹配的 DNS 查询进入允许列表,在 DNS TTL 过期时退出。
|
||||
|
||||
## 总结
|
||||
|
||||
这个例子把观察到的 DNS 结果变成有时间边界的 connect 策略。egress 和 ingress hook 建立可信的查询响应关联,TTL 控制地址生命周期,connect hook 对受保护端口执行结果。
|
||||
本示例把观察到的 DNS 结果转化为有时间边界的连接策略。egress 和 ingress hook 建立可信的查询与响应关联,TTL 控制地址生命周期,connect hook 对受保护端口执行策略。
|
||||
|
||||
> 如果你想深入了解 eBPF,请查看我们的教程代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或访问我们的网站 <https://eunomia.dev/tutorials/>。
|
||||
> 更多 eBPF 教程,请访问我们的代码仓库 <https://github.com/eunomia-bpf/bpf-developer-tutorial> 或网站 <https://eunomia.dev/tutorials/>。
|
||||
|
||||
## 参考资料
|
||||
|
||||
|
||||
Reference in New Issue
Block a user