HTTP reverse proxy for Tailscale

redirect https to fqdn

+89 -2
+29 -1
main.go
··· 16 16 "path/filepath" 17 17 "sort" 18 18 "strconv" 19 + "strings" 19 20 "syscall" 20 21 21 22 "github.com/oklog/run" ··· 27 28 "github.com/tailscale/hujson" 28 29 "tailscale.com/client/local" 29 30 "tailscale.com/client/tailscale/apitype" 31 + "tailscale.com/ipn/ipnstate" 30 32 "tailscale.com/tsnet" 31 33 tslogger "tailscale.com/types/logger" 32 34 ) ··· 356 358 357 359 // localTailnetTLSHandler serves HTTPS on the local tailnet. 358 360 func localTailnetTLSHandler(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { 359 - return localTailnetHandler(logger, lc, next) 361 + var ( 362 + handler = localTailnetHandler(logger, lc, next) 363 + dnsName string 364 + ) 365 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 366 + if r.TLS == nil { 367 + panic("TLS handler wants TLS") 368 + } 369 + 370 + if dnsName == "" { 371 + st, err := lc.StatusWithoutPeers(r.Context()) 372 + if err != nil { 373 + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 374 + logger.Error("tailscale status", slog.Any("err", err)) 375 + return 376 + } 377 + dnsName = strings.TrimSuffix(st.Self.DNSName, ".") 378 + } 379 + 380 + if strings.TrimSuffix(r.Host, ".") != dnsName { 381 + http.Redirect(w, r, fmt.Sprintf("https://%s%s", dnsName, r.RequestURI), http.StatusPermanentRedirect) 382 + return 383 + } 384 + 385 + handler.ServeHTTP(w, r) 386 + }) 360 387 } 361 388 362 389 // insecureFunnelHandler handles HTTPS requests coming from Tailscale Funnel nodes. ··· 368 395 369 396 type tailscaleLocalClient interface { 370 397 WhoIs(context.Context, string) (*apitype.WhoIsResponse, error) 398 + StatusWithoutPeers(context.Context) (*ipnstate.Status, error) 371 399 } 372 400 373 401 func tsWhoIs(lc tailscaleLocalClient, r *http.Request) (*apitype.WhoIsResponse, error) {
+60 -1
tsproxy_test.go
··· 14 14 "github.com/prometheus/client_golang/prometheus" 15 15 "github.com/prometheus/client_golang/prometheus/testutil" 16 16 "tailscale.com/client/tailscale/apitype" 17 + "tailscale.com/ipn/ipnstate" 17 18 "tailscale.com/tailcfg" 18 19 ) 19 20 20 21 type fakeLocalClient struct { 21 - whois func(context.Context, string) (*apitype.WhoIsResponse, error) 22 + whois func(context.Context, string) (*apitype.WhoIsResponse, error) 23 + status func(context.Context) (*ipnstate.Status, error) 22 24 } 23 25 24 26 func (c *fakeLocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) { 25 27 return c.whois(ctx, remoteAddr) 28 + } 29 + 30 + func (c *fakeLocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { 31 + return c.status(ctx) 26 32 } 27 33 28 34 func TestLocalTailnetHandler(t *testing.T) { ··· 108 114 } 109 115 } 110 116 }) 117 + } 118 + } 119 + 120 + func TestLocalTailnetTLSHandler(t *testing.T) { 121 + t.Parallel() 122 + 123 + lc := &fakeLocalClient{ 124 + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { 125 + return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "tagged-devices"}, Node: &tailcfg.Node{Tags: []string{"foo"}}}, nil 126 + }, 127 + status: func(_ context.Context) (*ipnstate.Status, error) { 128 + return &ipnstate.Status{Self: &ipnstate.PeerStatus{DNSName: "foo.ts.net."}}, nil 129 + }, 130 + } 131 + be := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 132 + fmt.Fprintln(w, "Hi from the backend.") 133 + }) 134 + px := httptest.NewTLSServer(localTailnetTLSHandler(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), lc, be)) 135 + defer px.Close() 136 + 137 + cli := px.Client() 138 + cli.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { 139 + return http.ErrUseLastResponse 140 + } 141 + 142 + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, px.URL+"/bar", nil) 143 + if err != nil { 144 + t.Fatal(err) 145 + } 146 + resp, err := cli.Do(req) 147 + if err != nil { 148 + t.Fatal(err) 149 + } 150 + defer resp.Body.Close() 151 + if want, got := http.StatusPermanentRedirect, resp.StatusCode; want != got { 152 + t.Fatalf("want status %d, got: %d", want, got) 153 + } 154 + if want, got := "https://foo.ts.net/bar", resp.Header.Get("location"); got != want { 155 + t.Fatalf("want Location %s, got: %s", want, got) 156 + } 157 + 158 + req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, px.URL, nil) 159 + if err != nil { 160 + t.Fatal(err) 161 + } 162 + req.Host = "foo.ts.net" 163 + resp, err = px.Client().Do(req) 164 + if err != nil { 165 + t.Fatal(err) 166 + } 167 + defer resp.Body.Close() 168 + if want, got := http.StatusOK, resp.StatusCode; want != got { 169 + t.Fatalf("want status %d, got: %d", want, got) 111 170 } 112 171 } 113 172