A reverse proxy receives requests and redirect them to the right host. It is very useful when you have only one server but need to serve several websites with domain names (or sub-domains).
In my case, I want to serve two sub-domains:
fadila.khadar.dev
, this static website.gitea.khadar.dev
, a self-hosted all-in-one software development service.
So the idea is to have a docker container running for each site to be served, and another one that will act as reverse proxy. I use docker compose for this, but I won’t cover this here.
The proxy will be defined in reverse-proxy.go
and should be configurable through the command line as follows:
./reverse-proxy --proxies "fadila.khadar.dev,http://website;gitea.khadar.dev,http://gitea"
--proxies
. Each directive consists of the request hostname to be redirected to the target hostname.
So first, let’s define the reverse proxy directive type and the reverse proxy config (port and reverse proxy directives):
// ReverseProxyDirective contains the configuration of a reverse proxy
// If a request is received with url containing requestHostname, it will be
// process by the defined target
type ReverseProxyDirective struct {
requestHostname string
targetHostname string
}
// ReverseProxyConfig contains the config to run the reverse proxy with
// port: port to run the reverse proxy on
// directives: array of ReverseProxyDirectives
type ReverseProxyConfig struct {
port uint
directives []ReverseProxyDirective
}
Let’s now create the necessary functions to parse the flags provided by the user:
// directiveFromString parses a directive string of the form "requestHosname,targetHostname"
func directiveFromString(s string) (ReverseProxyDirective, error) {
var proxyConfig ReverseProxyDirective
proxyStrings := strings.Split(s, ",")
if len(proxyStrings) != 2 {
return proxyConfig,
errors.New("Directive must be defined as follows: requestHostname,targetHostname")
}
proxyConfig.requestHostname = proxyStrings[0]
proxyConfig.targetHostname = proxyStrings[1]
return proxyConfig, nil
}
func parseReverseProxyConfig() (ReverseProxyConfig, error) {
var config ReverseProxyConfig
flag.UintVar(&config.port, "port", 8080, "port to listen and serve on")
var proxiesString string
flag.StringVar(&proxiesString, "proxies", "", "list of reverse proxy")
flag.Parse()
proxies := strings.Split(proxiesString, ";")
for _, proxyString := range proxies {
proxyConfig, err := directiveFromString(proxyString)
if err != nil {
return config, err
}
config.directives = append(config.directives, proxyConfig)
}
if config.port == 0 || config.port > 65535 {
return config, errors.New("Invalid port number:" + fmt.Sprintf("%d", config.port))
}
return config, nil
}
Now we need to do the real job: redirect the requests to the right container.
The Gitea documentation specifies:
- Set [server] ROOT_URL = https://git.example.com/ in your app.ini file.
- Make the reverse-proxy pass https://git.example.com/foo to http://gitea:3000/foo.
- Make sure the reverse-proxy does not decode the URI. The request https://git.example.com/a%2Fb should be passed as http://gitea:3000/a%2Fb.
- Make sure Host and X-Fowarded-Proto headers are correctly passed to Gitea to make Gitea see the real URL being visited.
The httputil
package provides NewSingleHostReverseProxy
to ease the process:
NewSingleHostReverseProxy
returns a new [ReverseProxy] that routes URLs to the scheme, host, and base path provided in target. If the target’s path is “/base” and the incoming request was for “/dir”, the target request will be for /base/dir.
NewSingleHostReverseProxy
does not rewrite the Host header.
This is what we will use. From the directives we create a map that will give us the right url for a given (sub-)domain.
type ReverseProxy struct {
targetByHostname map[string]*url.URL
}
// NewReverseProxy creates a new reverse proxy that will use the directives in directives.
func NewReverseProxy(directives []ReverseProxyDirective) (ReverseProxy, error) {
var proxyGroup ReverseProxy
proxyGroup.targetByHostname = make(map[string]*url.URL)
for _, config := range directives {
_, err := url.Parse(config.requestHostname)
if err != nil {
return proxyGroup, err
}
target, err := url.Parse(config.targetHostname)
if err != nil {
return proxyGroup, err
}
proxyGroup.targetByHostname[config.requestHostname] = target
}
return proxyGroup, nil
}
Proxy
function.
func (p *ReverseProxy) Proxy(ctx *gin.Context) {
fmt.Println(ctx.Request.Host)
proxy := httputil.NewSingleHostReverseProxy(p.targetByHostname[ctx.Request.Host])
if _, ok := p.targetByHostname[ctx.Request.Host]; !ok {
ctx.HTML(http.StatusNotFound, "notfound", gin.H{})
}
proxy.Director = func(req *http.Request) {
fmt.Println(ctx.RemoteIP(), " requests host: ", req.Host)
req.Header = ctx.Request.Header
req.Host = p.targetByHostname[ctx.Request.Host].Host
req.URL.Scheme = p.targetByHostname[ctx.Request.Host].Scheme
req.URL.Host = p.targetByHostname[ctx.Request.Host].Host
req.URL.Path = ctx.Param("proxyPath")
}
proxy.ServeHTTP(ctx.Writer, ctx.Request)
}
We’re almost done. We still have to setup the gin router correctly and define the main function.
func setupRouter(config ReverseProxyConfig) (*gin.Engine, error) {
fmt.Println("Using port ", config.port)
router := gin.Default()
if len(config.directives) > 0 {
proxyGroup, err := NewReverseProxy(config.directives)
if err != nil {
return nil, err
}
router.Any("/*proxyPath", proxyGroup.Proxy)
}
fmt.Println("Reverse proxies: ", len(config.directives))
router.SetTrustedProxies(nil)
return router, nil
}
func main() {
config, err := parseReverseProxyConfig()
if err != nil {
log.Fatal("Error: ", err)
}
r, err := setupRouter(config)
if err != nil {
log.Fatal("Error: ", err)
}
err = r.Run(":" + fmt.Sprintf("%d", config.port))
if err != nil {
log.Fatal("Error running the server: ", err)
}
}
https
in this gist.
This version takes one certificate that should contain all (sub-)domains served by the reverse proxy.
How this could be improved (maybe in a later note):
- split the code.
- have a debug flag.
- provide a way to associate one certificate per domain name.