Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,43 @@ Create a `config.json` file with the following example configuration:
"host_override": "",
"request_headers": {"X-Custom-Header": "MyValue"},
"response_headers": {"X-Response-Header": "ResponseValue"},
"removed_headers": ["Server", "X-Powered-By", "Set-Cookie"]
"remove_headers": ["Server", "X-Powered-By", "Set-Cookie"],
"condition": {
"header": "X-Api-Key",
"value": "secret-key"
},
"fallback_behavior": "404"
},
"another.com": {
"upstream": "https://another.com",
"host_override": "",
"request_headers": {"X-Another-Header": "AnotherValue"},
"response_headers": {"X-Another-Response": "AnotherResponseValue"},
"removed_headers": ["Server", "Set-Cookie"]
"remove_headers": ["Server", "Set-Cookie"]
}
}
```

### Conditional Proxying
You can add a `condition` object to a host config to only proxy requests that match a specific header or query parameter value. Only equality is supported:

- To match a header:
```json
"condition": {
"header": "X-Api-Key",
"value": "secret-key"
}
```
- To match a query parameter:
```json
"condition": {
"query_param": "token",
"value": "mytoken"
}
```

If the condition is not met, the proxy will use the `fallback_behavior` (e.g., "404", "bad_gateway", or proxy to a fallback upstream if `fallback_upstream` is set).

## Using Docker
### Build and Run the Container
```sh
Expand Down
23 changes: 21 additions & 2 deletions config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"response_headers": {
"X-Response-Header": "ResponseValue"
},
"removed_headers": [
"remove_headers": [
"Server",
"X-Powered-By",
"Set-Cookie"
Expand All @@ -23,9 +23,28 @@
"response_headers": {
"X-Another-Response": "AnotherResponseValue"
},
"removed_headers": [
"remove_headers": [
"Server",
"Set-Cookie"
]
},
"conditional.com": {
"upstream": "https://conditional-upstream.com",
"host_override": "",
"request_headers": {
"X-Conditional": "CondValue"
},
"response_headers": {
"X-Conditional-Response": "CondResponse"
},
"remove_headers": [
"Server"
],
"condition": {
"header": "X-Api-Key",
"value": "secret-key"
},
"fallback_behavior": "fallback_upstream",
"fallback_upstream": "https://fallback-upstream.com"
}
}
18 changes: 17 additions & 1 deletion internal/proxy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,23 @@ type ProxyConfig struct {
HostOverride string `json:"host_override"`
RequestHeaders map[string]string `json:"request_headers"`
ResponseHeaders map[string]string `json:"response_headers"`
RemovedHeaders []string `json:"removed_headers"`
RemoveHeaders []string `json:"remove_headers"`

// Conditional proxying
Condition *ProxyCondition `json:"condition,omitempty"`
// Fallback behavior: "fallback_upstream", "404", "bad_gateway", etc.
FallbackBehavior string `json:"fallback_behavior,omitempty"`
FallbackUpstream string `json:"fallback_upstream,omitempty"`
}

// ProxyCondition defines a condition for proxying
// Only one of Header or QueryParam is checked per condition
// If Value matches, proxy to Upstream, else fallback
// Example: {"header": "X-Api-Key", "value": "secret"}
type ProxyCondition struct {
Header string `json:"header,omitempty"`
QueryParam string `json:"query_param,omitempty"`
Value string `json:"value"`
}

// LoadConfig loads the proxy configuration from a JSON file
Expand Down
2 changes: 1 addition & 1 deletion internal/proxy/modifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func ModifyRequest(req *http.Request, c echo.Context, target *url.URL, config Pr
// ModifyResponseHeaders modifies the response headers
func ModifyResponseHeaders(res *http.Response, config ProxyConfig) {
// Remove unwanted response headers
for _, header := range config.RemovedHeaders {
for _, header := range config.RemoveHeaders {
res.Header.Del(header)
}
// Add additional response headers
Expand Down
74 changes: 52 additions & 22 deletions internal/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,63 @@ func (pm *ProxyManager) NewProxy() *echo.Echo {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Any("/*", pm.proxyHandler)
return e
}

// Reverse proxy handler
e.Any("/*", func(c echo.Context) error {
host := c.Request().Host
config, exists := pm.Proxies[host]
if !exists {
return c.JSON(http.StatusBadGateway, map[string]string{"error": "No upstream for host"})
}
func (pm *ProxyManager) proxyHandler(c echo.Context) error {
host := c.Request().Host
config, exists := pm.Proxies[host]
if !exists {
return c.JSON(http.StatusBadGateway, map[string]string{"error": "No upstream for host"})
}

target, err := url.Parse(config.Upstream)
if err != nil {
return err
}
if config.Condition != nil && !pm.checkCondition(c, config.Condition) {
return pm.handleFallback(c, config)
}

proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ModifyResponse = func(res *http.Response) error {
ModifyResponseHeaders(res, config)
return nil
}
return pm.serveProxy(c, config.Upstream, config)
}

proxy.Director = func(req *http.Request) {
ModifyRequest(req, c, target, config)
func (pm *ProxyManager) checkCondition(c echo.Context, cond *ProxyCondition) bool {
var actual string
if cond.Header != "" {
actual = c.Request().Header.Get(cond.Header)
} else if cond.QueryParam != "" {
actual = c.QueryParam(cond.QueryParam)
}
return actual == cond.Value
}

func (pm *ProxyManager) handleFallback(c echo.Context, config ProxyConfig) error {
switch config.FallbackBehavior {
case "fallback_upstream":
if config.FallbackUpstream != "" {
return pm.serveProxy(c, config.FallbackUpstream, config)
}
return c.JSON(http.StatusBadGateway, map[string]string{"error": "No fallback upstream configured"})
case "404":
return c.JSON(http.StatusNotFound, map[string]string{"error": "Not found"})
case "bad_gateway":
return c.JSON(http.StatusBadGateway, map[string]string{"error": "Bad gateway"})
default:
return c.JSON(http.StatusForbidden, map[string]string{"error": "Forbidden"})
}
}

proxy.ServeHTTP(c.Response(), c.Request())
func (pm *ProxyManager) serveProxy(c echo.Context, upstream string, config ProxyConfig) error {
target, err := url.Parse(upstream)
if err != nil {
return err
}
proxy := httputil.NewSingleHostReverseProxy(target)
proxy.ModifyResponse = func(res *http.Response) error {
ModifyResponseHeaders(res, config)
return nil
})

return e
}
proxy.Director = func(req *http.Request) {
ModifyRequest(req, c, target, config)
}
proxy.ServeHTTP(c.Response(), c.Request())
return nil
}
84 changes: 81 additions & 3 deletions tests/proxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import (
"github.com/stretchr/testify/assert"
)

const testResponseHeader = "X-Test-Response"
const condComHost = "cond.com"
const apiKeyHeader = "X-Api-Key"

func TestProxyHandler(t *testing.T) {
// Create a test upstream server
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Test-Response", "Success")
w.Header().Set(testResponseHeader, "Success")
w.WriteHeader(http.StatusOK)
w.Write([]byte("Hello from upstream"))
}))
Expand All @@ -25,7 +29,7 @@ func TestProxyHandler(t *testing.T) {
HostOverride: "",
RequestHeaders: map[string]string{"X-Custom-Header": "TestValue"},
ResponseHeaders: map[string]string{"X-Proxy-Header": "ProxyTest"},
RemovedHeaders: []string{"X-Test-Response"},
RemoveHeaders: []string{testResponseHeader},
},
}

Expand All @@ -45,6 +49,80 @@ func TestProxyHandler(t *testing.T) {
// Validate response
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Hello from upstream")
assert.NotContains(t, rec.Header(), "X-Test-Response") // Ensure removed header is gone
assert.NotContains(t, rec.Header(), testResponseHeader) // Ensure removed header is gone
assert.Equal(t, "ProxyTest", rec.Header().Get("X-Proxy-Header"))
}

func TestProxyConditionHeader(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Condition met"))
}))
defer upstream.Close()

config := map[string]proxy.ProxyConfig{
condComHost: {
Upstream: upstream.URL,
Condition: &proxy.ProxyCondition{
Header: apiKeyHeader,
Value: "secret-key",
},
FallbackBehavior: "404",
},
}
proxyManager := proxy.NewProxyManager(config)
e := proxyManager.NewProxy()

// Should proxy (header matches)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = condComHost
req.Header.Set(apiKeyHeader, "secret-key")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Condition met")

// Should fallback (header does not match)
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.Host = condComHost
req2.Header.Set(apiKeyHeader, "wrong-key")
rec2 := httptest.NewRecorder()
e.ServeHTTP(rec2, req2)
assert.Equal(t, http.StatusNotFound, rec2.Code)
}

func TestProxyConditionQueryParam(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("Query param met"))
}))
defer upstream.Close()

config := map[string]proxy.ProxyConfig{
condComHost: {
Upstream: upstream.URL,
Condition: &proxy.ProxyCondition{
QueryParam: "token",
Value: "abc123",
},
FallbackBehavior: "404",
},
}
proxyManager := proxy.NewProxyManager(config)
e := proxyManager.NewProxy()

// Should proxy (query param matches)
req := httptest.NewRequest(http.MethodGet, "/?token=abc123", nil)
req.Host = condComHost
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "Query param met")

// Should fallback (query param does not match)
req2 := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil)
req2.Host = condComHost
rec2 := httptest.NewRecorder()
e.ServeHTTP(rec2, req2)
assert.Equal(t, http.StatusNotFound, rec2.Code)
}