-
Notifications
You must be signed in to change notification settings - Fork 264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix: Add support for running CHProxy behind another proxy #225
Fix: Add support for running CHProxy behind another proxy #225
Conversation
@@ -696,6 +697,29 @@ func TestServe(t *testing.T) { | |||
}, | |||
startHTTP, | |||
}, | |||
{ | |||
"http request with default proxy headers", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might miss something but were do you check in this test that the remote addr is modified?
Also, you should add the test that when the proxy is disabled, nothing is changed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test works because allowed_networks: ["10.0.0.0/24"]
is set in the config. It would return a 403 if the remote addr wasn't modified.
The proxy disabled setting should be covered by the other tests (as the middleware is always used).
0a0d39e
to
bf0529b
Compare
I noticed an issue in the tests (I still need to update the PR). The test worked well in isolation but failed when I run all the tests: The tests rely on creating a I need to find a clean way to handle this in the code (I used |
40133e0
to
ada7596
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the work.
The logic looks good (minus the few edge cases I spotted regarding RFC 7239).
You should add a brief summary of the feature in chproxy/docs/content/en/index.md so that anyone reading chproxy.org can quickly see that chproxy can run behind another proxy.
You should also add more information (like the description of your PR) in chproxy/docs/content/en/configuration/server.md
Regarding the code organization, I have some concerns about creating a new package (called middleware, which is a bit vague) and unsing the chain of responsability/middleware pattern.
Out of context I like this pattern and it makes sense in a proxy. But, inside the chproxy codebase, it feels weird because it was not used before despite the fact that many features could have been implemented using this middleware pattern (like authentication, user limits ...). Moreover, all the small features are currently stored in the main package, so why adding a new one.
IMHO, this PR makes sense is if we want to refactor the current code and use a chaine of responsability pattern everywhere we can.
Since the code organization part is a bit opinionated, I'll let @sigua-cs & @Garnek20 give their point of views.
type Proxy struct { | ||
// Enable enables parsing proxy headers. In proxy mode, CHProxy will try to | ||
// parse the X-Forwarded-For, X-Real-IP or Forwarded header to extract the IP. If an other header is configured | ||
// in the proxy settings, CHProxy will use that header instead. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IMHO, you should add the fact that the first ip the list will be chosen
middleware/proxy_middleware.go
Outdated
return "" | ||
} | ||
|
||
return forSplits[0][4:] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it will not work for ipv6 since cf the RFC, ipv6 are quoted strings;
Note that as ":" and "[]" are not valid characters in "token", IPv6 addresses are written as "quoted-string".
middleware/proxy_middleware.go
Outdated
|
||
for _, split := range splits { | ||
trimmed := strings.TrimSpace(split) | ||
if strings.HasPrefix(trimmed, "for=") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you should make the comparison case insensitive because in the RFC 7239 the examples shows that it can be for or For:
Forwarded: For="[2001:db8:cafe::17]:4711" Forwarded: for=192.0.2.43, for=198.51.100.17
middleware/proxy_middleware.go
Outdated
for _, split := range splits { | ||
trimmed := strings.TrimSpace(split) | ||
if strings.HasPrefix(trimmed, "for=") { | ||
forSplits := strings.Split(trimmed, ", ") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you should also split by ";" because cf RFC it's a valid separator and since your return forSplits[0][4:], you'll return more than just the ip:
The header field value can be defined in ABNF syntax as:
Forwarded = 1#forwarded-element
forwarded-element = [ forwarded-pair ] *( ";" [ forwarded-pair ] )
Examples:
Forwarded: for=192.0.2.60;proto=http;by=203.0.113.43`
middleware/proxy_middleware_test.go
Outdated
t.remoteAddr = r.RemoteAddr | ||
} | ||
|
||
func TestProxyMiddleware(t *testing.T) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 nice tests
it might be worth adding the edge cases I mentioned above
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good to me, just IP validation for me is missing
@@ -105,7 +105,8 @@ func (n Networks) Contains(addr string) bool { | |||
|
|||
h, _, err := net.SplitHostPort(addr) | |||
if err != nil { | |||
panic(fmt.Sprintf("BUG: unexpected error while parsing RemoteAddr: %s", err)) | |||
// If we only have an IP address. This happens when the proxy middleware is enabled. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what if the err comes due to other reason ?
main.go
Outdated
@@ -187,8 +191,8 @@ func newTLSConfig(cfg config.HTTPS) *tls.Config { | |||
return &tlsCfg | |||
} | |||
|
|||
func listenAndServe(ln net.Listener, h http.Handler, cfg config.TimeoutCfg) error { | |||
s := &http.Server{ | |||
func createServer(ln net.Listener, h http.Handler, cfg config.TimeoutCfg) *http.Server { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we're changing that part let's be golang agnostic and therefore (new given it returns a pointer)
func createServer(ln net.Listener, h http.Handler, cfg config.TimeoutCfg) *http.Server { | |
func newServer(ln net.Listener, h http.Handler, cfg config.TimeoutCfg) *http.Server { |
middleware/proxy_middleware.go
Outdated
m.next.ServeHTTP(w, r) | ||
} | ||
|
||
func (m *ProxyMiddleware) getIP(r *http.Request) string { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should also validate input IP. Maybe with ip := net.ParseIP(h)
?
This PR adds a few new server settings: proxy.enable & proxy.header. proxy.enable adds enables the proxy middleware, which by default fetches the IP from well known proxy headers (X-Forwarded-For, X-Real-Ip, Forwarded). It will assume the first IP in the chain (if there are multiple) is the actual client IP. If proxy.header is set to a string, instead the proxy middleware will try to find a header that mathes that string (e.g. X-My-Proxy-Header). Signed-off-by: Lennard Eijsackers <lennardeijsackers92@gmail.com>
The tests where creating a race condition where a previous http.Server could still accept connections from a test. This lead to issues specifically with the proxy middleware tests, as the previous server instance accepted the traffic (an instance where the proxy middleware isn't enabled).
The tests where creating a race condition where a previous http.Server could still accept connections from a test. This lead to issues specifically with the proxy middleware tests, as the previous server instance accepted the traffic (an instance where the proxy middleware isn't enabled).
ada7596
to
0fee568
Compare
@@ -105,7 +105,8 @@ func (n Networks) Contains(addr string) bool { | |||
|
|||
h, _, err := net.SplitHostPort(addr) | |||
if err != nil { | |||
panic(fmt.Sprintf("BUG: unexpected error while parsing RemoteAddr: %s", err)) | |||
// If we only have an IP address. This happens when the proxy middleware is enabled. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you should remove the mention of middleware
@@ -0,0 +1,32 @@ | |||
--- |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
main.go
Outdated
|
||
proxySettings := proxySettings.Load().(*config.Proxy) | ||
proxyHandler := NewProxyHandler(proxySettings) | ||
remoteAddr := proxyHandler.GetRemoteAddr(r) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you extract the remoteAddr but I don't see where you do the logic r.RemoteAddr = remoteAddr
I'm not an expert but I guess this logic is needed given the pb, cf a lib doing the same as what we do https://github.com/gorilla/handlers/blob/v1.5.1/proxy_headers.go#L43
I might have loose it because I'm sure this logic was in your code 7 days ago
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See the code below, the extracted remoteAddr is passed to the ANs:
remoteAddr := proxyHandler.GetRemoteAddr(r)
var an *config.Networks
if r.TLS != nil {
an = allowedNetworksHTTPS.Load().(*config.Networks)
err = fmt.Errorf("https connections are not allowed from %s", remoteAddr)
} else {
an = allowedNetworksHTTP.Load().(*config.Networks)
err = fmt.Errorf("http connections are not allowed from %s", remoteAddr)
}
main.go
Outdated
@@ -234,15 +242,20 @@ func serveHTTP(rw http.ResponseWriter, r *http.Request) { | |||
promHandler.ServeHTTP(rw, r) | |||
case "/", "/query": | |||
var err error | |||
|
|||
proxySettings := proxySettings.Load().(*config.Proxy) | |||
proxyHandler := NewProxyHandler(proxySettings) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it's a bit picky but IMHO, instead of storing proxySettings, you could store a proxyHandler as an atomicValue. Indeed, conceptually, there is no reason to instantiate as new proxyHandler for every query. We only need to instantiate one every time the config change (which is what you're currently doing with config.Proxy who is only needed for the proxyHandler so not really needed in the end)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually that makes a lot of sense. I've updated the PR to load the proxyHandler.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @Blokje5 !
Description
This PR adds a few new server settings: proxy.enable & proxy.header. proxy.enable adds enables the proxy middleware, which by default fetches the IP from well known proxy headers (X-Forwarded-For, X-Real-Ip, Forwarded). It will assume the first IP in the chain (if there are multiple) is the actual client IP. If proxy.header is set to a string, instead the proxy middleware will try to find a header that mathes that string (e.g. X-My-Proxy-Header).
Fixes #216
Pull request type
Please check the type of change your PR introduces:
Checklist
Does this introduce a breaking change?
Further comments
There are some points of note:
an.Contains
method, we now ignore the fact that aRemoteAddr
might not contain a port. We could try to fetch this information from other headers (e.g.X-Forwarded-Port
) but we didn't seem to use the port anyway.The main takeaway is that we won't be able to solve all possible use cases, however this should provide decent "default" behaviour for running CHProxy behind another proxy or load balancer.