Skip to content

Commit f841bea

Browse files
authored
feat(egress): upstream health probes, active list, configurable probe name (opensandbox-group#655)
* feat(egress): upstream health probes, active list, configurable probe name * feat(egress): upstream health probes, active list, configurable probe name
1 parent 9bd27a6 commit f841bea

7 files changed

Lines changed: 340 additions & 25 deletions

File tree

.github/workflows/egress-test.yaml.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ jobs:
6161
chmod +x tests/smoke-dynamic-ip.sh
6262
./tests/smoke-dynamic-ip.sh
6363
64+
- name: Run dns upstream probe test
65+
working-directory: components/egress
66+
run: |
67+
chmod +x tests/smoke-dns-upstream-probe.sh
68+
./tests/smoke-dns-upstream-probe.sh
69+
6470
bench:
6571
runs-on: ubuntu-latest
6672
steps:

components/egress/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func main() {
7575
if err != nil {
7676
log.Fatalf("failed to init dns proxy: %v", err)
7777
}
78-
if err := proxy.Start(); err != nil {
78+
if err := proxy.Start(ctx); err != nil {
7979
log.Fatalf("failed to start dns proxy: %v", err)
8080
}
8181
log.Infof("dns proxy started on 127.0.0.1:15353")

components/egress/pkg/constants/configuration.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ const (
3333
EnvNameserverExempt = "OPENSANDBOX_EGRESS_NAMESERVER_EXEMPT"
3434

3535
// EnvDNSUpstream comma-separated upstream resolvers; each address must be a literal IPv4/IPv6 (optional :port). Hostnames are rejected (DNS recursion via REDIRECT).
36-
EnvDNSUpstream = "OPENSANDBOX_EGRESS_DNS_UPSTREAM"
37-
38-
// EnvDNSUpstreamTimeout is the per-upstream DNS forward timeout in whole seconds (default 5). Invalid/empty uses default.
39-
EnvDNSUpstreamTimeout = "OPENSANDBOX_EGRESS_DNS_UPSTREAM_TIMEOUT"
36+
EnvDNSUpstream = "OPENSANDBOX_EGRESS_DNS_UPSTREAM"
37+
EnvDNSUpstreamTimeout = "OPENSANDBOX_EGRESS_DNS_UPSTREAM_TIMEOUT"
38+
EnvDNSUpstreamProbe = "OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE"
39+
EnvDNSUpstreamProbeIntervalSec = "OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE_INTERVAL_SEC"
4040
)
4141

4242
const (
@@ -45,8 +45,7 @@ const (
4545
)
4646

4747
const (
48-
DefaultEgressServerAddr = ":18080"
49-
// ResolvNameserverCap is the max number of nameserver lines read from /etc/resolv.conf for nft allow-list merge and auto upstream chain (not configurable).
48+
DefaultEgressServerAddr = ":18080"
5049
ResolvNameserverCap = 10
5150
DefaultMaxEgressRules = 4096
5251
DefaultDNSUpstreamTimeoutSec = 5

components/egress/pkg/dnsproxy/proxy.go

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package dnsproxy
1616

1717
import (
18+
"context"
1819
"fmt"
1920
"net"
2021
"net/netip"
@@ -44,7 +45,12 @@ type Proxy struct {
4445
alwaysDeny []policy.EgressRule
4546
alwaysAllow []policy.EgressRule
4647
listenAddr string
47-
upstreams []string // ordered resolver chain; try next on forward failure
48+
upstreams []string // ordered resolver chain from discovery (immutable after New)
49+
upstreamMu sync.RWMutex
50+
activeUpstreams []string // healthy subset; same order as upstreams; used for forwarding
51+
upstreamProbeName string // wire name for probe (FQDN or "." for root)
52+
upstreamProbeQType uint16 // dns.TypeA or dns.TypeNS etc.
53+
upstreamProbeInterval time.Duration
4854
upstreamExchangeTimeout time.Duration
4955
servers []*dns.Server
5056
shutdownOnce sync.Once
@@ -70,9 +76,14 @@ func New(p *policy.NetworkPolicy, listenAddr string, alwaysDeny, alwaysAllow []p
7076
if err != nil {
7177
return nil, err
7278
}
79+
probeName, probeQType := upstreamProbeFromEnv()
7380
proxy := &Proxy{
7481
listenAddr: listenAddr,
7582
upstreams: upstreams,
83+
activeUpstreams: append([]string(nil), upstreams...),
84+
upstreamProbeName: probeName,
85+
upstreamProbeQType: probeQType,
86+
upstreamProbeInterval: upstreamProbeIntervalFromEnv(),
7687
upstreamExchangeTimeout: upstreamExchangeTimeoutFromEnv(),
7788
userPolicy: ensurePolicyDefaults(p),
7889
alwaysDeny: append([]policy.EgressRule(nil), alwaysDeny...),
@@ -101,7 +112,7 @@ func upstreamExchangeTimeoutFromEnv() time.Duration {
101112
return time.Duration(n) * time.Second
102113
}
103114

104-
func (p *Proxy) Start() error {
115+
func (p *Proxy) Start(ctx context.Context) error {
105116
handler := dns.HandlerFunc(p.serveDNS)
106117

107118
udpServer := &dns.Server{Addr: p.listenAddr, Net: "udp", Handler: handler}
@@ -118,13 +129,18 @@ func (p *Proxy) Start() error {
118129
}()
119130
}
120131

132+
timer := time.NewTimer(200 * time.Millisecond)
133+
defer timer.Stop()
121134
select {
122135
case err := <-errCh:
123136
return fmt.Errorf("dns proxy failed: %w", err)
124-
case <-time.After(200 * time.Millisecond):
125-
// small grace window; running fine
126-
return nil
137+
case <-timer.C:
138+
// listeners bound; start upstream probes only after DNS servers are up
127139
}
140+
141+
go p.runUpstreamProbes(ctx)
142+
143+
return nil
128144
}
129145

130146
// Shutdown stops UDP/TCP DNS listeners. Safe to call more than once.
@@ -193,8 +209,9 @@ func (p *Proxy) maybeNotifyResolved(domain string, resp *dns.Msg) {
193209
}
194210

195211
func (p *Proxy) forward(r *dns.Msg) (*dns.Msg, error) {
212+
list := p.forwardUpstreams()
196213
var lastErr error
197-
for i, upstream := range p.upstreams {
214+
for i, upstream := range list {
198215
c := &dns.Client{
199216
Timeout: p.upstreamExchangeTimeout,
200217
Dialer: p.dialerForUpstream(upstream),
@@ -209,7 +226,7 @@ func (p *Proxy) forward(r *dns.Msg) (*dns.Msg, error) {
209226
lastErr = fmt.Errorf("nil response from %s", upstream)
210227
continue
211228
}
212-
if tryNext, reason := p.shouldFailoverAfterResponse(resp, i); tryNext {
229+
if tryNext, reason := p.shouldFailoverAfterResponse(resp, i, len(list)); tryNext {
213230
lastErr = fmt.Errorf("%s from %s", reason, upstream)
214231
log.Warnf("[dns] upstream %s: %s; trying next", upstream, reason)
215232
continue
@@ -225,15 +242,15 @@ func (p *Proxy) forward(r *dns.Msg) (*dns.Msg, error) {
225242
// shouldFailoverAfterResponse returns whether to try the next upstream.
226243
// NXDOMAIN is not retried. NOERROR with an empty Answer (NODATA) is retried when another upstream exists:
227244
// a broken or non-recursive first hop may return empty NOERROR while a public resolver succeeds.
228-
func (p *Proxy) shouldFailoverAfterResponse(resp *dns.Msg, upstreamIdx int) (tryNext bool, reason string) {
245+
func (p *Proxy) shouldFailoverAfterResponse(resp *dns.Msg, upstreamIdx, upstreamCount int) (tryNext bool, reason string) {
229246
if resp == nil {
230247
return true, "nil response"
231248
}
232249
switch resp.Rcode {
233250
case dns.RcodeNameError:
234251
return false, ""
235252
case dns.RcodeSuccess:
236-
if len(resp.Answer) == 0 && upstreamIdx < len(p.upstreams)-1 {
253+
if len(resp.Answer) == 0 && upstreamIdx < upstreamCount-1 {
237254
return true, "empty NOERROR"
238255
}
239256
return false, ""
@@ -246,12 +263,13 @@ func (p *Proxy) shouldFailoverAfterResponse(resp *dns.Msg, upstreamIdx int) (try
246263
}
247264
}
248265

249-
// UpstreamHost returns the host part of the first upstream resolver, empty on parse error.
266+
// UpstreamHost returns the host part of the first upstream resolver used for forwarding, empty on parse error.
250267
func (p *Proxy) UpstreamHost() string {
251-
if len(p.upstreams) == 0 {
268+
list := p.forwardUpstreams()
269+
if len(list) == 0 {
252270
return ""
253271
}
254-
host, _, err := net.SplitHostPort(p.upstreams[0])
272+
host, _, err := net.SplitHostPort(list[0])
255273
if err != nil {
256274
return ""
257275
}
@@ -262,15 +280,17 @@ func (p *Proxy) UpstreamHost() string {
262280
// Passing nil reverts to the default deny-all policy.
263281
func (p *Proxy) UpdatePolicy(newPolicy *policy.NetworkPolicy) {
264282
p.policyMu.Lock()
283+
defer p.policyMu.Unlock()
284+
265285
p.userPolicy = ensurePolicyDefaults(newPolicy)
266286
p.refreshEffectivePolicy()
267-
p.policyMu.Unlock()
268287
}
269288

270289
// CurrentPolicy returns the user policy (POST/PATCH/GET), not the always-deny/allow overlay.
271290
func (p *Proxy) CurrentPolicy() *policy.NetworkPolicy {
272291
p.policyMu.RLock()
273292
defer p.policyMu.RUnlock()
293+
274294
return p.userPolicy
275295
}
276296

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
"context"
19+
"os"
20+
"strconv"
21+
"strings"
22+
"sync"
23+
"time"
24+
25+
"github.com/miekg/dns"
26+
27+
"github.com/alibaba/opensandbox/egress/pkg/constants"
28+
"github.com/alibaba/opensandbox/egress/pkg/log"
29+
)
30+
31+
const defaultUpstreamProbeInterval = 30 * time.Second
32+
33+
func upstreamProbeIntervalFromEnv() time.Duration {
34+
s := strings.TrimSpace(os.Getenv(constants.EnvDNSUpstreamProbeIntervalSec))
35+
if s == "" {
36+
return defaultUpstreamProbeInterval
37+
}
38+
n, err := strconv.Atoi(s)
39+
if err != nil || n < 1 {
40+
return defaultUpstreamProbeInterval
41+
}
42+
if n > 3600 {
43+
n = 3600
44+
}
45+
return time.Duration(n) * time.Second
46+
}
47+
48+
// upstreamProbeFromEnv returns the DNS question used for upstream liveness checks.
49+
// Default is root IN NS (primers/recursors answer without resolving a public TLD).
50+
// Set OPENSANDBOX_EGRESS_DNS_UPSTREAM_PROBE to an FQDN that your resolvers always
51+
// answer (e.g. split-horizon internal name) when the default is inappropriate.
52+
func upstreamProbeFromEnv() (name string, qtype uint16) {
53+
raw := strings.TrimSpace(os.Getenv(constants.EnvDNSUpstreamProbe))
54+
if raw == "" || raw == "." {
55+
return ".", dns.TypeNS
56+
}
57+
return dns.Fqdn(raw), dns.TypeA
58+
}
59+
60+
func (p *Proxy) runUpstreamProbes(ctx context.Context) {
61+
p.probeUpstreams()
62+
63+
t := time.NewTicker(p.upstreamProbeInterval)
64+
defer t.Stop()
65+
for {
66+
select {
67+
case <-ctx.Done():
68+
return
69+
case <-t.C:
70+
p.probeUpstreams()
71+
}
72+
}
73+
}
74+
75+
// forwardUpstreams returns the ordered list used for DNS forwarding: last healthy probe
76+
// results when non-empty; otherwise the configured chain (e.g. before first probe).
77+
func (p *Proxy) forwardUpstreams() []string {
78+
p.upstreamMu.RLock()
79+
active := p.activeUpstreams
80+
p.upstreamMu.RUnlock()
81+
if len(active) > 0 {
82+
return active
83+
}
84+
return p.upstreams
85+
}
86+
87+
// probeUpstreams checks each configured resolver in parallel and refreshes activeUpstreams.
88+
// If every probe fails, the full upstream list is kept so forwarding still attempts all resolvers.
89+
func (p *Proxy) probeUpstreams() {
90+
all := p.upstreams
91+
if len(all) == 0 {
92+
return
93+
}
94+
95+
timeout := probeExchangeTimeout(p.upstreamExchangeTimeout)
96+
healthy := make([]bool, len(all))
97+
var wg sync.WaitGroup
98+
for i := range all {
99+
wg.Add(1)
100+
go func(idx int, addr string) {
101+
defer wg.Done()
102+
healthy[idx] = p.probeOneUpstream(addr, timeout)
103+
}(i, all[i])
104+
}
105+
wg.Wait()
106+
107+
var active []string
108+
for i := range all {
109+
if healthy[i] {
110+
active = append(active, all[i])
111+
}
112+
}
113+
if len(active) == 0 {
114+
log.Warnf("[dns] all upstream probes failed; using full upstream list for forwarding")
115+
active = append([]string(nil), all...)
116+
}
117+
118+
p.upstreamMu.Lock()
119+
p.activeUpstreams = active
120+
p.upstreamMu.Unlock()
121+
}
122+
123+
func probeExchangeTimeout(upstreamTimeout time.Duration) time.Duration {
124+
const maxProbe = 2 * time.Second
125+
if upstreamTimeout <= 0 {
126+
return maxProbe
127+
}
128+
if upstreamTimeout > maxProbe {
129+
return maxProbe
130+
}
131+
return upstreamTimeout
132+
}
133+
134+
func (p *Proxy) probeOneUpstream(addr string, timeout time.Duration) bool {
135+
m := new(dns.Msg)
136+
m.SetQuestion(p.upstreamProbeName, p.upstreamProbeQType)
137+
m.RecursionDesired = true
138+
139+
c := &dns.Client{
140+
Timeout: timeout,
141+
Dialer: p.dialerForUpstream(addr),
142+
}
143+
resp, _, err := c.Exchange(m, addr)
144+
if err != nil {
145+
log.Errorf("[dns] upstream probe %s failed: %v", addr, err)
146+
return false
147+
}
148+
return resp != nil
149+
}

0 commit comments

Comments
 (0)