| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339 |
- // 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(s.polc))
- 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
- }
|