From f880c77df0fb9cd7dc5cfa6e7a930ea8b03fb4e6 Mon Sep 17 00:00:00 2001 From: Will Norris Date: Thu, 16 Nov 2023 17:23:35 -0800 Subject: client/web: split login from nodeUpdate This creates a new /api/up endpoint which is exposed in the login client, and is solely focused on logging in. Login has been removed from the nodeUpdate endpoint. This also adds support in the LoginClientView for a stopped node that just needs to reconnect, but not necessarily reauthenticate. This follows the same pattern in `tailscale up` of just setting the WantRunning user pref. Updates tailscale/corp#14335 Signed-off-by: Will Norris --- client/web/src/components/app.tsx | 11 +- .../web/src/components/views/login-client-view.tsx | 65 ----------- client/web/src/components/views/login-view.tsx | 103 ++++++++++++++++++ client/web/src/hooks/node-data.ts | 6 - client/web/web.go | 121 +++++++++++---------- 5 files changed, 173 insertions(+), 133 deletions(-) delete mode 100644 client/web/src/components/views/login-client-view.tsx create mode 100644 client/web/src/components/views/login-view.tsx diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index b90f993b7..03fe4cd95 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -3,7 +3,7 @@ import React, { useEffect } from "react" import LoginToggle from "src/components/login-toggle" import DeviceDetailsView from "src/components/views/device-details-view" import HomeView from "src/components/views/home-view" -import LoginClientView from "src/components/views/login-client-view" +import LoginView from "src/components/views/login-view" import SSHView from "src/components/views/ssh-view" import { UpdatingView } from "src/components/views/updating-view" import useAuth, { AuthResponse } from "src/hooks/auth" @@ -39,12 +39,11 @@ function WebClient({ return !data ? (
Loading...
- ) : data.Status === "NeedsLogin" || data.Status === "NoState" ? ( + ) : data.Status === "NeedsLogin" || + data.Status === "NoState" || + data.Status === "Stopped" ? ( // Client not on a tailnet, render login. - updateNode({ Reauthenticate: true })} - /> + ) : ( // Otherwise render the new web client. <> diff --git a/client/web/src/components/views/login-client-view.tsx b/client/web/src/components/views/login-client-view.tsx deleted file mode 100644 index 9133a6715..000000000 --- a/client/web/src/components/views/login-client-view.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react" -import { NodeData } from "src/hooks/node-data" -import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg" - -/** - * LoginClientView is rendered when the client is not authenticated - * to a tailnet. - */ -export default function LoginClientView({ - data, - onLoginClick, -}: { - data: NodeData - onLoginClick: () => void -}) { - return ( -
- - {data.IP ? ( - <> -
-

- Your device's key has expired. Reauthenticate this device by - logging in again, or{" "} - - learn more - - . -

-
- - - ) : ( - <> -
-

Log in

-

- Get started by logging in to your Tailscale network. - Or, learn more at{" "} - - tailscale.com - - . -

-
- - - )} -
- ) -} diff --git a/client/web/src/components/views/login-view.tsx b/client/web/src/components/views/login-view.tsx new file mode 100644 index 000000000..62a188691 --- /dev/null +++ b/client/web/src/components/views/login-view.tsx @@ -0,0 +1,103 @@ +import React, { useCallback } from "react" +import { apiFetch } from "src/api" +import { NodeData } from "src/hooks/node-data" +import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg" + +/** + * LoginView is rendered when the client is not authenticated + * to a tailnet. + */ +export default function LoginView({ + data, + refreshData, +}: { + data: NodeData + refreshData: () => void +}) { + const login = useCallback( + (opt: TailscaleUpOptions) => { + tailscaleUp(opt).then(refreshData) + }, + [refreshData] + ) + + return ( +
+ + {data.Status == "Stopped" ? ( + <> +
+

Connect

+

+ Your device is disconnected from Tailscale. +

+
+ + + ) : data.IP ? ( + <> +
+

+ Your device's key has expired. Reauthenticate this device by + logging in again, or{" "} + + learn more + + . +

+
+ + + ) : ( + <> +
+

Log in

+

+ Get started by logging in to your Tailscale network. + Or, learn more at{" "} + + tailscale.com + + . +

+
+ + + )} +
+ ) +} + +type TailscaleUpOptions = { + Reauthenticate?: boolean // force reauthentication +} + +function tailscaleUp(options: TailscaleUpOptions) { + return apiFetch("/up", "POST", options) + .then((r) => r.json()) + .then((d) => { + d.url && window.open(d.url, "_blank") + }) + .catch((e) => { + console.error("Failed to login:", e) + }) +} diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 991ccba60..20257e1a3 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -47,8 +47,6 @@ export type UserProfile = { export type NodeUpdate = { AdvertiseRoutes?: string AdvertiseExitNode?: boolean - Reauthenticate?: boolean - ForceLogout?: boolean } export type PrefsUpdate = { @@ -107,10 +105,6 @@ export default function useNodeData() { if (err) { throw new Error(err) } - const url = r["url"] - if (url) { - window.open(url, "_blank") - } refreshData() }) .catch((err) => { diff --git a/client/web/web.go b/client/web/web.go index 9457a564b..2819f8f11 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -371,22 +371,14 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo // which protects the handler using gorilla csrf. func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-CSRF-Token", csrf.Token(r)) - if r.URL.Path != "/api/data" { // only endpoint allowed for login client - http.Error(w, "invalid endpoint", http.StatusNotFound) - return - } - switch r.Method { - case httpm.GET: - // TODO(soniaappasamy): we may want a minimal node data response here + switch { + case r.URL.Path == "/api/data" && r.Method == httpm.GET: s.serveGetNodeData(w, r) - return - case httpm.POST: - // TODO(will): refactor to expose only a dedicated login method - s.servePostNodeUpdate(w, r) - return + case r.URL.Path == "/api/up" && r.Method == httpm.POST: + s.serveTailscaleUp(w, r) + default: + http.Error(w, "invalid endpoint or method", http.StatusNotFound) } - http.Error(w, "invalid endpoint", http.StatusNotFound) - return } type authType string @@ -648,19 +640,11 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { type nodeUpdate struct { AdvertiseRoutes string AdvertiseExitNode bool - Reauthenticate bool - ForceLogout bool } func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - st, err := s.lc.Status(r.Context()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - var postData nodeUpdate type mi map[string]any if err := json.NewDecoder(r.Body).Decode(&postData); err != nil { @@ -706,46 +690,30 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - var reauth, logout bool - if postData.Reauthenticate { - reauth = true - } - if postData.ForceLogout { - logout = true - } - s.logf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout) - url, err := s.tailscaleUp(r.Context(), st, postData) - s.logf("tailscaleUp = (URL %v, %v)", url != "", err) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - if url != "" { - json.NewEncoder(w).Encode(mi{"url": url}) - } else { - io.WriteString(w, "{}") - } + io.WriteString(w, "{}") } -func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) { - if postData.ForceLogout { - if err := s.lc.Logout(ctx); err != nil { - return "", fmt.Errorf("Logout error: %w", err) - } - return "", nil - } - +// tailscaleUp starts the daemon with the provided options. +// If reauthentication has been requested, an authURL is returned to complete device registration. +func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tailscaleUpOptions) (authURL string, retErr error) { origAuthURL := st.AuthURL isRunning := st.BackendState == ipn.Running.String() - forceReauth := postData.Reauthenticate - if !forceReauth { - if origAuthURL != "" { + if !opt.Reauthenticate { + switch { + case origAuthURL != "": return origAuthURL, nil - } - if isRunning { + case isRunning: return "", nil + case st.BackendState == ipn.Stopped.String(): + // stopped and not reauthenticating, so just start running + _, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ + WantRunning: true, + }, + WantRunningSet: true, + }) + return "", err } } @@ -767,7 +735,7 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData if !isRunning { s.lc.Start(ctx, ipn.Options{}) } - if forceReauth { + if opt.Reauthenticate { s.lc.StartLoginInteractive(ctx) } }() @@ -787,6 +755,47 @@ func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData } } +type tailscaleUpOptions struct { + // If true, force reauthentication of the client. + // Otherwise simply reconnect, the same as running `tailscale up`. + Reauthenticate bool +} + +// serveTailscaleUp serves requests to /api/up. +// If the user needs to authenticate, an authURL is provided in the response. +func (s *Server) serveTailscaleUp(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + st, err := s.lc.Status(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var opt tailscaleUpOptions + type mi map[string]any + if err := json.NewDecoder(r.Body).Decode(&opt); err != nil { + w.WriteHeader(400) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + s.logf("tailscaleUp(reauth=%v) ...", opt.Reauthenticate) + url, err := s.tailscaleUp(r.Context(), st, opt) + s.logf("tailscaleUp = (URL %v, %v)", url != "", err) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + if url != "" { + json.NewEncoder(w).Encode(mi{"url": url}) + } else { + io.WriteString(w, "{}") + } +} + // proxyRequestToLocalAPI proxies the web API request to the localapi. // // The web API request path is expected to exactly match a localapi path, -- cgit v1.3-3-g829e