Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

golink: listen on HTTPS and redirect HTTP traffic #99

Merged
merged 10 commits into from
Dec 18, 2023
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
72 changes: 70 additions & 2 deletions golink.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import (
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
"tailscale.com/util/dnsname"
)

const defaultHostname = "go"
Expand Down Expand Up @@ -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,
Expand All @@ -169,17 +171,55 @@ func Run() error {
if err := srv.Start(); err != nil {
return err
}

localClient, _ = srv.LocalClient()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're here, maybe now is a good time to

localClient, err = srv.LocalClient()
if err != nil {
  return err
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as the server is started, LocalClient promises not to report an error in this context.

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
}

Expand Down Expand Up @@ -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()
Expand Down
38 changes: 38 additions & 0 deletions golink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
4 changes: 2 additions & 2 deletions tmpl/help.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,15 @@ <h2 id="api">Application Programming Interface (API)</h2>
Visit <a href="/.export">go/.export</a> to export all saved links and their metadata in <a href="https://jsonlines.org/">JSON Lines format</a>.
This is useful to create data snapshots that can be restored later.

<pre>{{`$ curl go/.export
<pre>{{`$ 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":"[email protected]","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":"[email protected]","Clicks":4}`}}
</pre>

<p>
Create a new link by sending a POST request with a <code>short</code> and <code>long</code> value:

<pre>{{`$ curl -d short=cs -d long=https://cs.github.com/ go
<pre>{{`$ 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":"[email protected]"}`}}
</pre>

Expand Down