From d9d952ddc25706685867e6a3ea4035de0323c30a Mon Sep 17 00:00:00 2001 From: Mokhamad Rofiudin Date: Sun, 29 Jun 2025 01:13:33 +0700 Subject: [PATCH 1/2] feat: add conditional proxy using header or query param with fallback upstram or other behavior --- README.md | 29 ++++++++++++- config/config.example.json | 4 +- internal/proxy/config.go | 18 +++++++- internal/proxy/modifier.go | 2 +- internal/proxy/proxy.go | 74 +++++++++++++++++++++++---------- tests/proxy_test.go | 84 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 180 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 6e2335a..8a8953b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/config.example.json b/config/config.example.json index 0b74430..db66afc 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -8,7 +8,7 @@ "response_headers": { "X-Response-Header": "ResponseValue" }, - "removed_headers": [ + "remove_headers": [ "Server", "X-Powered-By", "Set-Cookie" @@ -23,7 +23,7 @@ "response_headers": { "X-Another-Response": "AnotherResponseValue" }, - "removed_headers": [ + "remove_headers": [ "Server", "Set-Cookie" ] diff --git a/internal/proxy/config.go b/internal/proxy/config.go index e24ff26..bf55484 100644 --- a/internal/proxy/config.go +++ b/internal/proxy/config.go @@ -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 diff --git a/internal/proxy/modifier.go b/internal/proxy/modifier.go index 5125743..6fb1f80 100644 --- a/internal/proxy/modifier.go +++ b/internal/proxy/modifier.go @@ -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 diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index fcfe935..96cda29 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -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 } diff --git a/tests/proxy_test.go b/tests/proxy_test.go index 4ede0e3..1fd4cdf 100644 --- a/tests/proxy_test.go +++ b/tests/proxy_test.go @@ -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")) })) @@ -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}, }, } @@ -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) +} From baa668d04aa255460b9519c465e3c66f58c219ac Mon Sep 17 00:00:00 2001 From: Mokhamad Rofiudin Date: Sun, 29 Jun 2025 01:42:39 +0700 Subject: [PATCH 2/2] chore: update config.example.json --- config/config.example.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/config/config.example.json b/config/config.example.json index db66afc..ab46f93 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -27,5 +27,24 @@ "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" } } \ No newline at end of file