diff --git a/README.md b/README.md index 1a10f1d..775b890 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,16 @@ If you're using Firefox, you might want to configure two options to make it easy with a value of _true_ * if you use HTTPS-Only Mode, [add an exception](https://support.mozilla.org/en-US/kb/https-only-prefs#w_add-exceptions-for-http-websites-when-youre-in-https-only-mode) + +## HTTPS + +When golink joins your tailnet it will check to see if HTTPS is enabled and +begin serving HTTPS traffic it detects that it is. When HTTPS is enabled golink +will redirect all requests received by the HTTP endpoint first to their internal +HTTPS equivalent before redirecting to the external link destination. + +**NB:** If you use `curl` to interact with the API of a golink instance with HTTPS +enabled over its HTTP interface you _must_ specify the `-L` flag to follow these +redirects or else your request will terminate early with an empty response. We +recommend the use of the `-L` flag in all deployments regardless of current +HTTPS status to avoid accidental outages should it be enabled in the future. diff --git a/golink.go b/golink.go index 560b232..0411eb9 100644 --- a/golink.go +++ b/golink.go @@ -36,6 +36,7 @@ import ( "tailscale.com/ipn" "tailscale.com/tailcfg" "tailscale.com/tsnet" + "tailscale.com/util/dnsname" ) const defaultHostname = "go" @@ -158,6 +159,7 @@ func Run() error { return errors.New("--hostname, if specified, cannot be empty") } + // create tsNet server and wait for it to be ready & connected. srv := &tsnet.Server{ ControlURL: *controlURL, Hostname: *hostname, @@ -169,17 +171,55 @@ func Run() error { if err := srv.Start(); err != nil { return err } + localClient, _ = srv.LocalClient() +out: + for { + upCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + status, err := srv.Up(upCtx) + if err == nil && status != nil { + break out + } + } - l80, err := srv.Listen("tcp", ":80") + statusCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + status, err := localClient.Status(statusCtx) if err != nil { return err } + enableTLS := status.Self.HasCap(tailcfg.CapabilityHTTPS) + fqdn := strings.TrimSuffix(status.Self.DNSName, ".") + httpHandler := serveHandler() + if enableTLS { + httpsHandler := HSTS(httpHandler) + httpHandler = redirectHandler(fqdn) + + httpsListener, err := srv.ListenTLS("tcp", ":443") + if err != nil { + return err + } + log.Println("Listening on :443") + go func() { + log.Printf("Serving https://%s/ ...", fqdn) + if err := http.Serve(httpsListener, httpsHandler); err != nil { + log.Fatal(err) + } + }() + } + + httpListener, err := srv.Listen("tcp", ":80") + log.Println("Listening on :80") + if err != nil { + return err + } log.Printf("Serving http://%s/ ...", *hostname) - if err := http.Serve(l80, serveHandler()); err != nil { + if err := http.Serve(httpListener, httpHandler); err != nil { return err } + return nil } @@ -286,6 +326,34 @@ func deleteLinkStats(link *Link) { db.DeleteStats(link.Short) } +// redirectHandler returns the http.Handler for serving all plaintext HTTP +// requests. It redirects all requests to the HTTPs version of the same URL. +func redirectHandler(hostname string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, (&url.URL{Scheme: "https", Host: hostname, Path: r.URL.Path}).String(), http.StatusFound) + }) +} + +// HSTS wraps the provided handler and sets Strict-Transport-Security header on +// responses. It inspects the Host header to ensure we do not specify HSTS +// response on non fully qualified domain name origins. +func HSTS(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, found := r.Header["Host"] + if found { + host := host[0] + fqdn, err := dnsname.ToFQDN(host) + if err == nil { + segCount := fqdn.NumLabels() + if segCount > 1 { + w.Header().Set("Strict-Transport-Security", "max-age=31536000") + } + } + } + h.ServeHTTP(w, r) + }) +} + // serverHandler returns the main http.Handler for serving all requests. func serveHandler() http.Handler { mux := http.NewServeMux() diff --git a/golink_test.go b/golink_test.go index 9506add..2cd29f8 100644 --- a/golink_test.go +++ b/golink_test.go @@ -524,3 +524,41 @@ func TestResolveLink(t *testing.T) { }) } } + +func TestNoHSTSShortDomain(t *testing.T) { + var err error + db, err = NewSQLiteDB(":memory:") + if err != nil { + t.Fatal(err) + } + db.Save(&Link{Short: "foobar", Long: "http://foobar/"}) + + tests := []struct { + host string + expectHsts bool + }{ + { + host: "go", + expectHsts: false, + }, + { + host: "go.prawn-universe.ts.net", + expectHsts: true, + }, + } + for _, tt := range tests { + name := "HSTS: " + tt.host + t.Run(name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/foobar", nil) + r.Header.Add("Host", tt.host) + + w := httptest.NewRecorder() + HSTS(serveHandler()).ServeHTTP(w, r) + + _, found := w.Header()["Strict-Transport-Security"] + if found != tt.expectHsts { + t.Errorf("HSTS expectation: domain %s want: %t got: %t", tt.host, tt.expectHsts, found) + } + }) + } +} diff --git a/tmpl/help.html b/tmpl/help.html index 0871803..b64eb36 100644 --- a/tmpl/help.html +++ b/tmpl/help.html @@ -114,7 +114,7 @@

Application Programming Interface (API)

Visit go/.export to export all saved links and their metadata in JSON Lines format. This is useful to create data snapshots that can be restored later. -
{{`$ curl go/.export
+
{{`$ curl -L go/.export
 {"Short":"go","Long":"http://go","Created":"2022-05-31T13:04:44.741457796-07:00","LastEdit":"2022-05-31T13:04:44.741457796-07:00","Owner":"amelie@example.com","Clicks":1}
 {"Short":"slack","Long":"https://company.slack.com/{{if .Path}}channels/{{PathEscape .Path}}{{end}}","Created":"2022-06-17T18:05:43.562948451Z","LastEdit":"2022-06-17T18:06:35.811398Z","Owner":"amelie@example.com","Clicks":4}`}}
 
@@ -122,7 +122,7 @@

Application Programming Interface (API)

Create a new link by sending a POST request with a short and long value: -

{{`$ curl -d short=cs -d long=https://cs.github.com/ go
+
{{`$ curl -L -d short=cs -d long=https://cs.github.com/ go
 {"Short":"cs","Long":"https://cs.github.com/","Created":"2022-06-03T22:15:29.993978392Z","LastEdit":"2022-06-03T22:15:29.993978392Z","Owner":"amelie@example.com"}`}}