Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions components/egress/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ func main() {
}
log.Infof("dns proxy started on 127.0.0.1:15353")

logSkipPatterns, err := policy.LoadLogSkipFile()
if err != nil {
log.Fatalf("failed to load outbound log skip file: %v", err)
}
if len(logSkipPatterns) > 0 {
proxy.SetLogSkip(logSkipPatterns)
log.Infof("loaded %d outbound log skip pattern(s) from /var/egress/rules/log_skip.always", len(logSkipPatterns))
}

if blockWebhookURL := strings.TrimSpace(os.Getenv(constants.EnvBlockedWebhook)); blockWebhookURL != "" {
blockedBroadcaster := events.NewBroadcaster(ctx, events.BroadcasterConfig{QueueSize: 256})
blockedBroadcaster.AddSubscriber(events.NewWebhookSubscriber(blockWebhookURL))
Expand Down
25 changes: 23 additions & 2 deletions components/egress/pkg/dnsproxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/miekg/dns"
Expand Down Expand Up @@ -60,6 +61,10 @@ type Proxy struct {
onResolved func(domain string, ips []nftables.ResolvedIP)
// Optional: async fan-out for denied lookups (e.g. webhook).
blockedBroadcaster *events.Broadcaster

// Hosts whose successful outbound DNS log line should be suppressed (audit
// errors are still logged). Loaded once at startup; nil means "log all".
logSkip atomic.Pointer[policy.DomainSet]
}

// New constructs the DNS proxy: discovers upstreams, default listen 127.0.0.1:15353 if listenAddr is "".
Expand Down Expand Up @@ -182,18 +187,34 @@ func (p *Proxy) serveDNS(w dns.ResponseWriter, r *dns.Msg) {
if err != nil {
telemetry.RecordDNSForward(elapsed)
logOutboundDNS(host, nil, "", err.Error())
log.Warnf("[dns] forward error for %s: %v", domain, err)
fail := new(dns.Msg)
fail.SetRcode(r, dns.RcodeServerFailure)
_ = w.WriteMsg(fail)
return
}
telemetry.RecordDNSForward(elapsed)
logOutboundDNS(host, resolvedIPStrings(resp), "", "")
if !p.shouldSkipOutboundLog(host) {
logOutboundDNS(host, resolvedIPStrings(resp), "", "")
}
p.maybeNotifyResolved(domain, resp)
_ = w.WriteMsg(resp)
}

// SetLogSkip replaces the set of hosts whose successful DNS outbound log line
// is suppressed. Passing nil or an empty slice restores the default of logging
// every outbound. Safe to call concurrently; reads use an atomic pointer.
func (p *Proxy) SetLogSkip(patterns []string) {
p.logSkip.Store(policy.NewDomainSet(patterns))
}

func (p *Proxy) shouldSkipOutboundLog(host string) bool {
ds := p.logSkip.Load()
if ds == nil || ds.Empty() {
return false
}
return ds.Match(host)
}

// maybeNotifyResolved calls onResolved before w.WriteMsg so dynamic nft allows are installed
// before the client receives the answer and may open a connection.
func (p *Proxy) maybeNotifyResolved(domain string, resp *dns.Msg) {
Expand Down
28 changes: 28 additions & 0 deletions components/egress/pkg/dnsproxy/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,31 @@ func TestMaybeNotifyResolved_NoCallWhenNoAOrAAAA(t *testing.T) {
// Expected: no callback
}
}

func TestProxyShouldSkipOutboundLog_Default(t *testing.T) {
p := &Proxy{}
require.False(t, p.shouldSkipOutboundLog("metadata.internal"),
"default (no SetLogSkip call) must preserve current behavior: log every outbound")
}

func TestProxyShouldSkipOutboundLog_MatchesAndMisses(t *testing.T) {
p := &Proxy{}
p.SetLogSkip([]string{"metadata.internal", "*.cluster.local"})

require.True(t, p.shouldSkipOutboundLog("metadata.internal"), "exact pattern hit")
require.True(t, p.shouldSkipOutboundLog("svc.cluster.local"), "wildcard subdomain hit")
require.True(t, p.shouldSkipOutboundLog("METADATA.INTERNAL."), "case + trailing dot normalised")
require.False(t, p.shouldSkipOutboundLog("cluster.local"),
"wildcard *.cluster.local must not match bare cluster.local")
require.False(t, p.shouldSkipOutboundLog("evil.com"), "non-listed host must not be skipped")
}

func TestProxyShouldSkipOutboundLog_ClearedByEmptyList(t *testing.T) {
p := &Proxy{}
p.SetLogSkip([]string{"metadata.internal"})
require.True(t, p.shouldSkipOutboundLog("metadata.internal"))

p.SetLogSkip(nil)
require.False(t, p.shouldSkipOutboundLog("metadata.internal"),
"clearing the list must re-enable logging for all hosts")
}
62 changes: 62 additions & 0 deletions components/egress/pkg/policy/domain_set.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package policy

import "strings"

// DomainSet is a read-only set of domain patterns used for membership checks
// (e.g. suppressing log output for selected hosts). Patterns use the same
// exact / wildcard-suffix semantics as policy egress rules: "foo.com" matches
// the bare host; "*.foo.com" matches any subdomain but not the bare host.
type DomainSet struct {
idx *compiledDomainIndex
}

// NewDomainSet compiles the given patterns into a DomainSet. Empty/whitespace
// entries are ignored. Patterns are assumed to be already validated as domain
// targets (e.g. via LoadLogSkipFile); invalid entries are not rejected here.
func NewDomainSet(patterns []string) *DomainSet {
if len(patterns) == 0 {
return &DomainSet{}
}
rules := make([]EgressRule, 0, len(patterns))
for _, p := range patterns {
p = strings.TrimSpace(p)
if p == "" {
continue
}
rules = append(rules, EgressRule{Action: ActionAllow, Target: p, targetKind: targetDomain})
}
if len(rules) == 0 {
return &DomainSet{}
}
return &DomainSet{idx: compileDomainIndex(rules)}
}

// Match reports whether host matches any pattern in the set. Hosts are
// normalised by lowercasing and stripping a trailing dot before lookup.
func (d *DomainSet) Match(host string) bool {
if d == nil || d.idx == nil || host == "" {
return false
}
h := strings.ToLower(strings.TrimSuffix(host, "."))
_, ok := d.idx.match(h)
return ok
}

// Empty reports whether the set has no patterns.
func (d *DomainSet) Empty() bool {
return d == nil || d.idx == nil
}
73 changes: 73 additions & 0 deletions components/egress/pkg/policy/domain_set_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package policy

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestDomainSet_ExactMatch(t *testing.T) {
d := NewDomainSet([]string{"metadata.internal"})
require.True(t, d.Match("metadata.internal"), "exact pattern must match identical host")
require.False(t, d.Match("other.internal"), "different host must not match")
require.False(t, d.Match("sub.metadata.internal"), "exact pattern must not match subdomain")
}

func TestDomainSet_WildcardMatchesSubdomainOnly(t *testing.T) {
d := NewDomainSet([]string{"*.cluster.local"})
require.True(t, d.Match("svc.cluster.local"), "wildcard must match one-level subdomain")
require.True(t, d.Match("a.b.cluster.local"), "wildcard must match deeper subdomain")
require.False(t, d.Match("cluster.local"), "wildcard *.foo must not match bare foo (matches compiledDomainIndex semantics)")
require.False(t, d.Match("notcluster.local"), "wildcard suffix must align on dot boundary")
}

func TestDomainSet_CombinedExactAndWildcard(t *testing.T) {
d := NewDomainSet([]string{"cluster.local", "*.cluster.local"})
require.True(t, d.Match("cluster.local"), "exact entry covers bare host")
require.True(t, d.Match("svc.cluster.local"), "wildcard entry covers subdomain")
}

func TestDomainSet_CaseInsensitive(t *testing.T) {
d := NewDomainSet([]string{"Metadata.Internal"})
require.True(t, d.Match("METADATA.INTERNAL"), "match must be case-insensitive")
}

func TestDomainSet_TrailingDotStripped(t *testing.T) {
d := NewDomainSet([]string{"metadata.internal"})
require.True(t, d.Match("metadata.internal."), "trailing dot in queried host must be stripped before match")
}

func TestDomainSet_EmptyOrNilNeverMatches(t *testing.T) {
require.False(t, NewDomainSet(nil).Match("foo.com"))
require.False(t, NewDomainSet([]string{}).Match("foo.com"))
require.False(t, NewDomainSet([]string{"", " "}).Match("foo.com"), "whitespace-only entries are ignored")
var d *DomainSet
require.False(t, d.Match("foo.com"), "nil receiver must not panic and must return false")
}

func TestDomainSet_Empty(t *testing.T) {
require.True(t, NewDomainSet(nil).Empty())
require.True(t, NewDomainSet([]string{" "}).Empty(), "whitespace-only patterns produce empty set")
require.False(t, NewDomainSet([]string{"foo.com"}).Empty())
var d *DomainSet
require.True(t, d.Empty(), "nil receiver reports empty")
}

func TestDomainSet_EmptyHostNeverMatches(t *testing.T) {
d := NewDomainSet([]string{"foo.com"})
require.False(t, d.Match(""), "empty host must not match anything")
}
71 changes: 71 additions & 0 deletions components/egress/pkg/policy/log_skip.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package policy

import (
"bufio"
"fmt"
"os"
"strings"
)

// Operator-managed list of domain patterns whose successful DNS resolutions
// should not produce an "egress outbound" log line. Missing file is ignored.
// One-shot load at startup; not hot-reloaded.
const logSkipFilePath = "/var/egress/rules/log_skip.always"

// LoadLogSkipFile reads optional /var/egress/rules/log_skip.always.
// Each non-empty/non-comment line is a domain pattern: "foo.com" (exact) or
// "*.foo.com" (subdomain wildcard). IP/CIDR entries are rejected — only
// host/domain entries are meaningful for matching against DNS query names.
func LoadLogSkipFile() ([]string, error) {
return loadLogSkipFile(logSkipFilePath)
}

func loadLogSkipFile(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return parseLogSkipLines(data, path)
}

func parseLogSkipLines(data []byte, pathForErr string) ([]string, error) {
var out []string
sc := bufio.NewScanner(strings.NewReader(string(data)))
lineNum := 0
for sc.Scan() {
lineNum++
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rule, err := ParseValidatedEgressRule(ActionAllow, line)
if err != nil {
return nil, fmt.Errorf("%s line %d: %w", pathForErr, lineNum, err)
}
if rule.targetKind != targetDomain {
return nil, fmt.Errorf("%s line %d: %q is not a domain pattern; only host/domain entries are supported", pathForErr, lineNum, line)
}
out = append(out, rule.Target)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("%s: %w", pathForErr, err)
}
return out, nil
}
84 changes: 84 additions & 0 deletions components/egress/pkg/policy/log_skip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package policy

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestLoadLogSkipFile_ParsesDomainsAndComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "log_skip.always")
body := []byte("# comment line\n" +
"metadata.internal\n" +
"\n" +
"*.cluster.local\n" +
" # indented comment is also ignored\n" +
" Foo.Example.Com \n")
require.NoError(t, os.WriteFile(path, body, 0o644))

patterns, err := loadLogSkipFile(path)
require.NoError(t, err)
// Raw user case is preserved here; DomainSet lowercases at compile/match time
// so downstream matching is still case-insensitive.
require.Equal(t, []string{"metadata.internal", "*.cluster.local", "Foo.Example.Com"}, patterns,
"comments and blank lines are dropped; surrounding whitespace stripped; order preserved")
}

func TestLoadLogSkipFile_BlankAndCommentsOnlyReturnsNil(t *testing.T) {
path := filepath.Join(t.TempDir(), "log_skip.always")
require.NoError(t, os.WriteFile(path, []byte("# just a comment\n\n \n# another\n"), 0o644))

patterns, err := loadLogSkipFile(path)
require.NoError(t, err)
require.Nil(t, patterns, "file with no effective entries must produce nil patterns")
}

func TestLoadLogSkipFile_MissingFileIsNotError(t *testing.T) {
patterns, err := loadLogSkipFile(filepath.Join(t.TempDir(), "does-not-exist"))
require.NoError(t, err, "missing file is a soft no-op")
require.Nil(t, patterns)
}

func TestLoadLogSkipFile_RejectsIPEntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "log_skip.always")
require.NoError(t, os.WriteFile(path, []byte("1.2.3.4\n"), 0o644))

_, err := loadLogSkipFile(path)
require.Error(t, err, "IP entries must be rejected — log skip matches DNS query names")
require.Contains(t, err.Error(), "not a domain pattern")
}

func TestLoadLogSkipFile_RejectsCIDREntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "log_skip.always")
require.NoError(t, os.WriteFile(path, []byte("10.0.0.0/8\n"), 0o644))

_, err := loadLogSkipFile(path)
require.Error(t, err, "CIDR entries must be rejected")
require.Contains(t, err.Error(), "not a domain pattern")
}

func TestLoadLogSkipFile_ReportsLineNumberOnRejectedEntry(t *testing.T) {
path := filepath.Join(t.TempDir(), "log_skip.always")
require.NoError(t, os.WriteFile(path, []byte("ok.example.com\n10.0.0.0/8\n"), 0o644))

_, err := loadLogSkipFile(path)
require.Error(t, err)
require.Contains(t, err.Error(), "line 2", "error must point at the offending line")
require.Contains(t, err.Error(), "not a domain pattern")
}
Loading
Loading