mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 19:15:34 +00:00
697f92f4a7
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>
463 lines
13 KiB
Go
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)
|
|
}
|
|
}
|