mirror of
				https://github.com/tailscale/tailscale.git
				synced 2025-10-26 05:41:04 +01:00 
			
		
		
		
	Updates #11058 Change-Id: I35e7ef9b90e83cac04ca93fd964ad00ed5b48430 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
		
			
				
	
	
		
			340 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			340 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright (c) Tailscale Inc & AUTHORS
 | |
| // SPDX-License-Identifier: BSD-3-Clause
 | |
| 
 | |
| package web
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"crypto/rand"
 | |
| 	"encoding/base64"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"net/http"
 | |
| 	"net/url"
 | |
| 	"slices"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"tailscale.com/client/tailscale/apitype"
 | |
| 	"tailscale.com/ipn/ipnstate"
 | |
| 	"tailscale.com/tailcfg"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	sessionCookieName   = "TS-Web-Session"
 | |
| 	sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
 | |
| )
 | |
| 
 | |
| // browserSession holds data about a user's browser session
 | |
| // on the full management web client.
 | |
| type browserSession struct {
 | |
| 	// ID is the unique identifier for the session.
 | |
| 	// It is passed in the user's "TS-Web-Session" browser cookie.
 | |
| 	ID            string
 | |
| 	SrcNode       tailcfg.NodeID
 | |
| 	SrcUser       tailcfg.UserID
 | |
| 	AuthID        string // from tailcfg.WebClientAuthResponse
 | |
| 	AuthURL       string // from tailcfg.WebClientAuthResponse
 | |
| 	Created       time.Time
 | |
| 	Authenticated bool
 | |
| }
 | |
| 
 | |
| // isAuthorized reports true if the given session is authorized
 | |
| // to be used by its associated user to access the full management
 | |
| // web client.
 | |
| //
 | |
| // isAuthorized is true only when s.Authenticated is true (i.e.
 | |
| // the user has authenticated the session) and the session is not
 | |
| // expired.
 | |
| // 2023-10-05: Sessions expire by default 30 days after creation.
 | |
| func (s *browserSession) isAuthorized(now time.Time) bool {
 | |
| 	switch {
 | |
| 	case s == nil:
 | |
| 		return false
 | |
| 	case !s.Authenticated:
 | |
| 		return false // awaiting auth
 | |
| 	case s.isExpired(now):
 | |
| 		return false // expired
 | |
| 	}
 | |
| 	return true
 | |
| }
 | |
| 
 | |
| // isExpired reports true if s is expired.
 | |
| // 2023-10-05: Sessions expire by default 30 days after creation.
 | |
| func (s *browserSession) isExpired(now time.Time) bool {
 | |
| 	return !s.Created.IsZero() && now.After(s.expires())
 | |
| }
 | |
| 
 | |
| // expires reports when the given session expires.
 | |
| func (s *browserSession) expires() time.Time {
 | |
| 	return s.Created.Add(sessionCookieExpiry)
 | |
| }
 | |
| 
 | |
| var (
 | |
| 	errNoSession          = errors.New("no-browser-session")
 | |
| 	errNotUsingTailscale  = errors.New("not-using-tailscale")
 | |
| 	errTaggedRemoteSource = errors.New("tagged-remote-source")
 | |
| 	errTaggedLocalSource  = errors.New("tagged-local-source")
 | |
| 	errNotOwner           = errors.New("not-owner")
 | |
| )
 | |
| 
 | |
| // getSession retrieves the browser session associated with the request,
 | |
| // if one exists.
 | |
| //
 | |
| // An error is returned in any of the following cases:
 | |
| //
 | |
| //   - (errNotUsingTailscale) The request was not made over tailscale.
 | |
| //
 | |
| //   - (errNoSession) The request does not have a session.
 | |
| //
 | |
| //   - (errTaggedRemoteSource) The source is remote (another node) and tagged.
 | |
| //     Users must use their own user-owned devices to manage other nodes'
 | |
| //     web clients.
 | |
| //
 | |
| //   - (errTaggedLocalSource) The source is local (the same node) and tagged.
 | |
| //     Tagged nodes can only be remotely managed, allowing ACLs to dictate
 | |
| //     access to web clients.
 | |
| //
 | |
| //   - (errNotOwner) The source is not the owner of this client (if the
 | |
| //     client is user-owned). Only the owner is allowed to manage the
 | |
| //     node via the web client.
 | |
| //
 | |
| // If no error is returned, the browserSession is always non-nil.
 | |
| // getTailscaleBrowserSession does not check whether the session has been
 | |
| // authorized by the user. Callers can use browserSession.isAuthorized.
 | |
| //
 | |
| // The WhoIsResponse is always populated, with a non-nil Node and UserProfile,
 | |
| // unless getTailscaleBrowserSession reports errNotUsingTailscale.
 | |
| func (s *Server) getSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, *ipnstate.Status, error) {
 | |
| 	whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
 | |
| 	status, statusErr := s.lc.StatusWithoutPeers(r.Context())
 | |
| 	switch {
 | |
| 	case whoIsErr != nil:
 | |
| 		return nil, nil, status, errNotUsingTailscale
 | |
| 	case statusErr != nil:
 | |
| 		return nil, whoIs, nil, statusErr
 | |
| 	case status.Self == nil:
 | |
| 		return nil, whoIs, status, errors.New("missing self node in tailscale status")
 | |
| 	case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
 | |
| 		return nil, whoIs, status, errTaggedLocalSource
 | |
| 	case whoIs.Node.IsTagged():
 | |
| 		return nil, whoIs, status, errTaggedRemoteSource
 | |
| 	case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
 | |
| 		return nil, whoIs, status, errNotOwner
 | |
| 	}
 | |
| 	srcNode := whoIs.Node.ID
 | |
| 	srcUser := whoIs.UserProfile.ID
 | |
| 
 | |
| 	cookie, err := r.Cookie(sessionCookieName)
 | |
| 	if errors.Is(err, http.ErrNoCookie) {
 | |
| 		return nil, whoIs, status, errNoSession
 | |
| 	} else if err != nil {
 | |
| 		return nil, whoIs, status, err
 | |
| 	}
 | |
| 	v, ok := s.browserSessions.Load(cookie.Value)
 | |
| 	if !ok {
 | |
| 		return nil, whoIs, status, errNoSession
 | |
| 	}
 | |
| 	session := v.(*browserSession)
 | |
| 	if session.SrcNode != srcNode || session.SrcUser != srcUser {
 | |
| 		// In this case the browser cookie is associated with another tailscale node.
 | |
| 		// Maybe the source browser's machine was logged out and then back in as a different node.
 | |
| 		// Return errNoSession because there is no session for this user.
 | |
| 		return nil, whoIs, status, errNoSession
 | |
| 	} else if session.isExpired(s.timeNow()) {
 | |
| 		// Session expired, remove from session map and return errNoSession.
 | |
| 		s.browserSessions.Delete(session.ID)
 | |
| 		return nil, whoIs, status, errNoSession
 | |
| 	}
 | |
| 	return session, whoIs, status, nil
 | |
| }
 | |
| 
 | |
| // newSession creates a new session associated with the given source user/node,
 | |
| // and stores it back to the session cache. Creating of a new session includes
 | |
| // generating a new auth URL from the control server.
 | |
| func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
 | |
| 	sid, err := s.newSessionID()
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	session := &browserSession{
 | |
| 		ID:      sid,
 | |
| 		SrcNode: src.Node.ID,
 | |
| 		SrcUser: src.UserProfile.ID,
 | |
| 		Created: s.timeNow(),
 | |
| 	}
 | |
| 
 | |
| 	if s.controlSupportsCheckMode(ctx) {
 | |
| 		// control supports check mode, so get a new auth URL and return.
 | |
| 		a, err := s.newAuthURL(ctx, src.Node.ID)
 | |
| 		if err != nil {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 		session.AuthID = a.ID
 | |
| 		session.AuthURL = a.URL
 | |
| 	} else {
 | |
| 		// control does not support check mode, so there is no additional auth we can do.
 | |
| 		session.Authenticated = true
 | |
| 	}
 | |
| 
 | |
| 	s.browserSessions.Store(sid, session)
 | |
| 	return session, nil
 | |
| }
 | |
| 
 | |
| // controlSupportsCheckMode returns whether the current control server supports web client check mode, to verify a user's identity.
 | |
| // We assume that only "tailscale.com" control servers support check mode.
 | |
| // This allows the web client to be used with non-standard control servers.
 | |
| // If an error occurs getting the control URL, this method returns true to fail closed.
 | |
| //
 | |
| // TODO(juanfont/headscale#1623): adjust or remove this when headscale supports check mode.
 | |
| func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
 | |
| 	prefs, err := s.lc.GetPrefs(ctx)
 | |
| 	if err != nil {
 | |
| 		return true
 | |
| 	}
 | |
| 	controlURL, err := url.Parse(prefs.ControlURLOrDefault())
 | |
| 	if err != nil {
 | |
| 		return true
 | |
| 	}
 | |
| 	return strings.HasSuffix(controlURL.Host, ".tailscale.com")
 | |
| }
 | |
| 
 | |
| // awaitUserAuth blocks until the given session auth has been completed
 | |
| // by the user on the control server, then updates the session cache upon
 | |
| // completion. An error is returned if control auth failed for any reason.
 | |
| func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
 | |
| 	if session.isAuthorized(s.timeNow()) {
 | |
| 		return nil // already authorized
 | |
| 	}
 | |
| 	a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
 | |
| 	if err != nil {
 | |
| 		// Clean up the session. Doing this on any error from control
 | |
| 		// server to avoid the user getting stuck with a bad session
 | |
| 		// cookie.
 | |
| 		s.browserSessions.Delete(session.ID)
 | |
| 		return err
 | |
| 	}
 | |
| 	if a.Complete {
 | |
| 		session.Authenticated = a.Complete
 | |
| 		s.browserSessions.Store(session.ID, session)
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (s *Server) newSessionID() (string, error) {
 | |
| 	raw := make([]byte, 16)
 | |
| 	for range 5 {
 | |
| 		if _, err := rand.Read(raw); err != nil {
 | |
| 			return "", err
 | |
| 		}
 | |
| 		cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw)
 | |
| 		if _, ok := s.browserSessions.Load(cookie); !ok {
 | |
| 			return cookie, nil
 | |
| 		}
 | |
| 	}
 | |
| 	return "", errors.New("too many collisions generating new session; please refresh page")
 | |
| }
 | |
| 
 | |
| // peerCapabilities holds information about what a source
 | |
| // peer is allowed to edit via the web UI.
 | |
| //
 | |
| // map value is true if the peer can edit the given feature.
 | |
| // Only capFeatures included in validCaps will be included.
 | |
| type peerCapabilities map[capFeature]bool
 | |
| 
 | |
| // canEdit is true if the peerCapabilities grant edit access
 | |
| // to the given feature.
 | |
| func (p peerCapabilities) canEdit(feature capFeature) bool {
 | |
| 	if p == nil {
 | |
| 		return false
 | |
| 	}
 | |
| 	if p[capFeatureAll] {
 | |
| 		return true
 | |
| 	}
 | |
| 	return p[feature]
 | |
| }
 | |
| 
 | |
| // isEmpty is true if p is either nil or has no capabilities
 | |
| // with value true.
 | |
| func (p peerCapabilities) isEmpty() bool {
 | |
| 	if p == nil {
 | |
| 		return true
 | |
| 	}
 | |
| 	for _, v := range p {
 | |
| 		if v == true {
 | |
| 			return false
 | |
| 		}
 | |
| 	}
 | |
| 	return true
 | |
| }
 | |
| 
 | |
| type capFeature string
 | |
| 
 | |
| const (
 | |
| 	// The following values should not be edited.
 | |
| 	// New caps can be added, but existing ones should not be changed,
 | |
| 	// as these exact values are used by users in tailnet policy files.
 | |
| 	//
 | |
| 	// IMPORTANT: When adding a new cap, also update validCaps slice below.
 | |
| 
 | |
| 	capFeatureAll       capFeature = "*"         // grants peer management of all features
 | |
| 	capFeatureSSH       capFeature = "ssh"       // grants peer SSH server management
 | |
| 	capFeatureSubnets   capFeature = "subnets"   // grants peer subnet routes management
 | |
| 	capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
 | |
| 	capFeatureAccount   capFeature = "account"   // grants peer ability to turn on auto updates and log out of node
 | |
| )
 | |
| 
 | |
| // validCaps contains the list of valid capabilities used in the web client.
 | |
| // Any capabilities included in a peer's grants that do not fall into this
 | |
| // list will be ignored.
 | |
| var validCaps []capFeature = []capFeature{
 | |
| 	capFeatureAll,
 | |
| 	capFeatureSSH,
 | |
| 	capFeatureSubnets,
 | |
| 	capFeatureExitNodes,
 | |
| 	capFeatureAccount,
 | |
| }
 | |
| 
 | |
| type capRule struct {
 | |
| 	CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
 | |
| }
 | |
| 
 | |
| // toPeerCapabilities parses out the web ui capabilities from the
 | |
| // given whois response.
 | |
| func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
 | |
| 	if whois == nil || status == nil {
 | |
| 		return peerCapabilities{}, nil
 | |
| 	}
 | |
| 	if whois.Node.IsTagged() {
 | |
| 		// We don't allow management *from* tagged nodes, so ignore caps.
 | |
| 		// The web client auth flow relies on having a true user identity
 | |
| 		// that can be verified through login.
 | |
| 		return peerCapabilities{}, nil
 | |
| 	}
 | |
| 
 | |
| 	if !status.Self.IsTagged() {
 | |
| 		// User owned nodes are only ever manageable by the owner.
 | |
| 		if status.Self.UserID != whois.UserProfile.ID {
 | |
| 			return peerCapabilities{}, nil
 | |
| 		} else {
 | |
| 			return peerCapabilities{capFeatureAll: true}, nil // owner can edit all features
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// For tagged nodes, we actually look at the granted capabilities.
 | |
| 	caps := peerCapabilities{}
 | |
| 	rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
 | |
| 	}
 | |
| 	for _, c := range rules {
 | |
| 		for _, f := range c.CanEdit {
 | |
| 			cap := capFeature(strings.ToLower(f))
 | |
| 			if slices.Contains(validCaps, cap) {
 | |
| 				caps[cap] = true
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 	return caps, nil
 | |
| }
 |