~egtann/srp

ca4b23f9add147f14dadb125bbce85c0c14c0eb7 — Evan Tann 1 year, 5 months ago 1e159ea
add redirect
2 files changed, 94 insertions(+), 11 deletions(-)

M README.md
M proxy.go
M README.md => README.md +11 -3
@@ 8,6 8,7 @@ whole lot more.
## Features

* Proxy requests from a host to one of many backend IPs/ports
* Redirection to and from hosts
* Automate HTTPS with TLS termination
* Load balance using a simple algorithm
* Check health automatically


@@ 28,13 29,13 @@ Then run `srp -h` for usage help.

The config file has two main parts:

1. Services maps requests to backend services.
1. API restricts access via an IP subnet.
1. Services maps requests to backend services or redirects.
1. API that restricts access via an IP subnet.

```json
{
	"Services": {
		"127.0.0.1:3000": {
		"www.example.com": {
			"HealthPath": "/health",
			"Backends": [
				"127.0.0.1:3001",


@@ 43,6 44,13 @@ The config file has two main parts:
				"127.0.0.1:3004",
				"127.0.0.1:3005"
			]
		},
		"example.com": {
			"Redirect": {
				"URL": "https://www.example.com",
				"DiscardPath": true,
				"Permanent": true
			}
		}
	},
	"API": {

M proxy.go => proxy.go +83 -8
@@ 3,6 3,7 @@ package srp
import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/ioutil"


@@ 10,6 11,7 @@ import (
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"sort"
	"strings"


@@ 36,16 38,43 @@ type ReverseProxy struct {
// Registry maps hosts to backends with other helpful info, such as
// healthchecks.
type Registry struct {
	// Services maps hostnames to one of the following:
	//
	// * IPs with optional healthcheck paths, OR
	// * A redirect to another hostname
	Services map[string]*backend
	API      struct {
		Subnet string
	}

	// API restricts internal API access to a subnet, which should be an
	// private LAN.
	API struct{ Subnet string }
}

// redirect describes how SRP should redirect to another host.
type redirect struct {
	// URL to which SRP should redirect. If DiscardPath is false (the
	// default), the URL's path will be overwritten.
	URL string

	// url is the parsed form of URL.
	url *url.URL

	// Permanent indicates whether the client should redirect itself in
	// future requests. By default the redirect is temporary.
	Permanent bool

	// DiscardPath will strip any path for the URL while redirecting. By
	// default the path is preserved.
	DiscardPath bool
}

type backend struct {
	HealthPath   string
	Backends     []string
	liveBackends []string

	// Redirect from a given hostname to another. If provided, HealthPath
	// and Backends MUST be empty.
	Redirect *redirect
}

// Logger logs error messages for the caller where those errors don't require


@@ 73,7 102,8 @@ func NewProxy(log Logger, reg *Registry) *ReverseProxy {
			reqID = xid.New().String()
			req.Header.Set("X-Request-ID", reqID)
		}
		log.ReqPrintf(reqID, "%s requested %s %s", req.RemoteAddr, req.Method, req.Host)
		log.ReqPrintf(reqID, "%s requested %s %s", req.RemoteAddr,
			req.Method, req.Host)
	}
	transport := newTransport(reg)
	errorHandler := func(w http.ResponseWriter, r *http.Request, err error) {


@@ 109,9 139,30 @@ func (r *ReverseProxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	// Handle redirects before proxying. Non-existent host errors will be
	// handled by the reverse proxy, so we don't need to handle them here.
	host, ok := r.reg.Services[req.Host]
	if ok && host.Redirect != nil {
		doRedirect(w, req, host.Redirect)
		return
	}
	r.rp.ServeHTTP(w, req)
}

func doRedirect(w http.ResponseWriter, r *http.Request, rdr *redirect) {
	uri := rdr.url
	if rdr.DiscardPath {
		uri.Path = ""
	} else {
		uri.Path = r.URL.Path
	}
	code := http.StatusTemporaryRedirect
	if rdr.Permanent {
		code = http.StatusPermanentRedirect
	}
	http.Redirect(w, r, uri.String(), code)
}

func newRegistry(r io.Reader) (*Registry, error) {
	byt, err := ioutil.ReadAll(r)
	if err != nil {


@@ 123,8 174,28 @@ func newRegistry(r io.Reader) (*Registry, error) {
		return nil, fmt.Errorf("unmarshal config: %w", err)
	}
	for host, v := range reg.Services {
		if host == "" {
			return nil, errors.New("host cannot be empty")
		}
		if v.Redirect != nil {
			if v.Redirect.URL == "" {
				return nil, fmt.Errorf("%s: URL cannot be empty", host)
			}
			if len(v.Backends) > 0 {
				return nil, fmt.Errorf("%s: Backends must be empty for redirect", host)
			}
			if v.HealthPath != "" {
				return nil, fmt.Errorf("%s: HealthPath must be empty for redirect", host)
			}
			v.Redirect.url, err = url.Parse(v.Redirect.URL)
			if err != nil {
				return nil, fmt.Errorf("parse %s: %w",
					v.Redirect.URL, err)
			}
			continue
		}
		if len(v.Backends) == 0 {
			return nil, fmt.Errorf("missing backends for %q", host)
			return nil, fmt.Errorf("%s: Backends cannot be empty", host)
		}
	}
	return reg, nil


@@ 141,7 212,8 @@ func NewRegistry(filename string) (*Registry, error) {
	return newRegistry(fi)
}

// Hosts for the registry. This automatically removes *.internal domains.
// Hosts for the registry suitable for generating HTTPS certificates. This
// automatically removes *.internal domains.
func (r *Registry) Hosts() []string {
	hosts := []string{}
	for k := range r.Services {


@@ 155,6 227,7 @@ func (r *Registry) Hosts() []string {
func (r *ReverseProxy) cloneRegistry() *Registry {
	r.mu.RLock()
	defer r.mu.RUnlock()

	return cloneRegistryNoLock(r.reg)
}



@@ 168,6 241,7 @@ func cloneRegistryNoLock(reg *Registry) *Registry {
	}
	for host, fe := range reg.Services {
		cfe := &backend{
			Redirect:     fe.Redirect,
			HealthPath:   fe.HealthPath,
			Backends:     make([]string, len(fe.Backends)),
			liveBackends: make([]string, len(fe.liveBackends)),


@@ 259,6 333,7 @@ func diff(reg1, reg2 *Registry) bool {
func (r *ReverseProxy) UpdateRegistry(reg *Registry) {
	r.mu.Lock()
	defer r.mu.Unlock()

	r.reg = reg
	r.rp.Transport = newTransport(reg)
}


@@ 293,11 368,11 @@ func newTransport(reg *Registry) http.RoundTripper {
		ctx context.Context,
		network, addr string,
	) (net.Conn, error) {
		ip, _, err := net.SplitHostPort(addr)
		hostNoPort, _, err := net.SplitHostPort(addr)
		if err != nil {
			return nil, fmt.Errorf("split host port: %w", err)
		}
		host, ok := reg.Services[ip]
		host, ok := reg.Services[hostNoPort]
		if !ok {
			return nil, fmt.Errorf("no host for %s", addr)
		}