├── .github └── workflows │ └── go.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.build.yml ├── gen └── gen.go ├── go.mod ├── go.sum ├── main.go └── reverseproxy ├── reverseproxy.go └── reverseproxy_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push, pull_request] 3 | jobs: 4 | 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: Set up Go 1.21 11 | uses: actions/setup-go@v2 12 | with: 13 | go-version: 1.21 14 | id: go 15 | 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v1 18 | 19 | - name: Get dependencies 20 | run: | 21 | go mod download 22 | 23 | - name: Build 24 | run: make 25 | 26 | - name: Test 27 | run: make test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .idea 3 | ssl-proxy 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18.3-alpine 2 | WORKDIR /go/src/github.com/suyashkumar/ssl-proxy 3 | RUN apk add --no-cache make git zip 4 | COPY . . 5 | RUN go get -u github.com/golang/dep/cmd/dep 6 | RUN make 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Suyash Kumar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINARY = ssl-proxy 2 | 3 | .PHONY: build 4 | build: 5 | go mod download 6 | go build -o ${BINARY} 7 | 8 | .PHONY: test 9 | test: 10 | go test -v ./... 11 | 12 | .PHONY: run 13 | run: 14 | make build 15 | ./${BINARY} 16 | 17 | .PHONY: release 18 | release: 19 | go mod download 20 | GOOS=linux GOARCH=amd64 go build -o build/${BINARY}-linux-amd64 .; 21 | GOOS=darwin GOARCH=amd64 go build -o build/${BINARY}-darwin-amd64 .; 22 | GOOS=windows GOARCH=amd64 go build -o build/${BINARY}-windows-amd64.exe .; 23 | cd build; \ 24 | tar -zcvf ssl-proxy-linux-amd64.tar.gz ssl-proxy-linux-amd64; \ 25 | tar -zcvf ssl-proxy-darwin-amd64.tar.gz ssl-proxy-darwin-amd64; \ 26 | zip -r ssl-proxy-windows-amd64.exe.zip ssl-proxy-windows-amd64.exe; 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

ssl-proxy

4 |

Simple single-command SSL reverse proxy with autogenerated certificates (LetsEncrypt, self-signed)

5 |

6 |

7 |

8 | 9 | A handy and simple way to add SSL to your thing running on a VM--be it your personal jupyter notebook or your team jenkins instance. `ssl-proxy` autogenerates SSL certs and proxies HTTPS traffic to an existing HTTP server in a single command. 10 | 11 | ## Usage 12 | ### With auto self-signed certificates 13 | ```sh 14 | ssl-proxy -from 0.0.0.0:4430 -to 127.0.0.1:8000 15 | ``` 16 | This will immediately generate self-signed certificates and begin proxying HTTPS traffic from https://0.0.0.0:4430 to http://127.0.0.1:8000. No need to ever call openssl. It will print the SHA256 fingerprint of the cert being used for you to perform manual certificate verification in the browser if you would like (before you "trust" the cert). 17 | 18 | I know `nginx` is often used for stuff like this, but I got tired of dealing with the boilerplate and wanted to explore something fun. So I ended up throwing this together. 19 | 20 | ### With auto LetsEncrypt SSL certificates 21 | ```sh 22 | ssl-proxy -from 0.0.0.0:443 -to 127.0.0.1:8000 -domain=mydomain.com 23 | ``` 24 | This will immediately generate, fetch, and serve real LetsEncrypt certificates for `mydomain.com` and begin proxying HTTPS traffic from https://0.0.0.0:443 to http://127.0.0.1:8000. For now, you need to ensure that `ssl-proxy` can bind port `:443` and that `mydomain.com` routes to the server running `ssl-proxy` (as you may have expected, this is not the tool you should be using if you have load-balancing over multiple servers or other deployment configurations). 25 | 26 | ### Provide your own certs 27 | ```sh 28 | ssl-proxy -cert cert.pem -key myKey.pem -from 0.0.0.0:4430 -to 127.0.0.1:8000 29 | ``` 30 | You can provide your own existing certs, of course. Jenkins still has issues serving the fullchain certs from letsencrypt properly, so this tool has come in handy for me there. 31 | 32 | ### Redirect HTTP -> HTTPS 33 | Simply include the `-redirectHTTP` flag when running the program. 34 | 35 | ## Installation 36 | Simply download and uncompress the proper prebuilt binary for your system from the [releases tab](https://github.com/suyashkumar/ssl-proxy/releases/). Then, add the binary to your path or start using it locally (`./ssl-proxy`). 37 | 38 | If you're using `wget`, you can fetch and uncompress the right binary for your OS using [`getbin.io`](https://github.com/suyashkumar/getbin) as follows: 39 | ```sh 40 | wget -qO- "https://getbin.io/suyashkumar/ssl-proxy" | tar xvz 41 | ``` 42 | or with `curl` (note you need to provide your os if using curl as one of `(darwin, windows, linux)` below): 43 | ```sh 44 | curl -LJ "https://getbin.io/suyashkumar/ssl-proxy?os=linux" | tar xvz 45 | ``` 46 | 47 | Shameless plug: [`suyashkumar/getbin (https://getbin.io)`](https://github.com/suyashkumar/getbin) is a general tool that can fetch the latest binaries from GitHub releases for your OS. Check it out :). 48 | 49 | ### Build from source 50 | #### Build from source using Docker 51 | You can build `ssl-proxy` for all platforms quickly using the included Docker configurations. 52 | 53 | If you have `docker-compose` installed: 54 | ```sh 55 | docker-compose -f docker-compose.build.yml up 56 | ``` 57 | will build linux, osx, and darwin binaries (x86) and place them in a `build/` folder in your current working directory. 58 | #### Build from source locally 59 | You must have Golang installed on your system along with `make` and [`dep`](https://github.com/golang/dep). Then simply clone the repository and run `make`. 60 | 61 | ## Attribution 62 | Icons made by Those Icons from www.flaticon.com is licensed by CC 3.0 BY 63 | -------------------------------------------------------------------------------- /docker-compose.build.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | build-release: 4 | build: . 5 | volumes: 6 | - $PWD/build:/go/src/github.com/suyashkumar/ssl-proxy/build 7 | command: make release 8 | -------------------------------------------------------------------------------- /gen/gen.go: -------------------------------------------------------------------------------- 1 | package gen 2 | 3 | import ( 4 | "bytes" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/sha256" 9 | "crypto/x509" 10 | "crypto/x509/pkix" 11 | "encoding/pem" 12 | "fmt" 13 | "log" 14 | "math/big" 15 | "os" 16 | "time" 17 | ) 18 | 19 | // Keys generates a new P256 ECDSA public private key pair for TLS. 20 | // It returns a bytes buffer for the PEM encoded private key and certificate. 21 | func Keys(validFor time.Duration) (cert, key *bytes.Buffer, fingerprint [32]byte, err error) { 22 | privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 23 | if err != nil { 24 | log.Fatalf("failed to generate private key: %s", err) 25 | return nil, nil, fingerprint, err 26 | } 27 | 28 | notBefore := time.Now() 29 | notAfter := notBefore.Add(validFor) 30 | 31 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 32 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 33 | if err != nil { 34 | log.Fatalf("failed to generate serial number: %s", err) 35 | return nil, nil, fingerprint, err 36 | } 37 | 38 | template := x509.Certificate{ 39 | SerialNumber: serialNumber, 40 | Subject: pkix.Name{ 41 | Organization: []string{"ssl-proxy"}, 42 | }, 43 | NotBefore: notBefore, 44 | NotAfter: notAfter, 45 | 46 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 47 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 48 | BasicConstraintsValid: true, 49 | } 50 | 51 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey) 52 | if err != nil { 53 | log.Fatalf("Failed to create certificate: %s", err) 54 | return nil, nil, fingerprint, err 55 | } 56 | 57 | // Encode and write certificate and key to bytes.Buffer 58 | cert = bytes.NewBuffer([]byte{}) 59 | pem.Encode(cert, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) 60 | 61 | key = bytes.NewBuffer([]byte{}) 62 | pem.Encode(key, pemBlockForKey(privKey)) 63 | 64 | fingerprint = sha256.Sum256(derBytes) 65 | 66 | return cert, key, fingerprint, nil //TODO: maybe return a struct instead of 4 multiple return items 67 | } 68 | 69 | func pemBlockForKey(key *ecdsa.PrivateKey) *pem.Block { 70 | b, err := x509.MarshalECPrivateKey(key) 71 | if err != nil { 72 | fmt.Fprintf(os.Stderr, "Unable to marshal ECDSA private key: %v", err) 73 | os.Exit(2) 74 | } 75 | return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} 76 | } 77 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/suyashkumar/ssl-proxy 2 | 3 | go 1.24 4 | 5 | require golang.org/x/crypto v0.36.0 6 | 7 | require ( 8 | golang.org/x/net v0.37.0 // indirect 9 | golang.org/x/text v0.23.0 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 2 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 3 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 4 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 5 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 6 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/suyashkumar/ssl-proxy/gen" 14 | "github.com/suyashkumar/ssl-proxy/reverseproxy" 15 | "golang.org/x/crypto/acme/autocert" 16 | ) 17 | 18 | var ( 19 | to = flag.String("to", "http://127.0.0.1:80", "the address and port for which to proxy requests to") 20 | fromURL = flag.String("from", "127.0.0.1:4430", "the tcp address and port this proxy should listen for requests on") 21 | certFile = flag.String("cert", "", "path to a tls certificate file. If not provided, ssl-proxy will generate one for you in ~/.ssl-proxy/") 22 | keyFile = flag.String("key", "", "path to a private key file. If not provided, ssl-proxy will generate one for you in ~/.ssl-proxy/") 23 | domain = flag.String("domain", "", "domain to mint letsencrypt certificates for. Usage of this parameter implies acceptance of the LetsEncrypt terms of service.") 24 | redirectHTTP = flag.Bool("redirectHTTP", false, "if true, redirects http requests from port 80 to https at your fromURL") 25 | ) 26 | 27 | const ( 28 | DefaultCertFile = "cert.pem" 29 | DefaultKeyFile = "key.pem" 30 | HTTPSPrefix = "https://" 31 | HTTPPrefix = "http://" 32 | ) 33 | 34 | func main() { 35 | flag.Parse() 36 | 37 | validCertFile := *certFile != "" 38 | validKeyFile := *keyFile != "" 39 | validDomain := *domain != "" 40 | 41 | // Determine if we need to generate self-signed certs 42 | if (!validCertFile || !validKeyFile) && !validDomain { 43 | // Use default file paths 44 | *certFile = DefaultCertFile 45 | *keyFile = DefaultKeyFile 46 | 47 | log.Printf("No existing cert or key specified, generating some self-signed certs for use (%s, %s)\n", *certFile, *keyFile) 48 | 49 | // Generate new keys 50 | certBuf, keyBuf, fingerprint, err := gen.Keys(365 * 24 * time.Hour) 51 | if err != nil { 52 | log.Fatal("Error generating default keys", err) 53 | } 54 | 55 | certOut, err := os.Create(*certFile) 56 | if err != nil { 57 | log.Fatal("Unable to create cert file", err) 58 | } 59 | certOut.Write(certBuf.Bytes()) 60 | 61 | keyOut, err := os.OpenFile(*keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 62 | if err != nil { 63 | log.Fatal("Unable to create the key file", err) 64 | } 65 | keyOut.Write(keyBuf.Bytes()) 66 | 67 | log.Printf("SHA256 Fingerprint: % X", fingerprint) 68 | 69 | } 70 | 71 | // Ensure the to URL is in the right form 72 | if !strings.HasPrefix(*to, HTTPPrefix) && !strings.HasPrefix(*to, HTTPSPrefix) { 73 | *to = HTTPPrefix + *to 74 | log.Println("Assuming -to URL is using http://") 75 | } 76 | 77 | // Parse toURL as a URL 78 | toURL, err := url.Parse(*to) 79 | if err != nil { 80 | log.Fatal("Unable to parse 'to' url: ", err) 81 | } 82 | 83 | // Setup reverse proxy ServeMux 84 | p := reverseproxy.Build(toURL) 85 | mux := http.NewServeMux() 86 | mux.Handle("/", p) 87 | 88 | log.Printf(green("Proxying calls from https://%s (SSL/TLS) to %s"), *fromURL, toURL) 89 | 90 | // Redirect http requests on port 80 to TLS port using https 91 | if *redirectHTTP { 92 | // Redirect to fromURL by default, unless a domain is specified--in that case, redirect using the public facing 93 | // domain 94 | redirectURL := *fromURL 95 | if validDomain { 96 | redirectURL = *domain 97 | } 98 | redirectTLS := func(w http.ResponseWriter, r *http.Request) { 99 | http.Redirect(w, r, "https://"+redirectURL+r.RequestURI, http.StatusMovedPermanently) 100 | } 101 | go func() { 102 | log.Println( 103 | fmt.Sprintf("Also redirecting https requests on port 80 to https requests on %s", redirectURL)) 104 | err := http.ListenAndServe(":80", http.HandlerFunc(redirectTLS)) 105 | if err != nil { 106 | log.Println("HTTP redirection server failure") 107 | log.Println(err) 108 | } 109 | }() 110 | } 111 | 112 | // Determine if we should serve over TLS with autogenerated LetsEncrypt certificates or not 113 | if validDomain { 114 | // Domain is present, use autocert 115 | // TODO: validate domain (though, autocert may do this) 116 | // TODO: for some reason this seems to only work on :443 117 | log.Printf("Domain specified, using LetsEncrypt to autogenerate and serve certs for %s\n", *domain) 118 | if !strings.HasSuffix(*fromURL, ":443") { 119 | log.Println("WARN: Right now, you must serve on port :443 to use autogenerated LetsEncrypt certs using the -domain flag, this may NOT WORK") 120 | } 121 | m := &autocert.Manager{ 122 | Cache: autocert.DirCache("certs"), 123 | Prompt: autocert.AcceptTOS, 124 | HostPolicy: autocert.HostWhitelist(*domain), 125 | } 126 | s := &http.Server{ 127 | Addr: *fromURL, 128 | TLSConfig: m.TLSConfig(), 129 | } 130 | s.Handler = mux 131 | log.Fatal(s.ListenAndServeTLS("", "")) 132 | } else { 133 | // Domain is not provided, serve TLS using provided/generated certificate files 134 | log.Fatal(http.ListenAndServeTLS(*fromURL, *certFile, *keyFile, mux)) 135 | } 136 | 137 | } 138 | 139 | // green takes an input string and returns it with the proper ANSI escape codes to render it green-colored 140 | // in a supported terminal. 141 | // TODO: if more colors used in the future, generalize or pull in an external pkg 142 | func green(in string) string { 143 | return fmt.Sprintf("\033[0;32m%s\033[0;0m", in) 144 | } 145 | -------------------------------------------------------------------------------- /reverseproxy/reverseproxy.go: -------------------------------------------------------------------------------- 1 | package reverseproxy 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | // Build initializes and returns a new ReverseProxy instance suitable for SSL proxying 11 | func Build(toURL *url.URL) *httputil.ReverseProxy { 12 | localProxy := &httputil.ReverseProxy{} 13 | addProxyHeaders := func(req *http.Request) { 14 | req.Header.Set(http.CanonicalHeaderKey("X-Forwarded-Proto"), "https") 15 | req.Header.Set(http.CanonicalHeaderKey("X-Forwarded-Port"), "443") // TODO: inherit another port if needed 16 | } 17 | localProxy.Director = newDirector(toURL, addProxyHeaders) 18 | 19 | return localProxy 20 | } 21 | 22 | // newDirector creates a base director that should be exactly what http.NewSingleHostReverseProxy() creates, but allows 23 | // for the caller to supply and extraDirector function to decorate to request to the downstream server 24 | func newDirector(target *url.URL, extraDirector func(*http.Request)) func(*http.Request) { 25 | targetQuery := target.RawQuery 26 | return func(req *http.Request) { 27 | req.URL.Scheme = target.Scheme 28 | req.URL.Host = target.Host 29 | req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path) 30 | if targetQuery == "" || req.URL.RawQuery == "" { 31 | req.URL.RawQuery = targetQuery + req.URL.RawQuery 32 | } else { 33 | req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery 34 | } 35 | if extraDirector != nil { 36 | extraDirector(req) 37 | } 38 | } 39 | } 40 | 41 | // singleJoiningSlash is a utility function that adds a single slash to a URL where appropriate, copied from 42 | // the httputil package 43 | // TODO: add test to ensure behavior does not diverge from httputil's implementation, as per Rob Pike's proverbs 44 | func singleJoiningSlash(a, b string) string { 45 | aslash := strings.HasSuffix(a, "/") 46 | bslash := strings.HasPrefix(b, "/") 47 | switch { 48 | case aslash && bslash: 49 | return a + b[1:] 50 | case !aslash && !bslash: 51 | return a + "/" + b 52 | } 53 | return a + b 54 | } 55 | -------------------------------------------------------------------------------- /reverseproxy/reverseproxy_test.go: -------------------------------------------------------------------------------- 1 | package reverseproxy 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/http/httputil" 7 | "net/url" 8 | "testing" 9 | ) 10 | 11 | // TestBuild_AddHeaders tests that Build's returned ReverseProxy Director adds the proper request headers 12 | func TestBuild_AddHeaders(t *testing.T) { 13 | u, err := url.Parse("http://127.0.0.1") 14 | if err != nil { 15 | t.Fatalf("got error %v, want nil", err) 16 | } 17 | proxy := Build(u) 18 | if proxy == nil { 19 | t.Fatal("got nil, want non-nil proxy") 20 | } 21 | 22 | req := httptest.NewRequest("GET", "/test", nil) 23 | proxy.Director(req) 24 | 25 | // Check that headers were added to req 26 | if got := req.Header.Get(http.CanonicalHeaderKey("X-Forwarded-Proto")); got != "https" { 27 | t.Errorf("X-Forwarded-Proto: got %q, want %q", got, "https") 28 | } 29 | if got := req.Header.Get(http.CanonicalHeaderKey("X-Forwarded-Port")); got != "443" { 30 | t.Errorf("X-Forwarded-Port: got %q, want %q", got, "443") 31 | } 32 | } 33 | 34 | func TestNewDirector(t *testing.T) { 35 | u, err := url.Parse("http://127.0.0.1") 36 | if err != nil { 37 | t.Fatalf("got error %v, want nil", err) 38 | } 39 | director := newDirector(u, nil) 40 | 41 | defaultProxy := httputil.NewSingleHostReverseProxy(u) 42 | defaultDirector := defaultProxy.Director 43 | 44 | expectedReq := httptest.NewRequest("GET", "/test", nil) 45 | testReq := httptest.NewRequest("GET", "/test", nil) 46 | 47 | defaultDirector(expectedReq) 48 | director(testReq) 49 | 50 | // Compare relevant fields of the requests 51 | if got, want := testReq.URL.String(), expectedReq.URL.String(); got != want { 52 | t.Errorf("URL: got %q, want %q", got, want) 53 | } 54 | if got, want := testReq.Host, expectedReq.Host; got != want { 55 | t.Errorf("Host: got %q, want %q", got, want) 56 | } 57 | if got, want := testReq.RemoteAddr, expectedReq.RemoteAddr; got != want { 58 | t.Errorf("RemoteAddr: got %q, want %q", got, want) 59 | } 60 | // TODO: add more test cases 61 | } 62 | --------------------------------------------------------------------------------