author | Alberto Bertogli
<albertito@blitiri.com.ar> 2017-08-06 00:52:00 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2017-08-06 00:52:00 UTC |
.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 +}