mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 04:55:31 +00:00
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 <will@tailscale.com>
This commit is contained in:
parent
28684b0538
commit
f880c77df0
@ -3,7 +3,7 @@ import React, { useEffect } from "react"
|
|||||||
import LoginToggle from "src/components/login-toggle"
|
import LoginToggle from "src/components/login-toggle"
|
||||||
import DeviceDetailsView from "src/components/views/device-details-view"
|
import DeviceDetailsView from "src/components/views/device-details-view"
|
||||||
import HomeView from "src/components/views/home-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 SSHView from "src/components/views/ssh-view"
|
||||||
import { UpdatingView } from "src/components/views/updating-view"
|
import { UpdatingView } from "src/components/views/updating-view"
|
||||||
import useAuth, { AuthResponse } from "src/hooks/auth"
|
import useAuth, { AuthResponse } from "src/hooks/auth"
|
||||||
@ -39,12 +39,11 @@ function WebClient({
|
|||||||
|
|
||||||
return !data ? (
|
return !data ? (
|
||||||
<div className="text-center py-14">Loading...</div>
|
<div className="text-center py-14">Loading...</div>
|
||||||
) : data.Status === "NeedsLogin" || data.Status === "NoState" ? (
|
) : data.Status === "NeedsLogin" ||
|
||||||
|
data.Status === "NoState" ||
|
||||||
|
data.Status === "Stopped" ? (
|
||||||
// Client not on a tailnet, render login.
|
// Client not on a tailnet, render login.
|
||||||
<LoginClientView
|
<LoginView data={data} refreshData={refreshData} />
|
||||||
data={data}
|
|
||||||
onLoginClick={() => updateNode({ Reauthenticate: true })}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
// Otherwise render the new web client.
|
// Otherwise render the new web client.
|
||||||
<>
|
<>
|
||||||
|
@ -1,22 +1,45 @@
|
|||||||
import React from "react"
|
import React, { useCallback } from "react"
|
||||||
|
import { apiFetch } from "src/api"
|
||||||
import { NodeData } from "src/hooks/node-data"
|
import { NodeData } from "src/hooks/node-data"
|
||||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LoginClientView is rendered when the client is not authenticated
|
* LoginView is rendered when the client is not authenticated
|
||||||
* to a tailnet.
|
* to a tailnet.
|
||||||
*/
|
*/
|
||||||
export default function LoginClientView({
|
export default function LoginView({
|
||||||
data,
|
data,
|
||||||
onLoginClick,
|
refreshData,
|
||||||
}: {
|
}: {
|
||||||
data: NodeData
|
data: NodeData
|
||||||
onLoginClick: () => void
|
refreshData: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const login = useCallback(
|
||||||
|
(opt: TailscaleUpOptions) => {
|
||||||
|
tailscaleUp(opt).then(refreshData)
|
||||||
|
},
|
||||||
|
[refreshData]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
<div className="mb-8 py-6 px-8 bg-white rounded-md shadow-2xl">
|
||||||
<TailscaleIcon className="my-2 mb-8" />
|
<TailscaleIcon className="my-2 mb-8" />
|
||||||
{data.IP ? (
|
{data.Status == "Stopped" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-3xl font-semibold mb-3">Connect</h3>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
Your device is disconnected from Tailscale.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => login({})}
|
||||||
|
className="button button-blue w-full mb-4"
|
||||||
|
>
|
||||||
|
Connect to Tailscale
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : data.IP ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<p className="text-gray-700">
|
<p className="text-gray-700">
|
||||||
@ -33,7 +56,7 @@ export default function LoginClientView({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onLoginClick}
|
onClick={() => login({ Reauthenticate: true })}
|
||||||
className="button button-blue w-full mb-4"
|
className="button button-blue w-full mb-4"
|
||||||
>
|
>
|
||||||
Reauthenticate
|
Reauthenticate
|
||||||
@ -53,7 +76,7 @@ export default function LoginClientView({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onLoginClick}
|
onClick={() => login({ Reauthenticate: true })}
|
||||||
className="button button-blue w-full mb-4"
|
className="button button-blue w-full mb-4"
|
||||||
>
|
>
|
||||||
Log In
|
Log In
|
||||||
@ -63,3 +86,18 @@ export default function LoginClientView({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
@ -47,8 +47,6 @@ export type UserProfile = {
|
|||||||
export type NodeUpdate = {
|
export type NodeUpdate = {
|
||||||
AdvertiseRoutes?: string
|
AdvertiseRoutes?: string
|
||||||
AdvertiseExitNode?: boolean
|
AdvertiseExitNode?: boolean
|
||||||
Reauthenticate?: boolean
|
|
||||||
ForceLogout?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrefsUpdate = {
|
export type PrefsUpdate = {
|
||||||
@ -107,10 +105,6 @@ export default function useNodeData() {
|
|||||||
if (err) {
|
if (err) {
|
||||||
throw new Error(err)
|
throw new Error(err)
|
||||||
}
|
}
|
||||||
const url = r["url"]
|
|
||||||
if (url) {
|
|
||||||
window.open(url, "_blank")
|
|
||||||
}
|
|
||||||
refreshData()
|
refreshData()
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
@ -371,22 +371,14 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
|
|||||||
// which protects the handler using gorilla csrf.
|
// which protects the handler using gorilla csrf.
|
||||||
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
||||||
if r.URL.Path != "/api/data" { // only endpoint allowed for login client
|
switch {
|
||||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
||||||
return
|
|
||||||
}
|
|
||||||
switch r.Method {
|
|
||||||
case httpm.GET:
|
|
||||||
// TODO(soniaappasamy): we may want a minimal node data response here
|
|
||||||
s.serveGetNodeData(w, r)
|
s.serveGetNodeData(w, r)
|
||||||
return
|
case r.URL.Path == "/api/up" && r.Method == httpm.POST:
|
||||||
case httpm.POST:
|
s.serveTailscaleUp(w, r)
|
||||||
// TODO(will): refactor to expose only a dedicated login method
|
default:
|
||||||
s.servePostNodeUpdate(w, r)
|
http.Error(w, "invalid endpoint or method", http.StatusNotFound)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type authType string
|
type authType string
|
||||||
@ -648,19 +640,11 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
|||||||
type nodeUpdate struct {
|
type nodeUpdate struct {
|
||||||
AdvertiseRoutes string
|
AdvertiseRoutes string
|
||||||
AdvertiseExitNode bool
|
AdvertiseExitNode bool
|
||||||
Reauthenticate bool
|
|
||||||
ForceLogout bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
defer r.Body.Close()
|
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
|
var postData nodeUpdate
|
||||||
type mi map[string]any
|
type mi map[string]any
|
||||||
if err := json.NewDecoder(r.Body).Decode(&postData); err != nil {
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
var reauth, logout bool
|
io.WriteString(w, "{}")
|
||||||
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, "{}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData nodeUpdate) (authURL string, retErr error) {
|
// tailscaleUp starts the daemon with the provided options.
|
||||||
if postData.ForceLogout {
|
// If reauthentication has been requested, an authURL is returned to complete device registration.
|
||||||
if err := s.lc.Logout(ctx); err != nil {
|
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tailscaleUpOptions) (authURL string, retErr error) {
|
||||||
return "", fmt.Errorf("Logout error: %w", err)
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
origAuthURL := st.AuthURL
|
origAuthURL := st.AuthURL
|
||||||
isRunning := st.BackendState == ipn.Running.String()
|
isRunning := st.BackendState == ipn.Running.String()
|
||||||
|
|
||||||
forceReauth := postData.Reauthenticate
|
if !opt.Reauthenticate {
|
||||||
if !forceReauth {
|
switch {
|
||||||
if origAuthURL != "" {
|
case origAuthURL != "":
|
||||||
return origAuthURL, nil
|
return origAuthURL, nil
|
||||||
}
|
case isRunning:
|
||||||
if isRunning {
|
|
||||||
return "", nil
|
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 {
|
if !isRunning {
|
||||||
s.lc.Start(ctx, ipn.Options{})
|
s.lc.Start(ctx, ipn.Options{})
|
||||||
}
|
}
|
||||||
if forceReauth {
|
if opt.Reauthenticate {
|
||||||
s.lc.StartLoginInteractive(ctx)
|
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.
|
// proxyRequestToLocalAPI proxies the web API request to the localapi.
|
||||||
//
|
//
|
||||||
// The web API request path is expected to exactly match a localapi path,
|
// The web API request path is expected to exactly match a localapi path,
|
||||||
|
Loading…
Reference in New Issue
Block a user