tailscale/client/web/web.go
Sonia Appasamy 697f92f4a7 client/web: refactor serveGetNodeData
Remove the "JSON" ending, we no longer have a non-JSON version,
it was removed in d74c771 when we switched from the legacy web
client to React.

Also combine getNodeData into serveGetNodeData now that serveGetNodeData
is the single caller of getNodeData.

A #cleanup

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-09-27 13:53:51 -04:00

463 lines
13 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Package web provides the Tailscale client for web.
package web
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"github.com/gorilla/csrf"
"tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/licenses"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/util/httpm"
"tailscale.com/version/distro"
)
// Server is the backend server for a Tailscale web client.
type Server struct {
lc *tailscale.LocalClient
devMode bool
cgiMode bool
pathPrefix string
assetsHandler http.Handler // serves frontend assets
apiHandler http.Handler // serves api endpoints; csrf-protected
}
// ServerOpts contains options for constructing a new Server.
type ServerOpts struct {
DevMode bool
// CGIMode indicates if the server is running as a CGI script.
CGIMode bool
// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
PathPrefix string
// LocalClient is the tailscale.LocalClient to use for this web server.
// If nil, a new one will be created.
LocalClient *tailscale.LocalClient
}
// NewServer constructs a new Tailscale web client server.
// The provided context should live for the duration of the Server's lifetime.
func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func()) {
if opts.LocalClient == nil {
opts.LocalClient = &tailscale.LocalClient{}
}
s = &Server{
devMode: opts.DevMode,
lc: opts.LocalClient,
cgiMode: opts.CGIMode,
pathPrefix: opts.PathPrefix,
}
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
// Create handler for "/api" requests with CSRF protection.
// We don't require secure cookies, since the web client is regularly used
// on network appliances that are served on local non-https URLs.
// The client is secured by limiting the interface it listens on,
// or by authenticating requests before they reach the web client.
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1)
return s, cleanup
}
// ServeHTTP processes all requests for the Tailscale web client.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler := s.serve
// if path prefix is defined, strip it from requests.
if s.pathPrefix != "" {
handler = enforcePrefix(s.pathPrefix, handler)
}
handler(w, r)
}
// authorize checks if the request is authorized to access the web client for those platforms that support it.
func authorize(w http.ResponseWriter, r *http.Request) (handled bool) {
if strings.HasPrefix(r.URL.Path, "/assets/") {
// don't require authorization for static assets
return false
}
switch distro.Get() {
case distro.Synology:
return authorizeSynology(w, r)
case distro.QNAP:
return authorizeQNAP(w, r)
}
return false
}
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
switch {
case authorize(w, r):
// Authenticate and authorize the request for platforms that support it.
// Return if the request was processed.
return
case strings.HasPrefix(r.URL.Path, "/api/"):
// Pass API requests through to the API handler.
s.apiHandler.ServeHTTP(w, r)
return
default:
if !s.devMode {
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
}
s.assetsHandler.ServeHTTP(w, r)
return
}
}
// 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.
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api")
switch {
case path == "/data":
switch r.Method {
case httpm.GET:
s.serveGetNodeData(w, r)
case httpm.POST:
s.servePostNodeUpdate(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
return
case strings.HasPrefix(path, "/local/"):
s.proxyRequestToLocalAPI(w, r)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
}
type nodeData struct {
Profile tailcfg.UserProfile
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) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
st, err := s.lc.Status(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
prefs, err := s.lc.GetPrefs(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0]
versionShort := strings.Split(st.Version, "-")[0]
data := &nodeData{
Profile: profile,
Status: st.BackendState,
DeviceName: deviceName,
LicensesURL: licenses.LicensesURL(),
TUNMode: st.TUN,
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
DSMVersion: distro.DSMVersion(),
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
IPNVersion: versionShort,
}
exitNodeRouteV4 := netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 := netip.MustParsePrefix("::/0")
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
data.AdvertiseExitNode = true
} else {
if data.AdvertiseRoutes != "" {
data.AdvertiseRoutes += ","
}
data.AdvertiseRoutes += r.String()
}
}
if len(st.TailscaleIPs) != 0 {
data.IP = st.TailscaleIPs[0].String()
}
if err := json.NewEncoder(w).Encode(*data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
}
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, "{}")
}
}
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
}
origAuthURL := st.AuthURL
isRunning := st.BackendState == ipn.Running.String()
forceReauth := postData.Reauthenticate
if !forceReauth {
if origAuthURL != "" {
return origAuthURL, nil
}
if isRunning {
return "", nil
}
}
// printAuthURL reports whether we should print out the
// provided auth URL from an IPN notify.
printAuthURL := func(url string) bool {
return url != origAuthURL
}
watchCtx, cancelWatch := context.WithCancel(ctx)
defer cancelWatch()
watcher, err := s.lc.WatchIPNBus(watchCtx, 0)
if err != nil {
return "", err
}
defer watcher.Close()
go func() {
if !isRunning {
s.lc.Start(ctx, ipn.Options{})
}
if forceReauth {
s.lc.StartLoginInteractive(ctx)
}
}()
for {
n, err := watcher.Next()
if err != nil {
return "", err
}
if n.ErrMessage != nil {
msg := *n.ErrMessage
return "", fmt.Errorf("backend error: %v", msg)
}
if url := n.BrowseToURL; url != nil && printAuthURL(*url) {
return *url, nil
}
}
}
// proxyRequestToLocalAPI proxies the web API request to the localapi.
//
// The web API request path is expected to exactly match a localapi path,
// with prefix /api/local/ rather than /localapi/.
//
// If the localapi path is not included in localapiAllowlist,
// the request is rejected.
func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/local")
if r.URL.Path == path { // missing prefix
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if !slices.Contains(localapiAllowlist, path) {
http.Error(w, fmt.Sprintf("%s not allowed from localapi proxy", path), http.StatusForbidden)
return
}
localAPIURL := "http://" + apitype.LocalAPIHost + "/localapi" + path
req, err := http.NewRequestWithContext(r.Context(), r.Method, localAPIURL, r.Body)
if err != nil {
http.Error(w, "failed to construct request", http.StatusInternalServerError)
return
}
// Make request to tailscaled localapi.
resp, err := s.lc.DoLocalRequest(req)
if err != nil {
http.Error(w, err.Error(), resp.StatusCode)
return
}
defer resp.Body.Close()
// Send response back to web frontend.
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, resp.Body); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// localapiAllowlist is an allowlist of localapi endpoints the
// web client is allowed to proxy to the client's localapi.
//
// Rather than exposing all localapi endpoints over the proxy,
// this limits to just the ones actually used from the web
// client frontend.
//
// TODO(sonia,will): Shouldn't expand this beyond the existing
// localapi endpoints until the larger web client auth story
// is worked out (tailscale/corp#14335).
var localapiAllowlist = []string{
"/v0/logout",
}
// csrfKey returns a key that can be used for CSRF protection.
// If an error occurs during key creation, the error is logged and the active process terminated.
// If the server is running in CGI mode, the key is cached to disk and reused between requests.
// If an error occurs during key storage, the error is logged and the active process terminated.
func (s *Server) csrfKey() []byte {
csrfFile := filepath.Join(os.TempDir(), "tailscale-web-csrf.key")
// if running in CGI mode, try to read from disk, but ignore errors
if s.cgiMode {
key, _ := os.ReadFile(csrfFile)
if len(key) == 32 {
return key
}
}
// create a new key
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
log.Fatalf("error generating CSRF key: %v", err)
}
// if running in CGI mode, try to write the newly created key to disk, and exit if it fails.
if s.cgiMode {
if err := os.WriteFile(csrfFile, key, 0600); err != nil {
log.Fatalf("unable to store CSRF key: %v", err)
}
}
return key
}
// enforcePrefix returns a HandlerFunc that enforces a given path prefix is used in requests,
// then strips it before invoking h.
// Unlike http.StripPrefix, it does not return a 404 if the prefix is not present.
// Instead, it returns a redirect to the prefix path.
func enforcePrefix(prefix string, h http.HandlerFunc) http.HandlerFunc {
if prefix == "" {
return h
}
// ensure that prefix always has both a leading and trailing slash so
// that relative links for JS and CSS assets work correctly.
if !strings.HasPrefix(prefix, "/") {
prefix = "/" + prefix
}
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
return func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, prefix) {
http.Redirect(w, r, prefix, http.StatusFound)
return
}
prefix = strings.TrimSuffix(prefix, "/")
http.StripPrefix(prefix, h).ServeHTTP(w, r)
}
}