From ad154ff218120e4b6626794a7b7aae13cde5d790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 17:28:17 +0200 Subject: [PATCH 1/5] chore(dependabot): group dependency updates into single PRs per ecosystem and add github ci action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Schäfer --- .github/dependabot.yml | 16 +++++++ .github/workflows/ci.yml | 91 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3938344..4f8502e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,22 @@ version: 2 + updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 1 + groups: + go-dependencies: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 1 + groups: + github-actions-dependencies: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..815ca69 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GOLANGCI_LINT_VERSION: "v2.12.2" + YAEGI_VERSION: "v0.16.1" + GOPATH: ${{ github.workspace }}/go + REPOPATH: ${{ github.workspace }}/go/src/github.com/fosrl/badger + +jobs: + ci: + name: Go CI + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ${{ env.REPOPATH }} + + steps: + - name: Check out code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + path: go/src/github.com/fosrl/badger + persist-credentials: false + fetch-depth: 2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go/src/github.com/fosrl/badger/.go-version + + - name: Show Go version + run: go version + + - name: Run CI checks + run: make ci + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 + continue-on-error: true + with: + version: ${{ env.GOLANGCI_LINT_VERSION }} + working-directory: ${{ env.REPOPATH }} + + - name: Validate Traefik plugin metadata + run: | + test -f .traefik.yml + grep -q '^displayName:' .traefik.yml + grep -q '^type:' .traefik.yml + grep -q '^import:' .traefik.yml + grep -q '^testData:' .traefik.yml + grep -q 'github.com/fosrl/badger' .traefik.yml + + yaegi: + name: Yaegi compatibility + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ${{ env.REPOPATH }} + + steps: + - name: Check out code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + path: go/src/github.com/fosrl/badger + persist-credentials: false + fetch-depth: 2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go/src/github.com/fosrl/badger/.go-version + + - name: Run Yaegi compatibility test + env: + GOPATH: ${{ env.GOPATH }} + run: make yaegi-test From 26a4de9f2a63164ce7530c031a668abf1dd5108d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 17:38:17 +0200 Subject: [PATCH 2/5] feat: rename main.go to badger.go to allign with traefik best practices and add test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Schäfer --- main.go => badger.go | 0 badger_test.go | 187 +++++++++++++++++++++++++++++++++++++++++++ ips/ips_test.go | 18 +++++ 3 files changed, 205 insertions(+) rename main.go => badger.go (100%) create mode 100644 badger_test.go create mode 100644 ips/ips_test.go diff --git a/main.go b/badger.go similarity index 100% rename from main.go rename to badger.go diff --git a/badger_test.go b/badger_test.go new file mode 100644 index 0000000..f8ab045 --- /dev/null +++ b/badger_test.go @@ -0,0 +1,187 @@ +package badger_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + badger "github.com/fosrl/badger" +) + +// newTestHandler builds a Badger handler with the given config, failing the test +// if construction returns an unexpected error. +func newTestHandler(t *testing.T, cfg *badger.Config, next http.Handler) http.Handler { + t.Helper() + h, err := badger.New(context.Background(), next, cfg, "test") + if err != nil { + t.Fatalf("New() returned unexpected error: %v", err) + } + return h +} + +func TestCreateConfig(t *testing.T) { + if badger.CreateConfig() == nil { + t.Fatal("CreateConfig() returned nil") + } +} + +func TestNewRequiresFieldsWhenForwardAuthEnabled(t *testing.T) { + cases := map[string]*badger.Config{ + "missing apiBaseUrl": { + UserSessionCookieName: "p_session_token", + ResourceSessionRequestParam: "p_session_request", + }, + "missing userSessionCookieName": { + APIBaseUrl: "http://localhost:3001", + ResourceSessionRequestParam: "p_session_request", + }, + "missing resourceSessionRequestParam": { + APIBaseUrl: "http://localhost:3001", + UserSessionCookieName: "p_session_token", + }, + } + + for name, cfg := range cases { + t.Run(name, func(t *testing.T) { + cfg.DisableDefaultCFIPs = true + if _, err := badger.New(context.Background(), nil, cfg, "test"); err == nil { + t.Fatalf("expected error for %q, got nil", name) + } + }) + } +} + +func TestNewSucceedsWhenForwardAuthDisabled(t *testing.T) { + cfg := &badger.Config{ + DisableForwardAuth: true, + DisableDefaultCFIPs: true, + } + h, err := badger.New(context.Background(), http.NotFoundHandler(), cfg, "test") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if h == nil { + t.Fatal("expected non-nil handler") + } +} + +func TestNewRejectsInvalidCIDR(t *testing.T) { + cfg := &badger.Config{ + DisableForwardAuth: true, + DisableDefaultCFIPs: true, + TrustIP: []string{"not-a-cidr"}, + } + if _, err := badger.New(context.Background(), http.NotFoundHandler(), cfg, "test"); err == nil { + t.Fatal("expected error for invalid CIDR, got nil") + } +} + +func TestServeHTTPDisableForwardAuthCallsNext(t *testing.T) { + called := false + next := http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + called = true + rw.WriteHeader(http.StatusOK) + }) + cfg := &badger.Config{DisableForwardAuth: true, DisableDefaultCFIPs: true} + h := newTestHandler(t, cfg, next) + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + h.ServeHTTP(rw, req) + + if !called { + t.Fatal("expected next handler to be called") + } + if rw.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rw.Code) + } +} + +func TestRealIPUntrustedUsesDirectIPAndStripsCFHeaders(t *testing.T) { + var forwarded *http.Request + next := http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { + forwarded = req + }) + cfg := &badger.Config{DisableForwardAuth: true, DisableDefaultCFIPs: true} + h := newTestHandler(t, cfg, next) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "198.51.100.7:9999" + req.Header.Set("CF-Connecting-IP", "1.2.3.4") + h.ServeHTTP(httptest.NewRecorder(), req) + + if got := forwarded.Header.Get("X-Real-Ip"); got != "198.51.100.7" { + t.Fatalf("expected X-Real-Ip from direct remote addr, got %q", got) + } + if got := forwarded.Header.Get("CF-Connecting-IP"); got != "" { + t.Fatalf("expected CF-Connecting-IP to be stripped for untrusted source, got %q", got) + } +} + +func TestRealIPTrustedProxyUsesCustomHeader(t *testing.T) { + var forwarded *http.Request + next := http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { + forwarded = req + }) + cfg := &badger.Config{ + DisableForwardAuth: true, + DisableDefaultCFIPs: true, + TrustIP: []string{"192.0.2.0/24"}, + CustomIPHeader: "X-Custom-IP", + } + h := newTestHandler(t, cfg, next) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.RemoteAddr = "192.0.2.10:12345" + req.Header.Set("X-Custom-IP", "203.0.113.5") + h.ServeHTTP(httptest.NewRecorder(), req) + + if got := forwarded.Header.Get("X-Forwarded-For"); got != "203.0.113.5" { + t.Fatalf("expected X-Forwarded-For from custom header, got %q", got) + } + if got := forwarded.Header.Get("X-Real-Ip"); got != "203.0.113.5" { + t.Fatalf("expected X-Real-Ip from custom header, got %q", got) + } +} + +func TestStripSessionCookiesPreservesUnrelated(t *testing.T) { + verify := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path != "/badger/verify-session" { + http.Error(rw, "unexpected path", http.StatusNotFound) + return + } + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(http.StatusOK) + _, _ = rw.Write([]byte(`{"data":{"valid":true}}`)) + })) + defer verify.Close() + + var forwarded *http.Request + next := http.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) { + forwarded = req + }) + cfg := &badger.Config{ + APIBaseUrl: verify.URL, + UserSessionCookieName: "p_session_token", + ResourceSessionRequestParam: "p_session_request", + DisableDefaultCFIPs: true, + } + h := newTestHandler(t, cfg, next) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.Header.Set("Cookie", "p_session_token=secret; other=keep") + h.ServeHTTP(httptest.NewRecorder(), req) + + if forwarded == nil { + t.Fatal("expected next handler to be called for valid session") + } + cookie := forwarded.Header.Get("Cookie") + if strings.Contains(cookie, "p_session_token") { + t.Fatalf("expected session cookie to be stripped, got %q", cookie) + } + if !strings.Contains(cookie, "other=keep") { + t.Fatalf("expected unrelated cookie to be preserved, got %q", cookie) + } +} diff --git a/ips/ips_test.go b/ips/ips_test.go new file mode 100644 index 0000000..251399b --- /dev/null +++ b/ips/ips_test.go @@ -0,0 +1,18 @@ +package ips + +import ( + "net" + "testing" +) + +func TestCFIPs(t *testing.T) { + cidrs := CFIPs() + if len(cidrs) == 0 { + t.Fatal("CFIPs() returned an empty list") + } + for _, cidr := range cidrs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + t.Errorf("CFIPs() returned invalid CIDR %q: %v", cidr, err) + } + } +} From a2faf3da6a5ef589b33aa2695213ae54da68414e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 17:39:21 +0200 Subject: [PATCH 3/5] chore: update Go version from 1.25 to 1.26 in .go-version and go.mod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Schäfer --- .go-version | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.go-version b/.go-version index 5e2b950..ea0928c 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.25 +1.26.4 diff --git a/go.mod b/go.mod index 4d701b0..658e3ae 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/fosrl/badger -go 1.25 +go 1.26 From c9ea508c74d452bef6c5194b1a9fb29315a2e9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 17:40:00 +0200 Subject: [PATCH 4/5] feat: add Makefile for build, lint and testing automation and add golangci config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Schäfer --- .golangci.yml | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 51 +++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 .golangci.yml create mode 100644 Makefile diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..7f68c96 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,156 @@ +# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json + +version: "2" +run: + timeout: 3m + +linters: + default: standard + enable: + # Existing / default-critical checks. + - govet + - staticcheck + - ineffassign + - unused + - misspell + + # Error handling / correctness. + - errcheck + - errorlint + - errname + - nilerr + - nilnesserr + - unconvert + - unparam + - wastedassign + + # HTTP / middleware safety. + - bodyclose + - noctx + # - canonicalheader # Too noisy, especially for tests. + + # Security. + - gosec + - bidichk + + # Maintainability, but with relaxed thresholds. + - revive + - gocyclo + - funlen + - goconst + - gocritic + + # Dependency/import hygiene. + - depguard + - gomoddirectives + + # Comments / TODO handling. + - godox + + settings: + govet: + enable-all: true + disable: + # Too noisy + - fieldalignment + + misspell: + locale: US + + gocyclo: + min-complexity: 20 + + funlen: + lines: -1 + statements: 80 + + goconst: + # Avoid noisy suggestions for small repeated strings. + min-len: 5 + min-occurrences: 4 + + godox: + keywords: + - FIXME + - BUG + + depguard: + rules: + main: + files: + - $all + - "!$test" + allow: + - $gostd + - github.com/fosrl/badger/ips + - github.com/fosrl/badger/version + + tests: + files: + - $test + allow: + - $gostd + - github.com/fosrl/badger + - github.com/fosrl/badger/ips + - github.com/fosrl/badger/version + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: errorf + - name: exported + disabled: true + - name: if-return + - name: increment-decrement + - name: indent-error-flow + - name: package-comments + disabled: true + - name: range + - name: receiver-naming + - name: redefines-builtin-id + - name: struct-tag + - name: superfluous-else + - name: time-naming + - name: unreachable-code + - name: unused-parameter + disabled: true + - name: var-declaration + - name: var-naming + + exclusions: + # Keep default golangci-lint exclusions enabled, but add repo-specific rules. + generated: strict + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + + rules: + # Test files are allowed to be longer and more repetitive. + - path: '(.+)_test\.go' + linters: + - funlen + - goconst + - dupl + - noctx + + # Cloudflare IP list is intentionally a large literal allowlist. + - path: '^ips/ips\.go$' + linters: + - goconst + - funlen + - revive + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +output: + show-stats: true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..063992b --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +YAEGI_VERSION ?= v0.16.1 +GOVULNCHECK_VERSION ?= v1.3.0 + +.PHONY: fmt tidy vet lint test vulncheck vendor yaegi-test ci ci-full clean + +fmt: + gofmt -w . + +tidy: + go mod tidy + +vet: + go vet ./... + +lint: + golangci-lint run + +test: + go test -race -cover ./... + +vulncheck: + go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) + govulncheck ./... + +vendor: + go mod vendor + +# Yaegi compatibility check. Vendors dependencies first so Yaegi can resolve +# local sub-packages (e.g. github.com/fosrl/badger/ips) without needing a +# plugins-local copy layout. Must be run from a GOPATH-compatible directory +# (go/src/github.com/fosrl/badger) for local sub-package resolution. +yaegi-test: vendor + go run github.com/traefik/yaegi/cmd/yaegi@$(YAEGI_VERSION) test -v . + +# Reproduce the CI checks locally (excluding yaegi and lint). +ci: + test -z "$$(gofmt -l .)" + go mod tidy + git diff --exit-code -- go.mod go.sum + go vet ./... + go test -race -cover ./... + go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) + govulncheck ./... + +# Full CI including lint and yaegi compatibility check. +ci-full: ci + $(MAKE) lint + $(MAKE) yaegi-test + +clean: + rm -rf vendor From 2d77b3e4f52c6a55e836518cd0b64791ffa49999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 7 Jun 2026 17:40:25 +0200 Subject: [PATCH 5/5] chore: update .gitignore and enhance README with additional details and badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marc Schäfer --- .gitignore | 24 ++++++++++++++++++++++-- README.md | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index f3cda28..69b460e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,22 @@ -go.sum -.DS_Store \ No newline at end of file +.DS_Store +.idea +*.iml + +vendor/* +bin/* +dist/* +# Yaegi / Traefik local-plugin runtime work directory. +plugins-local/* + +# Go coverage directories (GOCOVERDIR) +.cover/ +coverage.out +coverage.txt +*.cover.out +*_coverage.out +unit.out +int.out +nohup.out + +*.test +*.exe diff --git a/README.md b/README.md index 88e7d55..cc5cef6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # Pangolin Middleware: Badger +[![GitHub Release](https://img.shields.io/github/release/fosrl/badger?sort=semver)](https://github.com/fosrl/badger/releases) +[![Go Version](https://img.shields.io/github/go-mod/go-version/fosrl/badger)](https://github.com/fosrl/badger/blob/main/go.mod) +[![CI](https://github.com/fosrl/badger/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fosrl/badger/actions/workflows/ci.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/fosrl/badger)](https://goreportcard.com/report/github.com/fosrl/badger) +[![License](https://img.shields.io/github/license/fosrl/badger)](https://github.com/fosrl/badger/blob/main/LICENSE) +[![Traefik Plugin](https://img.shields.io/badge/Traefik-Plugin-24A1C1)](https://plugins.traefik.io/) +[![Pangolin](https://img.shields.io/badge/Pangolin-Middleware-blue)](https://github.com/fosrl/pangolin) + Badger is a middleware plugin designed to work with Traefik in conjunction with [Pangolin](https://github.com/fosrl/pangolin), an identity-aware reverse proxy and zero-trust VPN. Badger acts as an authentication bouncer, ensuring only authenticated and authorized requests are allowed through the proxy. > [!NOTE] @@ -7,6 +15,32 @@ Badger is a middleware plugin designed to work with Traefik in conjunction with This plugin is **required** to be installed alongside [Pangolin](https://github.com/fosrl/pangolin) to enforce secure authentication and session management. +## What Badger does + +Badger runs as a Traefik middleware in front of protected services. + +For each request, Badger can: + +1. determine the real client IP from Cloudflare or a trusted upstream proxy, +2. normalize `X-Real-IP` and `X-Forwarded-For` for downstream services, +3. call the Pangolin API to verify authentication and resource access, +4. allow, block, or redirect the request based on the verification result. + +## Modes + +### Pangolin authentication mode + +This is the default mode. Badger validates incoming requests against the +Pangolin API before forwarding them to the upstream service. + +### IP handling only mode + +Set `disableForwardAuth: true` to disable Pangolin authentication and only use +Badger for real-client-IP handling. + +Use this only when authentication is handled elsewhere or when you explicitly +want Badger to act only as an IP normalization middleware. + ## Installation Badger is automatically installed with Pangolin. Learn how to install Pangolin in the [Pangolin Documentation](https://docs.pangolin.net/self-host/quick-install).