support pulling real ip from proxy

This commit is contained in:
miloschwartz
2025-12-20 18:30:10 -05:00
parent 52f47fa24e
commit ad3c3b71e6
5 changed files with 307 additions and 14 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
go.sum go.sum
.DS_Store .DS_Store

View File

@@ -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 the Traefik reverse proxy in conjunction with [Pangolin](https://github.com/fosrl/pangolin), an identity-aware reverse proxy and 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 ## 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 ## 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 ```yaml
apiBaseUrl: "http://localhost:3001/api/v1" apiBaseUrl: "http://localhost:3001/api/v1"
@@ -20,6 +25,77 @@ userSessionCookieName: "p_session_token"
resourceSessionRequestParam: "p_session_request" 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:
```yaml
disableForwardAuth: true
# IP handling configuration (optional)
trustip:
- "10.0.0.0/8"
customIPHeader: "X-Forwarded-For"
```
### 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:
```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 ## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

28
ips/ips.go Normal file
View File

@@ -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",
}
}

147
main.go
View File

@@ -5,22 +5,40 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"strings" "strings"
"github.com/fosrl/badger/ips"
) )
type Config struct { type Config struct {
APIBaseUrl string `json:"apiBaseUrl"` APIBaseUrl string `json:"apiBaseUrl,omitempty"`
UserSessionCookieName string `json:"userSessionCookieName"` UserSessionCookieName string `json:"userSessionCookieName,omitempty"`
ResourceSessionRequestParam string `json:"resourceSessionRequestParam"` 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 { type Badger struct {
next http.Handler next http.Handler
name string name string
apiBaseUrl string apiBaseUrl string
userSessionCookieName string userSessionCookieName string
resourceSessionRequestParam string resourceSessionRequestParam string
disableForwardAuth bool
trustIP []*net.IPNet
customIPHeader string
} }
type VerifyBody struct { type VerifyBody struct {
@@ -68,16 +86,61 @@ func CreateConfig() *Config {
} }
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
return &Badger{ badger := &Badger{
next: next, next: next,
name: name, name: name,
apiBaseUrl: config.APIBaseUrl, apiBaseUrl: config.APIBaseUrl,
userSessionCookieName: config.UserSessionCookieName, userSessionCookieName: config.UserSessionCookieName,
resourceSessionRequestParam: config.ResourceSessionRequestParam, 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) { 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) cookies := p.extractCookies(req)
queryValues := req.URL.Query() queryValues := req.URL.Query()
@@ -86,7 +149,7 @@ func (p *Badger) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
body := ExchangeSessionBody{ body := ExchangeSessionBody{
RequestToken: &sessionRequestValue, RequestToken: &sessionRequestValue,
RequestHost: &req.Host, RequestHost: &req.Host,
RequestIP: &req.RemoteAddr, RequestIP: &realIP,
} }
jsonData, err := json.Marshal(body) jsonData, err := json.Marshal(body)
@@ -162,7 +225,7 @@ func (p *Badger) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
RequestPath: &req.URL.Path, RequestPath: &req.URL.Path,
RequestMethod: &req.Method, RequestMethod: &req.Method,
TLS: req.TLS != nil, TLS: req.TLS != nil,
RequestIP: &req.RemoteAddr, RequestIP: &realIP,
Headers: headers, Headers: headers,
Query: queryParams, Query: queryParams,
} }
@@ -318,3 +381,73 @@ func (p *Badger) renderRedirectPage(redirectURL string) string {
</body> </body>
</html>`, redirectURL, redirectURL) </html>`, 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)
}
}

56
updateCFIps.sh Executable file
View File

@@ -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