diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index b0e0ceed0..a173feba5 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -7,12 +7,19 @@ export default function App() { return (
-
-
- - -
-
) } diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index fd30b1c2e..3060721f9 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -1,8 +1,4 @@ -export type UserProfile = { - LoginName: string - DisplayName: string - ProfilePicURL: string -} +import { useEffect, useState } from "react" export type NodeData = { Profile: UserProfile @@ -20,29 +16,22 @@ export type NodeData = { IPNVersion: string } -// testData is static set of nodedata used during development. -// This can be removed once we have a real node data API. -const testData: NodeData = { - Profile: { - LoginName: "amelie", - DisplayName: "Amelie Pangolin", - ProfilePicURL: "https://login.tailscale.com/logo192.png", - }, - Status: "Running", - DeviceName: "amelies-laptop", - IP: "100.1.2.3", - AdvertiseExitNode: false, - AdvertiseRoutes: "", - LicensesURL: "https://tailscale.com/licenses/tailscale", - TUNMode: false, - IsSynology: true, - DSMVersion: 7, - IsUnraid: false, - UnraidToken: "", - IPNVersion: "0.1.0", +export type UserProfile = { + LoginName: string + DisplayName: string + ProfilePicURL: string } // useNodeData returns basic data about the current node. export default function useNodeData() { - return testData + const [data, setData] = useState() + + useEffect(() => { + fetch("/api/data") + .then((response) => response.json()) + .then((json) => setData(json)) + .catch((error) => console.error(error)) + }, []) + + return data } diff --git a/client/web/web.go b/client/web/web.go index 487ffe36a..c49cf404d 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -31,6 +31,7 @@ import ( "tailscale.com/net/netutil" "tailscale.com/tailcfg" "tailscale.com/util/groupmember" + "tailscale.com/util/httpm" "tailscale.com/version/distro" ) @@ -78,30 +79,6 @@ func init() { template.Must(tmpl.New("web.css").Parse(webCSS)) } -type tmplData struct { - Profile tailcfg.UserProfile - SynologyUser string - Status string - DeviceName string - IP string - AdvertiseExitNode bool - AdvertiseRoutes string - LicensesURL string - TUNMode bool - IsSynology bool - DSMVersion int // 6 or 7, if IsSynology=true - IsUnraid bool - UnraidToken string - IPNVersion string -} - -type postedData struct { - AdvertiseRoutes string - AdvertiseExitNode bool - Reauthenticate bool - ForceLogout bool -} - // authorize returns the name of the user accessing the web UI after verifying // whether the user has access to the web UI. The function will write the // error to the provided http.ResponseWriter. @@ -294,12 +271,26 @@ req.send(null); // ServeHTTP processes all requests for the Tailscale web client. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if s.devMode { + if r.URL.Path == "/api/data" { + user, err := authorize(w, r) + if err != nil { + return + } + switch r.Method { + case httpm.GET: + s.serveGetNodeDataJSON(w, r, user) + case httpm.POST: + s.servePostNodeUpdate(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + return + } // When in dev mode, proxy to the Vite dev server. s.devProxy.ServeHTTP(w, r) return } - ctx := r.Context() if authRedirect(w, r) { return } @@ -309,80 +300,49 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - if r.URL.Path == "/redirect" || r.URL.Path == "/redirect/" { + switch { + case r.URL.Path == "/redirect" || r.URL.Path == "/redirect/": io.WriteString(w, authenticationRedirectHTML) return + case r.Method == "POST": + s.servePostNodeUpdate(w, r) + return + default: + s.serveGetNodeData(w, r, user) + return } +} +type nodeData struct { + Profile tailcfg.UserProfile + SynologyUser string + Status string + DeviceName string + IP string + AdvertiseExitNode bool + AdvertiseRoutes string + LicensesURL string + TUNMode bool + IsSynology bool + DSMVersion int // 6 or 7, if IsSynology=true + IsUnraid bool + UnraidToken string + IPNVersion string +} + +func (s *Server) getNodeData(ctx context.Context, user string) (*nodeData, error) { st, err := s.lc.Status(ctx) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + return nil, err } prefs, err := s.lc.GetPrefs(ctx) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - if r.Method == "POST" { - defer r.Body.Close() - var postData postedData - type mi map[string]any - if err := json.NewDecoder(r.Body).Decode(&postData); err != nil { - w.WriteHeader(400) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - - routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - mp := &ipn.MaskedPrefs{ - AdvertiseRoutesSet: true, - WantRunningSet: true, - } - mp.Prefs.WantRunning = true - mp.Prefs.AdvertiseRoutes = routes - log.Printf("Doing edit: %v", mp.Pretty()) - - if _, err := s.lc.EditPrefs(ctx, mp); err != nil { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(mi{"error": err.Error()}) - return - } - - w.Header().Set("Content-Type", "application/json") - var reauth, logout bool - if postData.Reauthenticate { - reauth = true - } - if postData.ForceLogout { - logout = true - } - log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout) - url, err := s.tailscaleUp(r.Context(), st, postData) - log.Printf("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, "{}") - } - return + return nil, err } - profile := st.User[st.Self.UserID] deviceName := strings.Split(st.Self.DNSName, ".")[0] versionShort := strings.Split(st.Version, "-")[0] - data := tmplData{ + data := &nodeData{ SynologyUser: user, Profile: profile, Status: st.BackendState, @@ -410,16 +370,106 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if len(st.TailscaleIPs) != 0 { data.IP = st.TailscaleIPs[0].String() } + return data, nil +} +func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request, user string) { + data, err := s.getNodeData(r.Context(), user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } buf := new(bytes.Buffer) - if err := tmpl.Execute(buf, data); err != nil { + if err := tmpl.Execute(buf, *data); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Write(buf.Bytes()) } -func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, postData postedData) (authURL string, retErr error) { +func (s *Server) serveGetNodeDataJSON(w http.ResponseWriter, r *http.Request, user string) { + data, err := s.getNodeData(r.Context(), user) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := json.NewEncoder(w).Encode(*data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return +} + +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 { + w.WriteHeader(400) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + + routes, err := netutil.CalcAdvertiseRoutes(postData.AdvertiseRoutes, postData.AdvertiseExitNode) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + mp := &ipn.MaskedPrefs{ + AdvertiseRoutesSet: true, + WantRunningSet: true, + } + mp.Prefs.WantRunning = true + mp.Prefs.AdvertiseRoutes = routes + log.Printf("Doing edit: %v", mp.Pretty()) + + if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(mi{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + var reauth, logout bool + if postData.Reauthenticate { + reauth = true + } + if postData.ForceLogout { + logout = true + } + log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout) + url, err := s.tailscaleUp(r.Context(), st, postData) + log.Printf("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, "{}") + } + return +} + +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) diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index cf02c0b65..5865de17a 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -138,7 +138,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+ tailscale.com/util/groupmember from tailscale.com/client/web - tailscale.com/util/httpm from tailscale.com/client/tailscale + tailscale.com/util/httpm from tailscale.com/client/tailscale+ tailscale.com/util/lineread from tailscale.com/net/interfaces+ L tailscale.com/util/linuxfw from tailscale.com/net/netns tailscale.com/util/mak from tailscale.com/net/netcheck+