Exposing pools of machines to clients, or routing network traffic via an intermediary, are common techniques in distributed computing, and large networks. Network Address Translation, forward proxies, and reverse proxies, are three common techniques for managing network traffic at scale. However, I've always found each of these topics to be somewhat mystical, and I've never understood the fundamentals of how each technique works.
In this blog post, I will attempt to distil each idea into its simplest form, and write a code example where possible.
The first blog post on this topic covered:
This second part will:
Whereas NAT is implemented at the network-layer (i.e. at the IP layer, beneath TCP), proxies operate at the application-layer (e.g. HTTP). This means that proxies are normally specific to the protocol you intend them to operate on. In the examples below, I'll look at HTTP proxies, although proxies could be used for other protocols as well.
An HTTP proxy acts an intermediary between the client sending the request, and the server receiving the request. Unlike NAT, which transparently modifies network packets, a proxy accepts and terminates network connections, and then re-transmits requests to the destination.
This means that, when you make requests via a proxy, the proxy actually accepts and processes the request, before making a new request on your behalf to the downstream server.
Unlike NAT, this is very easy to illustrate in a simple Go application, because all of the work happens at the application layer. Here's a sample application:
package main
import (
"io"
"net/http"
)
func main() {
// The proxy handles all HTTP requests.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// The proxy makes a new request using the same HTTP verb and
// relative URL as the original request. For simplicity, the body
// is excluded, so in practice this would only work for GET
// requests.
req, err := http.NewRequest(r.Method, r.RequestURI, nil)
if err != nil {
panic(err)
}
// The proxy sends a new request, on behalf of the original
// client.
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
// The response is read by the proxy.
body, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
defer res.Body.Close()
// The contents of the proxied request are then written back
// to the HTTP response of the original client's request.
w.WriteHeader(res.StatusCode)
w.Write(body)
})
http.ListenAndServe(":8080", nil)
}
This simple example handles all web requests by:
In reality, this is a very simple example (it will only work for GET requests among other things). However, it does illustrate the following main points:
Whilst this is a very different approach to NAT, it does have the same effect of masking the source IP address from the destination server's point of view. Since all requests are re-originated from the proxy, it is the proxy's IP address that will appear as the source IP address, at least at the network layer. Whether or not the same is true at higher levels of the network stack (e.g. in HTTP headers) depends on your implementation, but forward proxies like this can also be used to mask source IP addresses for many of the same reasons as NAT.
A reverse proxy operates in much the same way as a forward proxy, except that the address that is proxied is configured in advance in the proxy, rather than being dynamically based on the client's request.
When a request is sent to a forward proxy, the request is forwarded on to the original recipient indicated by the client's HTTP request. When a request is sent to a reverse proxy, the proxy decides where to forward the request based on some predetermined configuration.
This makes reverse proxies useful for presenting a public IP address for a set of private resources, like a set of private, back-end servers.
This is illustrated in the following simple Golang application:
package main
import (
"io"
"net/http"
)
func main() {
// The proxy listens for all HTTP traffic.
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// The proxy handles the request by performing a pre-determined action. In
// reality, it is likley to forward the request to another upstream server,
// but in this example it simply makes a request to Google on behalf of
// the original client.
req, err := http.NewRequest(http.MethodGet, "https://www.google.com", nil)
if err != nil {
panic(err)
}
// The proxy makes the request on behalf of the client.
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
// The proxy copies the contents of the proxied response into
// the response to the original client's request.
w.WriteHeader(res.StatusCode)
_, err = io.Copy(w, res.Body)
if err != nil {
panic(err)
}
})
http.ListenAndServe(":8080", nil)
}
In this example, all requests to the reverse proxy are forwarded to https://www.google.com. This forwarding URL represents the static configuration that will determine how your proxy will route requests. If I run this example locally, and then curl -v http://localhost:8080, the Google home page is returned.
This two-part blog post has described the behaviour and characteristics of Network Address Translation, forward proxies, and reverse proxies. This is summarised below:
I hope you found this blog post useful in understanding these three networking techniques in the future, and you now consider them somewhat less mystical!