├── .gitignore ├── .gitmodules ├── .github ├── dependabot.yml ├── workflows │ ├── release.yml │ ├── update-go-version.yml │ ├── codeql.yml │ ├── lint-workflows.yml │ ├── lint-go.yml │ ├── vuln.yml │ ├── reproduce-binary.yml │ ├── size-report.yml │ └── test.yml └── actionlint-matcher.json ├── go.mod ├── cmd └── egress-eddie │ ├── version.go │ ├── main.go │ └── seccomp.go ├── .goreleaser.yml ├── LICENSE ├── .golangci.yml ├── mock.go ├── timedcache └── timed_cache.go ├── log.go ├── go.sum ├── README.md ├── config.go ├── filter_test.go ├── fuzz_test.go ├── config_test.go └── filter.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.bin 2 | *.html 3 | *.log 4 | *.out 5 | *.toml 6 | *.sh 7 | *.test 8 | 9 | ./cmd/egress-eddie/egress-eddie 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "testdata/fuzz"] 2 | path = testdata/fuzz 3 | url = git@github.com:capnspacehook/egress-eddie-corpus.git 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: gomod 8 | open-pull-requests-limit: 5 9 | directory: / 10 | schedule: 11 | interval: daily 12 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - "v*.*.*" 9 | 10 | jobs: 11 | release-binaries: 12 | permissions: 13 | contents: write 14 | id-token: write 15 | uses: capnspacehook/go-workflows/.github/workflows/release-binaries.yml@master 16 | -------------------------------------------------------------------------------- /.github/workflows/update-go-version.yml: -------------------------------------------------------------------------------- 1 | name: Update Go version 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | workflow_dispatch: {} 8 | 9 | jobs: 10 | update-go-version: 11 | permissions: 12 | contents: write 13 | pull-requests: write 14 | uses: capnspacehook/go-workflows/.github/workflows/update-go-version.yml@master 15 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: Run CodeQL 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | schedule: 11 | - cron: "00 13 * * 1" 12 | 13 | workflow_dispatch: {} 14 | 15 | jobs: 16 | codeql: 17 | permissions: 18 | actions: write 19 | contents: read 20 | security-events: write 21 | uses: capnspacehook/go-workflows/.github/workflows/codeql.yml@master 22 | -------------------------------------------------------------------------------- /.github/workflows/lint-workflows.yml: -------------------------------------------------------------------------------- 1 | name: Lint workflows 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - ".github/workflows/*" 9 | pull_request: 10 | branches: 11 | - "*" 12 | paths: 13 | - ".github/workflows/*" 14 | 15 | workflow_dispatch: {} 16 | 17 | jobs: 18 | lint-workflows: 19 | permissions: 20 | contents: read 21 | uses: capnspacehook/go-workflows/.github/workflows/lint-actions.yml@master 22 | -------------------------------------------------------------------------------- /.github/workflows/lint-go.yml: -------------------------------------------------------------------------------- 1 | name: Lint Go 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "**.go" 9 | - "go.mod" 10 | - "go.sum" 11 | - ".github/workflows/lint-go.yml" 12 | pull_request: 13 | branches: 14 | - "*" 15 | paths: 16 | - "**.go" 17 | - "go.mod" 18 | - "go.sum" 19 | - ".github/workflows/lint-go.yml" 20 | 21 | workflow_dispatch: {} 22 | 23 | jobs: 24 | lint-go: 25 | permissions: 26 | contents: read 27 | uses: capnspacehook/go-workflows/.github/workflows/lint-go.yml@master 28 | -------------------------------------------------------------------------------- /.github/actionlint-matcher.json: -------------------------------------------------------------------------------- 1 | { 2 | "problemMatcher": [ 3 | { 4 | "owner": "actionlint", 5 | "pattern": [ 6 | { 7 | "regexp": "^(?:\\x1b\\[\\d+m)?(.+?)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*:(?:\\x1b\\[\\d+m)*(\\d+)(?:\\x1b\\[\\d+m)*: (?:\\x1b\\[\\d+m)*(.+?)(?:\\x1b\\[\\d+m)* \\[(.+?)\\]$", 8 | "file": 1, 9 | "line": 2, 10 | "column": 3, 11 | "message": 4, 12 | "code": 5 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/vuln.yml: -------------------------------------------------------------------------------- 1 | name: Vulnerability scan 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "go.mod" 9 | - "go.sum" 10 | - "**.go" 11 | - ".github/workflows/vuln.yml" 12 | pull_request: 13 | branches: 14 | - "*" 15 | paths: 16 | - "go.mod" 17 | - "go.sum" 18 | - "**.go" 19 | - ".github/workflows/vuln.yml" 20 | schedule: 21 | - cron: "0 0 * * *" 22 | 23 | workflow_dispatch: {} 24 | 25 | jobs: 26 | vuln: 27 | permissions: 28 | contents: read 29 | uses: capnspacehook/go-workflows/.github/workflows/vuln.yml@master 30 | -------------------------------------------------------------------------------- /.github/workflows/reproduce-binary.yml: -------------------------------------------------------------------------------- 1 | name: Reproduce binary 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "**.go" 9 | - "go.mod" 10 | - "go.sum" 11 | - ".goreleaser.yml" 12 | - ".github/workflows/reproduce-binary.yml" 13 | pull_request: 14 | branches: 15 | - "*" 16 | paths: 17 | - "**.go" 18 | - "go.mod" 19 | - "go.sum" 20 | - ".goreleaser.yml" 21 | - ".github/workflows/reproduce-binary.yml" 22 | 23 | workflow_dispatch: {} 24 | 25 | jobs: 26 | reproduce-binary: 27 | permissions: 28 | contents: read 29 | uses: capnspacehook/go-workflows/.github/workflows/reproduce-binary.yml@master 30 | with: 31 | extra-build-flags: "-ldflags=-s -w -X main.version=devel" 32 | -------------------------------------------------------------------------------- /.github/workflows/size-report.yml: -------------------------------------------------------------------------------- 1 | name: Size report 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - "**.go" 9 | - "go.mod" 10 | - "go.sum" 11 | - ".github/workflows/size-report.yml" 12 | pull_request: 13 | branches: 14 | - "*" 15 | paths: 16 | - "**.go" 17 | - "go.mod" 18 | - "go.sum" 19 | - ".github/workflows/size-report.yml" 20 | 21 | workflow_dispatch: {} 22 | 23 | jobs: 24 | size-report: 25 | permissions: 26 | contents: write # so go-size-tracker can use git notes to cache size records 27 | pull-requests: write # so go-size-tracker can comment on PRs 28 | uses: capnspacehook/go-workflows/.github/workflows/size-report.yml@master 29 | with: 30 | working-directory: cmd/egress-eddie/ 31 | build-environment: | 32 | CGO_ENABLED=0 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/capnspacehook/egress-eddie 2 | 3 | go 1.25.5 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.5.0 7 | github.com/florianl/go-nfqueue v1.3.2 8 | github.com/google/gopacket v1.1.19 9 | github.com/landlock-lsm/go-landlock v0.0.0-20250303204525-1544bccde3a3 10 | github.com/mdlayher/netlink v1.8.0 11 | go.uber.org/zap v1.27.1 12 | golang.org/x/sys v0.39.0 13 | gvisor.dev/gvisor v0.0.0-20250911055229-61a46406f068 14 | ) 15 | 16 | // Test dependencies 17 | require ( 18 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be 19 | github.com/matryer/is v1.4.1 20 | go.uber.org/goleak v1.3.0 21 | ) 22 | 23 | require ( 24 | github.com/google/go-cmp v0.6.0 // indirect 25 | github.com/mdlayher/socket v0.5.1 // indirect 26 | go.uber.org/multierr v1.10.0 // indirect 27 | golang.org/x/net v0.43.0 // indirect 28 | golang.org/x/sync v0.8.0 // indirect 29 | golang.org/x/time v0.7.0 // indirect 30 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.70 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /cmd/egress-eddie/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime/debug" 7 | "text/tabwriter" 8 | ) 9 | 10 | var version = "devel" 11 | 12 | func printVersionInfo(info *debug.BuildInfo) { 13 | fmt.Printf("Egress Eddie %s\n\n", version) 14 | 15 | fmt.Print("Build Information:\n\n") 16 | buildtw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) 17 | fmt.Fprintf(buildtw, "Go version:\t%s\n", info.GoVersion) 18 | for _, setting := range info.Settings { 19 | key := setting.Key 20 | value := setting.Value 21 | 22 | switch setting.Key { 23 | case "-compiler": 24 | key = "Compiler" 25 | case "-ldflags": 26 | key = "Link Flags" 27 | case "vcs", "vcs.modified": 28 | continue 29 | case "vcs.revision": 30 | key = "Commit" 31 | case "vcs.time": 32 | key = "Commit Time" 33 | } 34 | 35 | fmt.Fprintf(buildtw, "%s:\t%s\n", key, value) 36 | } 37 | buildtw.Flush() 38 | 39 | fmt.Print("\nDependencies:\n\n") 40 | deptw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) 41 | fmt.Fprint(deptw, "Name\tVersion\tHash\n") 42 | for _, dep := range info.Deps { 43 | fmt.Fprintf(deptw, "%s\t%s\t%s\n", dep.Path, dep.Version, dep.Sum) 44 | } 45 | deptw.Flush() 46 | } 47 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | use: github-native 3 | sort: asc 4 | 5 | builds: 6 | - env: 7 | - CGO_ENABLED=0 8 | - GO111MODULE=on 9 | goos: 10 | - linux 11 | goarch: 12 | - amd64 13 | flags: 14 | - -buildmode=pie 15 | - -buildvcs=true 16 | - -trimpath 17 | mod_timestamp: '{{ .CommitTimestamp }}' 18 | ldflags: 19 | - '-s -w -X main.version={{ if .IsSnapshot }}devel{{ else }}{{ .Tag }}{{ end }}' 20 | main: ./cmd/egress-eddie 21 | 22 | archives: 23 | - id: binary-archive 24 | name_template: "{{ .ProjectName }}" 25 | format: binary 26 | - id: tar-archive 27 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 28 | format: tar.gz 29 | # hack to only add binary to archive 30 | files: 31 | - none* 32 | 33 | checksum: 34 | name_template: "checksums.txt" 35 | ids: 36 | - binary-archive 37 | - tar-archive 38 | 39 | signs: 40 | - id: checksum-signature 41 | cmd: cosign 42 | certificate: "${artifact}.crt" 43 | args: ["sign-blob", "--output-signature", "${signature}", "--output-certificate", "${certificate}", "${artifact}", "--yes"] 44 | artifacts: checksum 45 | 46 | release: 47 | ids: 48 | - checksum-signature 49 | - tar-archive 50 | prerelease: auto 51 | name_template: "{{ .Tag }}" 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Andrew LeFevre 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - asasalint 6 | - bidichk 7 | - copyloopvar 8 | - durationcheck 9 | - errcheck 10 | - errchkjson 11 | - errorlint 12 | - exptostd 13 | - fatcontext 14 | - forcetypeassert 15 | - gocheckcompilerdirectives 16 | - goconst 17 | - gocritic 18 | - govet 19 | - ineffassign 20 | - intrange 21 | - loggercheck 22 | - mirror 23 | - misspell 24 | - nilerr 25 | - nolintlint 26 | - nilnesserr 27 | - nilnil 28 | - paralleltest 29 | - perfsprint 30 | - prealloc 31 | - predeclared 32 | - reassign 33 | - revive 34 | - rowserrcheck 35 | - sqlclosecheck 36 | - thelper 37 | - tparallel 38 | - unconvert 39 | - unparam 40 | - unused 41 | - usestdlibvars 42 | - wastedassign 43 | settings: 44 | errcheck: 45 | exclude-functions: 46 | - (go.uber.org/zap/zapcore.ObjectEncoder).AddObject 47 | misspell: 48 | locale: US 49 | paralleltest: 50 | ignore-missing: true 51 | revive: 52 | rules: 53 | - name: blank-imports 54 | disabled: true 55 | exclusions: 56 | generated: lax 57 | presets: 58 | - comments 59 | - common-false-positives 60 | - legacy 61 | - std-error-handling 62 | paths: 63 | - third_party$ 64 | - builtin$ 65 | - examples$ 66 | issues: 67 | max-issues-per-linter: 0 68 | max-same-issues: 0 69 | formatters: 70 | enable: 71 | - gci 72 | - gofumpt 73 | settings: 74 | gci: 75 | sections: 76 | - standard 77 | - default 78 | - prefix(github.com/capnspacehook/egress-eddie) 79 | exclusions: 80 | generated: lax 81 | paths: 82 | - third_party$ 83 | - builtin$ 84 | - examples$ 85 | -------------------------------------------------------------------------------- /mock.go: -------------------------------------------------------------------------------- 1 | package egresseddie 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/netip" 9 | "sync" 10 | 11 | "github.com/florianl/go-nfqueue" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | var mockEnforcers map[uint16]*mockEnforcer 16 | 17 | type mockEnforcer struct { 18 | mtx sync.Mutex 19 | hook nfqueue.HookFunc 20 | verdicts map[uint32]int 21 | } 22 | 23 | func initMockEnforcers() { 24 | mockEnforcers = make(map[uint16]*mockEnforcer) 25 | } 26 | 27 | func newMockEnforcer(_ context.Context, _ *zap.Logger, queueNum uint16, _ bool, hook nfqueue.HookFunc) (enforcer, error) { 28 | if _, ok := mockEnforcers[queueNum]; ok { 29 | return nil, fmt.Errorf("a nfqueue with the queue number %d has already been started", queueNum) 30 | } 31 | 32 | mEnforcer := &mockEnforcer{ 33 | hook: hook, 34 | verdicts: make(map[uint32]int), 35 | } 36 | mockEnforcers[queueNum] = mEnforcer 37 | 38 | return mEnforcer, nil 39 | } 40 | 41 | func (m *mockEnforcer) SetVerdict(id uint32, verdict int) error { 42 | if id == 0 { 43 | return errors.New("id is zero") 44 | } 45 | 46 | // these are not the only valid verdicts, but they are the only 47 | // verdicts egress eddie will pass 48 | if verdict != nfqueue.NfDrop && verdict != nfqueue.NfAccept { 49 | return fmt.Errorf("invalid verdict %d", verdict) 50 | } 51 | 52 | m.mtx.Lock() 53 | m.verdicts[id] = verdict 54 | m.mtx.Unlock() 55 | 56 | return nil 57 | } 58 | 59 | func (m *mockEnforcer) Close() error { 60 | return nil 61 | } 62 | 63 | type mockResolver struct { 64 | addrs map[string][]netip.Addr 65 | hostnames map[string][]string 66 | } 67 | 68 | func (m *mockResolver) LookupNetIP(_ context.Context, _ string, host string) ([]netip.Addr, error) { 69 | if m.addrs == nil { 70 | return nil, &net.DNSError{IsNotFound: true} 71 | } 72 | 73 | if addrs, ok := m.addrs[host]; ok { 74 | return addrs, nil 75 | } 76 | 77 | return nil, &net.DNSError{IsNotFound: true} 78 | } 79 | 80 | func (m *mockResolver) LookupAddr(_ context.Context, addr string) ([]string, error) { 81 | if m.hostnames == nil { 82 | return nil, &net.DNSError{IsNotFound: true} 83 | } 84 | 85 | if addrs, ok := m.hostnames[addr]; ok { 86 | return addrs, nil 87 | } 88 | 89 | return nil, &net.DNSError{IsNotFound: true} 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | workflow_dispatch: {} 12 | 13 | jobs: 14 | race-test: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 15 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v6 20 | 21 | - name: Install Go 22 | uses: WillAbides/setup-go-faster@v1.14.0 23 | with: 24 | go-version-file: go.mod 25 | 26 | - name: Restore Go cache 27 | uses: capnspacehook/cache-go/restore@v2.0.1 28 | 29 | # the test is compiled and run as root so that egress eddie can 30 | # open nfqueues, which is a privileged operation 31 | - run: | 32 | go test -c -race -o egress-eddie.test 33 | sudo ./egress-eddie.test -enable-ipv6=false -test.timeout 5m -test.v 34 | 35 | - name: Save Go cache 36 | if: always() 37 | uses: capnspacehook/cache-go/save@v2.0.1 38 | 39 | binary-test: 40 | runs-on: ubuntu-latest 41 | timeout-minutes: 15 42 | steps: 43 | - name: Checkout code 44 | uses: actions/checkout@v6 45 | 46 | - name: Install Go 47 | uses: WillAbides/setup-go-faster@v1.14.0 48 | with: 49 | go-version-file: go.mod 50 | 51 | - name: Restore Go cache 52 | uses: capnspacehook/cache-go/restore@v2.0.1 53 | 54 | # run the same tests as above but use a binary to process packets 55 | # to test with landlock and seccomp filters active 56 | - name: Run binary tests 57 | run: | 58 | set +e 59 | 60 | CGO_ENABLED=0 go build -o ./egress-eddie ./cmd/egress-eddie 61 | go test -c -o egress-eddie.test 62 | sudo ./egress-eddie.test -binary-tests -enable-ipv6=false -test.timeout 5m -test.v -test.failfast 63 | # shellcheck disable=SC2181 64 | if [[ $? -ne 0 ]]; then 65 | echo "Syscall trace:" 66 | cat trace.txt 67 | exit 1 68 | fi 69 | 70 | - name: Save Go cache 71 | if: always() 72 | uses: capnspacehook/cache-go/save@v2.0.1 73 | 74 | fuzz: 75 | runs-on: ubuntu-latest 76 | timeout-minutes: 15 77 | steps: 78 | - name: Checkout code 79 | uses: actions/checkout@v6 80 | 81 | - name: Install Go 82 | uses: WillAbides/setup-go-faster@v1.14.0 83 | with: 84 | go-version-file: go.mod 85 | 86 | - name: Restore Go cache 87 | uses: capnspacehook/cache-go/restore@v2.0.1 88 | with: 89 | cache-fuzz-corpus: true 90 | 91 | - run: | 92 | go test -fuzz Fuzz -run Config -fuzztime 10m 93 | 94 | - name: Save Go cache 95 | if: always() 96 | uses: capnspacehook/cache-go/save@v2.0.1 97 | with: 98 | cache-fuzz-corpus: true 99 | -------------------------------------------------------------------------------- /timedcache/timed_cache.go: -------------------------------------------------------------------------------- 1 | package timedcache 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "go.uber.org/zap" 8 | ) 9 | 10 | // TimedCache is a concurrency-safe timed cache. It stores entries, not 11 | // key-value pairs. 12 | type TimedCache[T comparable] struct { 13 | mtx sync.RWMutex 14 | wg sync.WaitGroup 15 | logger *zap.Logger 16 | 17 | cache map[T]*countedTimer 18 | count bool 19 | } 20 | 21 | // countedTimer stores the optional count and deadline for eviction 22 | // of an entry stored in a TimedCache. 23 | type countedTimer struct { 24 | count int 25 | status chan timerStatus 26 | timer *time.Timer 27 | } 28 | 29 | // timerStatus is used to communicate with a child goroutine that is 30 | // waiting to delete a cache entry. 31 | type timerStatus uint8 32 | 33 | const ( 34 | reset timerStatus = iota // signals that the timer is getting reset 35 | start // signals that the timer has started 36 | stop // signals that the goroutine should finish 37 | ) 38 | 39 | // New creates a new timed cache. If count is true, entries will take 40 | // n RemoveEntry calls to be manually removed from the cache where n 41 | // is the number of times AddEntry is called with the same entry. 42 | // Entries will be removed from the cache when the deadline is reached 43 | // regardless of whether the cache is a counting cache or not. 44 | func New[T comparable](logger *zap.Logger, count bool) *TimedCache[T] { 45 | var t TimedCache[T] 46 | 47 | t.logger = logger 48 | t.cache = make(map[T]*countedTimer) 49 | t.count = count 50 | 51 | return &t 52 | } 53 | 54 | func (t *TimedCache[T]) AddEntry(entry T, ttl time.Duration) { 55 | t.mtx.Lock() 56 | defer t.mtx.Unlock() 57 | 58 | t.logger.Debug("adding entry", zap.Any("entry", entry)) 59 | 60 | ct, ok := t.cache[entry] 61 | if ok { 62 | if t.count { 63 | t.logger.Debug("incrementing count", zap.Any("entry", entry)) 64 | ct.count++ 65 | } 66 | 67 | ct.status <- reset 68 | if !ct.timer.Stop() { 69 | <-ct.timer.C 70 | } 71 | ct.timer.Reset(ttl) 72 | ct.status <- start 73 | return 74 | } 75 | 76 | timer := time.NewTimer(ttl) 77 | status := make(chan timerStatus) 78 | 79 | t.cache[entry] = &countedTimer{ 80 | count: 0, 81 | status: status, 82 | timer: timer, 83 | } 84 | 85 | t.wg.Add(1) 86 | go func() { 87 | defer t.wg.Done() 88 | 89 | running := true 90 | for running { 91 | select { 92 | case <-timer.C: 93 | running = false 94 | case s := <-status: 95 | switch s { 96 | case reset: 97 | // wait until timer is finished resetting 98 | <-status 99 | case start: 100 | // the timer has started, wait for another status 101 | case stop: 102 | return 103 | } 104 | } 105 | } 106 | 107 | t.logger.Debug("deleting entry", zap.Any("entry", entry)) 108 | 109 | t.mtx.Lock() 110 | delete(t.cache, entry) 111 | t.mtx.Unlock() 112 | }() 113 | } 114 | 115 | func (t *TimedCache[T]) EntryExists(entry T) bool { 116 | t.mtx.RLock() 117 | defer t.mtx.RUnlock() 118 | 119 | _, ok := t.cache[entry] 120 | 121 | return ok 122 | } 123 | 124 | func (t *TimedCache[T]) RemoveEntry(entry T) { 125 | t.mtx.Lock() 126 | defer t.mtx.Unlock() 127 | 128 | ct, ok := t.cache[entry] 129 | if !ok { 130 | return 131 | } 132 | 133 | if ct.count != 0 { 134 | t.logger.Debug("decrementing count", zap.Any("entry", entry)) 135 | ct.count-- 136 | return 137 | } 138 | 139 | // we have already acquired a mutex lock here, so tell the child 140 | // goroutine to stop so it won't attempt to also remove this entry 141 | // as well 142 | ct.status <- stop 143 | if !ct.timer.Stop() { 144 | <-ct.timer.C 145 | } 146 | 147 | t.logger.Debug("deleting entry", zap.Any("entry", entry)) 148 | delete(t.cache, entry) 149 | } 150 | 151 | // Stop kills all goroutines waiting on entry deadlines. Entries are not 152 | // removed from the cache. 153 | func (t *TimedCache[T]) Stop() { 154 | t.mtx.Lock() 155 | defer t.mtx.Unlock() 156 | 157 | for _, ct := range t.cache { 158 | ct.status <- stop 159 | if !ct.timer.Stop() { 160 | <-ct.timer.C 161 | } 162 | } 163 | 164 | t.wg.Wait() 165 | } 166 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package egresseddie 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/google/gopacket/layers" 7 | "go.uber.org/zap" 8 | "go.uber.org/zap/zapcore" 9 | ) 10 | 11 | // dnsFields returns a list of fields for a zap logger that describes 12 | // a DNS packet. 13 | func dnsFields(dns *layers.DNS, fullDNSLogging bool) []zap.Field { 14 | var ( 15 | fields []zap.Field 16 | flags []string 17 | ) 18 | 19 | if fullDNSLogging { 20 | fields = append(fields, zap.Uint8("opcode", uint8(dns.OpCode))) 21 | if dns.AA { 22 | flags = append(flags, "aa") 23 | } 24 | if dns.TC { 25 | flags = append(flags, "tc") 26 | } 27 | if dns.RD { 28 | flags = append(flags, "rd") 29 | } 30 | if dns.RA { 31 | flags = append(flags, "ra") 32 | } 33 | fields = append(fields, zap.Strings("flags", flags)) 34 | 35 | if dns.QR { 36 | fields = append(fields, zap.Uint8("resp-code", uint8(dns.ResponseCode))) 37 | } 38 | } 39 | 40 | if dns.QDCount > 0 { 41 | fields = append(fields, zap.Array("questions", dnsQuestions(dns.Questions))) 42 | } 43 | 44 | stringify := func(records []layers.DNSResourceRecord, key string) { 45 | if len(records) == 0 { 46 | return 47 | } 48 | // skip additionals containing empty OPTs 49 | if len(records) == 1 && records[0].Type == layers.DNSTypeOPT && len(records[0].OPT) == 0 { 50 | return 51 | } 52 | 53 | fields = append(fields, zap.Array(key, dnsRecords(records))) 54 | } 55 | 56 | stringify(dns.Answers, "answers") 57 | if fullDNSLogging { 58 | stringify(dns.Authorities, "authorities") 59 | stringify(dns.Additionals, "additionals") 60 | } 61 | 62 | return fields 63 | } 64 | 65 | type dnsQuestions []layers.DNSQuestion 66 | 67 | func (q dnsQuestions) MarshalLogArray(enc zapcore.ArrayEncoder) error { 68 | for i := range q { 69 | if err := enc.AppendObject(dnsQuestion(q[i])); err != nil { 70 | return err 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | 77 | type dnsQuestion layers.DNSQuestion 78 | 79 | func (q dnsQuestion) MarshalLogObject(enc zapcore.ObjectEncoder) error { 80 | enc.AddByteString("name", q.Name) 81 | enc.AddString("class", strings.ToLower(q.Class.String())) 82 | enc.AddString("type", strings.ToLower(q.Type.String())) 83 | return nil 84 | } 85 | 86 | type dnsRecords []layers.DNSResourceRecord 87 | 88 | func (r dnsRecords) MarshalLogArray(enc zapcore.ArrayEncoder) error { 89 | for i := range r { 90 | if err := enc.AppendObject(dnsRecord(r[i])); err != nil { 91 | return err 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | 98 | type dnsRecord layers.DNSResourceRecord 99 | 100 | func (r dnsRecord) MarshalLogObject(enc zapcore.ObjectEncoder) error { 101 | switch r.Type { 102 | case layers.DNSTypeA, layers.DNSTypeAAAA: 103 | enc.AddString("ip", r.IP.String()) 104 | case layers.DNSTypeCNAME: 105 | enc.AddByteString("name", r.CNAME) 106 | case layers.DNSTypeNS: 107 | enc.AddByteString("name", r.NS) 108 | case layers.DNSTypeMX: 109 | enc.AddUint16("pref", r.MX.Preference) 110 | enc.AddByteString("name", r.MX.Name) 111 | case layers.DNSTypeOPT: 112 | err := enc.AddArray("opts", dnsOpts(r.OPT)) 113 | if err != nil { 114 | return err 115 | } 116 | case layers.DNSTypePTR: 117 | enc.AddByteString("name", r.PTR) 118 | case layers.DNSTypeSOA: 119 | enc.AddByteString("mname", r.SOA.MName) 120 | enc.AddByteString("rname", r.SOA.RName) 121 | enc.AddUint32("serial", r.SOA.Serial) 122 | enc.AddUint32("refresh", r.SOA.Refresh) 123 | enc.AddUint32("retry", r.SOA.Retry) 124 | enc.AddUint32("expire", r.SOA.Expire) 125 | enc.AddUint32("min", r.SOA.Minimum) 126 | case layers.DNSTypeSRV: 127 | enc.AddUint16("priority", r.SRV.Priority) 128 | enc.AddUint16("weight", r.SRV.Weight) 129 | enc.AddUint16("port", r.SRV.Port) 130 | enc.AddByteString("name", r.SRV.Name) 131 | case layers.DNSTypeTXT: 132 | err := enc.AddArray("data", dnsTXTs(r.TXTs)) 133 | if err != nil { 134 | return err 135 | } 136 | case layers.DNSTypeURI: 137 | enc.AddUint16("priority", r.URI.Priority) 138 | enc.AddUint16("weight", r.URI.Weight) 139 | enc.AddByteString("name", r.URI.Target) 140 | } 141 | 142 | enc.AddString("type", strings.ToLower(r.Type.String())) 143 | 144 | return nil 145 | } 146 | 147 | type dnsOpts []layers.DNSOPT 148 | 149 | func (o dnsOpts) MarshalLogArray(enc zapcore.ArrayEncoder) error { 150 | for i := range o { 151 | if err := enc.AppendObject(dnsOpt(o[i])); err != nil { 152 | return err 153 | } 154 | } 155 | 156 | return nil 157 | } 158 | 159 | type dnsOpt layers.DNSOPT 160 | 161 | func (o dnsOpt) MarshalLogObject(enc zapcore.ObjectEncoder) error { 162 | enc.AddString("code", o.Code.String()) 163 | enc.AddBinary("data", o.Data) 164 | 165 | return nil 166 | } 167 | 168 | type dnsTXTs [][]byte 169 | 170 | func (t dnsTXTs) MarshalLogArray(enc zapcore.ArrayEncoder) error { 171 | for i := range t { 172 | enc.AppendByteString(t[i]) 173 | } 174 | 175 | return nil 176 | } 177 | -------------------------------------------------------------------------------- /cmd/egress-eddie/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "os/signal" 10 | "runtime/debug" 11 | "strings" 12 | "syscall" 13 | 14 | "github.com/landlock-lsm/go-landlock/landlock" 15 | llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" 16 | "go.uber.org/zap" 17 | "go.uber.org/zap/zapcore" 18 | 19 | egresseddie "github.com/capnspacehook/egress-eddie" 20 | ) 21 | 22 | //nolint:vet 23 | func usage() { 24 | fmt.Fprint(os.Stderr, ` 25 | Egress Eddie filters arbitrary outbound network traffic by hostname. 26 | 27 | eddie-eddie [flags] 28 | 29 | Egress Eddie filters DNS traffic and only allows requests and replies to 30 | specified hostnames. It then caches the IP addresses from allowed DNS replies 31 | and only allows traffic to go to them. 32 | 33 | Egress Eddie requires nftables/iptables rules to be set to function correctly; 34 | it will not modify firewall rules itself. For more information on how to 35 | correctly configure iptables to work with Egress Eddie and how to configure 36 | Egress Eddie itself see the GitHub link below. 37 | 38 | Egress Eddie accepts the following flags: 39 | 40 | `[1:]) 41 | flag.PrintDefaults() 42 | fmt.Fprint(os.Stderr, ` 43 | 44 | For more information, see https://github.com/capnspacehook/egress-eddie. 45 | `[1:]) 46 | } 47 | 48 | func main() { 49 | flag.Usage = usage 50 | configPath := flag.String("c", "egress-eddie.toml", "path of the config file") 51 | debugLogs := flag.Bool("d", false, "enable debug logging") 52 | logFullDNSPackets := flag.Bool("f", false, "enable full DNS packet logging") 53 | logPath := flag.String("l", "stdout", "path to log to") 54 | validateConfig := flag.Bool("t", false, "validate the config and exit") 55 | printVersion := flag.Bool("version", false, "print version and build information and exit") 56 | flag.Parse() 57 | 58 | info, ok := debug.ReadBuildInfo() 59 | if !ok { 60 | log.Fatal("build information not found") 61 | } 62 | 63 | if *printVersion { 64 | printVersionInfo(info) 65 | os.Exit(0) 66 | } 67 | 68 | logCfg := zap.NewProductionConfig() 69 | logCfg.OutputPaths = []string{*logPath} 70 | if *debugLogs { 71 | logCfg.Level.SetLevel(zap.DebugLevel) 72 | } 73 | logCfg.EncoderConfig.TimeKey = "time" 74 | logCfg.EncoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder 75 | logCfg.DisableCaller = true 76 | 77 | logger, err := logCfg.Build() 78 | if err != nil { 79 | log.Fatalf("error creating logger: %v", err) 80 | } 81 | 82 | var versionFields []zap.Field 83 | versionFields = append(versionFields, zap.String("version", version)) 84 | for _, buildSetting := range info.Settings { 85 | if buildSetting.Key == "vcs.revision" { 86 | versionFields = append(versionFields, zap.String("commit", buildSetting.Value)) 87 | } 88 | if buildSetting.Key == "CGO_ENABLED" && buildSetting.Value != "0" { 89 | logger.Fatal("this binary was built with cgo and will not function as intended; rebuild with cgo disabled") 90 | } 91 | } 92 | logger.Info("starting Egress Eddie", versionFields...) 93 | 94 | config, err := egresseddie.ParseConfig(*configPath) 95 | if *validateConfig { 96 | if err != nil { 97 | fmt.Fprintf(os.Stderr, "error parsing config: %v\n", err) 98 | os.Exit(1) 99 | } 100 | os.Exit(0) 101 | } 102 | if err != nil { 103 | logger.Fatal("error parsing config", zap.NamedError("error", err)) 104 | } 105 | 106 | // Try and apply landlock rules, preventing access to non-essential 107 | // files. Only recent versions of the kernel support landlock (5.13+), 108 | // but we will ignore errors if the kernel itself does not support it. 109 | // These rules can only be applied when egress-eddie does not need to make 110 | // network connections, as currently it seems landlock does not support 111 | // networking. 112 | needsNetworking := config.SelfDNSQueue.IPv4 != 0 || config.SelfDNSQueue.IPv6 != 0 113 | if !needsNetworking { 114 | var allowedPaths []landlock.Rule 115 | if *logPath != "stdout" && *logPath != "stderr" { 116 | allowedPaths = []landlock.Rule{ 117 | landlock.PathAccess(llsyscall.AccessFSWriteFile, *logPath), 118 | } 119 | } 120 | 121 | err = landlock.V1.RestrictPaths( 122 | allowedPaths..., 123 | ) 124 | if err != nil { 125 | if !strings.HasPrefix(err.Error(), "missing kernel Landlock support") { 126 | logger.Fatal("error creating landlock rules", zap.NamedError("error", err)) 127 | } 128 | } 129 | logger.Info("applied landlock rules") 130 | } 131 | 132 | ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 133 | 134 | filters, err := egresseddie.CreateFilters(ctx, logger, config, *logFullDNSPackets) 135 | if err != nil { 136 | logger.Fatal("error starting filters", zap.NamedError("error", err)) 137 | } 138 | 139 | defer func() { 140 | cancel() 141 | logger.Info("stopping filters") 142 | filters.Stop() 143 | }() 144 | 145 | // Install seccomp filters to severely limit what egress-eddie is 146 | // allowed to do. The landlock rules plus the seccomp filters 147 | // should make it extremely difficult for an attacker to do 148 | // anything of value from the context of an egress-eddie process. 149 | // The seccomp filters are installed after nfqueues are opened so 150 | // the related syscalls do not have to be allowed for the rest of 151 | // the process's lifetime. 152 | numAllowedSyscalls, err := installSeccompFilters(logger, needsNetworking) 153 | if err != nil { 154 | logger.Error("error setting seccomp rules", zap.NamedError("error", err)) 155 | return 156 | } 157 | logger.Info("applied seccomp filters", zap.Int("syscalls.allowed", numAllowedSyscalls)) 158 | 159 | // Start filters now that seccomp filters have been applied 160 | filters.Start() 161 | logger.Info("started filtering") 162 | 163 | <-ctx.Done() 164 | } 165 | -------------------------------------------------------------------------------- /cmd/egress-eddie/seccomp.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "go.uber.org/zap" 8 | "golang.org/x/sys/unix" 9 | "gvisor.dev/gvisor/pkg/abi/linux" 10 | "gvisor.dev/gvisor/pkg/log" 11 | "gvisor.dev/gvisor/pkg/seccomp" 12 | ) 13 | 14 | var allowedSyscalls = seccomp.MakeSyscallRules(map[uintptr]seccomp.SyscallRule{ 15 | unix.SYS_CLOCK_GETTIME: seccomp.PerArg{ 16 | seccomp.EqualTo(unix.CLOCK_MONOTONIC), 17 | seccomp.AnyValue{}, 18 | }, 19 | unix.SYS_CLONE: seccomp.PerArg{ 20 | // parent_tidptr and child_tidptr are always 0 because neither 21 | // CLONE_PARENT_SETTID nor CLONE_CHILD_SETTID are used. 22 | seccomp.EqualTo( 23 | unix.CLONE_VM | 24 | unix.CLONE_FS | 25 | unix.CLONE_FILES | 26 | unix.CLONE_SETTLS | 27 | unix.CLONE_SIGHAND | 28 | unix.CLONE_SYSVSEM | 29 | unix.CLONE_THREAD), 30 | seccomp.AnyValue{}, // newsp 31 | seccomp.EqualTo(0), // parent_tidptr 32 | seccomp.EqualTo(0), // child_tidptr 33 | seccomp.AnyValue{}, // tls 34 | }, 35 | unix.SYS_CLOSE: seccomp.MatchAll{}, 36 | unix.SYS_EPOLL_CTL: seccomp.Or{ 37 | seccomp.PerArg{ 38 | seccomp.AnyValue{}, 39 | seccomp.EqualTo(unix.EPOLL_CTL_ADD), 40 | seccomp.AnyValue{}, 41 | seccomp.AnyValue{}, 42 | }, 43 | seccomp.PerArg{ 44 | seccomp.AnyValue{}, 45 | seccomp.EqualTo(unix.EPOLL_CTL_DEL), 46 | seccomp.AnyValue{}, 47 | seccomp.AnyValue{}, 48 | }, 49 | }, 50 | unix.SYS_EPOLL_PWAIT: seccomp.MatchAll{}, 51 | unix.SYS_EXIT_GROUP: seccomp.MatchAll{}, 52 | unix.SYS_FCNTL: seccomp.Or{ 53 | seccomp.PerArg{ 54 | seccomp.AnyValue{}, 55 | seccomp.EqualTo(unix.F_GETFL), 56 | }, 57 | seccomp.PerArg{ 58 | seccomp.AnyValue{}, 59 | seccomp.EqualTo(unix.F_SETFL), 60 | }, 61 | }, 62 | unix.SYS_FSTAT: seccomp.MatchAll{}, 63 | unix.SYS_FUTEX: seccomp.Or{ 64 | seccomp.PerArg{ 65 | seccomp.AnyValue{}, 66 | seccomp.EqualTo(linux.FUTEX_WAIT | linux.FUTEX_PRIVATE_FLAG), 67 | seccomp.AnyValue{}, 68 | seccomp.AnyValue{}, 69 | seccomp.EqualTo(0), 70 | }, 71 | seccomp.PerArg{ 72 | seccomp.AnyValue{}, 73 | seccomp.EqualTo(linux.FUTEX_WAKE | linux.FUTEX_PRIVATE_FLAG), 74 | seccomp.AnyValue{}, 75 | seccomp.AnyValue{}, 76 | seccomp.EqualTo(0), 77 | }, 78 | }, 79 | unix.SYS_GETPID: seccomp.MatchAll{}, 80 | unix.SYS_GETTID: seccomp.MatchAll{}, 81 | unix.SYS_MADVISE: seccomp.MatchAll{}, 82 | unix.SYS_MMAP: seccomp.Or{ 83 | seccomp.PerArg{ 84 | seccomp.AnyValue{}, 85 | seccomp.AnyValue{}, 86 | seccomp.EqualTo(unix.PROT_READ | unix.PROT_WRITE), 87 | seccomp.EqualTo(unix.MAP_SHARED), 88 | seccomp.GreaterThan(0), 89 | seccomp.EqualTo(0), 90 | }, 91 | seccomp.PerArg{ 92 | seccomp.AnyValue{}, 93 | seccomp.AnyValue{}, 94 | seccomp.EqualTo(unix.PROT_READ | unix.PROT_WRITE), 95 | seccomp.EqualTo(unix.MAP_PRIVATE | unix.MAP_ANONYMOUS), 96 | seccomp.GreaterThan(0), 97 | seccomp.EqualTo(0), 98 | }, 99 | seccomp.PerArg{ 100 | seccomp.AnyValue{}, 101 | seccomp.AnyValue{}, 102 | seccomp.EqualTo(unix.PROT_READ | unix.PROT_WRITE), 103 | seccomp.EqualTo(unix.MAP_PRIVATE | unix.MAP_ANONYMOUS | unix.MAP_FIXED), 104 | seccomp.GreaterThan(0), 105 | seccomp.EqualTo(0), 106 | }, 107 | }, 108 | unix.SYS_MUNMAP: seccomp.MatchAll{}, 109 | unix.SYS_NANOSLEEP: seccomp.MatchAll{}, 110 | unix.SYS_NEWFSTATAT: seccomp.MatchAll{}, 111 | unix.SYS_PRCTL: seccomp.PerArg{ 112 | seccomp.EqualTo(unix.PR_SET_VMA), 113 | seccomp.AnyValue{}, 114 | seccomp.AnyValue{}, 115 | seccomp.AnyValue{}, 116 | seccomp.AnyValue{}, 117 | }, 118 | unix.SYS_PREAD64: seccomp.MatchAll{}, 119 | unix.SYS_READ: seccomp.MatchAll{}, 120 | unix.SYS_RECVMSG: seccomp.Or{ 121 | seccomp.PerArg{ 122 | seccomp.AnyValue{}, 123 | seccomp.AnyValue{}, 124 | seccomp.EqualTo(0), 125 | }, 126 | seccomp.PerArg{ 127 | seccomp.AnyValue{}, 128 | seccomp.AnyValue{}, 129 | seccomp.EqualTo(unix.MSG_PEEK), 130 | }, 131 | }, 132 | unix.SYS_RESTART_SYSCALL: seccomp.MatchAll{}, 133 | unix.SYS_RT_SIGACTION: seccomp.MatchAll{}, 134 | unix.SYS_RT_SIGPROCMASK: seccomp.MatchAll{}, 135 | unix.SYS_RT_SIGRETURN: seccomp.MatchAll{}, 136 | unix.SYS_SCHED_GETAFFINITY: seccomp.MatchAll{}, 137 | unix.SYS_SCHED_YIELD: seccomp.MatchAll{}, 138 | unix.SYS_SENDMSG: seccomp.PerArg{ 139 | seccomp.AnyValue{}, 140 | seccomp.AnyValue{}, 141 | seccomp.EqualTo(0), 142 | }, 143 | unix.SYS_SIGALTSTACK: seccomp.MatchAll{}, 144 | unix.SYS_TGKILL: seccomp.PerArg{ 145 | seccomp.EqualTo(uint64(os.Getpid())), 146 | }, 147 | unix.SYS_WRITE: seccomp.MatchAll{}, 148 | unix.SYS_UNAME: seccomp.MatchAll{}, 149 | }) 150 | 151 | var networkSyscalls = seccomp.MakeSyscallRules(map[uintptr]seccomp.SyscallRule{ 152 | unix.SYS_CONNECT: seccomp.MatchAll{}, 153 | unix.SYS_GETPEERNAME: seccomp.MatchAll{}, 154 | unix.SYS_GETSOCKNAME: seccomp.MatchAll{}, 155 | unix.SYS_OPENAT: seccomp.PerArg{ 156 | seccomp.AnyValue{}, 157 | seccomp.AnyValue{}, 158 | seccomp.EqualTo(unix.O_RDONLY | unix.O_CLOEXEC), 159 | }, 160 | unix.SYS_SETSOCKOPT: seccomp.Or{ 161 | seccomp.PerArg{ 162 | seccomp.AnyValue{}, 163 | seccomp.EqualTo(unix.SOL_SOCKET), 164 | seccomp.EqualTo(unix.SO_BROADCAST), 165 | seccomp.AnyValue{}, 166 | seccomp.EqualTo(4), 167 | }, 168 | seccomp.PerArg{ 169 | seccomp.AnyValue{}, 170 | seccomp.EqualTo(unix.SOL_IPV6), 171 | seccomp.EqualTo(unix.IPV6_V6ONLY), 172 | seccomp.AnyValue{}, 173 | seccomp.EqualTo(4), 174 | }, 175 | }, 176 | unix.SYS_SOCKET: seccomp.Or{ 177 | seccomp.PerArg{ 178 | seccomp.EqualTo(unix.AF_INET), 179 | seccomp.EqualTo(unix.SOCK_STREAM | unix.SOCK_NONBLOCK | unix.SOCK_CLOEXEC), 180 | seccomp.EqualTo(0), 181 | }, 182 | seccomp.PerArg{ 183 | seccomp.EqualTo(unix.AF_INET), 184 | seccomp.EqualTo(unix.SOCK_DGRAM | unix.SOCK_NONBLOCK | unix.SOCK_CLOEXEC), 185 | seccomp.EqualTo(0), 186 | }, 187 | seccomp.PerArg{ 188 | seccomp.EqualTo(unix.AF_INET6), 189 | seccomp.EqualTo(unix.SOCK_STREAM | unix.SOCK_NONBLOCK | unix.SOCK_CLOEXEC), 190 | seccomp.EqualTo(0), 191 | }, 192 | seccomp.PerArg{ 193 | seccomp.EqualTo(unix.AF_INET6), 194 | seccomp.EqualTo(unix.SOCK_DGRAM | unix.SOCK_NONBLOCK | unix.SOCK_CLOEXEC), 195 | seccomp.EqualTo(0), 196 | }, 197 | }, 198 | }) 199 | 200 | type nullEmitter struct{} 201 | 202 | func (nullEmitter) Emit(_ int, _ log.Level, _ time.Time, _ string, _ ...interface{}) { 203 | } 204 | 205 | func installSeccompFilters(logger *zap.Logger, needsNetworking bool) (int, error) { 206 | // only allow Egress Eddie to make outbound connections if DNS 207 | // requests will need to be made directly 208 | if needsNetworking { 209 | logger.Debug("allowing networking syscalls") 210 | allowedSyscalls.Merge(networkSyscalls) 211 | } 212 | 213 | // disable logging from seccomp package 214 | log.SetTarget(&nullEmitter{}) 215 | 216 | return allowedSyscalls.Size(), seccomp.Install(allowedSyscalls, seccomp.DenyNewExecMappings, seccomp.DefaultProgramOptions()) 217 | } 218 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= 2 | github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 4 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/florianl/go-nfqueue v1.3.2 h1:8DPzhKJHywpHJAE/4ktgcqveCL7qmMLsEsVD68C4x4I= 8 | github.com/florianl/go-nfqueue v1.3.2/go.mod h1:eSnAor2YCfMCVYrVNEhkLGN/r1L+J4uDjc0EUy0tfq4= 9 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 10 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 14 | github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 15 | github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= 16 | github.com/landlock-lsm/go-landlock v0.0.0-20250303204525-1544bccde3a3 h1:zcMi8R8vP0WrrXlFMNUBpDy/ydo3sTnCcUPowq1XmSc= 17 | github.com/landlock-lsm/go-landlock v0.0.0-20250303204525-1544bccde3a3/go.mod h1:RSub3ourNF8Hf+swvw49Catm3s7HVf4hzdFxDUnEzdA= 18 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 19 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 20 | github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA= 21 | github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM= 22 | github.com/mdlayher/netlink v1.8.0/go.mod h1:UhgKXUlDQhzb09DrCl2GuRNEglHmhYoWAHid9HK3594= 23 | github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs= 24 | github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= 25 | github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 29 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 30 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 31 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 32 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 33 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 34 | go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= 35 | go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 36 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 37 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 38 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 39 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 40 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 41 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 42 | golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 43 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 44 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 45 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 46 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 48 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 49 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 50 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 51 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 54 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 59 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 60 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 61 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 62 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 63 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 64 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 65 | golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= 66 | golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 67 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 68 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 69 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 70 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 71 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 72 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 73 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 74 | gvisor.dev/gvisor v0.0.0-20250911055229-61a46406f068 h1:95kdltF/maTDk/Wulj7V81cSLgjB/Mg/6eJmOKsey4U= 75 | gvisor.dev/gvisor v0.0.0-20250911055229-61a46406f068/go.mod h1:K16uJjZ+hSqDVsXhU2Rg2FpMN7kBvjZp/Ibt5BYZJjw= 76 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.70 h1:HsB2G/rEQiYyo1bGoQqHZ/Bvd6x1rERQTNdPr1FyWjI= 77 | kernel.org/pub/linux/libs/security/libcap/psx v1.2.70/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # egress-eddie 2 | 3 | ![Tests](https://github.com/capnspacehook/egress-eddie/actions/workflows/test.yml/badge.svg) 4 | 5 | `go install github.com/capnspacehook/egress-eddie/cmd/egress-eddie@latest` 6 | 7 | ## Purpose 8 | 9 | Egress Eddie is a simple tool designed to do one thing: filter outbound traffic by hostname on Linux. 10 | Iptables and nftables both only let you filter by IP address, generally if you want to filter 11 | by hostname you need a proxy for the specific protocol you're trying to filter. But Egress Eddie 12 | allows you to filter all TCP and UDP traffic by hostname, regardless of the protocol being used 13 | on top. 14 | 15 | Filtering by hostname can make it exceedingly difficult for both malware to phone home and misbehaving 16 | software to send unwanted telemetry. Combined with strong egress firewall rules, Egress Eddie can 17 | act as a failsafe, preventing attackers that are able to execute code on your machine from exfiltrating 18 | data or interactively taking control. 19 | 20 | ## How it works 21 | 22 | Egress Eddie utilizes nfqueue to intercept configured packets from iptables or nftables. It then filters 23 | DNS requests, only allowing requests for allowed hostnames. DNS responses to those requests are tracked, 24 | and only the IP addresses or hostnames present in DNS responses are allowed outbound for a configurable 25 | amount of time. 26 | 27 | ## Details 28 | 29 | All DNS requests that are sent to Egress Eddie are filtered to make sure the questions contain allowed 30 | hostnames, and all DNS responses are also filtered in the same way. Additionally, only DNS responses from 31 | an established connection are accepted, but DNS requests from either a new or established connections 32 | are accepted. 33 | 34 | A DNS message is allowed if all of the questions in that message have an explicitly allowed hostname as 35 | a suffix. For example, if `google.com` is an allowed hostname, DNS requests for 36 | `blog.google.com`, `groups.google.com`, and `google.com` would all be allowed. 37 | 38 | Accepted DNS answers of type `A` and `AAAA` cause the contained IPs to be allowed. DNS answers of type 39 | `CNAME`, `SRV`, `MX` and `NS` cause the contained hostnames to be allowed to be queried. All other accepted 40 | DNS answer types are passed through to the sender with no action taken by Egress Eddie. 41 | 42 | Normal traffic is only parsed up to the network layer (`IPv4` or `IPv6`). The source and destination 43 | IP addresses are inspected to ensure they match IPs returned from accepted DNS answers. 44 | 45 | ## Security 46 | 47 | Egress Eddie leverages `seccomp` to ensure that it will only use a handful of syscalls (default 28) 48 | with filtered arguments. This makes it very difficult for an attacker to do anything of value if 49 | they are somehow able to execute code in the context of a running Egress Eddie process. 50 | 51 | ## Permissions required 52 | 53 | After building, give the binary necessary capabilities: 54 | 55 | ```bash 56 | setcap 'cap_net_admin=+ep' egress-eddie 57 | ``` 58 | 59 | Special permissions are needed to interface with nfqueue. 60 | 61 | Alternatively, you *could* run Egress Eddie as root, though that is **not recommended** from a security standpoint. 62 | 63 | # Configuration 64 | 65 | Egress Eddie requires both iptables rules that send appropriate packets to Egress Eddie for 66 | inspection, and to be configured to look for those packets. 67 | 68 | ## Iptables rules: 69 | 70 | The requirements for iptables rules are pretty simple. Egress Eddie requires 3 sets of 71 | rules: sending DNS responses, sending DNS requests, and sending traffic to Egress Eddie. 72 | 73 | ### Sending DNS responses 74 | 75 | First, you'll need to add a rule that sends all DNS responses to Egress Eddie. This can be 76 | accomplished as so: 77 | 78 | ```bash 79 | # filter all DNS responses 80 | iptables -A INPUT -p udp --sport 53 -m state --state ESTABLISHED,RELATED -j NFQUEUE --queue-num 1 81 | ``` 82 | 83 | Note here that only UDP traffic over port 53 is sent to Egress Eddie, but DNS traffic can 84 | be sent over TCP as well. Additional rules are omitted for brevity. 85 | 86 | Sending only established traffic isn't required, but it is recommended as starting a DNS 87 | conversation with a response doesn't make any sense. 88 | 89 | This rule only needs to be added once, regardless of how many different types of traffic 90 | you want to filter. 91 | 92 | ### Sending DNS requests 93 | 94 | Next, you'll need to add a rule that sends DNS requests to Egress Eddie. You can either 95 | send all DNS requests and filter all hostnames at once, or send specific DNS requests 96 | so that you can more granularly filter by hostname. For example, you can filter outbound 97 | traffic by the user who created the connection in iptables, allowing you to filter DNS 98 | requests differently depending on who sent it. 99 | 100 | ```bash 101 | # filter all DNS requests 102 | iptables -A OUTPUT -p udp --dport 53 -j NFQUEUE --queue-num 1000 103 | 104 | # OR 105 | 106 | # filter DNS requests from a specific user, in this case admin 107 | iptables -A OUTPUT -m owner --uid-owner admin -p udp --dport 53 -j NFQUEUE --queue-num 1000 108 | ``` 109 | 110 | ### Sending traffic 111 | 112 | Finally, you'll need to add a rule that sends the actual traffic you want to filter to 113 | Egress Eddie. As before, you can send all traffic, or traffic from certain 114 | users. The following example rules will filter HTTP traffic: 115 | 116 | ```bash 117 | # filter HTTP requests 118 | iptables -A OUTPUT -p tcp --dport 80 -m state --state NEW -j NFQUEUE --queue-num 1001 119 | 120 | # OR 121 | 122 | # filter HTTP requests from a specific user, in this case admin 123 | iptables -A OUTPUT -p tcp --dport 80 -m owner --uid-owner admin -m state --state NEW -j NFQUEUE --queue-num 1001 124 | ``` 125 | 126 | Notice how only new packets are being sent to Egress Eddie. This is purely for performance 127 | reasons. You could send new and established HTTP packets for Egress Eddie to inspect, 128 | but that would have needless overhead; if the first packet is going to an allowed IP, all 129 | following packets in the same connection will also go to that allowed IP and can be safely 130 | allowed. 131 | 132 | ## Config file 133 | 134 | The various options in the config file mostly boil down to telling Egress Eddie which nfqueue 135 | numbers to open and use. Here's a simple config that only allows traffic to `github.com`, 136 | using the same nfqueue numbers that were set in iptables rules above: 137 | 138 | ```toml 139 | inboundDNSQueue.ipv4 = 1 140 | 141 | [[filters]] 142 | name = "example" 143 | dnsQueue.ipv4 = 1000 144 | trafficQueue.ipv4 = 1001 145 | allowAnswersFor = "5m" 146 | allowedHostnames = [ 147 | "github.com", 148 | ] 149 | ``` 150 | 151 | If you are filtering `IPv6` traffic and using ip6tables, set `inboundDNSQueue.ipv6`, 152 | `dnsQueue.ipv6`, and `trafficQueue.ipv6`. 153 | 154 | Next we create a filter, setting the nfqueue numbers used for DNS requests and traffic 155 | that we want filtered. The `name` of each filter is simply an identifier that will allow 156 | you to more easily read or search through Egress Eddie's logs. 157 | 158 | `allowAnswersFor` controls how long IPs and hostnames returned 159 | from DNS responses are allowed for. The syntax for specifying a duration is the 160 | [Go duration syntax](https://pkg.go.dev/time#ParseDuration). 161 | 162 | Finally `allowedHostnames` controls the hostnames that are allowed, which here is just `github.com`. 163 | 164 | ### Allowing all hostnames 165 | 166 | There may be situations where you want to filter the hostnames of a specific user or type 167 | of traffic, but allow other users or types of traffic flow unrestricted. I like to allow 168 | the root user to have unrestricted HTTP/S access for example, as if someone compromises the 169 | root account, then all other bets are off. 170 | 171 | To accomplish this, set `allowAllHostnames = true` and don't set both `trafficQueue` and 172 | `allowedHostnames`. Because all DNS responses must be inspected by Egress Eddie in order for it to 173 | function properly, all DNS requests must go through Egress Eddie as well. 174 | 175 | ## Example 176 | 177 | Here's an example that ties everything mentioned above together. It allows `apt` to access 178 | the standard Debian repositories, the `dev` user to pull Go modules, and the `root` user 179 | to have unrestricted DNS traffic. 180 | 181 | iptables rules: 182 | 183 | ```bash 184 | # filter all DNS responses 185 | iptables -A INPUT -p udp --sport 53 -j NFQUEUE --queue-num 1 186 | 187 | # filter DNS requests from apt 188 | iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner _apt -j NFQUEUE --queue-num 1000 189 | # filter HTTP/S requests from apt 190 | iptables -A OUTPUT -p tcp --dport 80 -m owner --uid-owner _apt -m state --state NEW -j NFQUEUE --queue-num 1001 191 | iptables -A OUTPUT -p tcp --dport 443 -m owner --uid-owner _apt -m state --state NEW -j NFQUEUE --queue-num 1001 192 | 193 | # filter DNS requests from the dev user 194 | iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner dev -j NFQUEUE --queue-num 2000 195 | # filter HTTP/S requests from the dev user 196 | iptables -A OUTPUT -p tcp --dport 80 -m owner --uid-owner dev -m state --state NEW -j NFQUEUE --queue-num 2001 197 | iptables -A OUTPUT -p tcp --dport 443 -m owner --uid-owner dev -m state --state NEW -j NFQUEUE --queue-num 2001 198 | 199 | # allow all DNS requests from the root user 200 | iptables -A OUTPUT -p udp --dport 53 -m owner --uid-owner root -j NFQUEUE --queue-num 3000 201 | ``` 202 | 203 | config file: 204 | 205 | ```toml 206 | inboundDNSQueue.ipv4 = 1 207 | 208 | # filter apt updating 209 | [[filters]] 210 | name = "apt updating" 211 | dnsQueue.ipv4 = 1000 212 | trafficQueue.ipv4 = 1001 213 | allowAnswersFor = "30m" 214 | allowedHostnames = [ 215 | "deb.debian.org", 216 | "security.debian.org", 217 | ] 218 | 219 | # filter go module traffic 220 | [[filters]] 221 | name = "go modules" 222 | dnsQueue.ipv4 = 2000 223 | trafficQueue.ipv4 = 2001 224 | allowAnswersFor = "5m" 225 | allowedHostnames = [ 226 | "proxy.golang.org", 227 | "sum.golang.org", 228 | ] 229 | 230 | # allow all root DNS requests 231 | [[filters]] 232 | name = "root allow all" 233 | dnsQueue.ipv4 = 3000 234 | allowAllHostnames = true 235 | ``` 236 | 237 | ## Verifying releases 238 | 239 | Starting from v1.1.1, binary checksum files are signed. You can verify 240 | released binaries to ensure they were not tampered with in transit. 241 | 242 | Verifying binaries requires [`cosign`](https://github.com/sigstore/cosign). 243 | 244 | ### Verifying binaries 245 | 246 | Download the checksums file, certificate, signature and the archive to the same directory. 247 | 248 | Extract the binary from the archive, verify the checksums file and verify the contents of the binary: 249 | 250 | ```sh 251 | tar xfs egress-eddie__linux_amd64.tar.gz 252 | cosign verify-blob --certificate checksums.txt.crt --signature checksums.txt.sig checksums.txt 253 | sha256sum -c checksums.txt 254 | ``` 255 | 256 | ### Reproducing released binaries 257 | 258 | You can also reproduce the released binaries to verify that they were built from this unmodified 259 | source code. Verifying binaries requires [`gorepro`](https://github.com/capnspacehook/gorepro). 260 | 261 | First, download the release archive and extract it. Clone this repro and go into it. 262 | 263 | Install `gorepro` and run it on the extracted release binary. `gorepro` will tell you if reproducing 264 | the binary was successful. Don't worry about checking out the correct tag or commit, gorepro will 265 | handle that for you. 266 | 267 | If you don't trust `gorepro` you can run it again additionally passing the `-d` flag. This will 268 | print the commands `gorepro` generated to reproduce the release binary. You can run the printed commands 269 | and verify for yourself that the reproduced binary is bit for bit identical to the released one. 270 | 271 | ```sh 272 | tar fxs egress-eddie__linux_amd64.tar.gz 273 | git clone https://github.com/capnspacehook/egress-eddie egress-eddie-src 274 | cd egress-eddie-src 275 | 276 | # reproduce binary 277 | go install github.com/capnspacehook/gorepro@latest 278 | gorepro -b="-ldflags=-s -w -X main.version " ../egress-eddie 279 | 280 | # reproduce by manually running command from gorepro 281 | BUILD_CMD="$(gorepro -d -b='-ldflags=-s -w -X main.version ' ../egress-eddie)" 282 | echo "$BUILD_CMD" 283 | "$BUILD_CMD" 284 | sha256sum egress-eddie egress-eddie.repro 285 | ``` 286 | 287 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package egresseddie 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "slices" 8 | "strconv" 9 | "strings" 10 | "time" 11 | _ "unsafe" // only needed for go:linkname directive 12 | 13 | "github.com/BurntSushi/toml" 14 | ) 15 | 16 | const selfFilterName = "self-filter" 17 | 18 | type queue struct { 19 | IPv4 uint16 20 | IPv6 uint16 21 | } 22 | 23 | func (q queue) valid() bool { 24 | if !q.eitherSet() { 25 | return true 26 | } 27 | 28 | return q.IPv4 != q.IPv6 29 | } 30 | 31 | func (q queue) eitherSet() bool { 32 | return q.IPv4 != 0 || q.IPv6 != 0 33 | } 34 | 35 | func (q queue) bothSet() bool { 36 | return q.IPv4 != 0 && q.IPv6 != 0 37 | } 38 | 39 | func queuesShared(q1, q2 queue) bool { 40 | if q1.IPv4 != 0 && q2.IPv4 != 0 && q1.IPv4 == q2.IPv4 { 41 | return true 42 | } 43 | if q1.IPv6 != 0 && q2.IPv6 != 0 && q1.IPv6 == q2.IPv6 { 44 | return true 45 | } 46 | 47 | if q1.IPv4 != 0 && q2.IPv6 != 0 && q1.IPv4 == q2.IPv6 { 48 | return true 49 | } 50 | if q1.IPv6 != 0 && q2.IPv4 != 0 && q1.IPv6 == q2.IPv4 { 51 | return true 52 | } 53 | 54 | return false 55 | } 56 | 57 | type Config struct { 58 | InboundDNSQueue queue 59 | SelfDNSQueue queue 60 | Filters []FilterOptions 61 | 62 | enforcerCreator enforcerCreator 63 | resolver resolver 64 | } 65 | 66 | type FilterOptions struct { 67 | Name string 68 | DNSQueue queue 69 | TrafficQueue queue 70 | AllowAllHostnames bool 71 | LookupUnknownIPs bool 72 | AllowAnswersFor time.Duration 73 | ReCacheEvery time.Duration 74 | AllowedHostnames []string 75 | CachedHostnames []string 76 | } 77 | 78 | func ParseConfig(confPath string) (*Config, error) { 79 | data, err := os.ReadFile(confPath) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return parseConfigBytes(data) 85 | } 86 | 87 | func parseConfigBytes(cb []byte) (*Config, error) { 88 | var config Config 89 | 90 | md, err := toml.Decode(string(cb), &config) 91 | if err != nil { 92 | return nil, err 93 | } 94 | if undec := md.Undecoded(); len(undec) > 0 { 95 | var sb strings.Builder 96 | sb.WriteString("unknown keys ") 97 | for i, key := range undec { 98 | sb.WriteString(strconv.Quote(key.String())) 99 | if i != len(undec)-1 { 100 | sb.WriteString(", ") 101 | } 102 | } 103 | 104 | return nil, errors.New(sb.String()) 105 | } 106 | 107 | if len(config.Filters) == 0 { 108 | return nil, errors.New("at least one filter must be specified") 109 | } 110 | if !config.InboundDNSQueue.eitherSet() { 111 | return nil, errors.New(`"inboundDNSQueue" must be set`) 112 | } 113 | if !config.InboundDNSQueue.valid() { 114 | return nil, errors.New(`"inboundDNSQueue.ipv4" and "inboundDNSQueue.ipv6" cannot be the same`) 115 | } 116 | 117 | ipv4Used := config.InboundDNSQueue.IPv4 != 0 118 | ipv6Used := config.InboundDNSQueue.IPv6 != 0 119 | 120 | var ( 121 | preformReverseLookups bool 122 | allCachedHostnames []string 123 | 124 | filterNames = make(map[string]int) 125 | filterQueues = make(map[uint16]string) 126 | ) 127 | 128 | for i, filterOpt := range config.Filters { 129 | if filterOpt.Name == "" { 130 | return nil, fmt.Errorf(`filter #%d: "name" must be set`, i) 131 | } 132 | 133 | if !filterOpt.DNSQueue.eitherSet() && len(filterOpt.CachedHostnames) == 0 && !filterOpt.LookupUnknownIPs { 134 | return nil, fmt.Errorf(`filter %q: "dnsQueue" must be set`, filterOpt.Name) 135 | } 136 | if !filterOpt.DNSQueue.valid() { 137 | return nil, fmt.Errorf(`filter %q: "dnsQueue.ipv4" and "dnsQueue.ipv6" cannot be the same`, filterOpt.Name) 138 | } 139 | if ipv4Used && filterOpt.DNSQueue.eitherSet() && filterOpt.DNSQueue.IPv4 == 0 { 140 | return nil, fmt.Errorf(`filter %q: "dnsQueue.ipv4" must be set when "inboundDNSQueue.ipv4" is set`, filterOpt.Name) 141 | } 142 | if !ipv4Used && filterOpt.DNSQueue.bothSet() { 143 | return nil, fmt.Errorf(`filter %q: "dnsQueue.ipv4" must not be set when "inboundDNSQueue.ipv4" is not set`, filterOpt.Name) 144 | } 145 | if ipv6Used && filterOpt.DNSQueue.eitherSet() && filterOpt.DNSQueue.IPv6 == 0 { 146 | return nil, fmt.Errorf(`filter %q: "dnsQueue.ipv6" must be set when "inboundDNSQueue.ipv6" is set`, filterOpt.Name) 147 | } 148 | if !ipv6Used && filterOpt.DNSQueue.bothSet() { 149 | return nil, fmt.Errorf(`filter %q: "dnsQueue.ipv6" must not be set when "inboundDNSQueue.ipv6" is not set`, filterOpt.Name) 150 | } 151 | if filterOpt.DNSQueue.eitherSet() && len(filterOpt.AllowedHostnames) == 0 && (len(filterOpt.CachedHostnames) > 0 || filterOpt.LookupUnknownIPs) { 152 | return nil, fmt.Errorf(`filter %q: "dnsQueue" must not be set when "allowedHostnames" is empty and either "cachedHostames" is not empty or "lookupUnknownIPs" is true`, filterOpt.Name) 153 | } 154 | if queuesShared(config.InboundDNSQueue, filterOpt.DNSQueue) { 155 | return nil, fmt.Errorf(`filter %q: "inboundDNSQueue" and "dnsQueue" must be different`, filterOpt.Name) 156 | } 157 | 158 | if !filterOpt.TrafficQueue.eitherSet() && !filterOpt.AllowAllHostnames { 159 | return nil, fmt.Errorf(`filter %q: "trafficQueue" must be set`, filterOpt.Name) 160 | } 161 | if !filterOpt.TrafficQueue.valid() { 162 | return nil, fmt.Errorf(`filter %q: "trafficQueue.ipv4" and "trafficQueue.ipv6" cannot be the same`, filterOpt.Name) 163 | } 164 | if ipv4Used && filterOpt.TrafficQueue.eitherSet() && filterOpt.TrafficQueue.IPv4 == 0 { 165 | return nil, fmt.Errorf(`filter %q: "trafficQueue.ipv4" must be set when "inboundDNSQueue.ipv4" is set`, filterOpt.Name) 166 | } 167 | if !ipv4Used && filterOpt.TrafficQueue.bothSet() { 168 | return nil, fmt.Errorf(`filter %q: "trafficQueue.ipv4" must not be set when "inboundDNSQueue.ipv4" is not set`, filterOpt.Name) 169 | } 170 | if ipv6Used && filterOpt.TrafficQueue.eitherSet() && filterOpt.TrafficQueue.IPv6 == 0 { 171 | return nil, fmt.Errorf(`filter %q: "trafficQueue.ipv6" must be set when "inboundDNSQueue.ipv6" is set`, filterOpt.Name) 172 | } 173 | if !ipv6Used && filterOpt.TrafficQueue.bothSet() { 174 | return nil, fmt.Errorf(`filter %q: "trafficQueue.ipv6" must not be set when "inboundDNSQueue.ipv6" is not set`, filterOpt.Name) 175 | } 176 | if filterOpt.TrafficQueue.eitherSet() && filterOpt.AllowAllHostnames { 177 | return nil, fmt.Errorf(`filter %q: "trafficQueue" must not be set when "allowAllHostnames" is true`, filterOpt.Name) 178 | } 179 | if queuesShared(config.InboundDNSQueue, filterOpt.TrafficQueue) { 180 | return nil, fmt.Errorf(`filter %q: "inboundDNSQueue" and "trafficQueue" must be different`, filterOpt.Name) 181 | } 182 | 183 | if queuesShared(filterOpt.DNSQueue, filterOpt.TrafficQueue) { 184 | return nil, fmt.Errorf(`filter %q: "dnsQueue" and "trafficQueue" must be different`, filterOpt.Name) 185 | } 186 | 187 | if len(filterOpt.AllowedHostnames) == 0 && !filterOpt.AllowAllHostnames && len(filterOpt.CachedHostnames) == 0 && !filterOpt.LookupUnknownIPs { 188 | return nil, fmt.Errorf(`filter %q: "allowedHostnames" must not be empty`, filterOpt.Name) 189 | } 190 | if len(filterOpt.AllowedHostnames) > 0 && filterOpt.AllowAllHostnames { 191 | return nil, fmt.Errorf(`filter %q: "allowedHostnames" must be empty when "allowAllHostnames" is true`, filterOpt.Name) 192 | } 193 | if filterOpt.AllowAnswersFor == 0 && len(filterOpt.AllowedHostnames) > 0 { 194 | return nil, fmt.Errorf(`filter %q: "allowAnswersFor" must be set when "allowedHostnames" is not empty`, filterOpt.Name) 195 | } 196 | if filterOpt.AllowAnswersFor != 0 && filterOpt.AllowAllHostnames { 197 | return nil, fmt.Errorf(`filter %q: "allowAnswersFor" must not be set when "allowAllHostnames" is true`, filterOpt.Name) 198 | } 199 | if filterOpt.AllowAnswersFor < 0 { 200 | return nil, fmt.Errorf(`filter %q: "allowAnswersFor" must not be negative`, filterOpt.Name) 201 | } 202 | 203 | if len(filterOpt.CachedHostnames) > 0 && filterOpt.AllowAllHostnames { 204 | return nil, fmt.Errorf(`filter %q: "cachedHostnames" must be empty when "allowAllHostnames" is true`, filterOpt.Name) 205 | } 206 | if filterOpt.ReCacheEvery == 0 && len(filterOpt.CachedHostnames) > 0 { 207 | return nil, fmt.Errorf(`filter %q: "reCacheEvery" must be set when "cachedHostnames" is not empty`, filterOpt.Name) 208 | } 209 | if filterOpt.ReCacheEvery != 0 && len(filterOpt.CachedHostnames) == 0 { 210 | return nil, fmt.Errorf(`filter %q: "reCacheEvery" must not be set when "cachedHostnames" is empty`, filterOpt.Name) 211 | } 212 | if filterOpt.ReCacheEvery < 0 { 213 | return nil, fmt.Errorf(`filter %q: "reCacheEvery" must not be negative`, filterOpt.Name) 214 | } 215 | 216 | for i, name := range filterOpt.AllowedHostnames { 217 | if !validDomainName(name) { 218 | return nil, fmt.Errorf("filter %q: allowed hostname %q is not a valid domain name", filterOpt.Name, name) 219 | } 220 | if slices.Contains(filterOpt.CachedHostnames, name) { 221 | return nil, fmt.Errorf("filter %q: allowed hostname %q is specified as a hostname to be cached as well", filterOpt.Name, name) 222 | } 223 | if i != len(filterOpt.AllowedHostnames)-1 && slices.Contains(filterOpt.AllowedHostnames[i+1:], name) { 224 | return nil, fmt.Errorf("filter %q: allowed hostname %q is specified more than once", filterOpt.Name, name) 225 | } 226 | } 227 | for i, name := range filterOpt.CachedHostnames { 228 | if !validDomainName(name) { 229 | return nil, fmt.Errorf("filter %q: hostname to be cached %q is not a valid domain name", filterOpt.Name, name) 230 | } 231 | if i != len(filterOpt.CachedHostnames)-1 && slices.Contains(filterOpt.CachedHostnames[i+1:], name) { 232 | return nil, fmt.Errorf("filter %q: hostname to be cached %q is specified more than once", filterOpt.Name, name) 233 | } 234 | } 235 | 236 | if idx, ok := filterNames[filterOpt.Name]; ok { 237 | return nil, fmt.Errorf(`filter #%d: filter name %q is already used by filter #%d`, i, filterOpt.Name, idx) 238 | } 239 | if filterOpt.DNSQueue.IPv4 != 0 { 240 | if name, ok := filterQueues[filterOpt.DNSQueue.IPv4]; ok { 241 | return nil, fmt.Errorf(`filter %q: "dnsQueue.ipv4" %d is already used by filter %q`, filterOpt.Name, filterOpt.DNSQueue.IPv4, name) 242 | } 243 | } 244 | if filterOpt.DNSQueue.IPv6 != 0 { 245 | if name, ok := filterQueues[filterOpt.DNSQueue.IPv6]; ok { 246 | return nil, fmt.Errorf(`filter %q: "dnsQueue.ipv6" %d is already used by filter %q`, filterOpt.Name, filterOpt.DNSQueue.IPv6, name) 247 | } 248 | } 249 | if filterOpt.TrafficQueue.IPv4 != 0 { 250 | if name, ok := filterQueues[filterOpt.TrafficQueue.IPv4]; ok { 251 | return nil, fmt.Errorf(`filter %q: "trafficQueue.ipv4" %d is already used by filter %q`, filterOpt.Name, filterOpt.TrafficQueue.IPv4, name) 252 | } 253 | } 254 | if filterOpt.TrafficQueue.IPv6 != 0 { 255 | if name, ok := filterQueues[filterOpt.TrafficQueue.IPv6]; ok { 256 | return nil, fmt.Errorf(`filter %q: "trafficQueue.ipv6" %d is already used by filter %q`, filterOpt.Name, filterOpt.TrafficQueue.IPv6, name) 257 | } 258 | } 259 | 260 | if filterOpt.LookupUnknownIPs { 261 | preformReverseLookups = true 262 | } 263 | if len(filterOpt.CachedHostnames) > 0 { 264 | allCachedHostnames = append(allCachedHostnames, filterOpt.CachedHostnames...) 265 | } 266 | 267 | filterNames[filterOpt.Name] = i 268 | if filterOpt.DNSQueue.IPv4 != 0 { 269 | filterQueues[filterOpt.DNSQueue.IPv4] = filterOpt.Name 270 | } 271 | if filterOpt.DNSQueue.IPv6 != 0 { 272 | filterQueues[filterOpt.DNSQueue.IPv6] = filterOpt.Name 273 | } 274 | if filterOpt.TrafficQueue.IPv4 != 0 { 275 | filterQueues[filterOpt.TrafficQueue.IPv4] = filterOpt.Name 276 | } 277 | if filterOpt.TrafficQueue.IPv6 != 0 { 278 | filterQueues[filterOpt.TrafficQueue.IPv6] = filterOpt.Name 279 | } 280 | } 281 | 282 | if !config.SelfDNSQueue.eitherSet() && (preformReverseLookups || len(allCachedHostnames) > 0) { 283 | return nil, errors.New(`"selfDNSQueue" must be set when at least one filter either sets "lookupUnknownIPs" to true or "cachedHostnames" is not empty`) 284 | } 285 | if config.SelfDNSQueue.eitherSet() && !preformReverseLookups && len(allCachedHostnames) == 0 { 286 | return nil, errors.New(`"selfDNSQueue" must only be set when at least one filter either sets "lookupUnknownIPs" to true or "cachedHostnames" is not empty`) 287 | } 288 | if !config.SelfDNSQueue.valid() { 289 | return nil, errors.New(`"selfDNSQueue.ipv4" and "selfDNSQueue.ipv6" cannot be the same`) 290 | } 291 | if ipv4Used && config.SelfDNSQueue.eitherSet() && config.SelfDNSQueue.IPv4 == 0 { 292 | return nil, errors.New(`"selfDNSQueue.ipv4" must be set when "inboundDNSQueue.ipv4" is set`) 293 | } 294 | if !ipv4Used && config.SelfDNSQueue.bothSet() { 295 | return nil, errors.New(`"selfDNSQueue.ipv4" must not be set when "inboundDNSQueue.ipv4" is not set`) 296 | } 297 | if ipv6Used && config.SelfDNSQueue.eitherSet() && config.SelfDNSQueue.IPv6 == 0 { 298 | return nil, errors.New(`"selfDNSQueue.ipv6" must be set when "inboundDNSQueue.ipv6" is set`) 299 | } 300 | if !ipv6Used && config.SelfDNSQueue.bothSet() { 301 | return nil, errors.New(`"selfDNSQueue.ipv6" must not be set when "inboundDNSQueue.ipv6" is not set`) 302 | } 303 | 304 | if queuesShared(config.InboundDNSQueue, config.SelfDNSQueue) { 305 | return nil, errors.New(`"inboundDNSQueue" and "selfDNSQueue" must be different`) 306 | } 307 | for _, filter := range config.Filters { 308 | if queuesShared(config.SelfDNSQueue, filter.DNSQueue) { 309 | return nil, fmt.Errorf(`filter %q: "selfDNSQueue" and "dnsQueue" must be different`, filter.Name) 310 | } 311 | if queuesShared(config.SelfDNSQueue, filter.TrafficQueue) { 312 | return nil, fmt.Errorf(`filter %q: "selfDNSQueue" and "trafficQueue" must be different`, filter.Name) 313 | } 314 | } 315 | 316 | // if 'selfDNSQueue' is specified, create a filter that will allow 317 | // Egress Eddie to only make required DNS queries 318 | if config.SelfDNSQueue.eitherSet() { 319 | selfFilter := FilterOptions{ 320 | Name: selfFilterName, 321 | DNSQueue: config.SelfDNSQueue, 322 | } 323 | 324 | if preformReverseLookups { 325 | selfFilter.AllowedHostnames = []string{ 326 | "in-addr.arpa", 327 | "ip6.arpa", 328 | } 329 | } 330 | if len(allCachedHostnames) > 0 { 331 | selfFilter.AllowedHostnames = append(selfFilter.AllowedHostnames, allCachedHostnames...) 332 | } 333 | 334 | config.Filters = append([]FilterOptions{selfFilter}, config.Filters...) 335 | } 336 | 337 | return &config, nil 338 | } 339 | 340 | func validDomainName(dn string) bool { 341 | if !isDomainName(dn) { 342 | return false 343 | } 344 | // A domain name ending with a dot is technically allowed (I think), 345 | // but because seemingly all DNS clients chop off the trailing dot 346 | // when making DNS requests, Egress Eddie can't properly validate 347 | // these domains. For simplicity, don't allow them. 348 | if dn[len(dn)-1] == '.' { 349 | return false 350 | } 351 | 352 | return true 353 | } 354 | 355 | //go:linkname isDomainName net.isDomainName 356 | func isDomainName(s string) bool 357 | -------------------------------------------------------------------------------- /filter_test.go: -------------------------------------------------------------------------------- 1 | package egresseddie 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "net/netip" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "syscall" 15 | "testing" 16 | "time" 17 | 18 | "github.com/anmitsu/go-shlex" 19 | "github.com/florianl/go-nfqueue" 20 | "github.com/matryer/is" 21 | "go.uber.org/goleak" 22 | "go.uber.org/zap" 23 | "go.uber.org/zap/zapcore" 24 | "golang.org/x/sys/unix" 25 | ) 26 | 27 | var ( 28 | binaryTests = flag.Bool("binary-tests", false, "use compiled binary to test with landlock and seccomp enabled") 29 | containerTests = flag.Bool("container-tests", false, "use Docker image to test with landlock and seccomp enabled") 30 | eddieBinary = flag.String("eddie-binary", "./egress-eddie", "path to compiled egress-eddie binary") 31 | eddieImage = flag.String("eddie-image", "egress-eddie:test", "Docker image to test with") 32 | // Github hosted runners don't support IPv6, so can't test with IPv6 33 | // in Github Actions 34 | // see https://github.com/actions/runner-images/issues/668 35 | enableIPv6 = flag.Bool("enable-ipv6", true, "enable testing IPv6") 36 | ) 37 | 38 | func TestFiltering(t *testing.T) { 39 | configStr := ` 40 | inboundDNSQueue.ipv4 = 1 41 | inboundDNSQueue.ipv6 = 10 42 | 43 | [[filters]] 44 | name = "test" 45 | dnsQueue.ipv4 = 1000 46 | dnsQueue.ipv6 = 1010 47 | trafficQueue.ipv4 = 1001 48 | trafficQueue.ipv6 = 1011 49 | allowAnswersFor = "3s" 50 | allowedHostnames = [ 51 | "debian.org", 52 | "facebook.com", 53 | "google.com", 54 | "gist.github.com", 55 | "twitter.com", 56 | ]` 57 | 58 | initFilters( 59 | t, 60 | configStr, 61 | []string{ 62 | "-A INPUT -p udp --sport 53 -m state --state ESTABLISHED -j NFQUEUE --queue-num 1", 63 | "-A OUTPUT -p udp --dport 53 -j NFQUEUE --queue-num 1000", 64 | "-A OUTPUT -p tcp --dport 443 -m state --state NEW -j NFQUEUE --queue-num 1001", 65 | }, 66 | []string{ 67 | "-A INPUT -p udp --sport 53 -m state --state ESTABLISHED -j NFQUEUE --queue-num 10", 68 | "-A OUTPUT -p udp --dport 53 -j NFQUEUE --queue-num 1010", 69 | "-A OUTPUT -p tcp --dport 443 -m state --state NEW -j NFQUEUE --queue-num 1011", 70 | }, 71 | ) 72 | client4, client6 := getHTTPClients() 73 | 74 | t.Run("allowed requests", func(t *testing.T) { 75 | is := is.New(t) 76 | 77 | err := makeHTTPReqs(client4, client6, "https://google.com") 78 | is.NoErr(err) // request to allowed hostname should succeed 79 | 80 | err = makeHTTPReqs(client4, client6, "https://news.google.com") 81 | is.NoErr(err) // request to allowed subdomain of hostname should succeed 82 | 83 | // TODO: github.com does not have AAAA record, so this will fail over 84 | // IPv6. Find other website that will work here 85 | err = makeHTTPReqs(client4, nil, "https://gist.github.com") 86 | is.NoErr(err) // request to allowed hostname should succeed 87 | 88 | err = makeHTTPReqs(client4, nil, "https://github.com") 89 | is.NoErr(err) // request to allowed hostname from response CNAME should succeed 90 | }) 91 | 92 | t.Run("blocked requests", func(t *testing.T) { 93 | is := is.New(t) 94 | 95 | err := makeHTTPReqs(client4, client6, "https://microsoft.com") 96 | is.True(reqFailed(err)) // request to disallowed hostname should fail 97 | 98 | err = makeHTTPReqs(client4, client6, "https://ggoogle.com") 99 | is.True(reqFailed(err)) // test subdomain matching works correctly 100 | 101 | _, err = client4.Get("https://1.1.1.1") 102 | is.True(reqFailed(err)) // request to IPv4 IP of disallowed hostname should fail 103 | if *enableIPv6 { 104 | _, err = client6.Get("https://[2606:4700:4700::1111]") 105 | is.True(reqFailed(err)) // request to IPv6 IP of disallowed hostname should fail 106 | } 107 | }) 108 | 109 | t.Run("MX", func(t *testing.T) { 110 | is := is.New(t) 111 | 112 | mailDomains, err := net.DefaultResolver.LookupMX(getTimeout(t), "twitter.com") 113 | is.NoErr(err) // MX request to allowed hostname should succeed 114 | 115 | for _, mailDomain := range mailDomains { 116 | _, _, err = lookupIPs(t, mailDomain.Host) 117 | is.NoErr(err) // lookup of allowed mail hostname should succeed 118 | } 119 | }) 120 | 121 | t.Run("NS", func(t *testing.T) { 122 | is := is.New(t) 123 | 124 | nameServers, err := net.DefaultResolver.LookupNS(getTimeout(t), "facebook.com") 125 | is.NoErr(err) // NS request to allowed hostname should succeed 126 | 127 | for _, nameServer := range nameServers { 128 | _, _, err = lookupIPs(t, nameServer.Host) 129 | is.NoErr(err) // lookup of allowed name server should succeed 130 | } 131 | }) 132 | 133 | t.Run("SRV", func(t *testing.T) { 134 | is := is.New(t) 135 | 136 | _, servers, err := net.DefaultResolver.LookupSRV(getTimeout(t), "https", "tcp", "deb.debian.org") 137 | is.NoErr(err) // SRV request to allowed hostname should succeed 138 | 139 | for _, server := range servers { 140 | _, _, err = lookupIPs(t, server.Target) 141 | is.NoErr(err) // lookup of allowed server should succeed 142 | } 143 | }) 144 | 145 | t.Run("expired IP", func(t *testing.T) { 146 | is := is.New(t) 147 | 148 | addrs4, addrs6, err := lookupIPs(t, "google.com") 149 | is.NoErr(err) // lookup of allowed hostname should succeed 150 | 151 | time.Sleep(4 * time.Second) // wait until IPs should expire 152 | 153 | _, err = client4.Get("https://" + addrs4[0].Unmap().String()) 154 | is.True(reqFailed(err)) // request to expired IPv4 IP should fail 155 | if *enableIPv6 { 156 | _, err = client6.Get("https://[" + addrs6[0].Unmap().String() + "]") 157 | is.True(reqFailed(err)) // request to expired IPv6 IP should fail 158 | } 159 | }) 160 | } 161 | 162 | func TestAllowAll(t *testing.T) { 163 | configStr := ` 164 | inboundDNSQueue.ipv4 = 1 165 | inboundDNSQueue.ipv6 = 10 166 | 167 | [[filters]] 168 | name = "test" 169 | dnsQueue.ipv4 = 1000 170 | dnsQueue.ipv6 = 1010 171 | allowAllHostnames = true` 172 | 173 | initFilters( 174 | t, 175 | configStr, 176 | []string{ 177 | "-A INPUT -p udp --sport 53 -m state --state ESTABLISHED -j NFQUEUE --queue-num 1", 178 | "-A OUTPUT -p udp --dport 53 -j NFQUEUE --queue-num 1000", 179 | }, 180 | []string{ 181 | "-A INPUT -p udp --sport 53 -m state --state ESTABLISHED -j NFQUEUE --queue-num 10", 182 | "-A OUTPUT -p udp --dport 53 -j NFQUEUE --queue-num 1010", 183 | }, 184 | ) 185 | client4, client6 := getHTTPClients() 186 | 187 | is := is.New(t) 188 | 189 | err := makeHTTPReqs(client4, client6, "https://harmony.shinesparkers.net") 190 | is.NoErr(err) // request to hostname should succeed 191 | } 192 | 193 | func TestCaching(t *testing.T) { 194 | configStr := ` 195 | inboundDNSQueue.ipv4 = 1 196 | inboundDNSQueue.ipv6 = 10 197 | selfDNSQueue.ipv4 = 100 198 | selfDNSQueue.ipv6 = 110 199 | 200 | [[filters]] 201 | name = "test" 202 | trafficQueue.ipv4 = 1001 203 | trafficQueue.ipv6 = 1011 204 | reCacheEvery = "1m" 205 | cachedHostnames = [ 206 | "digitalocean.com", 207 | ]` 208 | 209 | is := is.New(t) 210 | 211 | addrs, err := net.DefaultResolver.LookupNetIP(getTimeout(t), "ip4", "digitalocean.com") 212 | is.NoErr(err) 213 | 214 | initFilters( 215 | t, 216 | configStr, 217 | []string{ 218 | "-A INPUT -p udp --sport 53 -m state --state ESTABLISHED -j NFQUEUE --queue-num 1", 219 | "-A OUTPUT -p udp --dport 53 -j NFQUEUE --queue-num 100", 220 | "-A OUTPUT -p tcp --dport 80 -m state --state NEW -j NFQUEUE --queue-num 1001", 221 | }, 222 | []string{ 223 | "-A INPUT -p udp --sport 53 -m state --state ESTABLISHED -j NFQUEUE --queue-num 10", 224 | "-A OUTPUT -p udp --dport 53 -j NFQUEUE --queue-num 110", 225 | "-A OUTPUT -p tcp --dport 80 -m state --state NEW -j NFQUEUE --queue-num 1011", 226 | }, 227 | ) 228 | client4, _ := getHTTPClients() 229 | 230 | // wait until hostnames responses are cached by filters 231 | time.Sleep(3 * time.Second) 232 | 233 | for _, addr := range addrs { 234 | // skip IPv6 addresses, causes an error when preforming a GET request 235 | addr = addr.Unmap() 236 | 237 | resp, err := client4.Get("http://" + addr.String()) 238 | is.NoErr(err) // request to IP of cached hostname should succeed 239 | resp.Body.Close() 240 | } 241 | 242 | _, err = net.DefaultResolver.LookupNetIP(getTimeout(t), "ip4", "microsoft.com") 243 | is.True(reqFailed(err)) // lookup of disallowed domain should fail 244 | } 245 | 246 | func TestFiltersStart(t *testing.T) { 247 | if *binaryTests { 248 | t.Skip() 249 | } 250 | 251 | configBytes := []byte(` 252 | inboundDNSQueue.ipv6 = 10 253 | selfDNSQueue.ipv6 = 110 254 | 255 | [[filters]] 256 | name = "test" 257 | dnsQueue.ipv6 = 1010 258 | trafficQueue.ipv6 = 1011 259 | reCacheEvery = "1m" 260 | cachedHostnames = [ 261 | "example.com", 262 | ] 263 | allowAnswersFor = "1s" 264 | allowedHostnames = [ 265 | "test.org" 266 | ]`) 267 | 268 | is := is.New(t) 269 | 270 | config, err := parseConfigBytes(configBytes) 271 | is.NoErr(err) 272 | 273 | config.enforcerCreator = newMockEnforcer 274 | 275 | t.Run("filters waiting", func(t *testing.T) { 276 | is := is.New(t) 277 | 278 | initMockEnforcers() 279 | 280 | ctx, cancel := context.WithCancel(context.Background()) 281 | f, err := CreateFilters(ctx, zap.NewNop(), config, false) 282 | is.NoErr(err) 283 | t.Cleanup(func() { 284 | cancel() 285 | f.Stop() 286 | }) 287 | 288 | finishedAt := make(chan time.Time) 289 | 290 | go func() { 291 | mockEnforcers[config.InboundDNSQueue.IPv6].hook(nfqueue.Attribute{}) 292 | t.Log("finished DNS reply queue") 293 | finishedAt <- time.Now() 294 | }() 295 | // the self-filter will be the first filter 296 | testFilter := config.Filters[1] 297 | go func() { 298 | mockEnforcers[testFilter.DNSQueue.IPv6].hook(nfqueue.Attribute{}) 299 | t.Log("finished DNS request queue") 300 | finishedAt <- time.Now() 301 | }() 302 | go func() { 303 | mockEnforcers[testFilter.TrafficQueue.IPv6].hook(nfqueue.Attribute{}) 304 | t.Log("finished generic queue") 305 | finishedAt <- time.Now() 306 | }() 307 | 308 | time.Sleep(time.Second) 309 | startedAt := time.Now() 310 | f.Start() 311 | t.Log("starting filters") 312 | 313 | for range 3 { 314 | t := <-finishedAt 315 | is.True(t.After(startedAt)) // packet handling should have finished after filters were started 316 | } 317 | }) 318 | 319 | t.Run("stopping without starting", func(t *testing.T) { 320 | is := is.New(t) 321 | 322 | // test that goroutines are cleanly shutdown 323 | defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) 324 | 325 | // use real nfqueues 326 | config.enforcerCreator = nil 327 | ctx, cancel := context.WithCancel(context.Background()) 328 | f, err := CreateFilters(ctx, zap.NewNop(), config, false) 329 | is.NoErr(err) 330 | 331 | cancel() 332 | f.Stop() 333 | }) 334 | } 335 | 336 | func initFilters(t *testing.T, configStr string, iptablesRules, ip6tablesRules []string) { 337 | t.Helper() 338 | 339 | switch { 340 | case *binaryTests: 341 | initBinaryFilters(t, configStr, iptablesRules, ip6tablesRules) 342 | case *containerTests: 343 | initContainerFilters(t, configStr, ip6tablesRules, ip6tablesRules) 344 | default: 345 | initStandardFilters(t, configStr, iptablesRules, ip6tablesRules) 346 | } 347 | } 348 | 349 | func initBinaryFilters(t *testing.T, configStr string, iptablesRules, ip6tablesRules []string) { 350 | t.Helper() 351 | 352 | if _, err := exec.LookPath(*eddieBinary); err != nil { 353 | t.Fatalf("error finding egress eddie binary: %v", err) 354 | } 355 | if _, err := exec.LookPath("strace"); err != nil { 356 | t.Fatalf("error finding strace: %v", err) 357 | } 358 | 359 | configPath := filepath.Join(t.TempDir(), "config.toml") 360 | f, err := os.Create(configPath) 361 | if err != nil { 362 | t.Fatalf("error creating config file: %v", err) 363 | } 364 | if _, err = f.WriteString(configStr); err != nil { 365 | t.Fatalf("error writing config file: %v", err) 366 | } 367 | if err := f.Close(); err != nil { 368 | t.Fatalf("error closing config file: %v", err) 369 | } 370 | 371 | iptablesCmd(t, false, "-F") 372 | for _, command := range iptablesRules { 373 | iptablesCmd(t, false, command) 374 | } 375 | 376 | iptablesCmd(t, true, "-F") 377 | for _, command := range ip6tablesRules { 378 | iptablesCmd(t, true, command) 379 | } 380 | 381 | wd, err := os.Getwd() 382 | if err != nil { 383 | t.Fatalf("error getting working directory: %v", err) 384 | } 385 | tracePath := filepath.Join(wd, "trace.txt") 386 | 387 | // trace the binary so offending syscalls can be more easily found 388 | eddieCmd := exec.Command("strace", "-f", "-o", tracePath, *eddieBinary, "-c", configPath, "-d", "-f") 389 | eddieCmd.Stdout = os.Stdout 390 | eddieCmd.Stderr = os.Stderr 391 | eddieCmd.SysProcAttr = &syscall.SysProcAttr{ 392 | Setpgid: true, 393 | } 394 | if err := eddieCmd.Start(); err != nil { 395 | t.Fatalf("error starting egress eddie binary: %v", err) 396 | } 397 | 398 | time.Sleep(time.Second) 399 | 400 | t.Cleanup(func() { 401 | err := unix.Kill(-eddieCmd.Process.Pid, unix.SIGINT) 402 | if err != nil { 403 | t.Errorf("error killing egress eddie process: %v", err) 404 | } 405 | 406 | done := make(chan struct{}) 407 | go func() { 408 | err := eddieCmd.Wait() 409 | done <- struct{}{} 410 | if err != nil { 411 | var exitErr *exec.ExitError 412 | if errors.As(err, &exitErr) { 413 | t.Errorf("egress eddie exited with error: %v", err) 414 | } 415 | } 416 | }() 417 | 418 | timeout := time.After(3 * time.Second) 419 | select { 420 | case <-done: 421 | case <-timeout: 422 | t.Error("timeout waiting for egress eddie process to finish") 423 | _ = eddieCmd.Process.Kill() 424 | } 425 | 426 | iptablesCmd(t, false, "-F") 427 | iptablesCmd(t, true, "-F") 428 | }) 429 | } 430 | 431 | func initContainerFilters(t *testing.T, configStr string, iptablesRules, ip6tablesRules []string) { 432 | t.Helper() 433 | 434 | configPath := filepath.Join(t.TempDir(), "config.toml") 435 | f, err := os.Create(configPath) 436 | if err != nil { 437 | t.Fatalf("error creating config file: %v", err) 438 | } 439 | if _, err = f.WriteString(configStr); err != nil { 440 | t.Fatalf("error writing config file: %v", err) 441 | } 442 | if err := f.Close(); err != nil { 443 | t.Fatalf("error closing config file: %v", err) 444 | } 445 | 446 | for _, command := range iptablesRules { 447 | iptablesCmd(t, false, command) 448 | } 449 | 450 | for _, command := range ip6tablesRules { 451 | iptablesCmd(t, true, command) 452 | } 453 | 454 | volume := fmt.Sprintf("-v=%s:/config.toml:ro", configPath) 455 | dockerCmd := exec.Command( 456 | "docker", 457 | "run", 458 | "--cap-add=NET_ADMIN", 459 | "--net=host", 460 | volume, 461 | "--rm", 462 | *eddieImage, 463 | "-c=/config.toml", 464 | "-d", 465 | "-f", 466 | ) 467 | dockerCmd.Stdout = os.Stdout 468 | dockerCmd.Stderr = os.Stderr 469 | if err := dockerCmd.Start(); err != nil { 470 | t.Fatalf("error starting egress eddie container: %v", err) 471 | } 472 | 473 | time.Sleep(time.Second) 474 | 475 | t.Cleanup(func() { 476 | err := dockerCmd.Process.Signal(os.Interrupt) 477 | if err != nil { 478 | t.Errorf("error killing egress eddie container: %v", err) 479 | } 480 | 481 | if err := dockerCmd.Wait(); err != nil { 482 | var exitErr *exec.ExitError 483 | if errors.As(err, &exitErr) { 484 | t.Errorf("egress eddie exited with container: %v", err) 485 | } 486 | } 487 | 488 | iptablesCmd(t, false, "-F") 489 | iptablesCmd(t, true, "-F") 490 | }) 491 | } 492 | 493 | func initStandardFilters(t *testing.T, configStr string, iptablesRules, ip6tablesRules []string) { 494 | t.Helper() 495 | 496 | config, err := parseConfigBytes([]byte(configStr)) 497 | if err != nil { 498 | t.Fatalf("error parsing config: %v", err) 499 | } 500 | 501 | iptablesCmd(t, false, "-F") 502 | for _, command := range iptablesRules { 503 | iptablesCmd(t, false, command) 504 | } 505 | 506 | iptablesCmd(t, true, "-F") 507 | for _, command := range ip6tablesRules { 508 | iptablesCmd(t, true, command) 509 | } 510 | 511 | logCfg := zap.NewProductionConfig() 512 | logCfg.OutputPaths = []string{"stderr"} 513 | logCfg.Level.SetLevel(zap.DebugLevel) 514 | logCfg.EncoderConfig.TimeKey = "time" 515 | logCfg.EncoderConfig.EncodeTime = zapcore.RFC3339NanoTimeEncoder 516 | logCfg.DisableCaller = true 517 | 518 | logger, err := logCfg.Build() 519 | if err != nil { 520 | t.Fatalf("error creating logger: %v", err) 521 | } 522 | 523 | ctx, cancel := context.WithCancel(context.Background()) 524 | filters, err := CreateFilters(ctx, logger, config, true) 525 | if err != nil { 526 | t.Fatalf("error starting filters: %v", err) 527 | } 528 | filters.Start() 529 | 530 | t.Cleanup(func() { 531 | cancel() 532 | filters.Stop() 533 | iptablesCmd(t, false, "-F") 534 | iptablesCmd(t, true, "-F") 535 | }) 536 | } 537 | 538 | func iptablesCmd(t *testing.T, ipv6 bool, args string) { 539 | t.Helper() 540 | 541 | splitArgs, err := shlex.Split(args, true) 542 | if err != nil { 543 | t.Fatalf("error spitting command %v: %v", args, err) 544 | } 545 | 546 | cmd := "iptables" 547 | if ipv6 { 548 | cmd = "ip6tables" 549 | } 550 | 551 | if err := exec.Command(cmd, splitArgs...).Run(); err != nil { 552 | t.Fatalf("error running command %v: %v", args, err) 553 | } 554 | } 555 | 556 | func getHTTPClients() (*http.Client, *http.Client) { 557 | dialer := net.Dialer{ 558 | FallbackDelay: -1, 559 | } 560 | tp4 := &http.Transport{ 561 | DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { 562 | return dialer.DialContext(ctx, "tcp4", addr) 563 | }, 564 | MaxIdleConns: 1, 565 | DisableKeepAlives: true, 566 | } 567 | tp6 := &http.Transport{ 568 | DialContext: func(ctx context.Context, _, addr string) (net.Conn, error) { 569 | return dialer.DialContext(ctx, "tcp6", addr) 570 | }, 571 | MaxIdleConns: 1, 572 | DisableKeepAlives: true, 573 | } 574 | 575 | client4 := &http.Client{ 576 | Transport: tp4, 577 | Timeout: 3 * time.Second, 578 | } 579 | client6 := &http.Client{ 580 | Transport: tp6, 581 | Timeout: 3 * time.Second, 582 | } 583 | 584 | return client4, client6 585 | } 586 | 587 | func makeHTTPReqs(client4, client6 *http.Client, addr string) error { 588 | if client4 != nil { 589 | resp, err := client4.Get(addr) 590 | if err != nil { 591 | return err 592 | } 593 | resp.Body.Close() 594 | } 595 | 596 | if *enableIPv6 && client6 != nil { 597 | resp, err := client6.Get(addr) 598 | if err != nil { 599 | return err 600 | } 601 | resp.Body.Close() 602 | } 603 | 604 | return nil 605 | } 606 | 607 | func lookupIPs(t *testing.T, host string) (ips4 []netip.Addr, ips6 []netip.Addr, err error) { 608 | t.Helper() 609 | 610 | ips4, err = net.DefaultResolver.LookupNetIP(getTimeout(t), "ip4", host) 611 | if err != nil { 612 | return nil, nil, err 613 | } 614 | 615 | if *enableIPv6 { 616 | ips6, err = net.DefaultResolver.LookupNetIP(getTimeout(t), "ip6", host) 617 | if err != nil { 618 | return nil, nil, err 619 | } 620 | } 621 | 622 | return ips4, ips6, nil 623 | } 624 | 625 | func reqFailed(err error) bool { 626 | var dnsErr *net.DNSError 627 | if errors.As(err, &dnsErr) { 628 | return true 629 | } 630 | 631 | var netErr net.Error 632 | if errors.As(err, &netErr) && netErr.Timeout() { 633 | return true 634 | } 635 | 636 | return false 637 | } 638 | 639 | func getTimeout(t *testing.T) context.Context { 640 | t.Helper() 641 | 642 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 643 | t.Cleanup(cancel) 644 | 645 | return ctx 646 | } 647 | -------------------------------------------------------------------------------- /fuzz_test.go: -------------------------------------------------------------------------------- 1 | package egresseddie 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/netip" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/florianl/go-nfqueue" 13 | "github.com/google/gopacket" 14 | "github.com/google/gopacket/layers" 15 | "go.uber.org/zap" 16 | ) 17 | 18 | var ( 19 | // enable when debugging failures 20 | debugLogging = false 21 | dumpPackets = false 22 | 23 | disallowedIPv4Port = uint16(2001) 24 | disallowedIPv6Port = uint16(2011) 25 | ipv4Localhost = netip.MustParseAddr("127.0.0.1").AsSlice() 26 | ipv6Localhost = netip.MustParseAddr("::1").AsSlice() 27 | ipv4Answer = netip.MustParseAddr("1.2.3.4").AsSlice() 28 | ipv6Answer = netip.MustParseAddr("::1:2:3:4").AsSlice() 29 | ipv4Disallowed = netip.MustParseAddr("4.3.2.1").AsSlice() 30 | ipv6Disallowed = netip.MustParseAddr("::4:3:2:1").AsSlice() 31 | allowedCNAME = "cname.org" 32 | trafficPayload = gopacket.Payload([]byte("https://bit.ly/3aeUqbo")) 33 | ) 34 | 35 | func FuzzFiltering(f *testing.F) { 36 | for _, tt := range configTests { 37 | // only add valid configs to the corpus 38 | if strings.HasPrefix(tt.testName, "valid") { 39 | f.Add([]byte(tt.configStr)) 40 | } 41 | } 42 | 43 | logger := zap.NewNop() 44 | if debugLogging { 45 | var err error 46 | logger, err = zap.NewDevelopment() 47 | if err != nil { 48 | f.Fatalf("error creating logger: %v", err) 49 | } 50 | } 51 | 52 | f.Fuzz(func(t *testing.T, cb []byte) { 53 | config, err := parseConfigBytes(cb) 54 | if err != nil { 55 | t.SkipNow() 56 | } 57 | 58 | initMockEnforcers() 59 | config.enforcerCreator = newMockEnforcer 60 | config.resolver = &mockResolver{} 61 | 62 | // test that a config that passes validation won't cause a 63 | // error/panic when starting filters 64 | ctx, cancel := context.WithCancel(context.Background()) 65 | f, err := CreateFilters(ctx, logger, config, false) 66 | if err != nil { 67 | failAndDumpConfig(t, cb, "error starting filters: %v", err) 68 | } 69 | f.Start() 70 | 71 | allowIPv4Port := uint16(1000) 72 | allowIPv6Port := uint16(1010) 73 | 74 | // test that sending DNS requests, DNS responses, and traffic 75 | // will not cause a panic and behaves as expected 76 | for _, filter := range config.Filters { 77 | debugLog(logger, "testing DNS on filter %q", filter.Name) 78 | 79 | // TODO: handle cached hostnames and reverse lookups 80 | if len(filter.AllowedHostnames) == 0 && !filter.AllowAllHostnames { 81 | continue 82 | } 83 | 84 | allowedName := "google.com" 85 | if len(filter.AllowedHostnames) > 0 { 86 | allowedName = filter.AllowedHostnames[0] 87 | } 88 | // TODO: ensure this won't collide with another allowed hostname 89 | disallowedName := "no" + allowedName + "no" 90 | 91 | if filter.DNSQueue.eitherSet() { 92 | checkBlockingDNSRequests(t, logger, cb, filter, false, disallowedIPv4Port, allowedName, disallowedName) 93 | checkBlockingDNSRequests(t, logger, cb, filter, true, disallowedIPv6Port, allowedName, disallowedName) 94 | checkAllowingDNS(t, logger, cb, config, filter, allowIPv4Port, allowIPv6Port, allowedName, disallowedName) 95 | 96 | allowIPv4Port++ 97 | allowIPv6Port++ 98 | } 99 | 100 | checkBlockingUnknownDNSReplies(t, logger, cb, config, allowedName) 101 | } 102 | 103 | for _, filter := range config.Filters { 104 | debugLog(logger, "testing traffic on filter %q", filter.Name) 105 | 106 | if !filter.TrafficQueue.eitherSet() { 107 | continue 108 | } 109 | 110 | checkHandlingTraffic(t, logger, cb, filter) 111 | } 112 | 113 | cancel() 114 | f.Stop() 115 | }) 116 | } 117 | 118 | func checkBlockingDNSRequests(t *testing.T, logger *zap.Logger, cb []byte, filter FilterOptions, ipv6 bool, port uint16, allowedName, disallowedName string) { 119 | t.Helper() 120 | 121 | reqn := filter.DNSQueue.IPv4 122 | qType := layers.DNSTypeA 123 | answerIP := ipv4Answer 124 | if ipv6 { 125 | reqn = filter.DNSQueue.IPv6 126 | qType = layers.DNSTypeAAAA 127 | answerIP = ipv6Answer 128 | } 129 | 130 | if reqn == 0 { 131 | return 132 | } 133 | 134 | debugLog(logger, "send DNS request of allowed domain name on disallowed connection state") 135 | sendPacket(t, logger, cb, mockEnforcers[reqn], sendOpts{ 136 | ipv6: ipv6, 137 | srcPort: port, 138 | dstPort: 53, 139 | finalLayer: &layers.DNS{ 140 | QDCount: 1, 141 | Questions: []layers.DNSQuestion{ 142 | { 143 | Name: []byte(allowedName), 144 | Type: qType, 145 | Class: layers.DNSClassIN, 146 | }, 147 | }, 148 | }, 149 | connState: stateUntracked, 150 | expectedVerdict: nfqueue.NfDrop, 151 | }) 152 | debugLog(logger, "send DNS reply of allowed domain name on DNS request queue") 153 | sendPacket(t, logger, cb, mockEnforcers[reqn], sendOpts{ 154 | ipv6: ipv6, 155 | srcPort: port, 156 | dstPort: 53, 157 | finalLayer: &layers.DNS{ 158 | QDCount: 1, 159 | Questions: []layers.DNSQuestion{ 160 | { 161 | Name: []byte(allowedName), 162 | Type: qType, 163 | Class: layers.DNSClassIN, 164 | }, 165 | }, 166 | ANCount: 1, 167 | Answers: []layers.DNSResourceRecord{ 168 | { 169 | Name: []byte(allowedName), 170 | Type: qType, 171 | Class: layers.DNSClassIN, 172 | IP: answerIP, 173 | }, 174 | }, 175 | }, 176 | connState: stateNew, 177 | expectedVerdict: nfqueue.NfDrop, 178 | }) 179 | if !filter.AllowAllHostnames { 180 | debugLog(logger, "send DNS request of disallowed domain name") 181 | sendPacket(t, logger, cb, mockEnforcers[reqn], sendOpts{ 182 | ipv6: ipv6, 183 | srcPort: port, 184 | dstPort: 53, 185 | finalLayer: &layers.DNS{ 186 | QDCount: 1, 187 | Questions: []layers.DNSQuestion{ 188 | { 189 | Name: []byte(disallowedName), 190 | Type: qType, 191 | Class: layers.DNSClassIN, 192 | }, 193 | }, 194 | }, 195 | connState: stateNew, 196 | expectedVerdict: nfqueue.NfDrop, 197 | }) 198 | debugLog(logger, "send DNS request with no questions") 199 | sendPacket(t, logger, cb, mockEnforcers[reqn], sendOpts{ 200 | ipv6: ipv6, 201 | srcPort: port, 202 | dstPort: 53, 203 | finalLayer: &layers.DNS{}, 204 | connState: stateNew, 205 | expectedVerdict: nfqueue.NfDrop, 206 | }) 207 | debugLog(logger, "send DNS request of disallowed and allowed domain names") 208 | sendPacket(t, logger, cb, mockEnforcers[reqn], sendOpts{ 209 | ipv6: ipv6, 210 | srcPort: port, 211 | dstPort: 53, 212 | finalLayer: &layers.DNS{ 213 | QDCount: 1, 214 | Questions: []layers.DNSQuestion{ 215 | { 216 | Name: []byte(disallowedName), 217 | Type: qType, 218 | Class: layers.DNSClassIN, 219 | }, 220 | { 221 | Name: []byte(allowedName), 222 | Type: qType, 223 | Class: layers.DNSClassIN, 224 | }, 225 | }, 226 | }, 227 | connState: stateNew, 228 | expectedVerdict: nfqueue.NfDrop, 229 | }) 230 | debugLog(logger, "send DNS request of allowed and disallowed domain names") 231 | sendPacket(t, logger, cb, mockEnforcers[reqn], sendOpts{ 232 | ipv6: ipv6, 233 | srcPort: port, 234 | dstPort: 53, 235 | finalLayer: &layers.DNS{ 236 | QDCount: 1, 237 | Questions: []layers.DNSQuestion{ 238 | { 239 | Name: []byte(allowedName), 240 | Type: qType, 241 | Class: layers.DNSClassIN, 242 | }, 243 | { 244 | Name: []byte(disallowedName), 245 | Type: qType, 246 | Class: layers.DNSClassIN, 247 | }, 248 | }, 249 | }, 250 | connState: stateNew, 251 | expectedVerdict: nfqueue.NfDrop, 252 | }) 253 | } 254 | } 255 | 256 | func checkBlockingUnknownDNSReplies(t *testing.T, logger *zap.Logger, cb []byte, config *Config, allowedName string) { 257 | t.Helper() 258 | 259 | check := func(ipv6 bool, n uint16) { 260 | port := uint16(2001) 261 | qType := layers.DNSTypeA 262 | answerIP := ipv4Answer 263 | if ipv6 { 264 | port = uint16(2011) 265 | qType = layers.DNSTypeAAAA 266 | answerIP = ipv6Answer 267 | } 268 | 269 | debugLog(logger, "send DNS reply on a new connection") 270 | sendPacket(t, logger, cb, mockEnforcers[n], sendOpts{ 271 | ipv6: ipv6, 272 | srcPort: 53, 273 | dstPort: port, 274 | finalLayer: &layers.DNS{ 275 | QDCount: 1, 276 | Questions: []layers.DNSQuestion{ 277 | { 278 | Name: []byte(allowedName), 279 | Type: qType, 280 | Class: layers.DNSClassIN, 281 | }, 282 | }, 283 | ANCount: 1, 284 | Answers: []layers.DNSResourceRecord{ 285 | { 286 | Name: []byte(allowedName), 287 | Type: qType, 288 | Class: layers.DNSClassIN, 289 | IP: answerIP, 290 | }, 291 | }, 292 | }, 293 | connState: stateNew, 294 | expectedVerdict: nfqueue.NfDrop, 295 | }) 296 | debugLog(logger, "send DNS reply on an unknown connection") 297 | sendPacket(t, logger, cb, mockEnforcers[n], sendOpts{ 298 | ipv6: ipv6, 299 | srcPort: 53, 300 | dstPort: port, 301 | finalLayer: &layers.DNS{ 302 | QDCount: 1, 303 | Questions: []layers.DNSQuestion{ 304 | { 305 | Name: []byte(allowedName), 306 | Type: qType, 307 | Class: layers.DNSClassIN, 308 | }, 309 | }, 310 | ANCount: 1, 311 | Answers: []layers.DNSResourceRecord{ 312 | { 313 | Name: []byte(allowedName), 314 | Type: qType, 315 | Class: layers.DNSClassIN, 316 | IP: answerIP, 317 | }, 318 | }, 319 | }, 320 | connState: stateEstablished, 321 | expectedVerdict: nfqueue.NfDrop, 322 | }) 323 | } 324 | 325 | if n := config.InboundDNSQueue.IPv4; n != 0 { 326 | check(false, n) 327 | } 328 | if n := config.InboundDNSQueue.IPv6; n != 0 { 329 | check(true, n) 330 | } 331 | } 332 | 333 | func checkAllowingDNS(t *testing.T, logger *zap.Logger, cb []byte, config *Config, filter FilterOptions, ip4Port, ip6Port uint16, allowedName, disallowedName string) { 334 | t.Helper() 335 | 336 | // If answers are allowed for too short of a time, we don't 337 | // want to race against the connection getting forgotten. 338 | // The self filter only processes DNS responses so it won't 339 | // have an allowed answers duration set. 340 | attemptReplies := filter.DNSQueue == config.SelfDNSQueue || (filter.DNSQueue != config.SelfDNSQueue && filter.AllowAnswersFor >= time.Millisecond) 341 | allowVerdict := filter.AllowAllHostnames || filter.DNSQueue.eitherSet() 342 | 343 | check := func(ipv6 bool, reqn, rplyn uint16) { 344 | port := ip4Port 345 | rType := layers.DNSTypeA 346 | answerIP := ipv4Answer 347 | if ipv6 { 348 | port = ip6Port 349 | rType = layers.DNSTypeAAAA 350 | answerIP = ipv6Answer 351 | } 352 | 353 | sendAllowReq := func() { 354 | sendPacket(t, logger, cb, mockEnforcers[reqn], sendOpts{ 355 | ipv6: ipv6, 356 | srcPort: port, 357 | dstPort: 53, 358 | finalLayer: &layers.DNS{ 359 | QDCount: 1, 360 | Questions: []layers.DNSQuestion{ 361 | { 362 | Name: []byte(allowedName), 363 | Type: rType, 364 | Class: layers.DNSClassIN, 365 | }, 366 | }, 367 | }, 368 | connState: stateNew, 369 | expectedVerdict: nfqueue.NfAccept, 370 | }) 371 | } 372 | 373 | debugLog(logger, "send DNS request of allowed domain name") 374 | sendAllowReq() 375 | if rplyn != 0 && attemptReplies { 376 | debugLog(logger, "send DNS reply of allowed domain name") 377 | sendPacket(t, logger, cb, mockEnforcers[rplyn], sendOpts{ 378 | ipv6: ipv6, 379 | srcPort: 53, 380 | dstPort: port, 381 | finalLayer: &layers.DNS{ 382 | QDCount: 1, 383 | Questions: []layers.DNSQuestion{ 384 | { 385 | Name: []byte(allowedName), 386 | Type: rType, 387 | Class: layers.DNSClassIN, 388 | }, 389 | }, 390 | ANCount: 3, 391 | Answers: []layers.DNSResourceRecord{ 392 | { 393 | Name: []byte(allowedName), 394 | Type: rType, 395 | Class: layers.DNSClassIN, 396 | IP: answerIP, 397 | }, 398 | { 399 | Name: []byte(allowedName), 400 | Type: layers.DNSTypeCNAME, 401 | Class: layers.DNSClassIN, 402 | CNAME: []byte(allowedCNAME), 403 | }, 404 | }, 405 | }, 406 | connState: stateEstablished, 407 | expectedVerdict: boolToVerdict(allowVerdict), 408 | }) 409 | 410 | debugLog(logger, "send DNS request of allowed domain name (2)") 411 | sendAllowReq() 412 | debugLog(logger, "testing blocking known DNS replies") 413 | checkBlockingKnownDNSReplies(t, logger, cb, config, filter, ipv6, port, allowedName, disallowedName) 414 | 415 | if filter.DNSQueue != config.SelfDNSQueue { 416 | debugLog(logger, "send DNS request of allowed domain name from previous CNAME answer") 417 | sendPacket(t, logger, cb, mockEnforcers[reqn], sendOpts{ 418 | ipv6: ipv6, 419 | srcPort: port, 420 | dstPort: 53, 421 | finalLayer: &layers.DNS{ 422 | QDCount: 1, 423 | Questions: []layers.DNSQuestion{ 424 | { 425 | Name: []byte(allowedCNAME), 426 | Type: rType, 427 | Class: layers.DNSClassIN, 428 | }, 429 | }, 430 | }, 431 | connState: stateNew, 432 | expectedVerdict: nfqueue.NfAccept, 433 | }) 434 | 435 | checkBlockingDNSRequests(t, logger, cb, filter, ipv6, port, allowedCNAME, disallowedName) 436 | } 437 | } 438 | } 439 | 440 | if reqn, rplyn := filter.DNSQueue.IPv4, config.InboundDNSQueue.IPv4; reqn != 0 { 441 | check(false, reqn, rplyn) 442 | } 443 | if reqn, rplyn := filter.DNSQueue.IPv6, config.InboundDNSQueue.IPv6; reqn != 0 { 444 | check(true, reqn, rplyn) 445 | } 446 | } 447 | 448 | func checkBlockingKnownDNSReplies(t *testing.T, logger *zap.Logger, cb []byte, config *Config, filter FilterOptions, ipv6 bool, port uint16, allowedName, disallowedName string) { 449 | t.Helper() 450 | 451 | n := config.InboundDNSQueue.IPv4 452 | rType := layers.DNSTypeA 453 | answerIP := ipv4Answer 454 | if ipv6 { 455 | n = config.InboundDNSQueue.IPv6 456 | rType = layers.DNSTypeAAAA 457 | answerIP = ipv6Answer 458 | } 459 | 460 | debugLog(logger, "send DNS reply with disallowed domain name in question") 461 | sendPacket(t, logger, cb, mockEnforcers[n], sendOpts{ 462 | ipv6: ipv6, 463 | srcPort: 53, 464 | dstPort: port, 465 | finalLayer: &layers.DNS{ 466 | QDCount: 1, 467 | Questions: []layers.DNSQuestion{ 468 | { 469 | Name: []byte(disallowedName), 470 | Type: rType, 471 | Class: layers.DNSClassIN, 472 | }, 473 | }, 474 | ANCount: 1, 475 | Answers: []layers.DNSResourceRecord{ 476 | { 477 | Name: []byte(allowedName), 478 | Type: rType, 479 | Class: layers.DNSClassIN, 480 | IP: answerIP, 481 | }, 482 | }, 483 | }, 484 | connState: stateEstablished, 485 | expectedVerdict: nfqueue.NfDrop, 486 | }) 487 | 488 | if filter.DNSQueue != config.SelfDNSQueue { 489 | debugLog(logger, "send DNS reply with disallowed domain name in answer") 490 | sendPacket(t, logger, cb, mockEnforcers[n], sendOpts{ 491 | ipv6: ipv6, 492 | srcPort: 53, 493 | dstPort: port, 494 | finalLayer: &layers.DNS{ 495 | QDCount: 1, 496 | Questions: []layers.DNSQuestion{ 497 | { 498 | Name: []byte(allowedName), 499 | Type: rType, 500 | Class: layers.DNSClassIN, 501 | }, 502 | }, 503 | ANCount: 1, 504 | Answers: []layers.DNSResourceRecord{ 505 | { 506 | Name: []byte(disallowedName), 507 | Type: rType, 508 | Class: layers.DNSClassIN, 509 | IP: answerIP, 510 | }, 511 | }, 512 | }, 513 | connState: stateEstablished, 514 | expectedVerdict: nfqueue.NfDrop, 515 | }) 516 | } 517 | } 518 | 519 | func checkHandlingTraffic(t *testing.T, logger *zap.Logger, cb []byte, filter FilterOptions) { 520 | t.Helper() 521 | 522 | // If answers are allowed for too short of a time, we don't 523 | // want to race against the connection getting forgotten. 524 | // TODO: test reverse lookups 525 | allowVerdict := filter.DNSQueue.eitherSet() && filter.AllowAnswersFor >= time.Millisecond 526 | 527 | check := func(ipv6 bool, n uint16) { 528 | localhostIP := ipv4Localhost 529 | answerIP := ipv4Answer 530 | disallowedIP := ipv4Disallowed 531 | if ipv6 { 532 | localhostIP = ipv6Localhost 533 | answerIP = ipv6Answer 534 | disallowedIP = ipv6Disallowed 535 | } 536 | 537 | debugLog(logger, "send traffic with allowed dst IP") 538 | sendPacket(t, logger, cb, mockEnforcers[n], sendOpts{ 539 | ipv6: ipv6, 540 | srcIP: localhostIP, 541 | dstIP: answerIP, 542 | srcPort: 1337, 543 | dstPort: 420, 544 | finalLayer: trafficPayload, 545 | connState: stateNew, 546 | expectedVerdict: boolToVerdict(allowVerdict), 547 | }) 548 | debugLog(logger, "send traffic with allowed src IP") 549 | sendPacket(t, logger, cb, mockEnforcers[n], sendOpts{ 550 | ipv6: ipv6, 551 | srcIP: answerIP, 552 | dstIP: localhostIP, 553 | srcPort: 1337, 554 | dstPort: 420, 555 | finalLayer: trafficPayload, 556 | connState: stateNew, 557 | expectedVerdict: boolToVerdict(allowVerdict), 558 | }) 559 | debugLog(logger, "send traffic with disallowed dst IP") 560 | sendPacket(t, logger, cb, mockEnforcers[n], sendOpts{ 561 | ipv6: ipv6, 562 | srcIP: localhostIP, 563 | dstIP: disallowedIP, 564 | srcPort: 1337, 565 | dstPort: 420, 566 | finalLayer: trafficPayload, 567 | connState: stateNew, 568 | expectedVerdict: nfqueue.NfDrop, 569 | }) 570 | debugLog(logger, "send traffic with disallowed src IP") 571 | sendPacket(t, logger, cb, mockEnforcers[n], sendOpts{ 572 | ipv6: ipv6, 573 | srcIP: disallowedIP, 574 | dstIP: localhostIP, 575 | srcPort: 1337, 576 | dstPort: 420, 577 | finalLayer: trafficPayload, 578 | connState: stateNew, 579 | expectedVerdict: nfqueue.NfDrop, 580 | }) 581 | } 582 | 583 | if n := filter.TrafficQueue.IPv4; n != 0 { 584 | check(false, n) 585 | } 586 | if n := filter.TrafficQueue.IPv6; n != 0 { 587 | check(true, n) 588 | } 589 | } 590 | 591 | func failAndDumpConfig(t *testing.T, cb []byte, format string, a ...any) { 592 | t.Helper() 593 | 594 | t.Logf("config:\n---\n%s\n---\n\n", cb) 595 | panic(fmt.Sprintf(format, a...)) 596 | } 597 | 598 | func boolToVerdict(b bool) int { 599 | v := nfqueue.NfDrop 600 | if b { 601 | v = nfqueue.NfAccept 602 | } 603 | 604 | return v 605 | } 606 | 607 | type sendOpts struct { 608 | ipv6 bool 609 | srcIP net.IP 610 | dstIP net.IP 611 | srcPort uint16 612 | dstPort uint16 613 | finalLayer gopacket.SerializableLayer 614 | connState int 615 | expectedVerdict int 616 | } 617 | 618 | var ( 619 | buf = gopacket.NewSerializeBuffer() 620 | serializeOpts = gopacket.SerializeOptions{ 621 | FixLengths: true, 622 | } 623 | packetID = uint32(1) 624 | ) 625 | 626 | func sendPacket(t *testing.T, logger *zap.Logger, cb []byte, e *mockEnforcer, opts sendOpts) { 627 | t.Helper() 628 | 629 | var ( 630 | ipLayer gopacket.SerializableLayer 631 | ipLayerType = layers.IPProtocolIPv4 632 | verdictExpected bool 633 | ) 634 | if opts.ipv6 { 635 | ipLayerType = layers.IPProtocolIPv6 636 | } 637 | 638 | // if src or dst IPs aren't set, this is a DNS packet 639 | if opts.srcIP == nil || opts.dstIP == nil { 640 | // if src or dst IPs aren't set, set them to localhost for DNS packets 641 | if !opts.ipv6 { 642 | opts.srcIP = ipv4Localhost 643 | opts.dstIP = ipv4Localhost 644 | } else { 645 | opts.srcIP = ipv6Localhost 646 | opts.dstIP = ipv6Localhost 647 | } 648 | 649 | // Serialize and deserialize the DNS layer to ensure it can be 650 | // decoded without errors. The fuzzer can sometimes create names 651 | // in questions that can't be parsed by gopacket currently. 652 | err := gopacket.SerializeLayers(buf, serializeOpts, opts.finalLayer) 653 | if err != nil { 654 | failAndDumpConfig(t, cb, "error serializing DNS packet: %v", err) 655 | } 656 | 657 | var ( 658 | dnsLayer layers.DNS 659 | decoded = make([]gopacket.LayerType, 0, 1) 660 | ) 661 | 662 | parser := gopacket.NewDecodingLayerParser(layers.LayerTypeDNS, &dnsLayer) 663 | err = parser.DecodeLayers(buf.Bytes(), &decoded) 664 | if err == nil { 665 | verdictExpected = true 666 | } 667 | } 668 | 669 | if !opts.ipv6 { 670 | ipLayer = &layers.IPv4{ 671 | Protocol: layers.IPProtocolUDP, 672 | SrcIP: opts.srcIP, 673 | DstIP: opts.dstIP, 674 | } 675 | } else { 676 | ipLayer = &layers.IPv6{ 677 | NextHeader: layers.IPProtocolUDP, 678 | SrcIP: opts.srcIP, 679 | DstIP: opts.dstIP, 680 | } 681 | } 682 | 683 | err := gopacket.SerializeLayers(buf, serializeOpts, 684 | ipLayer, 685 | &layers.UDP{ 686 | SrcPort: layers.UDPPort(opts.srcPort), 687 | DstPort: layers.UDPPort(opts.dstPort), 688 | }, 689 | opts.finalLayer, 690 | ) 691 | if err != nil { 692 | failAndDumpConfig(t, cb, "error serializing packet: %v", err) 693 | } 694 | 695 | debugLog(logger, "sending packet: ipv6=%t srcPort=%d dstPort=%d connState=%d verdict=%d", 696 | opts.ipv6, 697 | opts.srcPort, 698 | opts.dstPort, 699 | opts.connState, 700 | opts.expectedVerdict, 701 | ) 702 | if dumpPackets { 703 | packet := gopacket.NewPacket(buf.Bytes(), ipLayerType, gopacket.Default) 704 | debugLog(logger, packet.Dump()) 705 | } 706 | 707 | e.hook(nfqueue.Attribute{ 708 | PacketID: ref(packetID), 709 | CtInfo: ref(uint32(opts.connState)), 710 | Payload: ref(buf.Bytes()), 711 | }) 712 | verdict, ok := e.verdicts[packetID] 713 | if verdictExpected && !ok { 714 | failAndDumpConfig(t, cb, "packet did not receive a verdict") 715 | } 716 | if verdict != opts.expectedVerdict { 717 | failAndDumpConfig(t, cb, "expected verdict %d got %d", opts.expectedVerdict, verdict) 718 | } 719 | delete(e.verdicts, packetID) 720 | 721 | packetID++ 722 | } 723 | 724 | func debugLog(logger *zap.Logger, format string, a ...any) { 725 | if debugLogging { 726 | logger.Sugar().Infof(format, a...) 727 | } 728 | } 729 | 730 | func ref[T any](t T) *T { 731 | return &t 732 | } 733 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package egresseddie 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/matryer/is" 8 | ) 9 | 10 | var configTests = []struct { 11 | testName string 12 | configStr string 13 | expectedConfig *Config 14 | expectedErr string 15 | }{ 16 | { 17 | testName: "unknown key", 18 | configStr: "foo=1", 19 | expectedConfig: nil, 20 | expectedErr: `unknown keys "foo"`, 21 | }, 22 | { 23 | testName: "empty", 24 | configStr: "", 25 | expectedConfig: nil, 26 | expectedErr: "at least one filter must be specified", 27 | }, 28 | { 29 | testName: "inboundDNSQueue not set", 30 | configStr: "[[filters]]", 31 | expectedConfig: nil, 32 | expectedErr: `"inboundDNSQueue" must be set`, 33 | }, 34 | { 35 | testName: "inboundDNSQueue not valid", 36 | configStr: ` 37 | inboundDNSQueue.ipv4 = 1 38 | inboundDNSQueue.ipv6 = 1 39 | 40 | [[filters]]`, 41 | expectedConfig: nil, 42 | expectedErr: `"inboundDNSQueue.ipv4" and "inboundDNSQueue.ipv6" cannot be the same`, 43 | }, 44 | { 45 | testName: "name not set", 46 | configStr: ` 47 | inboundDNSQueue.ipv4 = 1 48 | 49 | [[filters]]`, 50 | expectedConfig: nil, 51 | expectedErr: `filter #0: "name" must be set`, 52 | }, 53 | { 54 | testName: "dnsQueue not set", 55 | configStr: ` 56 | inboundDNSQueue.ipv4 = 1 57 | 58 | [[filters]] 59 | name = "foo"`, 60 | expectedConfig: nil, 61 | expectedErr: `filter "foo": "dnsQueue" must be set`, 62 | }, 63 | { 64 | testName: "dnsQueue not valid", 65 | configStr: ` 66 | inboundDNSQueue.ipv4 = 1 67 | 68 | [[filters]] 69 | name = "foo" 70 | dnsQueue.ipv4 = 1000 71 | dnsQueue.ipv6 = 1000`, 72 | expectedConfig: nil, 73 | expectedErr: `filter "foo": "dnsQueue.ipv4" and "dnsQueue.ipv6" cannot be the same`, 74 | }, 75 | { 76 | testName: "dnsQueue ipv4 not set", 77 | configStr: ` 78 | inboundDNSQueue.ipv4 = 1 79 | 80 | [[filters]] 81 | name = "foo" 82 | dnsQueue.ipv6 = 1000`, 83 | expectedConfig: nil, 84 | expectedErr: `filter "foo": "dnsQueue.ipv4" must be set when "inboundDNSQueue.ipv4" is set`, 85 | }, 86 | { 87 | testName: "dnsQueue ipv4 set", 88 | configStr: ` 89 | inboundDNSQueue.ipv6 = 1 90 | 91 | [[filters]] 92 | name = "foo" 93 | dnsQueue.ipv4 = 1000 94 | dnsQueue.ipv6 = 1010`, 95 | expectedConfig: nil, 96 | expectedErr: `filter "foo": "dnsQueue.ipv4" must not be set when "inboundDNSQueue.ipv4" is not set`, 97 | }, 98 | { 99 | testName: "dnsQueue ipv6 not set", 100 | configStr: ` 101 | inboundDNSQueue.ipv6 = 1 102 | 103 | [[filters]] 104 | name = "foo" 105 | dnsQueue.ipv4 = 1000`, 106 | expectedConfig: nil, 107 | expectedErr: `filter "foo": "dnsQueue.ipv6" must be set when "inboundDNSQueue.ipv6" is set`, 108 | }, 109 | { 110 | testName: "dnsQueue ipv6 set", 111 | configStr: ` 112 | inboundDNSQueue.ipv4 = 1 113 | 114 | [[filters]] 115 | name = "foo" 116 | dnsQueue.ipv4 = 1000 117 | dnsQueue.ipv6 = 1010`, 118 | expectedConfig: nil, 119 | expectedErr: `filter "foo": "dnsQueue.ipv6" must not be set when "inboundDNSQueue.ipv6" is not set`, 120 | }, 121 | { 122 | testName: "inboundDNSQueue and dnsQueue same", 123 | configStr: ` 124 | inboundDNSQueue.ipv4 = 1 125 | 126 | [[filters]] 127 | name = "foo" 128 | dnsQueue.ipv4 = 1 129 | `, 130 | expectedConfig: nil, 131 | expectedErr: `filter "foo": "inboundDNSQueue" and "dnsQueue" must be different`, 132 | }, 133 | { 134 | testName: "inboundDNSQueue and dnsQueue same mixed", 135 | configStr: ` 136 | inboundDNSQueue.ipv4 = 1 137 | inboundDNSQueue.ipv6 = 2 138 | 139 | [[filters]] 140 | name = "foo" 141 | dnsQueue.ipv4 = 2 142 | dnsQueue.ipv6 = 3 143 | `, 144 | expectedConfig: nil, 145 | expectedErr: `filter "foo": "inboundDNSQueue" and "dnsQueue" must be different`, 146 | }, 147 | { 148 | testName: "trafficQueue not set", 149 | configStr: ` 150 | inboundDNSQueue.ipv4 = 1 151 | 152 | [[filters]] 153 | name = "foo" 154 | dnsQueue.ipv4 = 1000`, 155 | expectedConfig: nil, 156 | expectedErr: `filter "foo": "trafficQueue" must be set`, 157 | }, 158 | { 159 | testName: "trafficQueue not valid", 160 | configStr: ` 161 | inboundDNSQueue.ipv4 = 1 162 | 163 | [[filters]] 164 | name = "foo" 165 | dnsQueue.ipv4 = 1000 166 | trafficQueue.ipv4 = 1001 167 | trafficQueue.ipv6 = 1001`, 168 | expectedConfig: nil, 169 | expectedErr: `filter "foo": "trafficQueue.ipv4" and "trafficQueue.ipv6" cannot be the same`, 170 | }, 171 | { 172 | testName: "trafficQueue ipv4 not set", 173 | configStr: ` 174 | inboundDNSQueue.ipv4 = 1 175 | 176 | [[filters]] 177 | name = "foo" 178 | dnsQueue.ipv4 = 1000 179 | trafficQueue.ipv6 = 1001`, 180 | expectedConfig: nil, 181 | expectedErr: `filter "foo": "trafficQueue.ipv4" must be set when "inboundDNSQueue.ipv4" is set`, 182 | }, 183 | { 184 | testName: "trafficQueue ipv4 set", 185 | configStr: ` 186 | inboundDNSQueue.ipv6 = 1 187 | 188 | [[filters]] 189 | name = "foo" 190 | dnsQueue.ipv6 = 1010 191 | trafficQueue.ipv4 = 1001 192 | trafficQueue.ipv6 = 1011`, 193 | expectedConfig: nil, 194 | expectedErr: `filter "foo": "trafficQueue.ipv4" must not be set when "inboundDNSQueue.ipv4" is not set`, 195 | }, 196 | { 197 | testName: "trafficQueue ipv6 not set", 198 | configStr: ` 199 | inboundDNSQueue.ipv6 = 1 200 | 201 | [[filters]] 202 | name = "foo" 203 | dnsQueue.ipv6 = 1000 204 | trafficQueue.ipv4 = 1001`, 205 | expectedConfig: nil, 206 | expectedErr: `filter "foo": "trafficQueue.ipv6" must be set when "inboundDNSQueue.ipv6" is set`, 207 | }, 208 | { 209 | testName: "trafficQueue ipv6 set", 210 | configStr: ` 211 | inboundDNSQueue.ipv4 = 1 212 | 213 | [[filters]] 214 | name = "foo" 215 | dnsQueue.ipv4 = 1000 216 | trafficQueue.ipv4 = 1001 217 | trafficQueue.ipv6 = 1011`, 218 | expectedConfig: nil, 219 | expectedErr: `filter "foo": "trafficQueue.ipv6" must not be set when "inboundDNSQueue.ipv6" is not set`, 220 | }, 221 | { 222 | testName: "inboundDNSQueue and trafficQueue same", 223 | configStr: ` 224 | inboundDNSQueue.ipv4 = 1 225 | 226 | [[filters]] 227 | name = "foo" 228 | dnsQueue.ipv4 = 1000 229 | trafficQueue.ipv4 = 1 230 | `, 231 | expectedConfig: nil, 232 | expectedErr: `filter "foo": "inboundDNSQueue" and "trafficQueue" must be different`, 233 | }, 234 | { 235 | testName: "dnsQueue and trafficQueue same", 236 | configStr: ` 237 | inboundDNSQueue.ipv4 = 1 238 | 239 | [[filters]] 240 | name = "foo" 241 | dnsQueue.ipv4 = 1000 242 | trafficQueue.ipv4 = 1000`, 243 | expectedConfig: nil, 244 | expectedErr: `filter "foo": "dnsQueue" and "trafficQueue" must be different`, 245 | }, 246 | { 247 | testName: "selfDNSQueue invalid", 248 | configStr: ` 249 | inboundDNSQueue.ipv4 = 1 250 | inboundDNSQueue.ipv6 = 10 251 | selfDNSQueue.ipv4 = 2 252 | selfDNSQueue.ipv6 = 2 253 | 254 | [[filters]] 255 | name = "foo" 256 | dnsQueue.ipv4 = 1000 257 | dnsQueue.ipv6 = 1010 258 | trafficQueue.ipv4 = 1001 259 | trafficQueue.ipv6 = 1011 260 | lookupUnknownIPs = true 261 | allowAnswersFor = "5s" 262 | allowedHostnames = ["foo"]`, 263 | expectedConfig: nil, 264 | expectedErr: `"selfDNSQueue.ipv4" and "selfDNSQueue.ipv6" cannot be the same`, 265 | }, 266 | { 267 | testName: "selfDNSQueue ipv4 not set", 268 | configStr: ` 269 | inboundDNSQueue.ipv4 = 1 270 | selfDNSQueue.ipv6 = 2 271 | 272 | [[filters]] 273 | name = "foo" 274 | dnsQueue.ipv4 = 1000 275 | trafficQueue.ipv4 = 1001 276 | lookupUnknownIPs = true 277 | allowAnswersFor = "5s" 278 | allowedHostnames = ["foo"]`, 279 | expectedConfig: nil, 280 | expectedErr: `"selfDNSQueue.ipv4" must be set when "inboundDNSQueue.ipv4" is set`, 281 | }, 282 | { 283 | testName: "selfDNSQueue ipv4 set", 284 | configStr: ` 285 | inboundDNSQueue.ipv6 = 1 286 | selfDNSQueue.ipv4 = 2 287 | selfDNSQueue.ipv6 = 3 288 | 289 | [[filters]] 290 | name = "foo" 291 | dnsQueue.ipv6 = 1000 292 | trafficQueue.ipv6 = 1001 293 | lookupUnknownIPs = true 294 | allowAnswersFor = "5s" 295 | allowedHostnames = ["foo"]`, 296 | expectedConfig: nil, 297 | expectedErr: `"selfDNSQueue.ipv4" must not be set when "inboundDNSQueue.ipv4" is not set`, 298 | }, 299 | { 300 | testName: "selfDNSQueue ipv6 not set", 301 | configStr: ` 302 | inboundDNSQueue.ipv6 = 1 303 | selfDNSQueue.ipv4 = 2 304 | 305 | [[filters]] 306 | name = "foo" 307 | dnsQueue.ipv6 = 1000 308 | trafficQueue.ipv6 = 1001 309 | lookupUnknownIPs = true 310 | allowAnswersFor = "5s" 311 | allowedHostnames = ["foo"]`, 312 | expectedConfig: nil, 313 | expectedErr: `"selfDNSQueue.ipv6" must be set when "inboundDNSQueue.ipv6" is set`, 314 | }, 315 | { 316 | testName: "selfDNSQueue ipv6 set", 317 | configStr: ` 318 | inboundDNSQueue.ipv4 = 1 319 | selfDNSQueue.ipv4 = 2 320 | selfDNSQueue.ipv6 = 3 321 | 322 | [[filters]] 323 | name = "foo" 324 | dnsQueue.ipv4 = 1000 325 | trafficQueue.ipv4 = 1001 326 | lookupUnknownIPs = true 327 | allowAnswersFor = "5s" 328 | allowedHostnames = ["foo"]`, 329 | expectedConfig: nil, 330 | expectedErr: `"selfDNSQueue.ipv6" must not be set when "inboundDNSQueue.ipv6" is not set`, 331 | }, 332 | { 333 | testName: "inboundDNSQueue and selfDNSQueue same", 334 | configStr: ` 335 | inboundDNSQueue.ipv4 = 1 336 | selfDNSQueue.ipv4 = 1 337 | 338 | [[filters]] 339 | name = "foo" 340 | dnsQueue.ipv4 = 1000 341 | trafficQueue.ipv4 = 1001 342 | lookupUnknownIPs = true 343 | allowAnswersFor = "5s" 344 | allowedHostnames = ["foo"]`, 345 | expectedConfig: nil, 346 | expectedErr: `"inboundDNSQueue" and "selfDNSQueue" must be different`, 347 | }, 348 | { 349 | testName: "trafficQueue and AllowAllHostnames set", 350 | configStr: ` 351 | inboundDNSQueue.ipv4 = 1 352 | 353 | [[filters]] 354 | name = "foo" 355 | dnsQueue.ipv4 = 1000 356 | trafficQueue.ipv4 = 1001 357 | allowAllHostnames = true`, 358 | expectedConfig: nil, 359 | expectedErr: `filter "foo": "trafficQueue" must not be set when "allowAllHostnames" is true`, 360 | }, 361 | { 362 | testName: "allowedHostnames empty", 363 | configStr: ` 364 | inboundDNSQueue.ipv4 = 1 365 | 366 | [[filters]] 367 | name = "foo" 368 | dnsQueue.ipv4 = 1000 369 | trafficQueue.ipv4 = 1001`, 370 | expectedConfig: nil, 371 | expectedErr: `filter "foo": "allowedHostnames" must not be empty`, 372 | }, 373 | { 374 | testName: "allowedHostnames not empty and allowAllHostnames is set", 375 | configStr: ` 376 | inboundDNSQueue.ipv4 = 1 377 | 378 | [[filters]] 379 | name = "foo" 380 | dnsQueue.ipv4 = 1000 381 | allowAllHostnames = true 382 | allowedHostnames = ["foo"]`, 383 | expectedConfig: nil, 384 | expectedErr: `filter "foo": "allowedHostnames" must be empty when "allowAllHostnames" is true`, 385 | }, 386 | { 387 | testName: "allowedHostnames not empty and allowAnswersFor is not set", 388 | configStr: ` 389 | inboundDNSQueue.ipv4 = 1 390 | 391 | [[filters]] 392 | name = "foo" 393 | dnsQueue.ipv4 = 1000 394 | trafficQueue.ipv4 = 1001 395 | allowedHostnames = ["foo"]`, 396 | expectedConfig: nil, 397 | expectedErr: `filter "foo": "allowAnswersFor" must be set when "allowedHostnames" is not empty`, 398 | }, 399 | { 400 | testName: "allowAllHostnames set and allowAnswersFor is set", 401 | configStr: ` 402 | inboundDNSQueue.ipv4 = 1 403 | 404 | [[filters]] 405 | name = "foo" 406 | dnsQueue.ipv4 = 1000 407 | allowAnswersFor = "5s" 408 | allowAllHostnames = true`, 409 | expectedConfig: nil, 410 | expectedErr: `filter "foo": "allowAnswersFor" must not be set when "allowAllHostnames" is true`, 411 | }, 412 | { 413 | testName: "negative allowAnswersFor", 414 | configStr: ` 415 | inboundDNSQueue.ipv4 = 1 416 | 417 | [[filters]] 418 | name = "foo" 419 | dnsQueue.ipv4 = 1000 420 | trafficQueue.ipv4 = 1001 421 | allowAnswersFor = "-1m" 422 | allowedHostnames = ["foo"]`, 423 | expectedConfig: nil, 424 | expectedErr: `filter "foo": "allowAnswersFor" must not be negative`, 425 | }, 426 | { 427 | testName: "cachedHostnames not empty and allowAllHostnames is set", 428 | configStr: ` 429 | inboundDNSQueue.ipv4 = 1 430 | 431 | [[filters]] 432 | name = "foo" 433 | allowAllHostnames = true 434 | cachedHostnames = ["foo"]`, 435 | expectedConfig: nil, 436 | expectedErr: `filter "foo": "cachedHostnames" must be empty when "allowAllHostnames" is true`, 437 | }, 438 | { 439 | testName: "cachedHostnames not empty and reCacheEvery is not set", 440 | configStr: ` 441 | inboundDNSQueue.ipv4 = 1 442 | 443 | [[filters]] 444 | name = "foo" 445 | trafficQueue.ipv4 = 1001 446 | cachedHostnames = ["foo"]`, 447 | expectedConfig: nil, 448 | expectedErr: `filter "foo": "reCacheEvery" must be set when "cachedHostnames" is not empty`, 449 | }, 450 | { 451 | testName: "cachedHostnames empty and reCacheEvery is set", 452 | configStr: ` 453 | inboundDNSQueue.ipv4 = 1 454 | 455 | [[filters]] 456 | name = "foo" 457 | dnsQueue.ipv4 = 1000 458 | trafficQueue.ipv4 = 1001 459 | reCacheEvery = "1s" 460 | allowAnswersFor = "5s" 461 | allowedHostnames = ["foo"]`, 462 | expectedConfig: nil, 463 | expectedErr: `filter "foo": "reCacheEvery" must not be set when "cachedHostnames" is empty`, 464 | }, 465 | { 466 | testName: "negative reCacheEvery", 467 | configStr: ` 468 | inboundDNSQueue.ipv4 = 1 469 | 470 | [[filters]] 471 | name = "foo" 472 | trafficQueue.ipv4 = 1001 473 | reCacheEvery = "-1m" 474 | cachedHostnames = ["foo"]`, 475 | expectedConfig: nil, 476 | expectedErr: `filter "foo": "reCacheEvery" must not be negative`, 477 | }, 478 | { 479 | testName: "dnsQueue set and cachedHostnames not empty", 480 | configStr: ` 481 | inboundDNSQueue.ipv4 = 1 482 | selfDNSQueue.ipv4 = 100 483 | 484 | [[filters]] 485 | name = "foo" 486 | dnsQueue.ipv4 = 1000 487 | trafficQueue.ipv4 = 1001 488 | reCacheEvery = "1s" 489 | cachedHostnames = ["foo"]`, 490 | expectedConfig: nil, 491 | expectedErr: `filter "foo": "dnsQueue" must not be set when "allowedHostnames" is empty and either "cachedHostames" is not empty or "lookupUnknownIPs" is true`, 492 | }, 493 | { 494 | testName: "dnsQueue and lookupUnknownIPs set", 495 | configStr: ` 496 | inboundDNSQueue.ipv4 = 1 497 | selfDNSQueue.ipv4 = 100 498 | 499 | [[filters]] 500 | name = "foo" 501 | dnsQueue.ipv4 = 1000 502 | trafficQueue.ipv4 = 1001 503 | lookupUnknownIPs = true`, 504 | expectedConfig: nil, 505 | expectedErr: `filter "foo": "dnsQueue" must not be set when "allowedHostnames" is empty and either "cachedHostames" is not empty or "lookupUnknownIPs" is true`, 506 | }, 507 | { 508 | testName: "selfDNSQueue set", 509 | configStr: ` 510 | inboundDNSQueue.ipv4 = 1 511 | selfDNSQueue.ipv4 = 100 512 | 513 | [[filters]] 514 | name = "foo" 515 | dnsQueue.ipv4 = 1000 516 | trafficQueue.ipv4 = 1001 517 | allowAnswersFor = "10s" 518 | allowedHostnames = ["foo"]`, 519 | expectedConfig: nil, 520 | expectedErr: `"selfDNSQueue" must only be set when at least one filter either sets "lookupUnknownIPs" to true or "cachedHostnames" is not empty`, 521 | }, 522 | { 523 | testName: "invalid allowed hostname", 524 | configStr: ` 525 | inboundDNSQueue.ipv4 = 1 526 | selfDNSQueue.ipv4 = 100 527 | 528 | [[filters]] 529 | name = "foo" 530 | dnsQueue.ipv4 = 1000 531 | trafficQueue.ipv4 = 1001 532 | allowAnswersFor = "10s" 533 | allowedHostnames = ["foo."]`, 534 | expectedConfig: nil, 535 | expectedErr: `filter "foo": allowed hostname "foo." is not a valid domain name`, 536 | }, 537 | { 538 | testName: "shared allowed and cached hostname", 539 | configStr: ` 540 | inboundDNSQueue.ipv4 = 1 541 | selfDNSQueue.ipv4 = 100 542 | 543 | [[filters]] 544 | name = "foo" 545 | dnsQueue.ipv4 = 1000 546 | trafficQueue.ipv4 = 1001 547 | allowAnswersFor = "10s" 548 | allowedHostnames = ["foo"] 549 | reCacheEvery = "10s" 550 | cachedHostnames = ["foo"]`, 551 | expectedConfig: nil, 552 | expectedErr: `filter "foo": allowed hostname "foo" is specified as a hostname to be cached as well`, 553 | }, 554 | { 555 | testName: "duplicate allowed hostname", 556 | configStr: ` 557 | inboundDNSQueue.ipv4 = 1 558 | selfDNSQueue.ipv4 = 100 559 | 560 | [[filters]] 561 | name = "foo" 562 | dnsQueue.ipv4 = 1000 563 | trafficQueue.ipv4 = 1001 564 | allowAnswersFor = "10s" 565 | allowedHostnames = [ 566 | "twice", 567 | "twice", 568 | ]`, 569 | expectedConfig: nil, 570 | expectedErr: `filter "foo": allowed hostname "twice" is specified more than once`, 571 | }, 572 | { 573 | testName: "invalid cached hostname", 574 | configStr: ` 575 | inboundDNSQueue.ipv4 = 1 576 | selfDNSQueue.ipv4 = 100 577 | 578 | [[filters]] 579 | name = "foo" 580 | trafficQueue.ipv4 = 1001 581 | reCacheEvery = "10s" 582 | cachedHostnames = ["foo."]`, 583 | expectedConfig: nil, 584 | expectedErr: `filter "foo": hostname to be cached "foo." is not a valid domain name`, 585 | }, 586 | { 587 | testName: "duplicate cached hostname", 588 | configStr: ` 589 | inboundDNSQueue.ipv4 = 1 590 | selfDNSQueue.ipv4 = 100 591 | 592 | [[filters]] 593 | name = "foo" 594 | trafficQueue.ipv4 = 1001 595 | reCacheEvery = "10s" 596 | cachedHostnames = [ 597 | "twice", 598 | "twice", 599 | ]`, 600 | expectedConfig: nil, 601 | expectedErr: `filter "foo": hostname to be cached "twice" is specified more than once`, 602 | }, 603 | { 604 | testName: "duplicate filter names", 605 | configStr: ` 606 | inboundDNSQueue.ipv4 = 1 607 | selfDNSQueue.ipv4 = 100 608 | 609 | [[filters]] 610 | name = "foo" 611 | dnsQueue.ipv4 = 1000 612 | trafficQueue.ipv4 = 1001 613 | allowAnswersFor = "10s" 614 | allowedHostnames = ["foo"] 615 | 616 | [[filters]] 617 | name = "foo" 618 | dnsQueue.ipv4 = 2000 619 | trafficQueue.ipv4 = 2001 620 | allowAnswersFor = "10s" 621 | allowedHostnames = ["bar"]`, 622 | expectedConfig: nil, 623 | expectedErr: `filter #1: filter name "foo" is already used by filter #0`, 624 | }, 625 | { 626 | testName: "duplicate dnsQueues", 627 | configStr: ` 628 | inboundDNSQueue.ipv4 = 1 629 | selfDNSQueue.ipv4 = 100 630 | 631 | [[filters]] 632 | name = "foo" 633 | dnsQueue.ipv4 = 1000 634 | trafficQueue.ipv4 = 1001 635 | allowAnswersFor = "10s" 636 | allowedHostnames = ["foo"] 637 | 638 | [[filters]] 639 | name = "bar" 640 | dnsQueue.ipv4 = 1000 641 | trafficQueue.ipv4 = 2001 642 | allowAnswersFor = "10s" 643 | allowedHostnames = ["bar"]`, 644 | expectedConfig: nil, 645 | expectedErr: `filter "bar": "dnsQueue.ipv4" 1000 is already used by filter "foo"`, 646 | }, 647 | { 648 | testName: "duplicate trafficQueues", 649 | configStr: ` 650 | inboundDNSQueue.ipv4 = 1 651 | selfDNSQueue.ipv4 = 100 652 | 653 | [[filters]] 654 | name = "foo" 655 | dnsQueue.ipv4 = 1000 656 | trafficQueue.ipv4 = 1001 657 | allowAnswersFor = "10s" 658 | allowedHostnames = ["foo"] 659 | 660 | [[filters]] 661 | name = "bar" 662 | dnsQueue.ipv4 = 2000 663 | trafficQueue.ipv4 = 1001 664 | allowAnswersFor = "10s" 665 | allowedHostnames = ["bar"]`, 666 | expectedConfig: nil, 667 | expectedErr: `filter "bar": "trafficQueue.ipv4" 1001 is already used by filter "foo"`, 668 | }, 669 | { 670 | testName: "selfDNSQueue and dnsQueue same", 671 | configStr: ` 672 | inboundDNSQueue.ipv4 = 1 673 | selfDNSQueue.ipv4 = 100 674 | 675 | [[filters]] 676 | name = "foo" 677 | dnsQueue.ipv4 = 100 678 | trafficQueue.ipv4 = 1001 679 | lookupUnknownIPs = true 680 | allowAnswersFor = "10s" 681 | allowedHostnames = ["foo"]`, 682 | expectedConfig: nil, 683 | expectedErr: `filter "foo": "selfDNSQueue" and "dnsQueue" must be different`, 684 | }, 685 | { 686 | testName: "selfDNSQueue and trafficQueue same", 687 | configStr: ` 688 | inboundDNSQueue.ipv4 = 1 689 | selfDNSQueue.ipv4 = 100 690 | 691 | [[filters]] 692 | name = "foo" 693 | dnsQueue.ipv4 = 1000 694 | trafficQueue.ipv4 = 100 695 | lookupUnknownIPs = true 696 | allowAnswersFor = "10s" 697 | allowedHostnames = ["foo"]`, 698 | expectedConfig: nil, 699 | expectedErr: `filter "foo": "selfDNSQueue" and "trafficQueue" must be different`, 700 | }, 701 | { 702 | testName: "valid allowAllHostnames is set", 703 | configStr: ` 704 | inboundDNSQueue.ipv4 = 1 705 | 706 | [[filters]] 707 | name = "foo" 708 | dnsQueue.ipv4 = 1000 709 | allowAllHostnames = true`, 710 | expectedConfig: &Config{ 711 | InboundDNSQueue: queue{ 712 | IPv4: 1, 713 | }, 714 | Filters: []FilterOptions{ 715 | { 716 | Name: "foo", 717 | DNSQueue: queue{ 718 | IPv4: 1000, 719 | }, 720 | AllowAllHostnames: true, 721 | }, 722 | }, 723 | }, 724 | expectedErr: "", 725 | }, 726 | { 727 | testName: "valid allowAllHostnames is not set", 728 | configStr: ` 729 | inboundDNSQueue.ipv4 = 1 730 | 731 | [[filters]] 732 | name = "foo" 733 | dnsQueue.ipv4 = 1000 734 | trafficQueue.ipv4 = 1001 735 | allowAnswersFor = "5s" 736 | allowedHostnames = [ 737 | "foo", 738 | "bar", 739 | "baz.barf", 740 | ]`, 741 | expectedConfig: &Config{ 742 | InboundDNSQueue: queue{ 743 | IPv4: 1, 744 | }, 745 | Filters: []FilterOptions{ 746 | { 747 | Name: "foo", 748 | DNSQueue: queue{ 749 | IPv4: 1000, 750 | }, 751 | TrafficQueue: queue{ 752 | IPv4: 1001, 753 | }, 754 | AllowAnswersFor: 5 * time.Second, 755 | AllowedHostnames: []string{ 756 | "foo", 757 | "bar", 758 | "baz.barf", 759 | }, 760 | }, 761 | }, 762 | }, 763 | expectedErr: "", 764 | }, 765 | { 766 | testName: "valid allowAllHostnames mixed", 767 | configStr: ` 768 | inboundDNSQueue.ipv4 = 1 769 | 770 | [[filters]] 771 | name = "foo" 772 | dnsQueue.ipv4 = 1000 773 | trafficQueue.ipv4 = 1001 774 | allowAnswersFor = "5s" 775 | allowedHostnames = [ 776 | "foo", 777 | "bar", 778 | "baz.barf", 779 | ] 780 | 781 | [[filters]] 782 | name = "bar" 783 | dnsQueue.ipv4 = 2000 784 | allowAllHostnames = true`, 785 | expectedConfig: &Config{ 786 | InboundDNSQueue: queue{ 787 | IPv4: 1, 788 | }, 789 | Filters: []FilterOptions{ 790 | { 791 | Name: "foo", 792 | DNSQueue: queue{ 793 | IPv4: 1000, 794 | }, 795 | TrafficQueue: queue{ 796 | IPv4: 1001, 797 | }, 798 | AllowAnswersFor: 5 * time.Second, 799 | AllowedHostnames: []string{ 800 | "foo", 801 | "bar", 802 | "baz.barf", 803 | }, 804 | }, 805 | { 806 | Name: "bar", 807 | DNSQueue: queue{ 808 | IPv4: 2000, 809 | }, 810 | AllowAllHostnames: true, 811 | }, 812 | }, 813 | }, 814 | expectedErr: "", 815 | }, 816 | { 817 | testName: "valid cachedHostnames", 818 | configStr: ` 819 | inboundDNSQueue.ipv4 = 1 820 | selfDNSQueue.ipv4 = 100 821 | 822 | [[filters]] 823 | name = "foo" 824 | trafficQueue.ipv4 = 1001 825 | reCacheEvery = "1s" 826 | cachedHostnames = [ 827 | "oof", 828 | "rab", 829 | ]`, 830 | expectedConfig: &Config{ 831 | InboundDNSQueue: queue{ 832 | IPv4: 1, 833 | }, 834 | SelfDNSQueue: queue{ 835 | IPv4: 100, 836 | }, 837 | Filters: []FilterOptions{ 838 | { 839 | Name: selfFilterName, 840 | DNSQueue: queue{ 841 | IPv4: 100, 842 | }, 843 | AllowedHostnames: []string{ 844 | "oof", 845 | "rab", 846 | }, 847 | }, 848 | { 849 | Name: "foo", 850 | TrafficQueue: queue{ 851 | IPv4: 1001, 852 | }, 853 | ReCacheEvery: time.Second, 854 | CachedHostnames: []string{ 855 | "oof", 856 | "rab", 857 | }, 858 | }, 859 | }, 860 | }, 861 | expectedErr: "", 862 | }, 863 | { 864 | testName: "valid lookupUnknownIPs", 865 | configStr: ` 866 | inboundDNSQueue.ipv4 = 1 867 | selfDNSQueue.ipv4 = 100 868 | 869 | [[filters]] 870 | name = "foo" 871 | trafficQueue.ipv4 = 1001 872 | lookupUnknownIPs = true`, 873 | expectedConfig: &Config{ 874 | InboundDNSQueue: queue{ 875 | IPv4: 1, 876 | }, 877 | SelfDNSQueue: queue{ 878 | IPv4: 100, 879 | }, 880 | Filters: []FilterOptions{ 881 | { 882 | Name: selfFilterName, 883 | DNSQueue: queue{ 884 | IPv4: 100, 885 | }, 886 | AllowedHostnames: []string{ 887 | "in-addr.arpa", 888 | "ip6.arpa", 889 | }, 890 | }, 891 | { 892 | Name: "foo", 893 | TrafficQueue: queue{ 894 | IPv4: 1001, 895 | }, 896 | LookupUnknownIPs: true, 897 | }, 898 | }, 899 | }, 900 | expectedErr: "", 901 | }, 902 | { 903 | testName: "valid allowedHostnames and cachedHostnames", 904 | configStr: ` 905 | inboundDNSQueue.ipv4 = 1 906 | selfDNSQueue.ipv4 = 100 907 | 908 | [[filters]] 909 | name = "foo" 910 | dnsQueue.ipv4 = 1000 911 | trafficQueue.ipv4 = 1001 912 | reCacheEvery = "1s" 913 | cachedHostnames = [ 914 | "oof", 915 | "rab", 916 | ] 917 | allowAnswersFor = "5s" 918 | allowedHostnames = [ 919 | "foo", 920 | "bar", 921 | "baz.barf", 922 | ]`, 923 | expectedConfig: &Config{ 924 | InboundDNSQueue: queue{ 925 | IPv4: 1, 926 | }, 927 | SelfDNSQueue: queue{ 928 | IPv4: 100, 929 | }, 930 | Filters: []FilterOptions{ 931 | { 932 | Name: selfFilterName, 933 | DNSQueue: queue{ 934 | IPv4: 100, 935 | }, 936 | AllowedHostnames: []string{ 937 | "oof", 938 | "rab", 939 | }, 940 | }, 941 | { 942 | Name: "foo", 943 | DNSQueue: queue{ 944 | IPv4: 1000, 945 | }, 946 | TrafficQueue: queue{ 947 | IPv4: 1001, 948 | }, 949 | ReCacheEvery: time.Second, 950 | AllowAnswersFor: 5 * time.Second, 951 | AllowedHostnames: []string{ 952 | "foo", 953 | "bar", 954 | "baz.barf", 955 | }, 956 | CachedHostnames: []string{ 957 | "oof", 958 | "rab", 959 | }, 960 | }, 961 | }, 962 | }, 963 | expectedErr: "", 964 | }, 965 | { 966 | testName: "valid lookupUnknownIPs", 967 | configStr: ` 968 | inboundDNSQueue.ipv4 = 1 969 | selfDNSQueue.ipv4 = 100 970 | 971 | [[filters]] 972 | name = "foo" 973 | dnsQueue.ipv4 = 1000 974 | trafficQueue.ipv4 = 1001 975 | lookupUnknownIPs = true 976 | allowAnswersFor = "5s" 977 | allowedHostnames = [ 978 | "foo", 979 | "bar", 980 | "baz.barf", 981 | ]`, 982 | expectedConfig: &Config{ 983 | InboundDNSQueue: queue{ 984 | IPv4: 1, 985 | }, 986 | SelfDNSQueue: queue{ 987 | IPv4: 100, 988 | }, 989 | Filters: []FilterOptions{ 990 | { 991 | Name: selfFilterName, 992 | DNSQueue: queue{ 993 | IPv4: 100, 994 | }, 995 | AllowedHostnames: []string{ 996 | "in-addr.arpa", 997 | "ip6.arpa", 998 | }, 999 | }, 1000 | { 1001 | Name: "foo", 1002 | DNSQueue: queue{ 1003 | IPv4: 1000, 1004 | }, 1005 | TrafficQueue: queue{ 1006 | IPv4: 1001, 1007 | }, 1008 | LookupUnknownIPs: true, 1009 | AllowAnswersFor: 5 * time.Second, 1010 | AllowedHostnames: []string{ 1011 | "foo", 1012 | "bar", 1013 | "baz.barf", 1014 | }, 1015 | }, 1016 | }, 1017 | }, 1018 | expectedErr: "", 1019 | }, 1020 | { 1021 | testName: "valid lookupUnknownIPs is set and cachedHostnames is not empty", 1022 | configStr: ` 1023 | inboundDNSQueue.ipv4 = 1 1024 | selfDNSQueue.ipv4 = 100 1025 | 1026 | [[filters]] 1027 | name = "foo" 1028 | dnsQueue.ipv4 = 1000 1029 | trafficQueue.ipv4 = 1001 1030 | lookupUnknownIPs = true 1031 | reCacheEvery = "1s" 1032 | cachedHostnames = [ 1033 | "oof", 1034 | "rab", 1035 | ] 1036 | allowAnswersFor = "5s" 1037 | allowedHostnames = [ 1038 | "foo", 1039 | "bar", 1040 | "baz.barf", 1041 | ]`, 1042 | expectedConfig: &Config{ 1043 | InboundDNSQueue: queue{ 1044 | IPv4: 1, 1045 | }, 1046 | SelfDNSQueue: queue{ 1047 | IPv4: 100, 1048 | }, 1049 | Filters: []FilterOptions{ 1050 | { 1051 | Name: selfFilterName, 1052 | DNSQueue: queue{ 1053 | IPv4: 100, 1054 | }, 1055 | AllowedHostnames: []string{ 1056 | "in-addr.arpa", 1057 | "ip6.arpa", 1058 | "oof", 1059 | "rab", 1060 | }, 1061 | }, 1062 | { 1063 | Name: "foo", 1064 | DNSQueue: queue{ 1065 | IPv4: 1000, 1066 | }, 1067 | TrafficQueue: queue{ 1068 | IPv4: 1001, 1069 | }, 1070 | LookupUnknownIPs: true, 1071 | ReCacheEvery: time.Second, 1072 | AllowAnswersFor: 5 * time.Second, 1073 | AllowedHostnames: []string{ 1074 | "foo", 1075 | "bar", 1076 | "baz.barf", 1077 | }, 1078 | CachedHostnames: []string{ 1079 | "oof", 1080 | "rab", 1081 | }, 1082 | }, 1083 | }, 1084 | }, 1085 | expectedErr: "", 1086 | }, 1087 | { 1088 | testName: "valid multiple filters", 1089 | configStr: ` 1090 | inboundDNSQueue.ipv4 = 1 1091 | inboundDNSQueue.ipv6 = 10 1092 | selfDNSQueue.ipv4 = 100 1093 | selfDNSQueue.ipv6 = 110 1094 | 1095 | [[filters]] 1096 | name = "test1" 1097 | dnsQueue.ipv4 = 1000 1098 | dnsQueue.ipv6 = 1010 1099 | trafficQueue.ipv4 = 1001 1100 | trafficQueue.ipv6 = 1011 1101 | allowAnswersFor = "5s" 1102 | allowedHostnames = [ 1103 | "foo", 1104 | "bar", 1105 | ] 1106 | 1107 | [[filters]] 1108 | name = "test2" 1109 | trafficQueue.ipv4 = 2010 1110 | trafficQueue.ipv6 = 2011 1111 | lookupUnknownIPs = true 1112 | 1113 | [[filters]] 1114 | name = "test3" 1115 | trafficQueue.ipv4 = 3001 1116 | trafficQueue.ipv6 = 3011 1117 | reCacheEvery = "1s" 1118 | cachedHostnames = [ 1119 | "oof", 1120 | "rab", 1121 | ] 1122 | 1123 | [[filters]] 1124 | name = "test4" 1125 | dnsQueue.ipv4 = 4000 1126 | dnsQueue.ipv6 = 4010 1127 | allowAllHostnames = true`, 1128 | expectedConfig: &Config{ 1129 | InboundDNSQueue: queue{ 1130 | IPv4: 1, 1131 | IPv6: 10, 1132 | }, 1133 | SelfDNSQueue: queue{ 1134 | IPv4: 100, 1135 | IPv6: 110, 1136 | }, 1137 | Filters: []FilterOptions{ 1138 | { 1139 | Name: "self-filter", 1140 | DNSQueue: queue{ 1141 | IPv4: 100, 1142 | IPv6: 110, 1143 | }, 1144 | AllowedHostnames: []string{ 1145 | "in-addr.arpa", 1146 | "ip6.arpa", 1147 | "oof", 1148 | "rab", 1149 | }, 1150 | }, 1151 | { 1152 | Name: "test1", 1153 | DNSQueue: queue{ 1154 | IPv4: 1000, 1155 | IPv6: 1010, 1156 | }, 1157 | TrafficQueue: queue{ 1158 | IPv4: 1001, 1159 | IPv6: 1011, 1160 | }, 1161 | AllowAnswersFor: 5 * time.Second, 1162 | AllowedHostnames: []string{ 1163 | "foo", 1164 | "bar", 1165 | }, 1166 | }, 1167 | { 1168 | Name: "test2", 1169 | TrafficQueue: queue{ 1170 | IPv4: 2010, 1171 | IPv6: 2011, 1172 | }, 1173 | LookupUnknownIPs: true, 1174 | }, 1175 | { 1176 | Name: "test3", 1177 | TrafficQueue: queue{ 1178 | IPv4: 3001, 1179 | IPv6: 3011, 1180 | }, 1181 | ReCacheEvery: time.Second, 1182 | CachedHostnames: []string{ 1183 | "oof", 1184 | "rab", 1185 | }, 1186 | }, 1187 | { 1188 | Name: "test4", 1189 | DNSQueue: queue{ 1190 | IPv4: 4000, 1191 | IPv6: 4010, 1192 | }, 1193 | AllowAllHostnames: true, 1194 | }, 1195 | }, 1196 | }, 1197 | expectedErr: "", 1198 | }, 1199 | } 1200 | 1201 | func TestParseConfig(t *testing.T) { 1202 | is := is.New(t) 1203 | for _, tt := range configTests { 1204 | t.Run(tt.testName, func(t *testing.T) { 1205 | is := is.New(t) 1206 | 1207 | config, err := parseConfigBytes([]byte(tt.configStr)) 1208 | if tt.expectedErr == "" { 1209 | is.NoErr(err) 1210 | } else { 1211 | is.Equal(err.Error(), tt.expectedErr) 1212 | } 1213 | is.Equal(config, tt.expectedConfig) 1214 | }) 1215 | } 1216 | } 1217 | -------------------------------------------------------------------------------- /filter.go: -------------------------------------------------------------------------------- 1 | package egresseddie 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/netip" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/florianl/go-nfqueue" 14 | "github.com/google/gopacket" 15 | "github.com/google/gopacket/layers" 16 | "github.com/mdlayher/netlink" 17 | "go.uber.org/zap" 18 | "golang.org/x/sys/unix" 19 | 20 | "github.com/capnspacehook/egress-eddie/timedcache" 21 | ) 22 | 23 | const ( 24 | // from github.com/torvalds/linux/tree/master/include/uapi/linux/netfilter/nf_conntrack_common.h 25 | stateEstablished = iota 26 | stateRelated 27 | stateNew 28 | stateIsReply 29 | stateEstablishedReply = stateEstablished + stateIsReply 30 | stateRelatedReply = stateRelated + stateIsReply 31 | stateUntracked = 7 32 | 33 | // give DNS connections a minute to finish max 34 | // TODO: should this be configurable? 35 | dnsQueryTimeout = time.Minute 36 | ) 37 | 38 | type FilterManager struct { 39 | signaler *signaler 40 | 41 | started bool 42 | 43 | fullDNSLogging bool 44 | logger *zap.Logger 45 | 46 | queueNum4 uint16 47 | queueNum6 uint16 48 | 49 | dnsRespNF4 enforcer 50 | dnsRespNF6 enforcer 51 | 52 | filters []*filter 53 | } 54 | 55 | type filter struct { 56 | dnsReqSignaler *signaler 57 | genericSignaler *signaler 58 | cachingSignaler *signaler 59 | 60 | started bool 61 | wg sync.WaitGroup 62 | 63 | opts *FilterOptions 64 | 65 | fullDNSLogging bool 66 | logger *zap.Logger 67 | 68 | dnsReqNF4 enforcer 69 | dnsReqNF6 enforcer 70 | genericNF4 enforcer 71 | genericNF6 enforcer 72 | 73 | res resolver 74 | 75 | connections *timedcache.TimedCache[connectionID] 76 | allowedIPs *timedcache.TimedCache[netip.Addr] 77 | additionalHostnames *timedcache.TimedCache[string] 78 | 79 | isSelfFilter bool 80 | } 81 | 82 | type signaler struct { 83 | readyCh chan struct{} 84 | abortCh chan struct{} 85 | } 86 | 87 | func newSignaler() *signaler { 88 | return &signaler{ 89 | readyCh: make(chan struct{}), 90 | abortCh: make(chan struct{}), 91 | } 92 | } 93 | 94 | func (s *signaler) ready() { 95 | close(s.readyCh) 96 | } 97 | 98 | func (s *signaler) isReady() <-chan struct{} { 99 | return s.readyCh 100 | } 101 | 102 | func (s *signaler) abort() { 103 | close(s.abortCh) 104 | } 105 | 106 | func (s *signaler) shouldAbort() <-chan struct{} { 107 | return s.abortCh 108 | } 109 | 110 | // connectionID is used to correlate DNS requests and responses from 111 | // the same connection 112 | type connectionID struct { 113 | isUDP bool 114 | src netip.AddrPort 115 | dst netip.AddrPort 116 | } 117 | 118 | func (c connectionID) String() string { 119 | var b strings.Builder 120 | 121 | if c.isUDP { 122 | b.WriteString("udp") 123 | } else { 124 | b.WriteString("tcp") 125 | } 126 | b.WriteRune('|') 127 | b.WriteString(c.src.String()) 128 | b.WriteRune('-') 129 | b.WriteString(c.dst.String()) 130 | 131 | return b.String() 132 | } 133 | 134 | type enforcer interface { 135 | SetVerdict(id uint32, verdict int) error 136 | Close() error 137 | } 138 | 139 | type resolver interface { 140 | LookupNetIP(ctx context.Context, network string, host string) ([]netip.Addr, error) 141 | LookupAddr(ctx context.Context, addr string) ([]string, error) 142 | } 143 | 144 | type enforcerCreator func(ctx context.Context, logger *zap.Logger, queueNum uint16, ipv6 bool, hook nfqueue.HookFunc) (enforcer, error) 145 | 146 | // CreateFilters creates packet filters. The returned FilterManager can 147 | // be used to start or stop packet filtering. 148 | func CreateFilters(ctx context.Context, logger *zap.Logger, config *Config, fullDNSLogging bool) (*FilterManager, error) { 149 | f := FilterManager{ 150 | signaler: newSignaler(), 151 | fullDNSLogging: fullDNSLogging, 152 | logger: logger, 153 | queueNum4: config.InboundDNSQueue.IPv4, 154 | queueNum6: config.InboundDNSQueue.IPv6, 155 | filters: make([]*filter, len(config.Filters)), 156 | } 157 | 158 | // if mock enforcers and resolver is not set, use real ones 159 | newEnforcer := config.enforcerCreator 160 | if newEnforcer == nil { 161 | newEnforcer = openNfQueue 162 | } 163 | res := config.resolver 164 | if res == nil { 165 | res = &net.Resolver{} 166 | } 167 | 168 | nf4, nf6, err := openNfQueues(ctx, logger, config.InboundDNSQueue, newEnforcer, func(ipv6 bool) nfqueue.HookFunc { 169 | return newDNSResponseCallback(&f, ipv6) 170 | }) 171 | if err != nil { 172 | return nil, err 173 | } 174 | f.dnsRespNF4 = nf4 175 | f.dnsRespNF6 = nf6 176 | 177 | for i := range config.Filters { 178 | isSelfFilter := config.SelfDNSQueue == config.Filters[i].DNSQueue 179 | filter, err := createFilter(ctx, logger, &config.Filters[i], isSelfFilter, f.fullDNSLogging, newEnforcer, res) 180 | if err != nil { 181 | // TODO: stop other filters here 182 | return nil, err 183 | } 184 | 185 | f.filters[i] = filter 186 | } 187 | 188 | return &f, nil 189 | } 190 | 191 | // Start starts packet filtering. 192 | func (f *FilterManager) Start() { 193 | // Let the DNS response callback know everything is setup. The 194 | // callback will be executing on another goroutine started by 195 | // nfqueue.RegisterWithErrorFunc, but only after a packet is 196 | // received on its nfqueue. 197 | f.signaler.ready() 198 | 199 | for i := range f.filters { 200 | f.filters[i].start() 201 | } 202 | 203 | f.started = true 204 | } 205 | 206 | // Stop stops packet filtering and cleans up owned resources. 207 | func (f *FilterManager) Stop() { 208 | // if the filters have not been started yet, tell running goroutines 209 | // to abort and finish 210 | if !f.started { 211 | f.signaler.abort() 212 | } 213 | 214 | if f.dnsRespNF4 != nil { 215 | f.dnsRespNF4.Close() 216 | } 217 | if f.dnsRespNF6 != nil { 218 | f.dnsRespNF6.Close() 219 | } 220 | 221 | for i := range f.filters { 222 | f.filters[i].close() 223 | } 224 | } 225 | 226 | func createFilter(ctx context.Context, logger *zap.Logger, opts *FilterOptions, isSelfFilter, fullDNSLogging bool, newEnforcer enforcerCreator, res resolver) (*filter, error) { 227 | filterLogger := logger 228 | if opts.Name != "" { 229 | filterLogger = filterLogger.With(zap.String("filter.name", opts.Name)) 230 | } 231 | 232 | f := filter{ 233 | dnsReqSignaler: newSignaler(), 234 | genericSignaler: newSignaler(), 235 | cachingSignaler: newSignaler(), 236 | opts: opts, 237 | fullDNSLogging: fullDNSLogging, 238 | logger: filterLogger, 239 | res: res, 240 | connections: timedcache.New[connectionID](logger, true), 241 | isSelfFilter: isSelfFilter, 242 | } 243 | 244 | if opts.TrafficQueue.eitherSet() { 245 | f.allowedIPs = timedcache.New[netip.Addr](f.logger, false) 246 | f.additionalHostnames = timedcache.New[string](filterLogger, false) 247 | 248 | nf4, nf6, err := openNfQueues(ctx, filterLogger, opts.TrafficQueue, newEnforcer, func(ipv6 bool) nfqueue.HookFunc { 249 | return newGenericCallback(ctx, &f, ipv6) 250 | }) 251 | if err != nil { 252 | return nil, fmt.Errorf("error starting traffic nfqueues: %w", err) 253 | } 254 | f.genericNF4 = nf4 255 | f.genericNF6 = nf6 256 | 257 | if len(f.opts.CachedHostnames) > 0 { 258 | f.wg.Add(1) 259 | go func() { 260 | defer f.wg.Done() 261 | 262 | f.cacheHostnames(ctx, filterLogger) 263 | }() 264 | } 265 | } 266 | 267 | if opts.DNSQueue.eitherSet() { 268 | nf4, nf6, err := openNfQueues(ctx, filterLogger, opts.DNSQueue, newEnforcer, func(ipv6 bool) nfqueue.HookFunc { 269 | return newDNSRequestCallback(&f, ipv6) 270 | }) 271 | if err != nil { 272 | return nil, fmt.Errorf("error starting DNS nfqueues: %w", err) 273 | } 274 | f.dnsReqNF4 = nf4 275 | f.dnsReqNF6 = nf6 276 | 277 | } 278 | 279 | return &f, nil 280 | } 281 | 282 | func openNfQueues(ctx context.Context, logger *zap.Logger, queues queue, newEnforcer enforcerCreator, hookGen func(ipv6 bool) nfqueue.HookFunc) (nf4 enforcer, nf6 enforcer, err error) { 283 | if queues.IPv4 != 0 { 284 | nf4, err = newEnforcer(ctx, logger, queues.IPv4, false, hookGen(false)) 285 | if err != nil { 286 | return nil, nil, err 287 | } 288 | } 289 | if queues.IPv6 != 0 { 290 | nf6, err = newEnforcer(ctx, logger, queues.IPv6, true, hookGen(true)) 291 | if err != nil { 292 | return nil, nil, err 293 | } 294 | } 295 | 296 | return nf4, nf6, nil 297 | } 298 | 299 | func openNfQueue(ctx context.Context, logger *zap.Logger, queueNum uint16, ipv6 bool, hook nfqueue.HookFunc) (enforcer, error) { 300 | afFamily := unix.AF_INET 301 | if ipv6 { 302 | afFamily = unix.AF_INET6 303 | } 304 | 305 | nfqConf := nfqueue.Config{ 306 | NfQueue: queueNum, 307 | MaxPacketLen: 0xffff, 308 | MaxQueueLen: 0xffff, 309 | AfFamily: uint8(afFamily), 310 | Copymode: nfqueue.NfQnlCopyPacket, 311 | Flags: nfqueue.NfQaCfgFlagConntrack, 312 | } 313 | 314 | nf, err := nfqueue.Open(&nfqConf) 315 | if err != nil { 316 | return nil, fmt.Errorf("error opening nfqueue: %w", err) 317 | } 318 | 319 | // close the nfqueue connection in case of an error 320 | var ok bool 321 | defer func() { 322 | if !ok { 323 | nf.Close() 324 | } 325 | }() 326 | 327 | // Set options to the nfqueue's netlink socket if possible to enable 328 | // better error messages and more strict checking of arguments from 329 | // the kernel. Ignore ENOPROTOOPT errors, that just means the kernel 330 | // doesn't support that option. 331 | err = nf.Con.SetOption(netlink.ExtendedAcknowledge, true) 332 | if err != nil && !errors.Is(err, unix.ENOPROTOOPT) { 333 | return nil, fmt.Errorf("error setting ExtendedAcknowledge netlink option: %w", err) 334 | } 335 | err = nf.Con.SetOption(netlink.GetStrictCheck, true) 336 | if err != nil && !errors.Is(err, unix.ENOPROTOOPT) { 337 | return nil, fmt.Errorf("error setting GetStrictCheck netlink option: %w", err) 338 | } 339 | 340 | if err := nf.RegisterWithErrorFunc(ctx, hook, newErrorCallback(logger)); err != nil { 341 | return nil, fmt.Errorf("error registering nfqueue: %w", err) 342 | } 343 | 344 | ok = true 345 | 346 | return nf, nil 347 | } 348 | 349 | func (f *filter) start() { 350 | if f.opts.DNSQueue.eitherSet() { 351 | f.dnsReqSignaler.ready() 352 | } 353 | if f.opts.TrafficQueue.eitherSet() { 354 | f.genericSignaler.ready() 355 | } 356 | if len(f.opts.CachedHostnames) > 0 { 357 | f.cachingSignaler.ready() 358 | } 359 | 360 | f.started = true 361 | } 362 | 363 | func (f *filter) cacheHostnames(ctx context.Context, logger *zap.Logger) { 364 | // wait until the filter manager is setup to prevent race conditions 365 | select { 366 | case <-f.cachingSignaler.isReady(): 367 | case <-f.cachingSignaler.shouldAbort(): 368 | // the filter manager has been stopped before it was started, 369 | // return so the parent filter can finish cleaning up 370 | return 371 | } 372 | 373 | logger.Debug("starting cache loop") 374 | 375 | var ( 376 | // add to the user supplied duration to ensure there isn't a 377 | // window where hostnames are not allowed 378 | ttl = f.opts.ReCacheEvery + dnsQueryTimeout 379 | timer = time.NewTimer(f.opts.ReCacheEvery) 380 | ) 381 | 382 | for { 383 | for i := range f.opts.CachedHostnames { 384 | logger.Info("caching lookup of hostname", zap.String("hostname", f.opts.CachedHostnames[i])) 385 | addrs, err := f.res.LookupNetIP(ctx, "ip", f.opts.CachedHostnames[i]) 386 | if err != nil { 387 | var dnsErr *net.DNSError 388 | if errors.As(err, &dnsErr) && dnsErr.IsNotFound { 389 | logger.Warn("could not resolve hostname", zap.String("hostname", f.opts.CachedHostnames[i])) 390 | continue 391 | } 392 | logger.Error("error resolving hostname", zap.String("hostname", f.opts.CachedHostnames[i]), zap.NamedError("error", err)) 393 | continue 394 | } 395 | 396 | for i := range addrs { 397 | logger.Info("allowing IP from cached lookup", zap.Stringer("ip", addrs[i])) 398 | f.allowedIPs.AddEntry(addrs[i], ttl) 399 | 400 | // If the IP address is an IPv4-mapped IPv6 address, 401 | // add the unwrapped IPv4 address too. That is what 402 | // will most likely be used. 403 | if addrs[i].Is4In6() { 404 | addrs[i] = addrs[i].Unmap() 405 | logger.Info("allowing IP from cached lookup", zap.Stringer("ip", addrs[i])) 406 | f.allowedIPs.AddEntry(addrs[i], ttl) 407 | } 408 | } 409 | } 410 | 411 | timer.Reset(f.opts.ReCacheEvery) 412 | select { 413 | case <-ctx.Done(): 414 | if !timer.Stop() { 415 | <-timer.C 416 | } 417 | logger.Debug("exiting cache loop") 418 | return 419 | case <-timer.C: 420 | } 421 | } 422 | } 423 | 424 | func (f *filter) close() { 425 | // if the filter has not been started yet, tell running goroutines 426 | // to abort and finish 427 | if !f.started { 428 | if f.opts.DNSQueue.eitherSet() { 429 | f.dnsReqSignaler.abort() 430 | } 431 | if f.opts.TrafficQueue.eitherSet() { 432 | f.genericSignaler.abort() 433 | } 434 | if len(f.opts.CachedHostnames) > 0 { 435 | f.cachingSignaler.abort() 436 | } 437 | } 438 | 439 | f.wg.Wait() 440 | 441 | if f.dnsReqNF4 != nil { 442 | f.dnsReqNF4.Close() 443 | } 444 | if f.dnsReqNF6 != nil { 445 | f.dnsReqNF6.Close() 446 | } 447 | if f.genericNF4 != nil { 448 | f.genericNF4.Close() 449 | } 450 | if f.genericNF6 != nil { 451 | f.genericNF6.Close() 452 | } 453 | 454 | f.connections.Stop() 455 | if f.allowedIPs != nil { 456 | f.allowedIPs.Stop() 457 | } 458 | if f.additionalHostnames != nil { 459 | f.additionalHostnames.Stop() 460 | } 461 | } 462 | 463 | func newDNSRequestCallback(f *filter, ipv6 bool) nfqueue.HookFunc { 464 | var queueNum uint16 465 | if !ipv6 { 466 | queueNum = f.opts.DNSQueue.IPv4 467 | } else { 468 | queueNum = f.opts.DNSQueue.IPv6 469 | } 470 | 471 | logger := f.logger.With(zap.String("filter.type", "dns-req")) 472 | logger = logger.With(zap.Uint16("queue.num", queueNum)) 473 | logger.Info("started nfqueue") 474 | 475 | return func(attr nfqueue.Attribute) int { 476 | // wait until the filter manager is setup to prevent race conditions 477 | select { 478 | case <-f.dnsReqSignaler.isReady(): 479 | // the filter manager has been stopped before it was started, 480 | // return so the parent filter can finish cleaning up 481 | case <-f.dnsReqSignaler.shouldAbort(): 482 | return 0 483 | } 484 | 485 | var dnsReqNF enforcer 486 | if !ipv6 { 487 | dnsReqNF = f.dnsReqNF4 488 | } else { 489 | dnsReqNF = f.dnsReqNF6 490 | } 491 | 492 | if attr.PacketID == nil { 493 | logger.Warn("got packet with no packet ID") 494 | return 0 495 | } 496 | if attr.CtInfo == nil { 497 | logger.Warn("got packet with no connection state") 498 | return 0 499 | } 500 | if attr.Payload == nil { 501 | logger.Warn("got packet with no payload") 502 | return 0 503 | } 504 | 505 | // verify DNS request is from a new or established connection 506 | if *attr.CtInfo != stateNew && !connIsEstablished(*attr.CtInfo) { 507 | logger.Warn("dropping DNS request with unknown state", zap.Uint32("conn.state", *attr.CtInfo)) 508 | 509 | if err := dnsReqNF.SetVerdict(*attr.PacketID, nfqueue.NfDrop); err != nil { 510 | logger.Error("error setting verdict", zap.String("error", err.Error())) 511 | } 512 | return 0 513 | } 514 | 515 | dns, connID, err := parseDNSPacket(*attr.Payload, ipv6, false) 516 | if err != nil { 517 | logger.Error("error parsing DNS packet", zap.NamedError("error", err)) 518 | return 0 519 | } 520 | logger := logger.With(zap.Stringer("conn.id", connID)) 521 | 522 | // drop DNS replies, they shouldn't be going to this filter 523 | if dns.QR || dns.ANCount > 0 { 524 | logger.Warn("dropping DNS reply sent to DNS request filter", dnsFields(dns, f.fullDNSLogging)...) 525 | if err := dnsReqNF.SetVerdict(*attr.PacketID, nfqueue.NfDrop); err != nil { 526 | logger.Error("error setting verdict", zap.String("error", err.Error())) 527 | } 528 | return 0 529 | } 530 | 531 | // validate DNS request questions are for allowed 532 | // hostnames, drop them otherwise 533 | if !f.opts.AllowAllHostnames && !f.validateDNSQuestions(dns) { 534 | logger.Warn("dropping DNS request", dnsFields(dns, f.fullDNSLogging)...) 535 | if err := dnsReqNF.SetVerdict(*attr.PacketID, nfqueue.NfDrop); err != nil { 536 | logger.Error("error setting verdict", zap.NamedError("error", err)) 537 | } 538 | return 0 539 | } 540 | 541 | logger.Info("allowing DNS request", dnsFields(dns, f.fullDNSLogging)...) 542 | 543 | logger.Debug("adding connection") 544 | f.connections.AddEntry(connID, dnsQueryTimeout) 545 | 546 | if err := dnsReqNF.SetVerdict(*attr.PacketID, nfqueue.NfAccept); err != nil { 547 | logger.Error("error setting verdict", zap.NamedError("error", err)) 548 | logger.Debug("removing connection") 549 | f.connections.RemoveEntry(connID) 550 | } 551 | 552 | return 0 553 | } 554 | } 555 | 556 | func connIsEstablished(state uint32) bool { 557 | return state == stateEstablished || state == stateRelated || state == stateIsReply || state == stateRelatedReply 558 | } 559 | 560 | func parseDNSPacket(packet []byte, ipv6, inbound bool) (*layers.DNS, connectionID, error) { 561 | var ( 562 | ip4 layers.IPv4 563 | ip6 layers.IPv6 564 | udp layers.UDP 565 | tcp layers.TCP 566 | dns layers.DNS 567 | parser *gopacket.DecodingLayerParser 568 | decoded = make([]gopacket.LayerType, 0, 3) 569 | ) 570 | 571 | // parse DNS packet 572 | if !ipv6 { 573 | parser = gopacket.NewDecodingLayerParser(layers.LayerTypeIPv4, &ip4, &udp, &tcp, &dns) 574 | } else { 575 | parser = gopacket.NewDecodingLayerParser(layers.LayerTypeIPv6, &ip6, &udp, &tcp, &dns) 576 | } 577 | 578 | if err := parser.DecodeLayers(packet, &decoded); err != nil { 579 | return nil, connectionID{}, err 580 | } 581 | if len(decoded) != 3 { 582 | return nil, connectionID{}, fmt.Errorf("%d layers were parsed, expecting 3", len(decoded)) 583 | } 584 | 585 | // build connection ID so dns requests/responses can be correlated 586 | var ( 587 | isUDP bool 588 | src, dst netip.Addr 589 | srcPort, dstPort uint16 590 | srcOK, dstOK bool 591 | ) 592 | 593 | if decoded[0] == layers.LayerTypeIPv4 { 594 | src, srcOK = netip.AddrFromSlice(ip4.SrcIP) 595 | dst, dstOK = netip.AddrFromSlice(ip4.DstIP) 596 | } else if decoded[0] == layers.LayerTypeIPv6 { 597 | src, srcOK = netip.AddrFromSlice(ip6.SrcIP) 598 | dst, dstOK = netip.AddrFromSlice(ip6.DstIP) 599 | } 600 | if !srcOK || !dstOK { 601 | return nil, connectionID{}, errors.New("error converting IPs") 602 | } 603 | 604 | if decoded[1] == layers.LayerTypeUDP { 605 | isUDP = true 606 | srcPort = uint16(udp.SrcPort) 607 | dstPort = uint16(udp.DstPort) 608 | } else { 609 | isUDP = false 610 | srcPort = uint16(tcp.SrcPort) 611 | dstPort = uint16(tcp.DstPort) 612 | } 613 | 614 | connID := connectionID{ 615 | isUDP: isUDP, 616 | } 617 | if inbound { 618 | connID.src = netip.AddrPortFrom(dst, dstPort) 619 | connID.dst = netip.AddrPortFrom(src, srcPort) 620 | } else { 621 | connID.src = netip.AddrPortFrom(src, srcPort) 622 | connID.dst = netip.AddrPortFrom(dst, dstPort) 623 | } 624 | 625 | return &dns, connID, nil 626 | } 627 | 628 | func (f *filter) validateDNSQuestions(dns *layers.DNS) bool { 629 | if dns.QDCount == 0 { 630 | // drop DNS requests with no questions; this probably 631 | // doesn't happen in practice but doesn't hurt to 632 | // handle this case 633 | return false 634 | } 635 | 636 | for i := range dns.Questions { 637 | // bail out if any of the questions don't contain an allowed 638 | // hostname 639 | qName := string(dns.Questions[i].Name) 640 | if !f.hostnameAllowed(qName) { 641 | return false 642 | } 643 | } 644 | 645 | return true 646 | } 647 | 648 | func (f *filter) hostnameAllowed(hostname string) bool { 649 | for j := range f.opts.AllowedHostnames { 650 | if hostname == f.opts.AllowedHostnames[j] || strings.HasSuffix(hostname, "."+f.opts.AllowedHostnames[j]) { 651 | return true 652 | } 653 | } 654 | 655 | // the self-filter doesn't have a nfqueue for generic traffic, and 656 | // therefore won't have a cache for additional hostnames 657 | if f.isSelfFilter { 658 | return false 659 | } 660 | 661 | return f.additionalHostnames.EntryExists(hostname) 662 | } 663 | 664 | func newDNSResponseCallback(f *FilterManager, ipv6 bool) nfqueue.HookFunc { 665 | var queueNum uint16 666 | if !ipv6 { 667 | queueNum = f.queueNum4 668 | } else { 669 | queueNum = f.queueNum6 670 | } 671 | 672 | logger := f.logger.With(zap.String("filter.type", "dns-resp")) 673 | logger = logger.With(zap.Uint16("queue.num", queueNum)) 674 | logger.Info("started nfqueue") 675 | 676 | return func(attr nfqueue.Attribute) int { 677 | // wait until the filter manager is setup to prevent race conditions 678 | select { 679 | case <-f.signaler.isReady(): 680 | case <-f.signaler.shouldAbort(): 681 | // the filter manager has been stopped before it was started, 682 | // return so the parent filter can finish cleaning up 683 | return 0 684 | } 685 | 686 | var dnsRespNF enforcer 687 | if !ipv6 { 688 | dnsRespNF = f.dnsRespNF4 689 | } else { 690 | dnsRespNF = f.dnsRespNF6 691 | } 692 | 693 | if attr.PacketID == nil { 694 | logger.Warn("got packet with no packet ID") 695 | return 0 696 | } 697 | if attr.CtInfo == nil { 698 | logger.Warn("got packet with no connection state") 699 | return 0 700 | } 701 | if attr.Payload == nil { 702 | logger.Warn("got packet with no payload") 703 | return 0 704 | } 705 | 706 | // since DNS requests are filtered above, we only process 707 | // DNS responses of established packets to make sure a 708 | // local attacker can't connect to disallowed IPs by 709 | // sending a DNS response with an attacker specified IP 710 | // as an answer, thereby allowing that IP 711 | if !connIsEstablished(*attr.CtInfo) { 712 | logger.Warn("dropping DNS response with that is not from an established connection", zap.Uint32("conn.state", *attr.CtInfo)) 713 | 714 | if err := dnsRespNF.SetVerdict(*attr.PacketID, nfqueue.NfDrop); err != nil { 715 | logger.Error("error setting verdict", zap.NamedError("error", err)) 716 | } 717 | return 0 718 | } 719 | 720 | dns, connID, err := parseDNSPacket(*attr.Payload, ipv6, true) 721 | if err != nil { 722 | logger.Error("error parsing DNS packet", zap.NamedError("error", err)) 723 | return 0 724 | } 725 | logger := logger.With(zap.Stringer("conn.id", connID)) 726 | 727 | var connFilter *filter 728 | for _, filter := range f.filters { 729 | if filter.connections.EntryExists(connID) { 730 | connFilter = filter 731 | break 732 | } 733 | } 734 | if connFilter == nil { 735 | logger.Warn("dropping DNS response from unknown connection", dnsFields(dns, f.fullDNSLogging)...) 736 | 737 | if err := dnsRespNF.SetVerdict(*attr.PacketID, nfqueue.NfDrop); err != nil { 738 | logger.Error("error setting verdict", zap.NamedError("error", err)) 739 | } 740 | return 0 741 | } 742 | logger.Debug("removing connection") 743 | connFilter.connections.RemoveEntry(connID) 744 | 745 | logger = logger.With(zap.String("dns-req.filter.name", connFilter.opts.Name)) 746 | // allow and don't process the DNS response if all hostnames 747 | // are allowed 748 | if !connFilter.opts.AllowAllHostnames { 749 | // validate DNS response questions are for allowed 750 | // hostnames, drop them otherwise; responses for disallowed 751 | // hostnames should never happen in theory, because we 752 | // block requests for disallowed hostnames but it doesn't 753 | // hurt to check 754 | if !connFilter.validateDNSQuestions(dns) { 755 | logger.Info("dropping DNS reply", dnsFields(dns, f.fullDNSLogging)...) 756 | if err := dnsRespNF.SetVerdict(*attr.PacketID, nfqueue.NfDrop); err != nil { 757 | logger.Error("error setting verdict", zap.NamedError("error", err)) 758 | } 759 | return 0 760 | } 761 | 762 | // don't process the DNS response if the filter it came 763 | // from is the self filter 764 | if !connFilter.isSelfFilter && dns.ANCount > 0 { 765 | ttl := connFilter.opts.AllowAnswersFor 766 | for _, answer := range dns.Answers { 767 | aName := string(answer.Name) 768 | if !connFilter.hostnameAllowed(aName) { 769 | logger.Info("dropping DNS reply", zap.ByteString("answer", answer.Name)) 770 | if err := dnsRespNF.SetVerdict(*attr.PacketID, nfqueue.NfDrop); err != nil { 771 | logger.Error("error setting verdict", zap.NamedError("error", err)) 772 | } 773 | return 0 774 | } 775 | 776 | switch answer.Type { 777 | case layers.DNSTypeA, layers.DNSTypeAAAA: 778 | // temporarily add A and AAAA answers to allowed IP list 779 | ip, ok := netip.AddrFromSlice(answer.IP) 780 | if !ok { 781 | logger.Error("error converting IP", zap.Stringer("answer.ip", answer.IP)) 782 | continue 783 | } 784 | 785 | connFilter.allowedIPs.AddEntry(ip, ttl) 786 | // If the IP address is an IPv4-mapped IPv6 address, 787 | // add the unwrapped IPv4 address too. That is what 788 | // will most likely be used. 789 | if ip.Is4In6() { 790 | connFilter.allowedIPs.AddEntry(ip.Unmap(), ttl) 791 | } 792 | case layers.DNSTypeCNAME, layers.DNSTypeSRV, layers.DNSTypeMX, layers.DNSTypeNS: 793 | // temporarily add CNAME, SRV, MX, and NS answers to allowed 794 | // hostnames list 795 | var name []byte 796 | switch answer.Type { 797 | case layers.DNSTypeCNAME: 798 | name = answer.CNAME 799 | case layers.DNSTypeSRV: 800 | name = answer.SRV.Name 801 | case layers.DNSTypeMX: 802 | name = answer.MX.Name 803 | case layers.DNSTypeNS: 804 | name = answer.NS 805 | } 806 | 807 | connFilter.additionalHostnames.AddEntry(string(name), ttl) 808 | default: 809 | // don't need to specifically handle other answer 810 | // types, the packet will be allowed so whoever 811 | // made the DNS request will see this answer 812 | } 813 | } 814 | } 815 | } 816 | 817 | logger.Info("allowing DNS reply", dnsFields(dns, f.fullDNSLogging)...) 818 | if err := dnsRespNF.SetVerdict(*attr.PacketID, nfqueue.NfAccept); err != nil { 819 | logger.Error("error setting verdict", zap.NamedError("error", err)) 820 | } 821 | 822 | return 0 823 | } 824 | } 825 | 826 | func newGenericCallback(ctx context.Context, f *filter, ipv6 bool) nfqueue.HookFunc { 827 | var queueNum uint16 828 | if !ipv6 { 829 | queueNum = f.opts.TrafficQueue.IPv4 830 | } else { 831 | queueNum = f.opts.TrafficQueue.IPv6 832 | } 833 | 834 | logger := f.logger.With(zap.String("filter.type", "traffic")) 835 | logger = logger.With(zap.Uint16("queue.num", queueNum)) 836 | logger.Info("started nfqueue") 837 | 838 | return func(attr nfqueue.Attribute) int { 839 | // wait until the filter manager is setup to prevent race conditions 840 | select { 841 | case <-f.genericSignaler.isReady(): 842 | case <-f.genericSignaler.shouldAbort(): 843 | // the filter manager has been stopped before it was started, 844 | // return so the parent filter can finish cleaning up 845 | return 0 846 | } 847 | 848 | var genericNF enforcer 849 | if !ipv6 { 850 | genericNF = f.genericNF4 851 | } else { 852 | genericNF = f.genericNF6 853 | } 854 | 855 | if attr.PacketID == nil { 856 | logger.Warn("got packet with no packet ID") 857 | return 0 858 | } 859 | if attr.Payload == nil { 860 | logger.Warn("got packet with no payload") 861 | return 0 862 | } 863 | 864 | var ( 865 | ip4 layers.IPv4 866 | ip6 layers.IPv6 867 | parser *gopacket.DecodingLayerParser 868 | decoded = make([]gopacket.LayerType, 1) 869 | ) 870 | 871 | // parse packet 872 | if !ipv6 { 873 | parser = gopacket.NewDecodingLayerParser(layers.LayerTypeIPv4) 874 | parser.IgnoreUnsupported = true 875 | parser.SetDecodingLayerContainer(gopacket.DecodingLayerArray(nil)) 876 | parser.AddDecodingLayer(&ip4) 877 | } else { 878 | parser = gopacket.NewDecodingLayerParser(layers.LayerTypeIPv6) 879 | parser.IgnoreUnsupported = true 880 | parser.SetDecodingLayerContainer(gopacket.DecodingLayerArray(nil)) 881 | parser.AddDecodingLayer(&ip6) 882 | } 883 | 884 | if err := parser.DecodeLayers(*attr.Payload, &decoded); err != nil { 885 | logger.Error("error parsing packet", zap.NamedError("error", err)) 886 | return 0 887 | } 888 | 889 | // get source and destination IP 890 | var ( 891 | src, dst netip.Addr 892 | srcOK, dstOK bool 893 | ) 894 | if decoded[0] == layers.LayerTypeIPv4 { 895 | src, srcOK = netip.AddrFromSlice(ip4.SrcIP) 896 | dst, dstOK = netip.AddrFromSlice(ip4.DstIP) 897 | if !srcOK || !dstOK { 898 | logger.Error("error converting IPs", zap.Stringer("conn.src", ip4.SrcIP), zap.Stringer("conn.dst", ip4.DstIP)) 899 | return 0 900 | } 901 | } else if decoded[0] == layers.LayerTypeIPv6 { 902 | src, srcOK = netip.AddrFromSlice(ip6.SrcIP) 903 | dst, dstOK = netip.AddrFromSlice(ip6.DstIP) 904 | if !srcOK || !dstOK { 905 | logger.Error("error converting IPs", zap.Stringer("conn.src", ip6.SrcIP), zap.Stringer("conn.dst", ip6.DstIP)) 906 | return 0 907 | } 908 | } 909 | 910 | // validate that either the source or destination IP is allowed 911 | var verdict int 912 | allowed, err := f.validateIPs(ctx, logger, src, dst) 913 | if err != nil { 914 | logger.Error("error validating IPs", zap.Stringer("conn.src", src), zap.Stringer("conn.dst", dst), zap.NamedError("error", err)) 915 | verdict = nfqueue.NfDrop 916 | } else { 917 | if allowed { 918 | logger.Info("allowing packet", zap.Stringer("conn.src", src), zap.Stringer("conn.dst", dst)) 919 | verdict = nfqueue.NfAccept 920 | } else { 921 | logger.Info("dropping packet", zap.Stringer("conn.src", src), zap.Stringer("conn.dst", dst)) 922 | verdict = nfqueue.NfDrop 923 | } 924 | } 925 | 926 | if err := genericNF.SetVerdict(*attr.PacketID, verdict); err != nil { 927 | logger.Error("error setting verdict", zap.NamedError("error", err)) 928 | } 929 | 930 | return 0 931 | } 932 | } 933 | 934 | func (f *filter) validateIPs(ctx context.Context, logger *zap.Logger, src, dst netip.Addr) (bool, error) { 935 | // check if the destination IP is allowed first, as most likely 936 | // we are validating an outbound connection 937 | if f.allowedIPs.EntryExists(dst) { 938 | return true, nil 939 | } 940 | 941 | // check if source IP is allowed; if reverse IP lookups are 942 | // disabled or the IP is allowed return early 943 | allowed := f.allowedIPs.EntryExists(src) 944 | if !f.opts.LookupUnknownIPs || allowed { 945 | return allowed, nil 946 | } 947 | 948 | // preform reverse IP lookups on the destination and then source 949 | // IPs only if the IPs are not private 950 | if !dst.IsPrivate() { 951 | allowed, err := f.lookupAndValidateIP(ctx, logger, dst) 952 | if err != nil { 953 | return false, err 954 | } 955 | if allowed { 956 | return true, nil 957 | } 958 | } 959 | 960 | if !src.IsPrivate() { 961 | return f.lookupAndValidateIP(ctx, logger, src) 962 | } 963 | 964 | return false, nil 965 | } 966 | 967 | func (f *filter) lookupAndValidateIP(ctx context.Context, logger *zap.Logger, ip netip.Addr) (bool, error) { 968 | ctx, cancel := context.WithTimeout(ctx, dnsQueryTimeout) 969 | defer cancel() 970 | 971 | logger.Info("preforming reverse IP lookup", zap.Stringer("ip", ip)) 972 | names, err := f.res.LookupAddr(ctx, ip.String()) 973 | if err != nil { 974 | // don't return error if IP simply couldn't be found 975 | var dnsErr *net.DNSError 976 | if errors.As(err, &dnsErr) && dnsErr.IsNotFound { 977 | return false, nil 978 | } 979 | return false, err 980 | } 981 | 982 | ttl := f.opts.AllowAnswersFor 983 | for i := range names { 984 | // remove trailing dot if necessary before searching through 985 | // allowed hostnames 986 | if names[i][len(names[i])-1] == '.' { 987 | names[i] = names[i][:len(names[i])-1] 988 | } 989 | 990 | if f.hostnameAllowed(names[i]) { 991 | logger.Info("allowing IP after reverse lookup", zap.Stringer("ip", ip)) 992 | f.allowedIPs.AddEntry(ip, ttl) 993 | return true, nil 994 | } 995 | } 996 | 997 | return false, nil 998 | } 999 | 1000 | func newErrorCallback(logger *zap.Logger) nfqueue.ErrorFunc { 1001 | return func(err error) int { 1002 | // skip noisy errors that aren't important when exiting 1003 | var nerr *netlink.OpError 1004 | if errors.As(err, &nerr) { 1005 | if strings.Contains(err.Error(), "i/o timeout") || 1006 | strings.Contains(err.Error(), "use of closed file") { 1007 | return 0 1008 | } 1009 | } 1010 | 1011 | logger.Error("netlink error", zap.NamedError("error", err)) 1012 | 1013 | return 0 1014 | } 1015 | } 1016 | --------------------------------------------------------------------------------