diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..122ea1c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +## Community Contribution License Agreement +By creating this pull request, I grant the project maintainers an unlimited, +perpetual license to use, modify, and redistribute these contributions under any terms they +choose, including both the AGPLv3 and the Fossorial Commercial license terms. I +represent that I have the right to grant this license for all contributed content. + +## Description + + +## How to test? \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3938344 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" diff --git a/.gitignore b/.gitignore index 40d8758..f3cda28 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ go.sum -.DS_Store +.DS_Store \ No newline at end of file diff --git a/.go-version b/.go-version index 14bee92..5e2b950 100644 --- a/.go-version +++ b/.go-version @@ -1 +1 @@ -1.23.2 +1.25 diff --git a/README.md b/README.md index 7b9163c..58fe41c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ -# Badger Plugin for Traefik with Pangolin Integration +# Pangolin Middleware: Badger -Badger is a middleware plugin designed to work with the Traefik reverse proxy in conjunction with [Pangolin](https://github.com/fosrl/pangolin), a multi-tenant tunneled reverse proxy server and management interface with identity and access management. Badger acts as an authentication bouncer, ensuring only authenticated and authorized requests are allowed through the proxy. +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. -This plugin is **required** to be configured alongside [Pangolin](https://github.com/fosrl/pangolin) to enforce secure authentication and session management. +> [!NOTE] +> Badger can also be used standalone for IP handling (Cloudflare and custom proxy support) without Pangolin. Simply set `disableForwardAuth: true` in your configuration. See the [Disabling Forward Auth](#disabling-forward-auth) section below for details. + +This plugin is **required** to be installed alongside [Pangolin](https://github.com/fosrl/pangolin) to enforce secure authentication and session management. ## Installation -Learn how to set up [Pangolin](https://github.com/fosrl/pangolin) and Badger in the [Pangolin Documentation](https://github.com/fosrl/pangolin). +Badger is automatically installed with Pangolin. Learn how to install Pangolin in the [Pangolin Documentation](https://docs.pangolin.net/self-host/quick-install). ## Configuration -Badger requires the following configuration parameters to be specified in your [Traefik configuration file](https://doc.traefik.io/traefik/getting-started/configuration-overview/). These coincide with the separate [Pangolin](https://github.com/fosrl/pangolin) configuration file. These options do not need to be configured unless you need to override the automatically provided config from Pangolin. +Pangolin will provide the necessary configuration to Badger automatically via the HTTP provider. However, you can override the configuration settings by manually providing them in the Traefik config. -### Configuration Options +### Required Configuration Options + +When forward auth is enabled (default), the following options are required: ```yaml apiBaseUrl: "http://localhost:3001/api/v1" @@ -20,6 +25,75 @@ userSessionCookieName: "p_session_token" resourceSessionRequestParam: "p_session_request" ``` +### Disabling Forward Auth + +To disable forward auth and only use IP handling, set `disableForwardAuth: true`. When enabled, all requests pass through without authentication, and the required configuration options above are not needed: + +Only do this if you do not need Pangolin's authentication features and only want IP handling. + +```yaml +disableForwardAuth: true +``` + +### IP Handling Configuration + +Badger automatically extracts the real client IP from requests. By default, it trusts Cloudflare IP ranges and uses the `CF-Connecting-IP` header. + +#### Using with Cloudflare (Default) + +No additional configuration needed. Badger automatically: + +- Trusts Cloudflare IP ranges +- Extracts IP from `CF-Connecting-IP` header +- Sets `X-Real-IP` and `X-Forwarded-For` headers for downstream services + +#### Using without Cloudflare + +If you're using a different proxy or load balancer, configure custom trusted IPs and/or a custom IP header: + +Ensure you always disable the default Cloudflare IP ranges by setting `disableDefaultCFIPs: true` and provide your own trusted IP ranges in CIDR format under `trustip` if using a different proxy. + +```yaml +apiBaseUrl: "http://localhost:3001/api/v1" +userSessionCookieName: "p_session_token" +resourceSessionRequestParam: "p_session_request" + +# Disable Cloudflare IP ranges +disableDefaultCFIPs: true + +# Add your proxy/load balancer IP ranges (CIDR format) +trustip: + - "10.0.0.0/8" + - "172.16.0.0/12" + +# Optional: Use a custom header instead of CF-Connecting-IP +customIPHeader: "X-Forwarded-For" +``` + +### Configuration Options Reference + +| Option | Type | Required\* | Default | Description | +| ----------------------------- | -------- | ---------- | ------- | ----------------------------------------------------------------------------------- | +| `disableForwardAuth` | bool | No | `false` | Disable forward auth; only IP handling is performed | +| `apiBaseUrl` | string | Yes\* | - | Base URL of the Pangolin API | +| `userSessionCookieName` | string | Yes\* | - | Cookie name for user sessions | +| `resourceSessionRequestParam` | string | Yes\* | - | Query parameter name for resource session requests | +| `trustip` | []string | No | `[]` | Array of trusted IP ranges in CIDR format | +| `disableDefaultCFIPs` | bool | No | `false` | Disable default Cloudflare IP ranges | +| `customIPHeader` | string | No | `""` | Custom header name to extract IP from (only used if request is from trusted source) | + +\* Required only when `disableForwardAuth` is `false` (default) + +## Updating Cloudflare IPs + +To update the Cloudflare IP ranges, run: + +```bash +./updateCFIps.sh +``` + +This fetches the latest IP ranges from Cloudflare and updates `ips/ips.go`. + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/go.mod b/go.mod index 2995107..4d701b0 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/fosrl/badger -go 1.23.1 +go 1.25 diff --git a/ips/ips.go b/ips/ips.go new file mode 100644 index 0000000..9aa4f5d --- /dev/null +++ b/ips/ips.go @@ -0,0 +1,28 @@ +package ips + +func CFIPs() []string { + return []string{ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", + } +} diff --git a/main.go b/main.go index c915806..6a1de66 100644 --- a/main.go +++ b/main.go @@ -5,22 +5,40 @@ import ( "context" "encoding/json" "fmt" + "net" "net/http" "strings" + + "github.com/fosrl/badger/ips" ) type Config struct { - APIBaseUrl string `json:"apiBaseUrl"` - UserSessionCookieName string `json:"userSessionCookieName"` - ResourceSessionRequestParam string `json:"resourceSessionRequestParam"` + APIBaseUrl string `json:"apiBaseUrl,omitempty"` + UserSessionCookieName string `json:"userSessionCookieName,omitempty"` + ResourceSessionRequestParam string `json:"resourceSessionRequestParam,omitempty"` + DisableForwardAuth bool `json:"disableForwardAuth,omitempty"` + TrustIP []string `json:"trustip,omitempty"` + DisableDefaultCFIPs bool `json:"disableDefaultCFIPs,omitempty"` + CustomIPHeader string `json:"customIPHeader,omitempty"` } +const ( + xRealIP = "X-Real-Ip" + xForwardFor = "X-Forwarded-For" + xForwardProto = "X-Forwarded-Proto" + cfConnectingIP = "CF-Connecting-IP" + cfVisitor = "CF-Visitor" +) + type Badger struct { next http.Handler name string apiBaseUrl string userSessionCookieName string resourceSessionRequestParam string + disableForwardAuth bool + trustIP []*net.IPNet + customIPHeader string } type VerifyBody struct { @@ -44,6 +62,7 @@ type VerifyResponse struct { Username *string `json:"username,omitempty"` Email *string `json:"email,omitempty"` Name *string `json:"name,omitempty"` + Role *string `json:"role,omitempty"` ResponseHeaders map[string]string `json:"responseHeaders,omitempty"` } `json:"data"` } @@ -67,16 +86,61 @@ func CreateConfig() *Config { } func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { - return &Badger{ + badger := &Badger{ next: next, name: name, apiBaseUrl: config.APIBaseUrl, userSessionCookieName: config.UserSessionCookieName, resourceSessionRequestParam: config.ResourceSessionRequestParam, - }, nil + disableForwardAuth: config.DisableForwardAuth, + customIPHeader: config.CustomIPHeader, + } + + // Validate required fields only if forward auth is enabled + if !config.DisableForwardAuth { + if config.APIBaseUrl == "" { + return nil, fmt.Errorf("apiBaseUrl is required when forward auth is enabled") + } + if config.UserSessionCookieName == "" { + return nil, fmt.Errorf("userSessionCookieName is required when forward auth is enabled") + } + if config.ResourceSessionRequestParam == "" { + return nil, fmt.Errorf("resourceSessionRequestParam is required when forward auth is enabled") + } + } + + if config.TrustIP != nil { + for _, v := range config.TrustIP { + _, trustip, err := net.ParseCIDR(v) + if err != nil { + return nil, err + } + badger.trustIP = append(badger.trustIP, trustip) + } + } + + if !config.DisableDefaultCFIPs { + for _, v := range ips.CFIPs() { + _, trustip, err := net.ParseCIDR(v) + if err != nil { + return nil, err + } + badger.trustIP = append(badger.trustIP, trustip) + } + } + + return badger, nil } func (p *Badger) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + realIP := p.getRealIP(req) + p.setIPHeaders(req, realIP) + + if p.disableForwardAuth { + p.next.ServeHTTP(rw, req) + return + } + cookies := p.extractCookies(req) queryValues := req.URL.Query() @@ -85,7 +149,7 @@ func (p *Badger) ServeHTTP(rw http.ResponseWriter, req *http.Request) { body := ExchangeSessionBody{ RequestToken: &sessionRequestValue, RequestHost: &req.Host, - RequestIP: &req.RemoteAddr, + RequestIP: &realIP, } jsonData, err := json.Marshal(body) @@ -161,7 +225,7 @@ func (p *Badger) ServeHTTP(rw http.ResponseWriter, req *http.Request) { RequestPath: &req.URL.Path, RequestMethod: &req.Method, TLS: req.TLS != nil, - RequestIP: &req.RemoteAddr, + RequestIP: &realIP, Headers: headers, Query: queryParams, } @@ -198,6 +262,7 @@ func (p *Badger) ServeHTTP(rw http.ResponseWriter, req *http.Request) { req.Header.Del("Remote-User") req.Header.Del("Remote-Email") req.Header.Del("Remote-Name") + req.Header.Del("Remote-Role") if result.Data.ResponseHeaders != nil { for key, value := range result.Data.ResponseHeaders { @@ -239,6 +304,10 @@ func (p *Badger) ServeHTTP(rw http.ResponseWriter, req *http.Request) { req.Header.Add("Remote-Name", *result.Data.Name) } + if result.Data.Role != nil { + req.Header.Add("Remote-Role", *result.Data.Role) + } + fmt.Println("Badger: Valid session") p.next.ServeHTTP(rw, req) return @@ -313,3 +382,73 @@ func (p *Badger) renderRedirectPage(redirectURL string) string { `, redirectURL, redirectURL) } + +func (p *Badger) getRealIP(req *http.Request) string { + // Check if request comes from a trusted source + isTrusted := p.isTrustedIP(req.RemoteAddr) + + // If custom IP header is configured, use it + if p.customIPHeader != "" { + if customIP := req.Header.Get(p.customIPHeader); customIP != "" && isTrusted { + return customIP + } + } + + // Default: use CF-Connecting-IP if from trusted source + if isTrusted { + if cfIP := req.Header.Get(cfConnectingIP); cfIP != "" { + return cfIP + } + } + + // Fallback: extract IP from RemoteAddr + ip, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + // If parsing fails, return RemoteAddr as-is (might be just IP without port) + return req.RemoteAddr + } + return ip +} + +func (p *Badger) isTrustedIP(remoteAddr string) bool { + ipStr, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + return false + } + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, network := range p.trustIP { + if network.Contains(ip) { + return true + } + } + return false +} + +func (p *Badger) setIPHeaders(req *http.Request, realIP string) { + isTrusted := p.isTrustedIP(req.RemoteAddr) + + if isTrusted { + // Handle CF-Visitor header for scheme + if req.Header.Get(cfVisitor) != "" { + var cfVisitorValue struct { + Scheme string `json:"scheme"` + } + if err := json.Unmarshal([]byte(req.Header.Get(cfVisitor)), &cfVisitorValue); err == nil { + req.Header.Set(xForwardProto, cfVisitorValue.Scheme) + } + } + + // Set headers with the real IP (already extracted from CF-Connecting-IP or custom header) + req.Header.Set(xForwardFor, realIP) + req.Header.Set(xRealIP, realIP) + } else { + // Not from trusted source, use direct IP + req.Header.Set(xRealIP, realIP) + // Remove CF headers if present + req.Header.Del(cfVisitor) + req.Header.Del(cfConnectingIP) + } +} diff --git a/updateCFIps.sh b/updateCFIps.sh new file mode 100755 index 0000000..f6c5d25 --- /dev/null +++ b/updateCFIps.sh @@ -0,0 +1,56 @@ +rm CFIPs.txt +curl https://www.cloudflare.com/ips-v4 >>CFIPs.txt +echo "" >>CFIPs.txt +curl https://www.cloudflare.com/ips-v6 >>CFIPs.txt +echo "" >>CFIPs.txt + +OUTPUT_GO_CONFIG="./ips/ips.go" +OUTPUT_GO_CONFIG_OLD="./ips-temp.go" + +mv $OUTPUT_GO_CONFIG $OUTPUT_GO_CONFIG_OLD + +echo "// Package ips contains a list of current cloud flare IP ranges" >>$OUTPUT_GO_CONFIG +echo "package ips" >>$OUTPUT_GO_CONFIG +echo "" >>$OUTPUT_GO_CONFIG +echo "// CFIPs is the CloudFlare Server IP list (this is checked on build)." >>$OUTPUT_GO_CONFIG +echo "func CFIPs() []string {" >>$OUTPUT_GO_CONFIG +echo " return []string{" >>$OUTPUT_GO_CONFIG + +cat CFIPs.txt | while read line || [[ -n $line ]]; do + printf '%s\n' "CF IP: $line" + echo " \"${line}\"," >>$OUTPUT_GO_CONFIG +done + +echo " }" >>$OUTPUT_GO_CONFIG +echo "}" >>$OUTPUT_GO_CONFIG + +rm CFIPs.txt + +if [ "${1}" == "pc" ]; then + echo "Run on pre-commit hook." + if cmp --silent -- "$OUTPUT_GO_CONFIG" "$OUTPUT_GO_CONFIG_OLD"; then + echo "No changes, nothing to worry about" + else + echo "Cloud flare have changed their IPs, adding changes to commit." + touch ./.commit + fi + + rm $OUTPUT_GO_CONFIG_OLD + exit +fi + +if [ "${1}" != "ci" ]; then + echo "Not run on CI, exit ok" + rm $OUTPUT_GO_CONFIG_OLD + exit +fi + +if cmp --silent -- "$OUTPUT_GO_CONFIG" "$OUTPUT_GO_CONFIG_OLD"; then + echo "No changes to Cloud Flare IP list" + rm $OUTPUT_GO_CONFIG_OLD +else + echo "Cloud flare have changed their IPs, re-run updateCFIps.sh and commit the changes!" + rm $OUTPUT_GO_CONFIG_OLD + exit 6 +fi +