diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index eb403a5e7..004bc39c5 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -1,5 +1,6 @@ import React from "react" import { Footer, Header, IP, State } from "src/components/legacy" +import useAuth, { AuthResponse } from "src/hooks/auth" import useNodeData, { NodeData } from "src/hooks/node-data" import { ReactComponent as ConnectedDeviceIcon } from "src/icons/connected-device.svg" import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg" @@ -19,14 +20,7 @@ export default function App() { return !needsLogin && (data.DebugMode === "login" || data.DebugMode === "full") ? ( -
- {data.DebugMode === "login" ? ( - - ) : ( - - )} -
+ ) : ( // Legacy client UI
@@ -40,7 +34,34 @@ export default function App() { ) } -function LoginView(props: NodeData) { +function WebClient(props: NodeData) { + const { data: auth, loading: loadingAuth, waitOnAuth } = useAuth() + + if (loadingAuth) { + return
Loading...
+ } + + return ( +
+ {props.DebugMode === "full" && auth?.ok ? ( + + ) : ( + + )} +
+
+ ) +} + +function ReadonlyView({ + data, + auth, + waitOnAuth, +}: { + data: NodeData + auth?: AuthResponse + waitOnAuth: () => Promise +}) { return ( <>
@@ -48,14 +69,14 @@ function LoginView(props: NodeData) {
- +
Owned by
{/* TODO(sonia): support tagged node profile view more eloquently */} - {props.Profile.LoginName} + {data.Profile.LoginName}
@@ -64,19 +85,29 @@ function LoginView(props: NodeData) {
- {props.DeviceName} + {data.DeviceName}
-
{props.IP}
+
{data.IP}
- + {data.DebugMode === "full" && ( + + )}
) } -function ManageView(props: NodeData) { +function ManagementView(props: NodeData) { return (
@@ -101,6 +132,7 @@ function ManageView(props: NodeData) { Tailscale is up and running. You can connect to this device from devices in your tailnet by using its name or IP address.

+
) } diff --git a/client/web/src/hooks/auth.ts b/client/web/src/hooks/auth.ts new file mode 100644 index 000000000..1fa26aec5 --- /dev/null +++ b/client/web/src/hooks/auth.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from "react" +import { apiFetch } from "src/api" + +export type AuthResponse = { + ok: boolean + authUrl?: string +} + +// useAuth reports and refreshes Tailscale auth status +// for the web client. +export default function useAuth() { + const [data, setData] = useState() + const [loading, setLoading] = useState(false) + + const loadAuth = useCallback((wait?: boolean) => { + const url = wait ? "/auth?wait=true" : "/auth" + setLoading(true) + return apiFetch(url, "GET") + .then((r) => r.json()) + .then((d) => { + setLoading(false) + setData(d) + }) + .catch((error) => { + setLoading(false) + console.error(error) + }) + }, []) + + useEffect(() => { + loadAuth() + }, []) + + const waitOnAuth = useCallback(() => loadAuth(true), []) + + return { data, loading, waitOnAuth } +} diff --git a/client/web/web.go b/client/web/web.go index 93b0a2600..3827f71c7 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -5,8 +5,10 @@ package web import ( + "bytes" "context" "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -19,6 +21,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" "time" "github.com/gorilla/csrf" @@ -58,7 +61,8 @@ type Server struct { // // The map provides a lookup of the session by cookie value // (browserSession.ID => browserSession). - browserSessions sync.Map + browserSessions sync.Map + controlServerURL atomic.Value // access through getControlServerURL } const ( @@ -77,25 +81,26 @@ type browserSession struct { // ID is the unique identifier for the session. // It is passed in the user's "TS-Web-Session" browser cookie. ID string - SrcNode tailcfg.StableNodeID + SrcNode tailcfg.NodeID SrcUser tailcfg.UserID - AuthURL string // control server URL for user to authenticate the session - Authenticated time.Time // when zero, authentication not complete + AuthURL string // control server URL for user to authenticate the session + Created time.Time + Authenticated bool } // isAuthorized reports true if the given session is authorized // to be used by its associated user to access the full management // web client. // -// isAuthorized is true only when s.Authenticated is non-zero -// (i.e. the user has authenticated the session) and the session -// is not expired. -// 2023-10-05: Sessions expire by default after 30 days. +// isAuthorized is true only when s.Authenticated is true (i.e. +// the user has authenticated the session) and the session is not +// expired. +// 2023-10-05: Sessions expire by default 30 days after creation. func (s *browserSession) isAuthorized() bool { switch { case s == nil: return false - case s.Authenticated.IsZero(): + case !s.Authenticated: return false // awaiting auth case s.isExpired(): // TODO: add time field to server? return false // expired @@ -104,20 +109,20 @@ func (s *browserSession) isAuthorized() bool { } // isExpired reports true if s is expired. -// 2023-10-05: Sessions expire by default after 30 days. -// If s.Authenticated is zero, isExpired reports false. +// 2023-10-05: Sessions expire by default 30 days after creation. func (s *browserSession) isExpired() bool { - return !s.Authenticated.IsZero() && s.Authenticated.Before(time.Now().Add(-sessionCookieExpiry)) // TODO: add time field to server? + return !s.Created.IsZero() && time.Now().After(s.expires()) // TODO: add time field to server? +} + +// expires reports when the given session expires. +func (s *browserSession) expires() time.Time { + return s.Created.Add(sessionCookieExpiry) } // ServerOpts contains options for constructing a new Server. type ServerOpts struct { DevMode bool - // LoginOnly indicates that the server should only serve the minimal - // login client and not the full web client. - LoginOnly bool - // CGIMode indicates if the server is running as a CGI script. CGIMode bool @@ -223,7 +228,11 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo return true case strings.HasPrefix(r.URL.Path, "/api/"): // All other /api/ endpoints require a valid browser session. - session, err := s.getTailscaleBrowserSession(r) + // + // TODO(sonia): s.getTailscaleBrowserSession calls whois again, + // should try and use the above call instead of running another + // localapi request. + session, _, err := s.getTailscaleBrowserSession(r) if err != nil || !session.isAuthorized() { http.Error(w, "no valid session", http.StatusUnauthorized) return false @@ -275,6 +284,7 @@ var ( errNotUsingTailscale = errors.New("not-using-tailscale") errTaggedSource = errors.New("tagged-source") errNotOwner = errors.New("not-owner") + errFailedAuth = errors.New("failed-auth") ) // getTailscaleBrowserSession retrieves the browser session associated with @@ -296,70 +306,122 @@ var ( // If no error is returned, the browserSession is always non-nil. // getTailscaleBrowserSession does not check whether the session has been // authorized by the user. Callers can use browserSession.isAuthorized. -func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, error) { +// +// The WhoIsResponse is always populated, with a non-nil Node and UserProfile, +// unless getTailscaleBrowserSession reports errNotUsingTailscale. +func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) { whoIs, err := s.lc.WhoIs(r.Context(), r.RemoteAddr) switch { case err != nil: - return nil, errNotUsingTailscale + return nil, nil, errNotUsingTailscale case whoIs.Node.IsTagged(): - return nil, errTaggedSource + return nil, whoIs, errTaggedSource } - srcNode := whoIs.Node.StableID + srcNode := whoIs.Node.ID srcUser := whoIs.UserProfile.ID status, err := s.lc.StatusWithoutPeers(r.Context()) switch { case err != nil: - return nil, err + return nil, whoIs, err case status.Self == nil: - return nil, errors.New("missing self node in tailscale status") + return nil, whoIs, errors.New("missing self node in tailscale status") case !status.Self.IsTagged() && status.Self.UserID != srcUser: - return nil, errNotOwner + return nil, whoIs, errNotOwner } cookie, err := r.Cookie(sessionCookieName) if errors.Is(err, http.ErrNoCookie) { - return nil, errNoSession + return nil, whoIs, errNoSession } else if err != nil { - return nil, err + return nil, whoIs, err } v, ok := s.browserSessions.Load(cookie.Value) if !ok { - return nil, errNoSession + return nil, whoIs, errNoSession } session := v.(*browserSession) if session.SrcNode != srcNode || session.SrcUser != srcUser { // In this case the browser cookie is associated with another tailscale node. // Maybe the source browser's machine was logged out and then back in as a different node. // Return errNoSession because there is no session for this user. - return nil, errNoSession + return nil, whoIs, errNoSession } else if session.isExpired() { // Session expired, remove from session map and return errNoSession. s.browserSessions.Delete(session.ID) - return nil, errNoSession + return nil, whoIs, errNoSession } - return session, nil + return session, whoIs, nil } type authResponse struct { OK bool `json:"ok"` // true when user has valid auth session AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take - Error string `json:"error,omitempty"` // filled when Ok is false } func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) { + if r.Method != httpm.GET { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } var resp authResponse - session, err := s.getTailscaleBrowserSession(r) + session, whois, err := s.getTailscaleBrowserSession(r) switch { case err != nil && !errors.Is(err, errNoSession): - resp = authResponse{OK: false, Error: err.Error()} + http.Error(w, err.Error(), http.StatusUnauthorized) + return case session == nil: - // TODO(tailscale/corp#14335): Create a new auth path from control, - // and store back to s.browserSessions and request cookie. + // Create a new session. + d, err := s.getOrAwaitAuthURL(r.Context(), "", whois.Node.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + sid, err := s.newSessionID() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + session := &browserSession{ + ID: sid, + SrcNode: whois.Node.ID, + SrcUser: whois.UserProfile.ID, + AuthURL: d.URL, + Created: time.Now(), + } + s.browserSessions.Store(sid, session) + // Set the cookie on browser. + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sid, + Raw: sid, + Path: "/", + Expires: session.expires(), + }) + resp = authResponse{OK: false, AuthURL: d.URL} case !session.isAuthorized(): - // TODO(tailscale/corp#14335): Check on the session auth path status from control, - // and store back to s.browserSessions. + if r.URL.Query().Get("wait") == "true" { + // Client requested we block until user completes auth. + d, err := s.getOrAwaitAuthURL(r.Context(), session.AuthURL, whois.Node.ID) + if errors.Is(err, errFailedAuth) { + http.Error(w, "user is unauthorized", http.StatusUnauthorized) + s.browserSessions.Delete(session.ID) // clean up the failed session + return + } else if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if d.Complete { + session.Authenticated = d.Complete + s.browserSessions.Store(session.ID, session) + } + } + if session.isAuthorized() { + resp = authResponse{OK: true} + } else { + resp = authResponse{OK: false, AuthURL: session.AuthURL} + } default: resp = authResponse{OK: true} } @@ -371,6 +433,84 @@ func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") } +func (s *Server) newSessionID() (string, error) { + raw := make([]byte, 16) + for i := 0; i < 5; i++ { + if _, err := rand.Read(raw); err != nil { + return "", err + } + cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw) + if _, ok := s.browserSessions.Load(cookie); !ok { + return cookie, nil + } + } + return "", errors.New("too many collisions generating new session; please refresh page") +} + +func (s *Server) getControlServerURL(ctx context.Context) (string, error) { + if v := s.controlServerURL.Load(); v != nil { + v, _ := v.(string) + return v, nil + } + prefs, err := s.lc.GetPrefs(ctx) + if err != nil { + return "", err + } + url := prefs.ControlURLOrDefault() + s.controlServerURL.Store(url) + return url, nil +} + +// getOrAwaitAuthURL connects to the control server for user auth, +// with the following behavior: +// +// 1. If authURL is provided empty, a new auth URL is created on the +// control server and reported back here, which can then be used +// to redirect the user on the frontend. +// 2. If authURL is provided non-empty, the connection to control +// blocks until the user has completed the URL. getOrAwaitAuthURL +// terminates when either the URL is completed, or ctx is canceled. +func (s *Server) getOrAwaitAuthURL(ctx context.Context, authURL string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { + serverURL, err := s.getControlServerURL(ctx) + if err != nil { + return nil, err + } + type data struct { + ID string + Src tailcfg.NodeID + } + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(data{ + ID: strings.TrimPrefix(authURL, serverURL), + Src: src, + }); err != nil { + return nil, err + } + url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client" + req, err := http.NewRequestWithContext(ctx, "POST", url, &b) + if err != nil { + return nil, err + } + resp, err := s.lc.DoLocalRequest(req) + if err != nil { + return nil, err + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + // User completed auth, but control server reported + // them unauthorized to manage this node. + return nil, errFailedAuth + } else if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed request: %s", body) + } + var authResp *tailcfg.WebClientAuthResponse + if err := json.Unmarshal(body, &authResp); err != nil { + return nil, err + } + return authResp, nil +} + // serveAPI serves requests for the web client api. // It should only be called by Server.ServeHTTP, via Server.apiHandler, // which protects the handler using gorilla csrf. diff --git a/client/web/web_test.go b/client/web/web_test.go index b44aeb4ef..60fd29287 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" "time" @@ -18,6 +19,7 @@ import ( "github.com/google/go-cmp/cmp" "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/net/memnet" "tailscale.com/tailcfg" @@ -151,15 +153,15 @@ func TestGetTailscaleBrowserSession(t *testing.T) { tags := views.SliceOf([]string{"tag:server"}) tailnetNodes := map[string]*apitype.WhoIsResponse{ userANodeIP: { - Node: &tailcfg.Node{StableID: "Node1"}, + Node: &tailcfg.Node{ID: 1}, UserProfile: userA, }, userBNodeIP: { - Node: &tailcfg.Node{StableID: "Node2"}, + Node: &tailcfg.Node{ID: 2}, UserProfile: userB, }, taggedNodeIP: { - Node: &tailcfg.Node{StableID: "Node3", Tags: tags.AsSlice()}, + Node: &tailcfg.Node{ID: 3, Tags: tags.AsSlice()}, }, } @@ -174,21 +176,24 @@ func TestGetTailscaleBrowserSession(t *testing.T) { // Add some browser sessions to cache state. userASession := &browserSession{ ID: "cookie1", - SrcNode: "Node1", + SrcNode: 1, SrcUser: userA.ID, - Authenticated: time.Time{}, // not yet authenticated + Created: time.Now(), + Authenticated: false, // not yet authenticated } userBSession := &browserSession{ ID: "cookie2", - SrcNode: "Node2", + SrcNode: 2, SrcUser: userB.ID, - Authenticated: time.Now().Add(-2 * sessionCookieExpiry), // expired + Created: time.Now().Add(-2 * sessionCookieExpiry), + Authenticated: true, // expired } userASessionAuthorized := &browserSession{ ID: "cookie3", - SrcNode: "Node1", + SrcNode: 1, SrcUser: userA.ID, - Authenticated: time.Now(), // authenticated and not expired + Created: time.Now(), + Authenticated: true, // authenticated and not expired } s.browserSessions.Store(userASession.ID, userASession) s.browserSessions.Store(userBSession.ID, userBSession) @@ -281,7 +286,7 @@ func TestGetTailscaleBrowserSession(t *testing.T) { if tt.cookie != "" { r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) } - session, err := s.getTailscaleBrowserSession(r) + session, _, err := s.getTailscaleBrowserSession(r) if !errors.Is(err, tt.wantError) { t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err) } @@ -322,9 +327,10 @@ func TestAuthorizeRequest(t *testing.T) { validCookie := "ts-cookie" s.browserSessions.Store(validCookie, &browserSession{ ID: validCookie, - SrcNode: remoteNode.Node.StableID, + SrcNode: remoteNode.Node.ID, SrcUser: user.ID, - Authenticated: time.Now(), + Created: time.Now(), + Authenticated: true, }) tests := []struct { @@ -391,6 +397,149 @@ func TestAuthorizeRequest(t *testing.T) { } } +func TestServeTailscaleAuth(t *testing.T) { + user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} + self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID} + remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user} + remoteIP := "100.100.100.101" + + lal := memnet.Listen("local-tailscaled.sock:80") + defer lal.Close() + localapi := mockLocalAPI(t, + map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, + func() *ipnstate.PeerStatus { return self }, + ) + defer localapi.Close() + go localapi.Serve(lal) + + s := &Server{ + lc: &tailscale.LocalClient{Dial: lal.Dial}, + tsDebugMode: "full", + } + + successCookie := "ts-cookie-success" + s.browserSessions.Store(successCookie, &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: time.Now(), + AuthURL: testControlURL + testAuthPathSuccess, + }) + failureCookie := "ts-cookie-failure" + s.browserSessions.Store(failureCookie, &browserSession{ + ID: failureCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: time.Now(), + AuthURL: testControlURL + testAuthPathError, + }) + expiredCookie := "ts-cookie-expired" + s.browserSessions.Store(expiredCookie, &browserSession{ + ID: expiredCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: time.Now().Add(-sessionCookieExpiry * 2), + AuthURL: testControlURL + "/a/old-auth-url", + }) + + tests := []struct { + name string + cookie string + query string + wantStatus int + wantResp *authResponse + wantNewCookie bool // new cookie generated + }{ + { + name: "new-session-created", + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + }, { + name: "query-existing-incomplete-session", + cookie: successCookie, + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess}, + }, { + name: "transition-to-successful-session", + cookie: successCookie, + // query "wait" indicates the FE wants to make + // local api call to wait until session completed. + query: "wait=true", + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: true}, + }, { + name: "query-existing-complete-session", + cookie: successCookie, + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: true}, + }, { + name: "transition-to-failed-session", + cookie: failureCookie, + query: "wait=true", + wantStatus: http.StatusUnauthorized, + wantResp: nil, + }, { + name: "failed-session-cleaned-up", + cookie: failureCookie, + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + }, { + name: "expired-cookie-gets-new-session", + cookie: expiredCookie, + wantStatus: http.StatusOK, + wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/auth", nil) + r.URL.RawQuery = tt.query + r.RemoteAddr = remoteIP + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) + w := httptest.NewRecorder() + s.serveTailscaleAuth(w, r) + res := w.Result() + defer res.Body.Close() + if gotStatus := res.StatusCode; tt.wantStatus != gotStatus { + t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus) + } + var gotResp *authResponse + if res.StatusCode == http.StatusOK { + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &gotResp); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(gotResp, tt.wantResp) { + t.Errorf("wrong response; want=%v, got=%v", tt.wantResp, gotResp) + } + var gotCookie bool + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName { + gotCookie = true + break + } + } + if gotCookie != tt.wantNewCookie { + t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie) + } + }) + } +} + +var ( + testControlURL = "http://localhost:8080" + testAuthPath = "/a/12345" + testAuthPathSuccess = "/a/will-succeed" + testAuthPathError = "/a/will-error" +) + // mockLocalAPI constructs a test localapi handler that can be used // to simulate localapi responses without a functioning tailnet. // @@ -423,6 +572,43 @@ func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self fu } w.Header().Set("Content-Type", "application/json") return + case "/localapi/v0/prefs": + prefs := ipn.Prefs{ControlURL: testControlURL} + if err := json.NewEncoder(w).Encode(prefs); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth + type reqData struct { + ID string + Src tailcfg.NodeID + } + var data reqData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if data.Src == 0 { + http.Error(w, "missing Src node", http.StatusBadRequest) + return + } + var resp *tailcfg.WebClientAuthResponse + if data.ID == "" { + resp = &tailcfg.WebClientAuthResponse{URL: testControlURL + testAuthPath} + } else if data.ID == testAuthPathSuccess { + resp = &tailcfg.WebClientAuthResponse{Complete: true} + } else if data.ID == testAuthPathError { + http.Error(w, "authenticated as wrong user", http.StatusUnauthorized) + return + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return default: t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) } diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index f9b040e8f..4151d94e7 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -2179,7 +2179,6 @@ func (h *Handler) serveDebugWebClient(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(resp.StatusCode) } func defBool(a string, def bool) bool {