~egtann/srp

88118f3ca0b8930e61ddc56e7b9d20260772ef31 — Evan Tann 2 years ago
initial commit
5 files changed, 345 insertions(+), 0 deletions(-)

A LICENSE
A README.md
A cmd/srp/main.go
A go.mod
A proxy.go
A  => LICENSE +13 -0
@@ 1,13 @@
Copyright 2018 Evan Tann

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

A  => README.md +61 -0
@@ 1,61 @@
# SRP

SRP stands for Simple Reverse Proxy. It does what it says on the tin, and not a
whole lot more.

**Warning: this is alpha quality software and not ready for production.**

## Features

* Proxy requests from a host to one of many backend IPs/ports
* Automate HTTPS with TLS termination
* Load balance using a simple algorithm
* Check health automatically
* Live reloaded config file

And nothing else.

## Installing

```bash
go get github.com/egtann/srp/cmd/srp
```

Then run `srp -h` for usage help.

## Config file format

The config file maps requests to backend services.

```json
{
	"127.0.0.1:3000": {
		"HealthPath": "/health",
		"Backends": [
			"127.0.0.1:3001",
			"127.0.0.1:3002",
			"127.0.0.1:3003",
			"127.0.0.1:3004",
			"127.0.0.1:3005"
		]
	}
}
```

## Automatic healthchecks

If you provide a `HealthPath` in the config file, SRP will check the health of
your servers every few seconds and stop sending requests to any that fail until
the health checks start succeeding. Additionally, if any single request fails,
SRP will try that same request again using a different backend (3 tries max).

## Why build SRP?

HAProxy, Nginx, Apache, etc. don't do automatic HTTPS. They're also very
complex, which is far beyond the need of most projects. Several new Go-based
reverse proxies that use autocert, such as Traefik and Caddy, are very large
and complex as well, with plenty of opportunity for bugs.

Instead, SRP keeps it simple. There's a much smaller surface for bugs. It's
easier and faster to debug if issues occur (especially nice when they occur in
production and you need to roll out a fix quickly).

A  => cmd/srp/main.go +82 -0
@@ 1,82 @@
package main

import (
	"crypto/tls"
	"flag"
	"log"
	"math/rand"
	"net/http"
	"strings"
	"time"

	"github.com/egtann/srp"
	"golang.org/x/crypto/acme/autocert"
)

func main() {
	portTmp := flag.String("p", "3000", "port")
	config := flag.String("c", "config.json", "config file")
	sslURL := flag.String("url", "", "enable ssl on the proxy's url (optional)")
	flag.Parse()

	rand.Seed(time.Now().UnixNano())
	reg, err := srp.NewRegistry(*config)
	if err != nil {
		log.Fatal(err)
	}
	proxy := srp.NewProxy(&Logger{}, reg)

	go func() {
		// Check health at boot and constantly
		client := &http.Client{Timeout: 10 * time.Second}
		err := proxy.CheckHealth(client)
		if err != nil {
			log.Println("failed to check health:", err)
		}
		for range time.Tick(time.Second) {
			err := proxy.CheckHealth(client)
			if err != nil {
				log.Println("failed to check health:", err)
				continue
			}
		}
	}()

	// Start the proxy using SSL if possible
	if len(*sslURL) > 0 {
		hosts := append(reg.Hosts(), *sslURL)
		m := &autocert.Manager{
			Prompt:     autocert.AcceptTOS,
			HostPolicy: autocert.HostWhitelist(hosts...),
		}
		go http.ListenAndServe(":http", m.HTTPHandler(nil))
		s := &http.Server{
			Addr:           ":https",
			Handler:        proxy,
			TLSConfig:      &tls.Config{GetCertificate: m.GetCertificate},
			ReadTimeout:    10 * time.Second,
			WriteTimeout:   10 * time.Second,
			MaxHeaderBytes: 1 << 20,
		}
		log.Println("listening on 443")
		log.Fatal(s.ListenAndServe())
	} else {
		port := strings.TrimLeft(*portTmp, ":")
		log.Println("listening on", port)
		s := &http.Server{
			Addr:           ":" + port,
			Handler:        proxy,
			ReadTimeout:    10 * time.Second,
			WriteTimeout:   10 * time.Second,
			MaxHeaderBytes: 1 << 20,
		}
		log.Fatal(s.ListenAndServe())
	}
}

// Logger implements the srp.Logger interface.
type Logger struct{}

func (l *Logger) Printf(format string, vals ...interface{}) {
	log.Printf(format, vals...)
}

A  => go.mod +6 -0
@@ 1,6 @@
module github.com/egtann/srp

require (
	github.com/pkg/errors v0.8.0
	golang.org/x/crypto v0.0.0-20180608092829-8ac0e0d97ce4
)

A  => proxy.go +183 -0
@@ 1,183 @@
package srp

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"math/rand"
	"net"
	"net/http"
	"net/http/httputil"
	"sync"
	"time"

	"github.com/pkg/errors"
)

type ReverseProxy struct {
	rp  httputil.ReverseProxy
	reg Registry
	mu  sync.RWMutex
	log Logger
}

type Registry map[string]*struct {
	HealthPath   string
	Backends     []string
	liveBackends []string
}

type Logger interface {
	Printf(format string, vals ...interface{})
}

// NewProxy from a given Registry.
func NewProxy(log Logger, reg Registry) *ReverseProxy {
	director := func(req *http.Request) {
		req.URL.Scheme = "http"
		req.URL.Host = req.Host
	}
	transport := newTransport(reg)
	rp := httputil.ReverseProxy{Director: director, Transport: transport}
	return &ReverseProxy{rp: rp, log: log, reg: reg}
}

// ServeHTTP implements the http.RoundTripper interface.
func (r *ReverseProxy) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	r.mu.RLock()
	defer r.mu.RUnlock()
	r.rp.ServeHTTP(w, req)
}

// NewRegistry for a given configuration file. This reports an error if any
// frontend host has no backends.
func NewRegistry(filename string) (Registry, error) {
	byt, err := ioutil.ReadFile(filename)
	if err != nil {
		return nil, errors.Wrapf(err, "read file %q", filename)
	}
	reg := Registry{}
	err = json.Unmarshal(byt, &reg)
	if err != nil {
		return nil, errors.Wrap(err, "unmarshal config")
	}
	for host, v := range reg {
		if len(v.Backends) == 0 {
			return nil, fmt.Errorf("missing backends for %q", host)
		}
	}
	return reg, nil
}

// Hosts for the registry.
func (r Registry) Hosts() []string {
	hosts := []string{}
	for k := range r {
		hosts = append(hosts, k)
	}
	return hosts
}

// CheckHealth of backend servers in the registry concurrently, up to 10 at a
// time. If an unexpected error is returned during any of the checks,
// CheckHealth immediately exits, reporting that error.
func (r *ReverseProxy) CheckHealth(client *http.Client) error {
	regClone := Registry{}
	for k, v := range r.reg {
		regClone[k] = v
	}
	semaphore := make(chan int, 10)
	for host, frontend := range regClone {
		if len(frontend.HealthPath) == 0 {
			continue
		}
		liveBackends := []string{}
		ipchan := make(chan string)
		errchan := make(chan error, 1)
		for _, ip := range frontend.Backends {
			target := "http://" + ip + frontend.HealthPath
			semaphore <- 1
			go ping(client, ip, target, semaphore, ipchan, errchan)
		}
		f := regClone[host]
		for i := 0; i < len(frontend.Backends); i++ {
			select {
			case ip := <-ipchan:
				if ip == "" {
					continue
				}
				liveBackends = append(liveBackends, ip)
			case err := <-errchan:
				return errors.Wrap(err, "err on channel")
			}
		}
		f.liveBackends = liveBackends
	}

	// Update the registry
	r.mu.Lock()
	defer r.mu.Unlock()
	r.reg = regClone
	r.rp.Transport = newTransport(regClone)
	return nil
}

func newTransport(reg Registry) http.RoundTripper {
	return &http.Transport{
		Proxy: http.ProxyFromEnvironment,
		Dial: func(network, addr string) (net.Conn, error) {
			endpoints := reg[addr].Backends
			randInt := rand.Int()
			endpoint := endpoints[randInt%len(endpoints)]
			conn, err := net.Dial(network, endpoint)
			if len(endpoints) < 2 || err == nil {
				return conn, err
			}
			// Retry on other endpoints if there are multiple
			conn, err = net.Dial(network, endpoints[(randInt+1)%len(endpoints)])
			if len(endpoints) < 3 || err == nil {
				return conn, err
			}
			return net.Dial(network, endpoints[(randInt+2)%len(endpoints)])
		},
		MaxIdleConns:          100,
		IdleConnTimeout:       30 * time.Second,
		TLSHandshakeTimeout:   10 * time.Second,
		ResponseHeaderTimeout: 10 * time.Second,
	}
}

func ping(
	client *http.Client,
	ip, target string,
	semaphore chan int,
	ipchan chan string,
	errchan chan error,
) {
	defer func() {
		semaphore <- 1
	}()
	req, err := http.NewRequest("GET", target, nil)
	if err != nil {
		errchan <- errors.Wrap(err, "new request")
		return
	}
	resp, err := client.Do(req)
	if err != nil {
		log.Printf("%s: failed connection: %s", ip, err)
		ipchan <- ""
		return
	}
	if err = resp.Body.Close(); err != nil {
		errchan <- errors.Wrap(err, "close resp body")
		return
	}
	if resp.StatusCode != http.StatusOK {
		log.Printf("%s: expected status code 200, got %d",
			ip, resp.StatusCode)
		ipchan <- ""
		return
	}
	ipchan <- ip
}