├── bin └── .gitignore ├── keys └── .gitignore ├── go.mod ├── reverseproxy ├── listener.go ├── target.go └── reverseproxy.go ├── go.sum ├── .gitignore ├── readme.md └── main.go /bin/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /keys/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fideloper/myproxy 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/gorilla/mux v1.8.0 // indirect 7 | golang.org/x/net v0.1.0 // indirect 8 | golang.org/x/text v0.4.0 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /reverseproxy/listener.go: -------------------------------------------------------------------------------- 1 | package reverseproxy 2 | 3 | import "net" 4 | 5 | type Listener struct { 6 | Addr string 7 | TLSCert string 8 | TLSKey string 9 | } 10 | 11 | func (l *Listener) Make() (net.Listener, error) { 12 | return net.Listen("tcp", l.Addr) 13 | } 14 | 15 | func (l *Listener) ServesTLS() bool { 16 | return len(l.TLSCert) > 0 && len(l.TLSKey) > 0 17 | } 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 2 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 3 | golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= 4 | golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 5 | golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= 6 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | .idea 24 | -------------------------------------------------------------------------------- /reverseproxy/target.go: -------------------------------------------------------------------------------- 1 | package reverseproxy 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "net/url" 6 | "sync" 7 | ) 8 | 9 | type Target struct { 10 | router *mux.Router 11 | upstreams []*url.URL 12 | lastUpstream int 13 | lock sync.Mutex 14 | } 15 | 16 | // SelectTarget will load balance amongst available 17 | // targets using a round-robin algorithm 18 | func (t *Target) SelectTarget() *url.URL { 19 | count := len(t.upstreams) 20 | if count == 1 { 21 | return t.upstreams[0] 22 | } 23 | 24 | t.lock.Lock() 25 | defer t.lock.Unlock() 26 | 27 | next := t.lastUpstream + 1 28 | if next >= count { 29 | next = 0 30 | } 31 | 32 | t.lastUpstream = next 33 | 34 | return t.upstreams[next] 35 | } 36 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # My Proxy 2 | This is my (reverse) proxy. There are many like it, but this one is mine. 3 | 4 | I've written about [developing this reverse proxy](https://fideloper.com/go-http). 5 | 6 | ## TODO 7 | 8 | Some ideas that might be fun to incorporate 9 | 10 | - [x] multiple targets 11 | - Decide on upstream target based on hostname, uri, port, etc 12 | - [x] Multiple listeners - port 80, 443, and whatever else we want to configure 13 | - [x] graceful shutdown 14 | - [ ] Dynamic configuration 15 | - graceful reloading 16 | - [x] Multiple backends (AKA load balancing) 17 | - [ ] Health checks 18 | - [ ] Dynamic behavior on incoming requests (e.g. send job to SQS) 19 | - [ ] Dynamic behavior on returned requests (e.g. read a response header and replay request somewhere else) 20 | - [ ] h2c (backend, or backend AND frontend)? 21 | - [ ] fastcgi? 22 | - [ ] WAF 23 | - [ ] Logging 24 | - [ ] Prometheus metrics 25 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/fideloper/myproxy/reverseproxy" 5 | "github.com/gorilla/mux" 6 | "log" 7 | "os" 8 | "os/signal" 9 | ) 10 | 11 | func main() { 12 | r := &reverseproxy.ReverseProxy{} 13 | 14 | // Handle URI /foo 15 | a := mux.NewRouter() 16 | a.Host("fid.dev").Path("/foo") 17 | r.AddTarget([]string{"http://localhost:8000"}, a) 18 | 19 | // This has to go above the fallback target 20 | b := mux.NewRouter() 21 | b.Host("localhost:8888") 22 | r.AddTarget([]string{"http://localhost:8004"}, b) 23 | 24 | // Handle anything else 25 | r.AddTarget([]string{ 26 | "http://localhost:8001", 27 | "http://localhost:8002", 28 | "http://localhost:8003", 29 | }, nil) 30 | 31 | // Listen for http:// on alt port 32 | r.AddListener(":8888") 33 | 34 | // Listen for http:// 35 | r.AddListener(":80") 36 | 37 | // Listen for https:// 38 | r.AddListenerTLS(":443", "keys/fid.dev.pem", "keys/fid.dev-key.pem") 39 | 40 | if err := r.Start(); err != nil { 41 | log.Fatal(err) 42 | } 43 | 44 | c := make(chan os.Signal, 1) 45 | // We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) 46 | // SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught. 47 | signal.Notify(c, os.Interrupt) 48 | 49 | // Block until we receive our signal. 50 | <-c 51 | 52 | // Graceful shutdown 53 | r.Stop() 54 | } 55 | -------------------------------------------------------------------------------- /reverseproxy/reverseproxy.go: -------------------------------------------------------------------------------- 1 | package reverseproxy 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/gorilla/mux" 7 | "log" 8 | "net/http" 9 | "net/http/httputil" 10 | "net/url" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | type ReverseProxy struct { 17 | listeners []Listener 18 | proxy *httputil.ReverseProxy 19 | servers []*http.Server 20 | targets []*Target 21 | } 22 | 23 | // AddTarget adds an upstream server to use for a request that matches 24 | // a given gorilla/mux Router. These are matched via Director function. 25 | func (r *ReverseProxy) AddTarget(upstreams []string, router *mux.Router) error { 26 | var upstreamPool []*url.URL 27 | for _, upstream := range upstreams { 28 | url, err := url.Parse(upstream) 29 | 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if router == nil { 35 | router = mux.NewRouter() 36 | router.PathPrefix("/") 37 | } 38 | 39 | upstreamPool = append(upstreamPool, url) 40 | } 41 | 42 | r.targets = append(r.targets, &Target{ 43 | router: router, 44 | upstreams: upstreamPool, 45 | }) 46 | 47 | return nil 48 | } 49 | 50 | // AddListener adds a listener for non-TLS connections on the given address 51 | func (r *ReverseProxy) AddListener(address string) { 52 | l := Listener{ 53 | Addr: address, 54 | } 55 | 56 | r.listeners = append(r.listeners, l) 57 | } 58 | 59 | // AddListenerTLS adds a listener for TLS connections on the given address 60 | func (r *ReverseProxy) AddListenerTLS(address, tlsCert, tlsKey string) { 61 | l := Listener{ 62 | Addr: address, 63 | TLSCert: tlsCert, 64 | TLSKey: tlsKey, 65 | } 66 | 67 | r.listeners = append(r.listeners, l) 68 | } 69 | 70 | // Start will listen on configured listeners 71 | func (r *ReverseProxy) Start() error { 72 | r.proxy = &httputil.ReverseProxy{ 73 | Director: r.Director(), 74 | } 75 | 76 | for _, l := range r.listeners { 77 | listener, err := l.Make() 78 | if err != nil { 79 | // todo: Close any listeners that 80 | // were created successfully 81 | return err 82 | } 83 | 84 | srv := &http.Server{Handler: r.proxy} 85 | 86 | r.servers = append(r.servers, srv) 87 | 88 | // TODO: Handle unexpected errors from our servers 89 | if l.ServesTLS() { 90 | go func() { 91 | if err := srv.ServeTLS(listener, l.TLSCert, l.TLSKey); !errors.Is(err, http.ErrServerClosed) { 92 | log.Println(err) 93 | } 94 | }() 95 | } else { 96 | go func() { 97 | if err := srv.Serve(listener); !errors.Is(err, http.ErrServerClosed) { 98 | log.Println(err) 99 | } 100 | }() 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | 107 | // Stop will gracefully shut down all listening servers 108 | func (r *ReverseProxy) Stop() { 109 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 110 | defer cancel() 111 | 112 | var wg sync.WaitGroup 113 | 114 | for _, srv := range r.servers { 115 | srv := srv 116 | wg.Add(1) 117 | go func() { 118 | defer wg.Done() 119 | if err := srv.Shutdown(ctx); err != nil { 120 | log.Println(err) 121 | } 122 | log.Println("A listener was shutdown successfully") 123 | }() 124 | } 125 | 126 | // Wait for all servers to shut down 127 | wg.Wait() 128 | log.Println("Server shut down") 129 | } 130 | 131 | // Director returns a function for use in http.ReverseProxy.Director. 132 | // The function matches the incoming request to a specific target and 133 | // sets the request object to be sent to the matched upstream server. 134 | func (r *ReverseProxy) Director() func(req *http.Request) { 135 | return func(req *http.Request) { 136 | for _, t := range r.targets { 137 | match := &mux.RouteMatch{} 138 | if t.router.Match(req, match) { 139 | upstream := t.SelectTarget() 140 | var targetQuery = upstream.RawQuery 141 | req.URL.Scheme = upstream.Scheme 142 | req.URL.Host = upstream.Host 143 | req.URL.Path, req.URL.RawPath = joinURLPath(upstream, req.URL) 144 | if targetQuery == "" || req.URL.RawQuery == "" { 145 | req.URL.RawQuery = targetQuery + req.URL.RawQuery 146 | } else { 147 | req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery 148 | } 149 | if _, ok := req.Header["User-Agent"]; !ok { 150 | // explicitly disable User-Agent so it's not set to default value 151 | req.Header.Set("User-Agent", "") 152 | } 153 | break 154 | } 155 | } 156 | } 157 | } 158 | 159 | func singleJoiningSlash(a, b string) string { 160 | aslash := strings.HasSuffix(a, "/") 161 | bslash := strings.HasPrefix(b, "/") 162 | switch { 163 | case aslash && bslash: 164 | return a + b[1:] 165 | case !aslash && !bslash: 166 | return a + "/" + b 167 | } 168 | return a + b 169 | } 170 | 171 | func joinURLPath(a, b *url.URL) (path, rawpath string) { 172 | if a.RawPath == "" && b.RawPath == "" { 173 | return singleJoiningSlash(a.Path, b.Path), "" 174 | } 175 | // Same as singleJoiningSlash, but uses EscapedPath to determine 176 | // whether a slash should be added 177 | apath := a.EscapedPath() 178 | bpath := b.EscapedPath() 179 | 180 | aslash := strings.HasSuffix(apath, "/") 181 | bslash := strings.HasPrefix(bpath, "/") 182 | 183 | switch { 184 | case aslash && bslash: 185 | return a.Path + b.Path[1:], apath + bpath[1:] 186 | case !aslash && !bslash: 187 | return a.Path + "/" + b.Path, apath + "/" + bpath 188 | } 189 | return a.Path + b.Path, apath + bpath 190 | } 191 | --------------------------------------------------------------------------------