14 Commits

Author SHA1 Message Date
Milo Schwartz
1d15b5b175 Merge pull request #17 from fosrl/dev
1.3.0
2025-12-21 13:59:58 -08:00
miloschwartz
3eea242a8e strip remote role 2025-12-21 16:58:26 -05:00
miloschwartz
2bc45f31f7 update readme 2025-12-20 21:37:42 -05:00
miloschwartz
ad3c3b71e6 support pulling real ip from proxy 2025-12-20 18:30:10 -05:00
miloschwartz
52f47fa24e Merge branch 'main' into dev 2025-12-20 17:21:40 -05:00
Julien Breton
83e894f23f Feature: Allow Basic Auth challenge 2025-12-20 16:44:47 -05:00
Owen Schwartz
7d75628d86 Merge pull request #15 from fosrl/oschwartz10612-patch-1
Update README.md
2025-11-08 14:46:30 -08:00
Owen Schwartz
4adf71ec3c Update README.md
Fixes #14
2025-11-08 14:46:21 -08:00
Owen Schwartz
881b9d665e Merge pull request #12 from Pallavikumarimdb/feat/string-interpolation-in-the-headers
Role in headers
2025-11-08 14:42:16 -08:00
Pallavi Kumari
88c453cae1 role in headers 2025-10-06 22:10:23 +05:30
Owen Schwartz
d3adb46c6a Merge pull request #8 from Lokowitz/sync-go-version
Sync go version
2025-09-21 20:11:38 -04:00
Marvin
a8ba33b9b5 add PULL_REQUEST_TEMPLATE
add dependabot
update to go 1.25
2025-08-17 12:02:19 +00:00
Marvin
11c7340e63 go.mod aktualisieren 2025-07-26 10:29:15 +02:00
Marvin
e933d3ca62 .go-version aktualisieren 2025-07-26 10:28:59 +02:00
9 changed files with 394 additions and 22 deletions

10
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

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

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"

2
.gitignore vendored
View File

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

View File

@@ -1 +1 @@
1.23.2
1.25

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

2
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/fosrl/badger
go 1.23.1
go 1.25

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

224
main.go
View File

@@ -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 {
@@ -38,12 +56,14 @@ type VerifyBody struct {
type VerifyResponse struct {
Data struct {
Valid bool `json:"valid"`
RedirectURL *string `json:"redirectUrl"`
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Name *string `json:"name,omitempty"`
ResponseHeaders map[string]string `json:"responseHeaders,omitempty"`
HeaderAuthChallenged bool `json:"headerAuthChallenged"`
Valid bool `json:"valid"`
RedirectURL *string `json:"redirectUrl"`
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"`
}
@@ -66,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()
@@ -84,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)
@@ -160,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,
}
@@ -197,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 {
@@ -204,6 +270,20 @@ func (p *Badger) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
}
}
if result.Data.HeaderAuthChallenged {
fmt.Println("Badger: challenging client for header authentication")
rw.Header().Add("WWW-Authenticate", "Basic realm=\"pangolin\"")
if result.Data.RedirectURL != nil && *result.Data.RedirectURL != "" {
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(http.StatusUnauthorized)
rw.Write([]byte(p.renderRedirectPage(*result.Data.RedirectURL)))
} else {
http.Error(rw, "Unauthorized", http.StatusUnauthorized)
}
return
}
if result.Data.RedirectURL != nil && *result.Data.RedirectURL != "" {
fmt.Println("Badger: Redirecting to", *result.Data.RedirectURL)
http.Redirect(rw, req, *result.Data.RedirectURL, http.StatusFound)
@@ -224,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
@@ -254,3 +338,117 @@ func (p *Badger) getScheme(req *http.Request) string {
}
return "http"
}
func (p *Badger) renderRedirectPage(redirectURL string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Redirecting...</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f5f5f5;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
a {
color: #0066cc;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<p>Redirecting...</p>
<p>If you are not redirected automatically, <a href="%s">click here</a>.</p>
</div>
<script>
window.location.href = "%s";
</script>
</body>
</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