diff --git a/go.mod b/go.mod index 9137df0..00a746d 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.2 require ( github.com/google/go-cmp v0.7.0 + github.com/lstoll/oidc v1.0.0-beta.4.0.20250106123456-6ffce62670fe github.com/oklog/run v1.1.0 github.com/prometheus/client_golang v1.21.1 github.com/prometheus/common v0.63.0 @@ -56,7 +57,6 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/lstoll/oidc v1.0.0-beta.4.0.20250106123456-6ffce62670fe // indirect github.com/mdlayher/genetlink v1.3.2 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/sdnotify v1.0.0 // indirect diff --git a/main.go b/tsproxy.go similarity index 77% rename from main.go rename to tsproxy.go index ec99788..a5112a5 100644 --- a/main.go +++ b/tsproxy.go @@ -30,7 +30,6 @@ import ( "github.com/tailscale/hujson" "tailscale.com/client/local" "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn/ipnstate" "tailscale.com/tsnet" tslogger "tailscale.com/types/logger" ) @@ -69,7 +68,7 @@ type upstream struct { Name string Backend string Prometheus bool - Funnel *funnelConfig `json:"funnel,omitempty"` + Funnel *funnelConfig } type funnelConfig struct { @@ -265,7 +264,7 @@ func tsproxy(ctx context.Context) error { return fmt.Errorf("tailscale: wait for tsnet %s to be ready: %w", upstream.Name, err) } - srv = &http.Server{Handler: instrument(localTailnetHandler(log, lc, proxy))} + srv = &http.Server{Handler: instrument(redirect(st.Self.DNSName, false, tailnet(log, lc, proxy)))} ln, err := ts.Listen("tcp", ":80") if err != nil { return fmt.Errorf("tailscale: listen for %s on port 80: %w", upstream.Name, err) @@ -287,13 +286,14 @@ func tsproxy(ctx context.Context) error { { var srv *http.Server g.Add(func() error { - _, err := ts.Up(ctx) + st, err := ts.Up(ctx) if err != nil { return fmt.Errorf("tailscale: wait for tsnet %s to be ready: %w", upstream.Name, err) } + srv = &http.Server{ TLSConfig: &tls.Config{GetCertificate: lc.GetCertificate}, - Handler: instrument(localTailnetTLSHandler(log, lc, proxy)), + Handler: instrument(redirect(st.Self.DNSName, true, tailnet(log, lc, proxy))), } ln, err := ts.Listen("tcp", ":443") @@ -304,37 +304,38 @@ func tsproxy(ctx context.Context) error { }, func(_ error) { if srv != nil { if err := srv.Close(); err != nil { - log.Error("TLS server shutdown", lerr(err)) + log.Error("server shutdown", lerr(err)) } } cancel() }) } if funnel := upstream.Funnel; funnel != nil { - if !funnel.Insecure && funnel.Issuer == "" { - return fmt.Errorf("upstream %s: funnel must set issuer or insecure", upstream.Name) - } { var srv *http.Server g.Add(func() error { - _, err := ts.Up(ctx) + st, err := ts.Up(ctx) if err != nil { return fmt.Errorf("tailscale: wait for tsnet %s to be ready: %w", upstream.Name, err) } - srv = &http.Server{} - if funnel.Issuer != "" { - handler, err := oidcFunnelHandler(ctx, log, lc, funnel, proxy) + var handler http.Handler + switch { + case funnel.Insecure: + handler = insecureFunnel(log, lc, proxy) + case funnel.Issuer != "": + redir := &url.URL{Scheme: "https", Host: strings.TrimSuffix(st.Self.DNSName, "."), Path: ".oidc-callback"} + wrapper, err := middleware.NewFromDiscovery(ctx, nil, funnel.Issuer, funnel.ClientID, funnel.ClientSecret, redir.String()) if err != nil { - return fmt.Errorf("oidc: %w", err) + return fmt.Errorf("oidc middleware for %s: %w", upstream.Name, err) } - srv.Handler = handler - } else if funnel.Insecure { - srv.Handler = insecureFunnelHandler(log, lc, proxy) - } else { - panic("funnel misconfigured") + wrapper.OAuth2Config.Scopes = append(wrapper.OAuth2Config.Scopes, oidc.ScopeProfile) + + handler = wrapper.Wrap(oidcFunnel(log, lc, proxy)) + default: + return fmt.Errorf("upstream %s must set funnel.insecure or funnel.issuer", upstream.Name) } - srv.Handler = instrument(srv.Handler) + srv = &http.Server{Handler: instrument(redirect(st.Self.DNSName, true, handler))} ln, err := ts.ListenFunnel("tcp", ":443", tsnet.FunnelOnly()) if err != nil { @@ -344,7 +345,7 @@ func tsproxy(ctx context.Context) error { }, func(_ error) { if srv != nil { if err := srv.Close(); err != nil { - log.Error("TLS server shutdown", lerr(err)) + log.Error("server shutdown", lerr(err)) } } cancel() @@ -356,8 +357,26 @@ func tsproxy(ctx context.Context) error { return g.Run() } -// localTailnetHandler serves plain-HTTP on the local tailnet. -func localTailnetHandler(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { +func redirect(fqdn string, forceSSL bool, next http.Handler) http.Handler { + if fqdn == "" { + panic("redirect: fqdn cannot be empty") + } + fqdn = strings.TrimSuffix(fqdn, ".") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if forceSSL && r.TLS == nil { + http.Redirect(w, r, fmt.Sprintf("https://%s%s", fqdn, r.RequestURI), http.StatusPermanentRedirect) + return + } + + if r.TLS != nil && strings.TrimSuffix(r.Host, ".") != fqdn { + http.Redirect(w, r, fmt.Sprintf("https://%s%s", fqdn, r.RequestURI), http.StatusPermanentRedirect) + return + } + next.ServeHTTP(w, r) + }) +} + +func tailnet(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { whois, err := tsWhoIs(lc, r) if err != nil { @@ -379,74 +398,41 @@ func localTailnetHandler(logger *slog.Logger, lc tailscaleLocalClient, next http }) } -// localTailnetTLSHandler serves HTTPS on the local tailnet. -func localTailnetTLSHandler(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { - var ( - handler = localTailnetHandler(logger, lc, next) - dnsName string - ) +func insecureFunnel(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.TLS == nil { - panic("tailnet handler wants tls") - } - - if dnsName == "" { - st, err := lc.StatusWithoutPeers(r.Context()) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - logger.ErrorContext(r.Context(), "tailscale status", slog.Any("err", err)) - return - } - dnsName = strings.TrimSuffix(st.Self.DNSName, ".") + whois, err := tsWhoIs(lc, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + logger.ErrorContext(r.Context(), "tailscale whois", lerr(err)) + return } - - if strings.TrimSuffix(r.Host, ".") != dnsName { - http.Redirect(w, r, fmt.Sprintf("https://%s%s", dnsName, r.RequestURI), http.StatusPermanentRedirect) + if !whois.Node.IsTagged() { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + logger.ErrorContext(r.Context(), "funnel handler got request from non-tagged node") return } - handler.ServeHTTP(w, r) + next.ServeHTTP(w, r) }) } -// insecureFunnelHandler handles HTTPS requests coming from Tailscale Funnel nodes. -// This is marked insecure because the upstream is exposed to the public Internet. -// The upstream is responsible for implementing authentication. -func insecureFunnelHandler(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { - return localTailnetTLSHandler(logger, lc, next) -} - -// oidcFunnelHandlers serves Funnel requests, requiring authentication via the configured OIDC issuer. -func oidcFunnelHandler(ctx context.Context, logger *slog.Logger, lc tailscaleLocalClient, cfg *funnelConfig, next http.Handler) (http.Handler, error) { - st, err := lc.StatusWithoutPeers(ctx) - if err != nil { - return nil, fmt.Errorf("tailscale status: %w", err) - } - - redir := &url.URL{Scheme: "https", Path: ".oidc-callback"} - redir.Host = strings.TrimSuffix(st.Self.DNSName, ".") - - wrapper, err := middleware.NewFromDiscovery(ctx, nil, cfg.Issuer, cfg.ClientID, cfg.ClientSecret, redir.String()) - if err != nil { - return nil, fmt.Errorf("oidc middleware: %w", err) - } - wrapper.OAuth2Config.Scopes = append(wrapper.OAuth2Config.Scopes, oidc.ScopeProfile) - - return wrapper.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.TLS == nil { - panic("oidc handler wants tls") - } - - _, err := tsWhoIs(lc, r) +func oidcFunnel(logger *slog.Logger, lc tailscaleLocalClient, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + whois, err := tsWhoIs(lc, r) if err != nil { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) logger.ErrorContext(r.Context(), "tailscale whois", lerr(err)) return } + if !whois.Node.IsTagged() { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + logger.ErrorContext(r.Context(), "funnel handler got request from non-tagged node") + return + } tok := middleware.IDJWTFromContext(r.Context()) if tok == nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) logger.ErrorContext(r.Context(), "jwt token missing") return } @@ -467,13 +453,12 @@ func oidcFunnelHandler(ctx context.Context, logger *slog.Logger, lc tailscaleLoc req.Header.Set("X-Webauth-User", email) req.Header.Set("X-Webauth-Name", name) - next.ServeHTTP(w, r) - })), nil + next.ServeHTTP(w, req) + }) } type tailscaleLocalClient interface { WhoIs(context.Context, string) (*apitype.WhoIsResponse, error) - StatusWithoutPeers(context.Context) (*ipnstate.Status, error) } func tsWhoIs(lc tailscaleLocalClient, r *http.Request) (*apitype.WhoIsResponse, error) { diff --git a/tsproxy_test.go b/tsproxy_test.go index e575c91..2c748eb 100644 --- a/tsproxy_test.go +++ b/tsproxy_test.go @@ -8,165 +8,305 @@ import ( "log/slog" "net/http" "net/http/httptest" + "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" ) type fakeLocalClient struct { - whois func(context.Context, string) (*apitype.WhoIsResponse, error) - status func(context.Context) (*ipnstate.Status, error) + whois func(context.Context, string) (*apitype.WhoIsResponse, error) } func (c *fakeLocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) { + if c.whois == nil { + return nil, errors.New("not implemented") + } return c.whois(ctx, remoteAddr) } -func (c *fakeLocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { - return c.status(ctx) -} - -func TestLocalTailnetHandler(t *testing.T) { +func TestTSHandlers(t *testing.T) { t.Parallel() + logger := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) + for _, tc := range []struct { name string whois func(context.Context, string) (*apitype.WhoIsResponse, error) - want int + handler func(*slog.Logger, tailscaleLocalClient, http.Handler) http.Handler + wantNext bool + wantStatus int wantHeaders map[string]string + wantBody string }{ { - name: "tailscale whois error", + name: "tailnet: tailscale whois error", + handler: tailnet, whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { return nil, errors.New("whois error") }, - want: http.StatusInternalServerError, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", }, { - name: "tailscale whois no profile", + name: "tailnet: tailscale whois no profile", + handler: tailnet, whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { - return &apitype.WhoIsResponse{}, nil + return &apitype.WhoIsResponse{Node: &tailcfg.Node{Tags: []string{"foo"}}}, nil }, - want: http.StatusInternalServerError, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", }, { - name: "tailscale whois no node", + name: "tailnet: tailscale whois no node", + handler: tailnet, whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "login"}}, nil }, - want: http.StatusInternalServerError, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", }, { - name: "tailscale whois ok (tagged node)", + name: "tailnet: tailscale whois ok (tagged node)", + handler: tailnet, whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "tagged-devices"}, Node: &tailcfg.Node{Tags: []string{"foo"}}}, nil }, - want: http.StatusOK, + wantNext: true, + wantStatus: http.StatusOK, + wantBody: "OK", + wantHeaders: map[string]string{ + "X-Webauth-User": "", + "X-Webauth-Name": "", + }, }, { - name: "tailscale whois ok (user)", + name: "tailnet: tailscale whois ok (user)", + handler: tailnet, whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "login", DisplayName: "name"}, Node: &tailcfg.Node{Name: "login.ts.net"}}, nil }, - want: http.StatusOK, + wantNext: true, + wantStatus: http.StatusOK, + wantBody: "OK", wantHeaders: map[string]string{ "X-Webauth-User": "login", "X-Webauth-Name": "name", }, }, + { + name: "insecure: tailscale whois error", + handler: insecureFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return nil, errors.New("whois error") + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", + }, + { + name: "insecure: tailscale whois no profile", + handler: insecureFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{Node: &tailcfg.Node{Tags: []string{"foo"}}}, nil + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", + }, + { + name: "insure: tailscale whois no node", + handler: insecureFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "login"}}, nil + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", + }, + { + name: "insecure: tagged node", + handler: insecureFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "tagged-devices"}, Node: &tailcfg.Node{Tags: []string{"foo"}}}, nil + }, + wantNext: true, + wantStatus: http.StatusOK, + wantBody: "OK", + wantHeaders: map[string]string{ + "X-Webauth-User": "", + "X-Webauth-Name": "", + }, + }, + { + name: "insecure: user node", + handler: insecureFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "login", DisplayName: "name"}, Node: &tailcfg.Node{Name: "login.ts.net"}}, nil + }, + wantStatus: http.StatusUnauthorized, + wantBody: "Unauthorized", + }, + { + name: "oidc: tailscale whois error", + handler: oidcFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return nil, errors.New("whois error") + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", + }, + { + name: "oidc: tailscale whois no profile", + handler: oidcFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{Node: &tailcfg.Node{Tags: []string{"foo"}}}, nil + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", + }, + { + name: "oidc: tailscale whois no node", + handler: oidcFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "login"}}, nil + }, + wantStatus: http.StatusInternalServerError, + wantBody: "Internal Server Error", + }, + { + name: "oidc: user node", + handler: oidcFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "login", DisplayName: "name"}, Node: &tailcfg.Node{Name: "login.ts.net"}}, nil + }, + wantStatus: http.StatusUnauthorized, + wantBody: "Unauthorized", + }, + { + name: "oidc: tagged node", + handler: oidcFunnel, + whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { + return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "tagged-devices"}, Node: &tailcfg.Node{Tags: []string{"tag:ingress"}}}, nil + }, + wantStatus: http.StatusUnauthorized, + wantBody: "Unauthorized", + }, } { - tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - lc := &fakeLocalClient{whois: tc.whois} - be := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - for k, v := range r.Header { - w.Header().Set(k, v[0]) - } - fmt.Fprintln(w, "Hi from the backend.") - }) - px := httptest.NewServer(localTailnetHandler(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), lc, be)) - defer px.Close() - resp, err := http.Get(px.URL) + var nextReq *http.Request + h := tc.handler(logger, &fakeLocalClient{whois: tc.whois}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextReq = r + fmt.Fprintf(w, "OK") + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://example.com/path", nil)) + resp := w.Result() + + if want, got := tc.wantStatus, resp.StatusCode; want != got { + t.Errorf("want status %d, got: %d", want, got) + } + + body, err := io.ReadAll(resp.Body) if err != nil { t.Fatal(err) } - defer resp.Body.Close() - - if want, got := tc.want, resp.StatusCode; want != got { - t.Errorf("want status %d, got: %d", want, got) + if !strings.Contains(string(body), tc.wantBody) { + t.Errorf("want body %q, got: %q", tc.wantBody, string(body)) } - if tc.wantHeaders == nil { - tc.wantHeaders = map[string]string{ - "X-Webauth-User": "", - "X-Webauth-Name": "", - } + if tc.wantNext && nextReq == nil { + t.Fatalf("next handler not called") } for k, want := range tc.wantHeaders { - if got := resp.Header.Get(k); got != want { - t.Errorf("want header %s %s, got: %s", k, want, got) + if got := nextReq.Header.Get(k); got != want { + t.Errorf("want header %s = %s, got: %s", k, want, got) } } }) } } -func TestLocalTailnetTLSHandler(t *testing.T) { +func TestRedirectHandler(t *testing.T) { t.Parallel() - lc := &fakeLocalClient{ - whois: func(_ context.Context, _ string) (*apitype.WhoIsResponse, error) { - return &apitype.WhoIsResponse{UserProfile: &tailcfg.UserProfile{LoginName: "tagged-devices"}, Node: &tailcfg.Node{Tags: []string{"foo"}}}, nil + for _, tc := range []struct { + name string + forceSSL bool + fqdn string + request *http.Request + wantNext bool + wantStatus int + wantLocation string + }{ + { + name: "forceSSL: redirect", + forceSSL: true, + fqdn: "http://example.com", + request: httptest.NewRequest("", "/path", nil), + wantStatus: http.StatusPermanentRedirect, + wantLocation: "https://example.com/path", }, - status: func(_ context.Context) (*ipnstate.Status, error) { - return &ipnstate.Status{Self: &ipnstate.PeerStatus{DNSName: "foo.ts.net."}}, nil + { + name: "forceSSL: ok", + forceSSL: true, + fqdn: "example.com", + request: httptest.NewRequest("", "https://example.com/path", nil), + wantNext: true, + wantStatus: http.StatusOK, }, - } - be := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Hi from the backend.") - }) - px := httptest.NewTLSServer(localTailnetTLSHandler(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), lc, be)) - defer px.Close() - - cli := px.Client() - cli.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - } + { + name: "fqdn: redirect", + fqdn: "example.ts.net", + request: httptest.NewRequest("", "https://example/path", nil), + wantStatus: http.StatusPermanentRedirect, + wantLocation: "https://example.ts.net/path", + }, + { + name: "fqdn: ok", + fqdn: "example.ts.net", + request: httptest.NewRequest("", "https://example.ts.net/path", nil), + wantNext: true, + wantStatus: http.StatusOK, + }, + { + name: "fqdn: ok (not tls)", + fqdn: "example.ts.net", + request: httptest.NewRequest("", "/path", nil), + wantNext: true, + wantStatus: http.StatusOK, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, px.URL+"/bar", nil) - if err != nil { - t.Fatal(err) - } - resp, err := cli.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if want, got := http.StatusPermanentRedirect, resp.StatusCode; want != got { - t.Fatalf("want status %d, got: %d", want, got) - } - if want, got := "https://foo.ts.net/bar", resp.Header.Get("location"); got != want { - t.Fatalf("want Location %s, got: %s", want, got) - } + var nextReq *http.Request + h := redirect(tc.fqdn, tc.forceSSL, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextReq = r + fmt.Fprintf(w, "OK") + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, tc.request) + resp := w.Result() - req, err = http.NewRequestWithContext(t.Context(), http.MethodGet, px.URL, nil) - if err != nil { - t.Fatal(err) - } - req.Host = "foo.ts.net" - resp, err = px.Client().Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if want, got := http.StatusOK, resp.StatusCode; want != got { - t.Fatalf("want status %d, got: %d", want, got) + if want, got := tc.wantStatus, resp.StatusCode; want != got { + t.Errorf("want status %d, got: %d", want, got) + } + + if tc.wantNext && nextReq == nil { + t.Fatalf("next handler not called") + } + if !tc.wantNext && nextReq != nil { + t.Fatalf("next handler was called") + } + if nextReq != nil { + if want, got := tc.wantLocation, nextReq.Header.Get("Location"); got != want { + t.Errorf("want Location header %s, got: %s", want, got) + } + } + }) } }