git » gofer » commit 4f3285a

Initial commit

author Alberto Bertogli
2017-08-06 00:52:00 UTC
committer Alberto Bertogli
2017-08-06 00:52:00 UTC

Initial commit

.gitignore +15 -0
config/config.go +88 -0
gofer.conf.example +22 -0
gofer.go +77 -0
gofer_test.go +35 -0
proxy/http.go +105 -0
proxy/http_test.go +112 -0
util/util.go +117 -0

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..664073b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+
+# Ignore anything beginning with a dot: these are usually temporary or
+# unimportant.
+.*
+
+# Exceptions to the rules above: files we care about that would otherwise be
+# excluded.
+!.gitignore
+
+# The binary.
+gofer
+
+# Configuration and certificates, to prevent accidents.
+*.conf
+*.pem
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..a8a2f2f
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,88 @@
+// Package config implements the gofer configuration.
+package config
+
+import (
+	"bytes"
+	"fmt"
+	"io/ioutil"
+
+	"github.com/BurntSushi/toml"
+)
+
+type Config struct {
+	ControlAddr string `toml:"control_addr"`
+
+	HTTP  []*HTTP
+	HTTPS []*HTTPS
+
+	// Map of name -> routes.
+	Routes map[string]RouteTable
+}
+
+type HTTP struct {
+	Addr       string
+	RouteTable RouteTable `toml:"routes",omitempty`
+	BaseRoutes string     `toml:"base_routes"`
+}
+
+type HTTPS struct {
+	HTTP
+	Certs string
+}
+
+type RouteTable map[string]string
+
+// mergeRoutes merges the table src into dst, by adding the entries in src
+// that are missing from dst.
+func mergeRoutes(src, dst RouteTable) {
+	for k, v := range src {
+		if _, ok := dst[k]; !ok {
+			dst[k] = v
+		}
+	}
+}
+
+func (c Config) ToString() (string, error) {
+	buf := new(bytes.Buffer)
+	if err := toml.NewEncoder(buf).Encode(c); err != nil {
+		return "", err
+	}
+
+	return buf.String(), nil
+}
+
+func Load(filename string) (*Config, error) {
+	contents, err := ioutil.ReadFile(filename)
+	if err != nil {
+		return nil, fmt.Errorf("error reading config file: %v", err)
+	}
+	return LoadString(string(contents))
+}
+
+func LoadString(contents string) (*Config, error) {
+	conf := &Config{}
+	_, err := toml.Decode(contents, conf)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing config: %v", err)
+	}
+
+	// Link routes.
+	for _, https := range conf.HTTPS {
+		if https.RouteTable == nil {
+			https.RouteTable = RouteTable{}
+		}
+		if https.BaseRoutes != "" {
+			mergeRoutes(conf.Routes[https.BaseRoutes], https.RouteTable)
+		}
+	}
+	for _, http := range conf.HTTP {
+		if http.RouteTable == nil {
+			http.RouteTable = RouteTable{}
+		}
+		if http.BaseRoutes != "" {
+			mergeRoutes(conf.Routes[http.BaseRoutes], http.RouteTable)
+		}
+	}
+
+	return conf, nil
+}
diff --git a/gofer.conf.example b/gofer.conf.example
new file mode 100644
index 0000000..6fa0d96
--- /dev/null
+++ b/gofer.conf.example
@@ -0,0 +1,22 @@
+
+# Address for the control/debug http.
+control_addr = "127.0.0.1:8081"
+
+# HTTP(s) proxy
+[[https]]
+addr = ":https"
+certs = "/etc/letsencrypt/live/"
+
+# Take the routes from the definition below as a baseline.
+base_routes = "default"
+
+# Extend the base routes.
+routes = { "/local/" = "http://localhost:99/" }
+
+[[http]]
+addr = ":http"
+base_routes = "default"
+
+[routes.default]
+"/" = "http://localhost:8080/"
+
diff --git a/gofer.go b/gofer.go
new file mode 100644
index 0000000..46861a8
--- /dev/null
+++ b/gofer.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+	"flag"
+	"net/http"
+	"runtime"
+	"time"
+
+	"blitiri.com.ar/go/gofer/config"
+	"blitiri.com.ar/go/gofer/proxy"
+	"blitiri.com.ar/go/gofer/util"
+)
+
+// Flags.
+var (
+	configfile = flag.String("configfile", "gofer.conf",
+		"Configuration file")
+)
+
+func main() {
+	flag.Parse()
+	util.InitLog()
+
+	conf, err := config.Load(*configfile)
+	if err != nil {
+		util.Log.Fatalf("error reading config file: %v", err)
+	}
+
+	for _, https := range conf.HTTPS {
+		go proxy.HTTPS(*https)
+	}
+
+	for _, http := range conf.HTTP {
+		go proxy.HTTP(*http)
+	}
+
+	// Monitoring server.
+	if conf.ControlAddr != "" {
+		mux := http.NewServeMux()
+		mux.HandleFunc("/debug/stack", dumpStack)
+		mux.HandleFunc("/debug/config", dumpConfigFunc(conf))
+
+		server := http.Server{
+			Addr:     conf.ControlAddr,
+			ErrorLog: util.Log,
+			Handler:  mux,
+		}
+
+		util.Log.Printf("%s Starting monitoring server ", server.Addr)
+		util.Log.Fatal(server.ListenAndServe())
+	} else {
+		util.Log.Print("No monitoring server, idle loop")
+		time.Sleep(1 * time.Hour)
+	}
+}
+
+// dumpStack handler for the control listener.
+func dumpStack(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	buf := make([]byte, 500*1024)
+	c := runtime.Stack(buf, true)
+	w.Write(buf[:c])
+}
+
+// dumpConfig handler for the control listener.
+func dumpConfigFunc(conf *config.Config) http.HandlerFunc {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		s, err := conf.ToString()
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+		w.Write([]byte(s))
+	})
+}
diff --git a/gofer_test.go b/gofer_test.go
new file mode 100644
index 0000000..0a374a4
--- /dev/null
+++ b/gofer_test.go
@@ -0,0 +1,35 @@
+package main
+
+import (
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"blitiri.com.ar/go/gofer/config"
+)
+
+func TestDumpConfig(t *testing.T) {
+	conf, err := config.Load("gofer.conf.example")
+	if err != nil {
+		t.Fatalf("error loading config example: %v", err)
+	}
+
+	srv := httptest.NewServer(http.HandlerFunc(dumpConfigFunc(conf)))
+
+	res, err := http.Get(srv.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	body, err := ioutil.ReadAll(res.Body)
+	res.Body.Close()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("\n----- 8< -----\n%s\n----- 8< -----\n", body)
+	if !strings.Contains(string(body), "localhost") {
+		t.Errorf("expected body to contain 'localhost'")
+	}
+}
diff --git a/proxy/http.go b/proxy/http.go
new file mode 100644
index 0000000..07c1273
--- /dev/null
+++ b/proxy/http.go
@@ -0,0 +1,105 @@
+package proxy
+
+import (
+	"net/http"
+	"net/http/httputil"
+	"net/url"
+	"path"
+	"strings"
+
+	"blitiri.com.ar/go/gofer/config"
+	"blitiri.com.ar/go/gofer/util"
+)
+
+func httpServer(conf config.HTTP) *http.Server {
+	srv := &http.Server{
+		Addr:     conf.Addr,
+		ErrorLog: util.Log,
+		// TODO: timeouts.
+	}
+
+	// Load route table.
+	mux := http.NewServeMux()
+	srv.Handler = mux
+	for from, to := range conf.RouteTable {
+		toURL, err := url.Parse(to)
+		if err != nil {
+			util.Log.Fatalf("route %q -> %q: destination is not a valid URL: %v",
+				from, to, err)
+		}
+		util.Log.Printf("%s route %q -> %q", srv.Addr, from, toURL)
+		mux.Handle(from, makeProxy(from, toURL))
+	}
+
+	return srv
+}
+
+func HTTP(conf config.HTTP) {
+	srv := httpServer(conf)
+	util.Log.Printf("HTTP proxy on %q", conf.Addr)
+	err := srv.ListenAndServe()
+	util.Log.Fatalf("HTTP proxy exited: %v", err)
+}
+
+func HTTPS(conf config.HTTPS) {
+	var err error
+	srv := httpServer(conf.HTTP)
+
+	srv.TLSConfig, err = util.LoadCerts(conf.Certs)
+	if err != nil {
+		util.Log.Fatalf("error loading certs: %v", err)
+	}
+
+	util.Log.Printf("HTTPS proxy on %q", srv.Addr)
+	err = srv.ListenAndServeTLS("", "")
+	util.Log.Fatalf("HTTPS proxy exited: %v", err)
+}
+
+func makeProxy(from string, to *url.URL) http.Handler {
+	proxy := &httputil.ReverseProxy{}
+	proxy.ErrorLog = util.Log
+	proxy.Transport = transport
+
+	// Director that strips "from" from the request path, so that if we have
+	// this config:
+	//   /a/ -> http://dst/b
+	// then a request for /a/x goes to http://dst/b/x, not http://dst/b/a/x.
+	proxy.Director = func(req *http.Request) {
+		req.URL.Scheme = to.Scheme
+		req.URL.Host = to.Host
+		req.URL.RawQuery = req.URL.RawQuery
+		req.URL.Path = path.Join(to.Path, strings.TrimPrefix(req.URL.Path, from))
+		if req.URL.Path == "" || req.URL.Path[0] != '/' {
+			req.URL.Path = "/" + req.URL.Path
+		}
+
+		// If the user agent is not set, prevent a fall back to the default value.
+		if _, ok := req.Header["User-Agent"]; !ok {
+			req.Header.Set("User-Agent", "")
+		}
+
+		// Note we don't do this so we can have routes independent of virtual
+		// hosts. The downside is that if the destination scheme is HTTPS,
+		// this causes issues with the TLS SNI negotiation.
+		//req.Host = to.Host
+	}
+
+	return proxy
+}
+
+type loggingTransport struct{}
+
+func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+	response, err := http.DefaultTransport.RoundTrip(req)
+	errs := ""
+	if err != nil {
+		errs = " (" + err.Error() + ")"
+	}
+	util.Log.Printf("%s %s %s -> %d%s", req.RemoteAddr, req.Proto, req.URL,
+		response.StatusCode, errs)
+
+	return response, err
+}
+
+// Use a single logging transport, we don't need more than one.
+var transport = &loggingTransport{}
diff --git a/proxy/http_test.go b/proxy/http_test.go
new file mode 100644
index 0000000..f4ffd8c
--- /dev/null
+++ b/proxy/http_test.go
@@ -0,0 +1,112 @@
+package proxy
+
+import (
+	"fmt"
+	"io/ioutil"
+	"log"
+	"net"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"blitiri.com.ar/go/gofer/config"
+)
+
+const configTemplate = `
+[[http]]
+addr = "$FRONTEND_ADDR"
+routes = { "/be/" = "$BACKEND_URL" }
+`
+const backendResponse = "backend response\n"
+
+func TestSimple(t *testing.T) {
+	backend := httptest.NewServer(
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			fmt.Fprint(w, backendResponse)
+		}))
+	defer backend.Close()
+
+	feAddr := getFreePort()
+
+	configStr := strings.NewReplacer(
+		"$FRONTEND_ADDR", feAddr,
+		"$BACKEND_URL", backend.URL,
+	).Replace(configTemplate)
+
+	conf, err := config.LoadString(configStr)
+	if err != nil {
+		log.Fatal(err)
+	}
+	t.Logf("conf.HTTP[0]: %#v", *conf.HTTP[0])
+
+	go HTTP(*conf.HTTP[0])
+
+	waitForHTTPServer(feAddr)
+
+	testGet(t, "http://"+feAddr+"/be", 200)
+	testGet(t, "http://"+feAddr+"/be/", 200)
+	testGet(t, "http://"+feAddr+"/be/2", 200)
+	testGet(t, "http://"+feAddr+"/be/3", 200)
+	testGet(t, "http://"+feAddr+"/x", 404)
+}
+
+func testGet(t *testing.T, url string, expectedStatus int) {
+	t.Logf("URL: %s", url)
+	resp, err := http.Get(url)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Logf("status %v", resp.Status)
+
+	if resp.StatusCode != expectedStatus {
+		t.Errorf("expected status %d, got %v", expectedStatus, resp.Status)
+		t.Errorf("response: %#v", resp)
+	}
+
+	// We don't care about the body for non-200 responses.
+	if resp.StatusCode != http.StatusOK {
+		return
+	}
+
+	b, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(b) != backendResponse {
+		t.Errorf("expected body = %q, got %q", backendResponse, string(b))
+	}
+
+	t.Logf("response body: %q", b)
+}
+
+// WaitForHTTPServer waits 5 seconds for an HTTP server to start, and returns
+// an error if it fails to do so.
+// It does this by repeatedly querying the server until it either replies or
+// times out.
+func waitForHTTPServer(addr string) error {
+	c := http.Client{
+		Timeout: 100 * time.Millisecond,
+	}
+
+	deadline := time.Now().Add(5 * time.Second)
+	tick := time.Tick(100 * time.Millisecond)
+
+	for (<-tick).Before(deadline) {
+		_, err := c.Get("http://" + addr + "/testpoke")
+		if err == nil {
+			return nil
+		}
+	}
+
+	return fmt.Errorf("timed out")
+}
+
+// Get a free (TCP) port. This is hacky and not race-free, but it works well
+// enough for testing purposes.
+func getFreePort() string {
+	l, _ := net.Listen("tcp", "localhost:0")
+	defer l.Close()
+	return l.Addr().String()
+}
diff --git a/util/util.go b/util/util.go
new file mode 100644
index 0000000..43c721e
--- /dev/null
+++ b/util/util.go
@@ -0,0 +1,117 @@
+// Package util implements some common utilities.
+package util
+
+import (
+	"crypto/tls"
+	"flag"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"log"
+	"log/syslog"
+	"os"
+	"path/filepath"
+)
+
+// Log is used to log messages.
+var Log *log.Logger
+
+func init() {
+	// Always have a log from early initialization; this helps with coding
+	// errors and can simplify some tests.
+	Log = log.New(os.Stderr, "<early> ",
+		log.Ldate|log.Ltime|log.Lmicroseconds|log.Lshortfile)
+}
+
+// Flags.
+var (
+	logfile = flag.String("logfile", "-",
+		"File to write logs to, use '-' for stdout")
+)
+
+func InitLog() {
+	var err error
+	var logfd io.Writer
+
+	if *logfile == "-" {
+		logfd = os.Stdout
+	} else if *logfile != "" {
+		logfd, err = os.OpenFile(*logfile,
+			os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
+		if err != nil {
+			log.Fatalf("error opening log file %s: %v",
+				*logfile, err)
+		}
+	} else {
+		logfd, err = syslog.New(
+			syslog.LOG_INFO|syslog.LOG_DAEMON, "gofer")
+		if err != nil {
+			log.Fatalf("error opening syslog: %v", err)
+		}
+	}
+
+	Log = log.New(logfd, "",
+		log.Ldate|log.Ltime|log.Lmicroseconds|log.Lshortfile)
+}
+
+// LoadCerts loads certificates from the given directory, and returns a TLS
+// config including them.
+func LoadCerts(certDir string) (*tls.Config, error) {
+	tlsConfig := &tls.Config{}
+
+	infos, err := ioutil.ReadDir(certDir)
+	if err != nil {
+		return nil, fmt.Errorf("ReadDir(%q): %v", certDir, err)
+	}
+	for _, info := range infos {
+		name := info.Name()
+		dir := filepath.Join(certDir, name)
+		if fi, err := os.Stat(dir); err == nil && !fi.IsDir() {
+			// Skip non-directories.
+			continue
+		}
+
+		certPath := filepath.Join(dir, "fullchain.pem")
+		if _, err := os.Stat(certPath); os.IsNotExist(err) {
+			continue
+		}
+		keyPath := filepath.Join(dir, "privkey.pem")
+		if _, err := os.Stat(keyPath); os.IsNotExist(err) {
+			continue
+		}
+
+		cert, err := tls.LoadX509KeyPair(certPath, keyPath)
+		if err != nil {
+			return nil, fmt.Errorf("error loading pair (%q, %q): %v",
+				certPath, keyPath, err)
+		}
+		tlsConfig.Certificates = append(tlsConfig.Certificates, cert)
+	}
+
+	if len(tlsConfig.Certificates) == 0 {
+		return nil, fmt.Errorf("no certificates found in %q", certDir)
+	}
+
+	tlsConfig.BuildNameToCertificate()
+
+	return tlsConfig, nil
+}
+
+func BidirCopy(src, dst io.ReadWriter) {
+	done := make(chan bool, 2)
+
+	go func() {
+		io.Copy(src, dst)
+		done <- true
+	}()
+
+	go func() {
+		io.Copy(dst, src)
+		done <- true
+	}()
+
+	// Return when one of the two completes.
+	// The other goroutine will remain alive, it is up to the caller to create
+	// the conditions to complete it (e.g. by closing one of the sides).
+	<-done
+}