mirror of https://github.com/tailscale/tailscale/
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
853 lines
25 KiB
Go
853 lines
25 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"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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/net/tsaddr"
|
|
"tailscale.com/tailcfg"
|
|
"tailscale.com/types/logger"
|
|
"tailscale.com/util/httpm"
|
|
"tailscale.com/version/distro"
|
|
)
|
|
|
|
// ListenPort is the static port used for the web client when run inside tailscaled.
|
|
// (5252 are the numbers above the letters "TSTS" on a qwerty keyboard.)
|
|
const ListenPort = 5252
|
|
|
|
// Server is the backend server for a Tailscale web client.
|
|
type Server struct {
|
|
mode ServerMode
|
|
|
|
logf logger.Logf
|
|
lc *tailscale.LocalClient
|
|
timeNow func() time.Time
|
|
|
|
// devMode indicates that the server run with frontend assets
|
|
// served by a Vite dev server, allowing for local development
|
|
// on the web client frontend.
|
|
devMode bool
|
|
cgiMode bool
|
|
pathPrefix string
|
|
|
|
apiHandler http.Handler // serves api endpoints; csrf-protected
|
|
assetsHandler http.Handler // serves frontend assets
|
|
assetsCleanup func() // called from Server.Shutdown
|
|
|
|
// browserSessions is an in-memory cache of browser sessions for the
|
|
// full management web client, which is only accessible over Tailscale.
|
|
//
|
|
// Users obtain a valid browser session by connecting to the web client
|
|
// over Tailscale and verifying their identity by authenticating on the
|
|
// control server.
|
|
//
|
|
// browserSessions get reset on every Server restart.
|
|
//
|
|
// The map provides a lookup of the session by cookie value
|
|
// (browserSession.ID => browserSession).
|
|
browserSessions sync.Map
|
|
}
|
|
|
|
// ServerMode specifies the mode of a running web.Server.
|
|
type ServerMode string
|
|
|
|
const (
|
|
// LoginServerMode serves a readonly login client for logging a
|
|
// node into a tailnet, and viewing a readonly interface of the
|
|
// node's current Tailscale settings.
|
|
//
|
|
// In this mode, API calls are authenticated via platform auth.
|
|
LoginServerMode ServerMode = "login"
|
|
|
|
// ManageServerMode serves a management client for editing tailscale
|
|
// settings of a node.
|
|
//
|
|
// This mode restricts the app to only being assessible over Tailscale,
|
|
// and API calls are authenticated via browser sessions associated with
|
|
// the source's Tailscale identity. If the source browser does not have
|
|
// a valid session, a readonly version of the app is displayed.
|
|
ManageServerMode ServerMode = "manage"
|
|
|
|
// LegacyServerMode serves the legacy web client, visible to users
|
|
// prior to release of tailscale/corp#14335.
|
|
LegacyServerMode ServerMode = "legacy"
|
|
)
|
|
|
|
var (
|
|
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
|
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
|
)
|
|
|
|
// ServerOpts contains options for constructing a new Server.
|
|
type ServerOpts struct {
|
|
// Mode specifies the mode of web client being constructed.
|
|
Mode ServerMode
|
|
|
|
// 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
|
|
|
|
// TimeNow optionally provides a time function.
|
|
// time.Now is used as default.
|
|
TimeNow func() time.Time
|
|
|
|
// Logf optionally provides a logger function.
|
|
// log.Printf is used as default.
|
|
Logf logger.Logf
|
|
}
|
|
|
|
// NewServer constructs a new Tailscale web client server.
|
|
// If err is empty, s is always non-nil.
|
|
// ctx is only required to live the duration of the NewServer call,
|
|
// and not the lifespan of the web server.
|
|
func NewServer(opts ServerOpts) (s *Server, err error) {
|
|
switch opts.Mode {
|
|
case LoginServerMode, ManageServerMode, LegacyServerMode:
|
|
// valid types
|
|
case "":
|
|
return nil, fmt.Errorf("must specify a Mode")
|
|
default:
|
|
return nil, fmt.Errorf("invalid Mode provided")
|
|
}
|
|
if opts.LocalClient == nil {
|
|
opts.LocalClient = &tailscale.LocalClient{}
|
|
}
|
|
s = &Server{
|
|
mode: opts.Mode,
|
|
logf: opts.Logf,
|
|
devMode: envknob.Bool("TS_DEBUG_WEB_CLIENT_DEV"),
|
|
lc: opts.LocalClient,
|
|
cgiMode: opts.CGIMode,
|
|
pathPrefix: opts.PathPrefix,
|
|
timeNow: opts.TimeNow,
|
|
}
|
|
if s.timeNow == nil {
|
|
s.timeNow = time.Now
|
|
}
|
|
if s.logf == nil {
|
|
s.logf = log.Printf
|
|
}
|
|
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
|
|
|
|
var metric string // clientmetric to report on startup
|
|
|
|
// 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))
|
|
if s.mode == LoginServerMode {
|
|
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
|
metric = "web_login_client_initialization"
|
|
} else {
|
|
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
|
|
metric = "web_client_initialization"
|
|
}
|
|
|
|
// Don't block startup on reporting metric.
|
|
// Report in separate go routine with 5 second timeout.
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
s.lc.IncrementCounter(ctx, metric, 1)
|
|
}()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Server) Shutdown() {
|
|
s.logf("web.Server: shutting down")
|
|
if s.assetsCleanup != nil {
|
|
s.assetsCleanup()
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
|
if s.mode == ManageServerMode {
|
|
// In manage mode, requests must be sent directly to the bare Tailscale IP address.
|
|
// If a request comes in on any other hostname, redirect.
|
|
if s.requireTailscaleIP(w, r) {
|
|
return // user was redirected
|
|
}
|
|
|
|
// serve HTTP 204 on /ok requests as connectivity check
|
|
if r.Method == httpm.GET && r.URL.Path == "/ok" {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
if !s.devMode {
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
// TODO: use CSP nonce or hash to eliminate need for unsafe-inline
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self' 'unsafe-inline'; img-src * data:")
|
|
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
switch {
|
|
case r.URL.Path == "/api/auth" && r.Method == httpm.GET:
|
|
s.serveAPIAuth(w, r) // serve auth status
|
|
return
|
|
case r.URL.Path == "/api/auth/session/new" && r.Method == httpm.GET:
|
|
s.serveAPIAuthSessionNew(w, r) // create new session
|
|
return
|
|
case r.URL.Path == "/api/auth/session/wait" && r.Method == httpm.GET:
|
|
s.serveAPIAuthSessionWait(w, r) // wait for session to be authorized
|
|
return
|
|
}
|
|
if ok := s.authorizeRequest(w, r); !ok {
|
|
http.Error(w, "not authorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
// Pass API requests through to the API handler.
|
|
s.apiHandler.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if !s.devMode {
|
|
s.lc.IncrementCounter(r.Context(), "web_client_page_load", 1)
|
|
}
|
|
s.assetsHandler.ServeHTTP(w, r)
|
|
}
|
|
|
|
// requireTailscaleIP redirects an incoming request if the HTTP request was not made to a bare Tailscale IP address.
|
|
// The request will be redirected to the Tailscale IP, port 5252, with the original request path.
|
|
// This allows any custom hostname to be used to access the device, but protects against DNS rebinding attacks.
|
|
// Returns true if the request has been fully handled, either be returning a redirect or an HTTP error.
|
|
func (s *Server) requireTailscaleIP(w http.ResponseWriter, r *http.Request) (handled bool) {
|
|
const (
|
|
ipv4ServiceHost = tsaddr.TailscaleServiceIPString
|
|
ipv6ServiceHost = "[" + tsaddr.TailscaleServiceIPv6String + "]"
|
|
)
|
|
// allow requests on quad-100 (or ipv6 equivalent)
|
|
if r.Host == ipv4ServiceHost || r.Host == ipv6ServiceHost {
|
|
return false
|
|
}
|
|
|
|
st, err := s.lc.StatusWithoutPeers(r.Context())
|
|
if err != nil {
|
|
s.logf("error getting status: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return true
|
|
}
|
|
|
|
var ipv4 string // store the first IPv4 address we see for redirect later
|
|
for _, ip := range st.Self.TailscaleIPs {
|
|
if ip.Is4() {
|
|
if r.Host == fmt.Sprintf("%s:%d", ip, ListenPort) {
|
|
return false
|
|
}
|
|
ipv4 = ip.String()
|
|
}
|
|
if ip.Is6() && r.Host == fmt.Sprintf("[%s]:%d", ip, ListenPort) {
|
|
return false
|
|
}
|
|
}
|
|
newURL := *r.URL
|
|
newURL.Host = fmt.Sprintf("%s:%d", ipv4, ListenPort)
|
|
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
|
|
return true
|
|
}
|
|
|
|
// authorizeRequest reports whether the request from the web client
|
|
// is authorized to be completed.
|
|
// It reports true if the request is authorized, and false otherwise.
|
|
// authorizeRequest manages writing out any relevant authorization
|
|
// errors to the ResponseWriter itself.
|
|
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
|
if s.mode == ManageServerMode { // client using tailscale auth
|
|
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
|
switch {
|
|
case err != nil:
|
|
// All requests must be made over tailscale.
|
|
http.Error(w, "must access over tailscale", http.StatusUnauthorized)
|
|
return false
|
|
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
|
// Readonly endpoint allowed without browser session.
|
|
return true
|
|
case strings.HasPrefix(r.URL.Path, "/api/"):
|
|
// All other /api/ endpoints require a valid browser session.
|
|
//
|
|
// TODO(sonia): s.getSession calls whois again,
|
|
// should try and use the above call instead of running another
|
|
// localapi request.
|
|
session, _, err := s.getSession(r)
|
|
if err != nil || !session.isAuthorized(s.timeNow()) {
|
|
http.Error(w, "no valid session", http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
return true
|
|
default:
|
|
// No additional auth on non-api (assets, index.html, etc).
|
|
return true
|
|
}
|
|
}
|
|
// Client using system-specific auth.
|
|
switch distro.Get() {
|
|
case distro.Synology:
|
|
resp, _ := authorizeSynology(r)
|
|
return resp.OK
|
|
case distro.QNAP:
|
|
resp, _ := authorizeQNAP(r)
|
|
return resp.OK
|
|
default:
|
|
return true // no additional auth for this distro
|
|
}
|
|
}
|
|
|
|
// serveLoginAPI serves requests for the web login client.
|
|
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
|
|
// 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
|
|
s.serveGetNodeData(w, r)
|
|
return
|
|
}
|
|
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
type authType string
|
|
|
|
var (
|
|
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
|
|
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
|
|
)
|
|
|
|
type authResponse struct {
|
|
OK bool `json:"ok"` // true when user has valid auth session
|
|
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
|
|
}
|
|
|
|
// serverAPIAuth handles requests to the /api/auth endpoint
|
|
// and returns an authResponse indicating the current auth state and any steps the user needs to take.
|
|
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
|
|
var resp authResponse
|
|
|
|
session, _, err := s.getSession(r)
|
|
switch {
|
|
case err != nil && errors.Is(err, errNotUsingTailscale):
|
|
// not using tailscale, so perform platform auth
|
|
switch distro.Get() {
|
|
case distro.Synology:
|
|
resp, err = authorizeSynology(r)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
case distro.QNAP:
|
|
resp, err = authorizeQNAP(r)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
default:
|
|
resp.OK = true // no additional auth for this distro
|
|
}
|
|
case err != nil && (errors.Is(err, errNotOwner) ||
|
|
errors.Is(err, errNotUsingTailscale) ||
|
|
errors.Is(err, errTaggedLocalSource) ||
|
|
errors.Is(err, errTaggedRemoteSource)):
|
|
// These cases are all restricted to the readonly view.
|
|
// No auth action to take.
|
|
resp = authResponse{OK: false}
|
|
case err != nil && !errors.Is(err, errNoSession):
|
|
// Any other error.
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
case session.isAuthorized(s.timeNow()):
|
|
resp = authResponse{OK: true}
|
|
default:
|
|
resp = authResponse{OK: false, AuthNeeded: tailscaleAuth}
|
|
}
|
|
|
|
writeJSON(w, resp)
|
|
}
|
|
|
|
type newSessionAuthResponse struct {
|
|
AuthURL string `json:"authUrl,omitempty"`
|
|
}
|
|
|
|
// serveAPIAuthSessionNew handles requests to the /api/auth/session/new endpoint.
|
|
func (s *Server) serveAPIAuthSessionNew(w http.ResponseWriter, r *http.Request) {
|
|
session, whois, err := s.getSession(r)
|
|
if err != nil && !errors.Is(err, errNoSession) {
|
|
// Source associated with request not allowed to create
|
|
// a session for this web client.
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if session == nil {
|
|
// Create a new session.
|
|
// If one already existed, we return that authURL rather than creating a new one.
|
|
session, err = s.newSession(r.Context(), whois)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Set the cookie on browser.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: session.ID,
|
|
Raw: session.ID,
|
|
Path: "/",
|
|
Expires: session.expires(),
|
|
})
|
|
}
|
|
|
|
writeJSON(w, newSessionAuthResponse{AuthURL: session.AuthURL})
|
|
}
|
|
|
|
// serveAPIAuthSessionWait handles requests to the /api/auth/session/wait endpoint.
|
|
func (s *Server) serveAPIAuthSessionWait(w http.ResponseWriter, r *http.Request) {
|
|
session, _, err := s.getSession(r)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if session.isAuthorized(s.timeNow()) {
|
|
return // already authorized
|
|
}
|
|
if err := s.awaitUserAuth(r.Context(), session); err != nil {
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
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 {
|
|
ID tailcfg.StableNodeID
|
|
Status string
|
|
DeviceName string
|
|
TailnetName string // TLS cert name
|
|
IP string // IPv4
|
|
IPv6 string
|
|
OS string
|
|
IPNVersion string
|
|
|
|
Profile tailcfg.UserProfile
|
|
IsTagged bool
|
|
Tags []string
|
|
|
|
KeyExpiry string // time.RFC3339
|
|
KeyExpired bool
|
|
|
|
TUNMode bool
|
|
IsSynology bool
|
|
DSMVersion int // 6 or 7, if IsSynology=true
|
|
IsUnraid bool
|
|
UnraidToken string
|
|
URLPrefix string // if set, the URL prefix the client is served behind
|
|
|
|
AdvertiseExitNode bool
|
|
AdvertiseRoutes string
|
|
|
|
LicensesURL string
|
|
|
|
DebugMode string // empty when not running in any debug mode
|
|
}
|
|
|
|
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
|
|
}
|
|
var debugMode string
|
|
if s.mode == ManageServerMode {
|
|
debugMode = "full"
|
|
} else if s.mode == LoginServerMode {
|
|
debugMode = "login"
|
|
}
|
|
data := &nodeData{
|
|
ID: st.Self.ID,
|
|
Status: st.BackendState,
|
|
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
|
|
TailnetName: st.CurrentTailnet.MagicDNSSuffix,
|
|
OS: st.Self.OS,
|
|
IPNVersion: strings.Split(st.Version, "-")[0],
|
|
Profile: st.User[st.Self.UserID],
|
|
IsTagged: st.Self.IsTagged(),
|
|
KeyExpired: st.Self.Expired,
|
|
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"),
|
|
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
|
|
LicensesURL: licenses.LicensesURL(),
|
|
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
|
|
}
|
|
for _, ip := range st.TailscaleIPs {
|
|
if ip.Is4() {
|
|
data.IP = ip.String()
|
|
} else if ip.Is6() {
|
|
data.IPv6 = ip.String()
|
|
}
|
|
if data.IP != "" && data.IPv6 != "" {
|
|
break
|
|
}
|
|
}
|
|
if st.Self.Tags != nil {
|
|
data.Tags = st.Self.Tags.AsSlice()
|
|
}
|
|
if st.Self.KeyExpiry != nil {
|
|
data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
|
|
}
|
|
for _, r := range prefs.AdvertiseRoutes {
|
|
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
|
data.AdvertiseExitNode = true
|
|
} else {
|
|
if data.AdvertiseRoutes != "" {
|
|
data.AdvertiseRoutes += ","
|
|
}
|
|
data.AdvertiseRoutes += r.String()
|
|
}
|
|
}
|
|
writeJSON(w, *data)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
prefs, err := s.lc.GetPrefs(r.Context())
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
isCurrentlyExitNode := slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV4) || slices.Contains(prefs.AdvertiseRoutes, exitNodeRouteV6)
|
|
|
|
if postData.AdvertiseExitNode != isCurrentlyExitNode {
|
|
if postData.AdvertiseExitNode {
|
|
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_enable", 1)
|
|
} else {
|
|
s.lc.IncrementCounter(r.Context(), "web_client_advertise_exitnode_disable", 1)
|
|
}
|
|
}
|
|
|
|
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
|
|
s.logf("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
|
|
}
|
|
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) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(data); err != nil {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|