Skip to content

Commit cbd22e6

Browse files
authored
feat(egress): add nameserver exempt for direct DNS forwarding (#356)
* feat(egress): add nameserver exempt for direct DNS forwarding * fix(egress): nameserver exempt to IP-only and ensure nft allow set * chore(egress): add unittest for ParseNameserverExemptList * fix(egress): cache nameserver exempt addrs and split exempt tests
1 parent 5d2f88d commit cbd22e6

8 files changed

Lines changed: 217 additions & 9 deletions

File tree

components/egress/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ The egress control is implemented as a **Sidecar** that shares the network names
4242
- Mode (`OPENSANDBOX_EGRESS_MODE`, default `dns`):
4343
- `dns`: DNS proxy only, no nftables (IP/CIDR rules have no effect at L2).
4444
- `dns+nft`: enable nftables; if nft apply fails, fallback to `dns`. IP/CIDR enforcement and DoH/DoT blocking require this mode.
45+
- **Nameserver exempt**
46+
Set `OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT` to a comma-separated list of **nameserver IPs** (e.g. `26.26.26.26` or `26.26.26.26,100.100.2.116`). Only single IPs are supported; CIDR entries are ignored. Traffic to these IPs on port 53 is not redirected to the proxy (iptables RETURN). In `dns+nft` mode, these IPs are also merged into the nft allow set so proxy upstream traffic to them (sent without SO_MARK) is accepted. Use when the upstream is reachable only via a specific route (e.g. tunnel) and SO_MARK would send proxy traffic elsewhere.
4547
- **DNS and nft mode (nameserver whitelist)**
4648
In `dns+nft` mode, the sidecar automatically allows:
4749
- **127.0.0.1** — so packets redirected by iptables to the proxy (127.0.0.1:15353) are accepted by nft.
@@ -178,6 +180,5 @@ More details in [docs/benchmark.md](docs/benchmark.md).
178180
179181
- **"iptables setup failed"**: Ensure the sidecar container has `--cap-add=NET_ADMIN`.
180182
- **DNS resolution fails for all domains**:
181-
- Check if the upstream DNS (from `/etc/resolv.conf`) is reachable.
182-
- In `dns+nft` mode, the sidecar whitelists nameserver IPs from resolv.conf at startup; check logs for `[dns] whitelisting proxy listen + N nameserver(s)` and ensure `/etc/resolv.conf` is readable and contains valid, reachable nameservers. The proxy prefers the first non-loopback nameserver from resolv.conf; if only loopback exists (e.g. Docker 127.0.0.11), it is used (proxy upstream traffic bypasses the redirect). Fallback to 8.8.8.8 only when resolv.conf is empty or unreadable.
183+
Check upstream reachability from the sidecar (`ip route`, `dig @<upstream> . NS +timeout=3`). In `dns+nft` mode, check logs for `[dns] whitelisting proxy listen + N nameserver(s)`.
183184
- **Traffic not blocked**: If nftables apply fails, the sidecar falls back to dns; check logs, `nft list table inet opensandbox`, and `CAP_NET_ADMIN`.

components/egress/main.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package main
1616

1717
import (
1818
"context"
19+
"net/netip"
1920
"os"
2021
"os/signal"
2122
"strings"
@@ -44,6 +45,12 @@ func main() {
4445
}
4546

4647
allowIPs := AllowIPsForNft("/etc/resolv.conf")
48+
// Merge nameserver exempt IPs into nft allow set so proxy traffic to them (no SO_MARK) is allowed in dns+nft mode.
49+
for _, addr := range dnsproxy.ParseNameserverExemptList() {
50+
if !containsAddr(allowIPs, addr) {
51+
allowIPs = append(allowIPs, addr)
52+
}
53+
}
4754

4855
mode := parseMode()
4956
log.Infof("enforcement mode: %s", mode)
@@ -57,7 +64,11 @@ func main() {
5764
}
5865
log.Infof("dns proxy started on 127.0.0.1:15353")
5966

60-
if err := iptables.SetupRedirect(15353); err != nil {
67+
exemptDst := dnsproxy.ParseNameserverExemptList()
68+
if len(exemptDst) > 0 {
69+
log.Infof("nameserver exempt list: %v (proxy upstream in this list will not set SO_MARK)", exemptDst)
70+
}
71+
if err := iptables.SetupRedirect(15353, exemptDst); err != nil {
6172
log.Fatalf("failed to install iptables redirect: %v", err)
6273
}
6374
log.Infof("iptables redirect configured (OUTPUT 53 -> 15353) with SO_MARK bypass for proxy upstream traffic")
@@ -98,6 +109,15 @@ func isTruthy(v string) bool {
98109
}
99110
}
100111

112+
func containsAddr(addrs []netip.Addr, a netip.Addr) bool {
113+
for _, x := range addrs {
114+
if x == a {
115+
return true
116+
}
117+
}
118+
return false
119+
}
120+
101121
func parseMode() string {
102122
mode := strings.ToLower(strings.TrimSpace(os.Getenv(constants.EnvEgressMode)))
103123
switch mode {

components/egress/pkg/constants/configuration.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ const (
2323
EnvEgressRules = "OPENSANDBOX_EGRESS_RULES"
2424
EnvEgressLogLevel = "OPENSANDBOX_EGRESS_LOG_LEVEL"
2525
EnvMaxNameservers = "OPENSANDBOX_EGRESS_MAX_NS"
26+
27+
// EnvNameserverExempt comma-separated IPs; proxy upstream to these is not marked and is allowed in nft allow set
28+
EnvNameserverExempt = "OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT"
2629
)
2730

2831
const (
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright 2026 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package dnsproxy
16+
17+
import (
18+
"net/netip"
19+
"os"
20+
"strings"
21+
"sync"
22+
23+
"github.com/alibaba/opensandbox/egress/pkg/constants"
24+
)
25+
26+
var (
27+
exemptListOnce sync.Once
28+
exemptAddrs []netip.Addr
29+
exemptSet map[netip.Addr]struct{}
30+
)
31+
32+
// ParseNameserverExemptList returns IPs from OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT (comma-separated).
33+
// Only single IPs are accepted; invalid or CIDR entries are skipped. Result is cached. Used for nft allow set, iptables, and UpstreamInExemptList.
34+
func ParseNameserverExemptList() []netip.Addr {
35+
exemptListOnce.Do(func() { parseNameserverExemptListUncached() })
36+
return exemptAddrs
37+
}
38+
39+
func parseNameserverExemptListUncached() {
40+
raw := strings.TrimSpace(os.Getenv(constants.EnvNameserverExempt))
41+
if raw == "" {
42+
exemptAddrs = nil
43+
exemptSet = nil
44+
return
45+
}
46+
set := make(map[netip.Addr]struct{})
47+
var out []netip.Addr
48+
for _, s := range strings.Split(raw, ",") {
49+
s = strings.TrimSpace(s)
50+
if s == "" {
51+
continue
52+
}
53+
if addr, err := netip.ParseAddr(s); err == nil {
54+
if _, exists := set[addr]; exists {
55+
continue
56+
}
57+
set[addr] = struct{}{}
58+
out = append(out, addr)
59+
}
60+
}
61+
exemptAddrs = out
62+
exemptSet = set
63+
}
64+
65+
// UpstreamInExemptList returns true when upstreamHost is in the nameserver exempt list (exact IP match).
66+
// When true, the proxy should not set SO_MARK so upstream traffic follows normal routing (e.g. via tun).
67+
func UpstreamInExemptList(upstreamHost string) bool {
68+
addr, err := netip.ParseAddr(upstreamHost)
69+
if err != nil {
70+
return false
71+
}
72+
ParseNameserverExemptList() // ensure cache is initialized
73+
_, ok := exemptSet[addr]
74+
return ok
75+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright 2026 Alibaba Group Holding Ltd.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package dnsproxy
16+
17+
import (
18+
"net/netip"
19+
"reflect"
20+
"sync"
21+
"testing"
22+
23+
"github.com/alibaba/opensandbox/egress/pkg/constants"
24+
)
25+
26+
func resetNameserverExemptCache(t *testing.T) {
27+
t.Helper()
28+
exemptAddrs = nil
29+
exemptSet = nil
30+
exemptListOnce = sync.Once{}
31+
}
32+
33+
func TestParseNameserverExemptList_IPOnly(t *testing.T) {
34+
t.Setenv(constants.EnvNameserverExempt, "1.1.1.1, 2001:db8::1 ,invalid, 10.0.0.0/8, ,")
35+
resetNameserverExemptCache(t)
36+
37+
got := ParseNameserverExemptList()
38+
want := []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("2001:db8::1")}
39+
if !reflect.DeepEqual(got, want) {
40+
t.Fatalf("ParseNameserverExemptList() = %v, want %v", got, want)
41+
}
42+
43+
// Cached result should stay the same on subsequent calls.
44+
if got2 := ParseNameserverExemptList(); !reflect.DeepEqual(got2, want) {
45+
t.Fatalf("cached ParseNameserverExemptList() = %v, want %v", got2, want)
46+
}
47+
}
48+
49+
func TestUpstreamInExemptList_IPOnly(t *testing.T) {
50+
t.Setenv(constants.EnvNameserverExempt, "1.1.1.1,2001:db8::1")
51+
resetNameserverExemptCache(t)
52+
53+
if !UpstreamInExemptList("1.1.1.1") {
54+
t.Fatalf("expected IPv4 upstream to be exempt")
55+
}
56+
if !UpstreamInExemptList("2001:db8::1") {
57+
t.Fatalf("expected IPv6 upstream to be exempt")
58+
}
59+
if UpstreamInExemptList("10.0.0.2") {
60+
t.Fatalf("unexpected exempt match for non-listed IP")
61+
}
62+
if UpstreamInExemptList("not-an-ip") {
63+
t.Fatalf("invalid IP string should not match")
64+
}
65+
}
66+
67+
func TestUpstreamInExemptList_CIDRIgnored(t *testing.T) {
68+
t.Setenv(constants.EnvNameserverExempt, "10.0.0.0/24")
69+
resetNameserverExemptCache(t)
70+
71+
if got := ParseNameserverExemptList(); len(got) != 0 {
72+
t.Fatalf("CIDR should be ignored in exempt list, got %v", got)
73+
}
74+
if UpstreamInExemptList("10.0.0.5") {
75+
t.Fatalf("CIDR should not make upstream exempt")
76+
}
77+
}

components/egress/pkg/dnsproxy/proxy_linux.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,30 @@ package dnsproxy
1818

1919
import (
2020
"net"
21+
"sync"
2122
"syscall"
2223
"time"
2324

2425
"golang.org/x/sys/unix"
2526

2627
"github.com/alibaba/opensandbox/egress/pkg/constants"
28+
"github.com/alibaba/opensandbox/egress/pkg/log"
2729
)
2830

31+
var exemptDialerLogOnce sync.Once
32+
2933
// dialerWithMark sets SO_MARK so iptables can RETURN marked packets (bypass
30-
// redirect for proxy's own upstream DNS queries).
34+
// redirect for proxy's own upstream DNS queries). When upstream is in the nameserver
35+
// exempt list, returns a plain dialer (no mark) so upstream traffic follows normal
36+
// routing (e.g. via tun); iptables still does not redirect by destination exempt.
3137
func (p *Proxy) dialerWithMark() *net.Dialer {
38+
if UpstreamInExemptList(p.UpstreamHost()) {
39+
exemptDialerLogOnce.Do(func() {
40+
log.Infof("[dns] upstream %s in nameserver exempt list, not setting SO_MARK", p.UpstreamHost())
41+
})
42+
return &net.Dialer{Timeout: 5 * time.Second}
43+
}
44+
3245
return &net.Dialer{
3346
Timeout: 5 * time.Second,
3447
Control: func(network, address string, c syscall.RawConn) error {

components/egress/pkg/iptables/redirect.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package iptables
1616

1717
import (
1818
"fmt"
19+
"net/netip"
1920
"os/exec"
2021
"strconv"
2122

@@ -24,14 +25,30 @@ import (
2425
)
2526

2627
// SetupRedirect installs OUTPUT nat redirect for DNS (udp/tcp 53 -> port).
27-
// Packets carrying mark bypassMark will RETURN (used by the proxy's own upstream
28-
// queries to avoid redirect loops). Requires CAP_NET_ADMIN inside the namespace.
29-
func SetupRedirect(port int) error {
28+
//
29+
// exemptDst: optional list of destination IPs; traffic to these is not redirected. Packets carrying mark are also RETURNed (proxy's own upstream). Requires CAP_NET_ADMIN.
30+
func SetupRedirect(port int, exemptDst []netip.Addr) error {
3031
log.Infof("installing iptables DNS redirect: OUTPUT port 53 -> %d (mark %s bypass)", port, constants.MarkHex)
3132
targetPort := strconv.Itoa(port)
3233

33-
rules := [][]string{
34-
// Bypass packets marked by the proxy itself (see dnsproxy dialer).
34+
var rules [][]string
35+
for _, d := range exemptDst {
36+
addr := d
37+
dStr := d.String()
38+
if addr.Is4() {
39+
rules = append(rules,
40+
[]string{"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-d", dStr, "-j", "RETURN"},
41+
[]string{"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-d", dStr, "-j", "RETURN"},
42+
)
43+
} else {
44+
rules = append(rules,
45+
[]string{"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-d", dStr, "-j", "RETURN"},
46+
[]string{"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-d", dStr, "-j", "RETURN"},
47+
)
48+
}
49+
}
50+
// Bypass packets marked by the proxy itself (see dnsproxy dialer).
51+
markAndRedirect := [][]string{
3552
{"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"},
3653
{"iptables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-m", "mark", "--mark", constants.MarkHex, "-j", "RETURN"},
3754
// Redirect all other DNS traffic to local proxy port.
@@ -43,6 +60,7 @@ func SetupRedirect(port int) error {
4360
{"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "udp", "--dport", "53", "-j", "REDIRECT", "--to-port", targetPort},
4461
{"ip6tables", "-t", "nat", "-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-j", "REDIRECT", "--to-port", targetPort},
4562
}
63+
rules = append(rules, markAndRedirect...)
4664

4765
for _, args := range rules {
4866
if output, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {

components/egress/pkg/nftables/manager.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ func NewManagerWithOptions(opts Options) *Manager {
7777
}
7878

7979
// ApplyStatic reconciles static allow/deny IP and CIDR entries into nftables.
80+
//
8081
// It creates a dedicated table/chain and overwrites previous state.
8182
// Uses the same mutex as AddResolvedIPs so a /policy update never overlaps a DNS
8283
// callback: without this, add-element could run while the table is being deleted/recreated

0 commit comments

Comments
 (0)