|
|
|
// 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"
|
|
|
|
"path/filepath"
|
|
|
|
"slices"
|
|
|
|
"strings"
|
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/gorilla/csrf"
|
|
|
|
"tailscale.com/client/tailscale"
|
|
|
|
"tailscale.com/client/tailscale/apitype"
|
|
|
|
"tailscale.com/clientupdate"
|
|
|
|
"tailscale.com/envknob"
|
|
|
|
"tailscale.com/hostinfo"
|
|
|
|
"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"
|
|
|
|
"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
|
|
|
|
|
|
|
|
// newAuthURL creates a new auth URL that can be used to validate
|
|
|
|
// a browser session to manage this web client.
|
|
|
|
newAuthURL func(ctx context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
|
|
|
|
// waitWebClientAuthURL blocks until the associated auth URL has
|
|
|
|
// been completed by its user, or until ctx is canceled.
|
|
|
|
waitAuthURL func(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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"
|
|
|
|
|
|
|
|
// ReadOnlyServerMode is identical to LoginServerMode,
|
|
|
|
// but does not present a login button to switch to manage mode,
|
|
|
|
// even if the management client is running and reachable.
|
|
|
|
//
|
|
|
|
// This is designed for platforms where the device is configured by other means,
|
|
|
|
// such as Home Assistant's declarative YAML configuration.
|
|
|
|
ReadOnlyServerMode ServerMode = "readonly"
|
|
|
|
|
|
|
|
// 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"
|
|
|
|
)
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
// The following two fields are required and used exclusively
|
|
|
|
// in ManageServerMode to facilitate the control server login
|
|
|
|
// check step for authorizing browser sessions.
|
|
|
|
|
|
|
|
// NewAuthURL should be provided as a function that generates
|
|
|
|
// a new tailcfg.WebClientAuthResponse.
|
|
|
|
// This field is required for ManageServerMode mode.
|
|
|
|
NewAuthURL func(ctx context.Context, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
|
|
|
|
// WaitAuthURL should be provided as a function that blocks until
|
|
|
|
// the associated tailcfg.WebClientAuthResponse has been marked
|
|
|
|
// as completed.
|
|
|
|
// This field is required for ManageServerMode mode.
|
|
|
|
WaitAuthURL func(ctx context.Context, id string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error)
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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, ReadOnlyServerMode, ManageServerMode:
|
|
|
|
// 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,
|
|
|
|
newAuthURL: opts.NewAuthURL,
|
|
|
|
waitAuthURL: opts.WaitAuthURL,
|
|
|
|
}
|
|
|
|
if opts.PathPrefix != "" {
|
|
|
|
// Enforce that path prefix always has a single leading '/'
|
|
|
|
// so that it is treated as a relative URL path.
|
|
|
|
// We strip multiple leading '/' to prevent schema-less offsite URLs like "//example.com".
|
|
|
|
//
|
|
|
|
// See https://github.com/tailscale/corp/issues/16268.
|
|
|
|
s.pathPrefix = "/" + strings.TrimLeft(path.Clean(opts.PathPrefix), "/\\")
|
|
|
|
}
|
|
|
|
if s.mode == ManageServerMode {
|
|
|
|
if opts.NewAuthURL == nil {
|
|
|
|
return nil, fmt.Errorf("must provide a NewAuthURL implementation")
|
|
|
|
}
|
|
|
|
if opts.WaitAuthURL == nil {
|
|
|
|
return nil, fmt.Errorf("must provide WaitAuthURL implementation")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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))
|
|
|
|
switch s.mode {
|
|
|
|
case LoginServerMode:
|
|
|
|
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
|
|
|
metric = "web_login_client_initialization"
|
|
|
|
case ReadOnlyServerMode:
|
|
|
|
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
|
|
|
metric = "web_readonly_client_initialization"
|
|
|
|
case ManageServerMode:
|
|
|
|
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.
|
client/web: only enforce path prefix in CGI mode
The client has changed a bit since we introduced the path prefix. It is
now used for two things:
- its original purpose, of ensuring that when the client is run in CGI
mode at arbitrary paths, then relative paths for assets continue to
work
- we also now pass the path to the frontend and use wouter to manage
routes for the various subpages of the client.
When the client is run behind a reverse proxy (as it is in Home
Assistant), it is common for the proxy to rewrite the request so that
the backend application doesn't see the path it's being served at. In
this case, we don't need to call enforcePrefix, since it's already
stripped before it reaches us. However, wouter (or react router
library) still sees the original path in the browser, and needs to know
what part of it is the prefix that needs to be stripped off.
We're handling this by now only calling enforcePrefix when run in CGI
mode. For Home Assistant, or any other platform that runs the client
behind a reverse proxy with a custom path, they will still need to pass
the `-prefix` flag to `tailscale web`, but we will only use it for route
handling in the frontend.
Updates #10261
Signed-off-by: Will Norris <will@tailscale.com>
12 months ago
|
|
|
if s.cgiMode && 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 {
|
|
|
|
// This hash corresponds to the inline script in index.html that runs when the react app is unavailable.
|
|
|
|
// It was generated from https://csplite.com/csp/sha/.
|
|
|
|
// If the contents of the script are changed, this hash must be updated.
|
|
|
|
const indexScriptHash = "sha384-CW2AYVfS14P7QHZN27thEkMLKiCj3YNURPoLc1elwiEkMVHeuYTWkJOEki1F3nZc"
|
|
|
|
|
|
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src * data:; script-src 'self' '"+indexScriptHash+"'")
|
|
|
|
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
|
|
|
|
}
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
ipv4, ipv6 := s.selfNodeAddresses(r, st)
|
|
|
|
if r.Host == fmt.Sprintf("%s:%d", ipv4.String(), ListenPort) {
|
|
|
|
return false // already accessing over Tailscale IP
|
|
|
|
}
|
|
|
|
if r.Host == fmt.Sprintf("[%s]:%d", ipv6.String(), ListenPort) {
|
|
|
|
return false // already accessing over Tailscale IP
|
|
|
|
}
|
|
|
|
|
|
|
|
// Not currently accessing via Tailscale IP,
|
|
|
|
// redirect them.
|
|
|
|
|
|
|
|
var preferV6 bool
|
|
|
|
if ap, err := netip.ParseAddrPort(r.Host); err == nil {
|
|
|
|
// If Host was already ipv6, keep them on same protocol.
|
|
|
|
preferV6 = ap.Addr().Is6()
|
|
|
|
}
|
|
|
|
|
|
|
|
newURL := *r.URL
|
|
|
|
if (preferV6 && ipv6.IsValid()) || !ipv4.IsValid() {
|
|
|
|
newURL.Host = fmt.Sprintf("[%s]:%d", ipv6.String(), ListenPort)
|
|
|
|
} else {
|
|
|
|
newURL.Host = fmt.Sprintf("%s:%d", ipv4.String(), ListenPort)
|
|
|
|
}
|
|
|
|
http.Redirect(w, r, newURL.String(), http.StatusMovedPermanently)
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// selfNodeAddresses return the Tailscale IPv4 and IPv6 addresses for the self node.
|
|
|
|
// st is expected to be a status with peers included.
|
|
|
|
func (s *Server) selfNodeAddresses(r *http.Request, st *ipnstate.Status) (ipv4, ipv6 netip.Addr) {
|
|
|
|
for _, ip := range st.Self.TailscaleIPs {
|
|
|
|
if ip.Is4() {
|
|
|
|
ipv4 = ip
|
|
|
|
} else if ip.Is6() {
|
|
|
|
ipv6 = ip
|
|
|
|
}
|
|
|
|
if ipv4.IsValid() && ipv6.IsValid() {
|
|
|
|
break // found both IPs
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if whois, err := s.lc.WhoIs(r.Context(), r.RemoteAddr); err == nil {
|
|
|
|
// The source peer connecting to this node may know it by a different
|
|
|
|
// IP than the node knows itself as. Specifically, this may be the case
|
|
|
|
// if the peer is coming from a different tailnet (sharee node), as IPs
|
|
|
|
// are specific to each tailnet.
|
|
|
|
// Here, we check if the source peer knows the node by a different IP,
|
|
|
|
// and return the peer's version if so.
|
|
|
|
if knownIPv4 := whois.Node.SelfNodeV4MasqAddrForThisPeer; knownIPv4 != nil {
|
|
|
|
ipv4 = *knownIPv4
|
|
|
|
}
|
|
|
|
if knownIPv6 := whois.Node.SelfNodeV6MasqAddrForThisPeer; knownIPv6 != nil {
|
|
|
|
ipv6 = *knownIPv6
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return ipv4, ipv6
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
session, _, _, err := s.getSession(r)
|
|
|
|
switch {
|
|
|
|
case errors.Is(err, errNotUsingTailscale):
|
|
|
|
// 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 valid browser session.
|
|
|
|
return true
|
|
|
|
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
|
|
|
|
// Special case metric endpoint that is allowed without a browser session.
|
|
|
|
return true
|
|
|
|
case strings.HasPrefix(r.URL.Path, "/api/"):
|
|
|
|
// All other /api/ endpoints require a valid browser session.
|
|
|
|
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:
|
|
|
|
authorized, _ := authorizeSynology(r)
|
|
|
|
return authorized
|
|
|
|
case distro.QNAP:
|
|
|
|
authorized, _ := authorizeQNAP(r)
|
|
|
|
return authorized
|
|
|
|
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))
|
|
|
|
switch {
|
|
|
|
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
|
|
|
s.serveGetNodeData(w, r)
|
|
|
|
case r.URL.Path == "/api/up" && r.Method == httpm.POST:
|
|
|
|
s.serveTailscaleUp(w, r)
|
|
|
|
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
|
|
|
|
s.serveDeviceDetailsClick(w, r)
|
|
|
|
default:
|
|
|
|
http.Error(w, "invalid endpoint or method", http.StatusNotFound)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type apiHandler[data any] struct {
|
|
|
|
s *Server
|
|
|
|
w http.ResponseWriter
|
|
|
|
r *http.Request
|
|
|
|
|
|
|
|
// permissionCheck allows for defining whether a requesting peer's
|
|
|
|
// capabilities grant them access to make the given data update.
|
|
|
|
// If permissionCheck reports false, the request fails as unauthorized.
|
|
|
|
permissionCheck func(data data, peer peerCapabilities) bool
|
|
|
|
}
|
|
|
|
|
|
|
|
// newHandler constructs a new api handler which restricts the given request
|
|
|
|
// to the specified permission check. If the permission check fails for
|
|
|
|
// the peer associated with the request, an unauthorized error is returned
|
|
|
|
// to the client.
|
|
|
|
func newHandler[data any](s *Server, w http.ResponseWriter, r *http.Request, permissionCheck func(data data, peer peerCapabilities) bool) *apiHandler[data] {
|
|
|
|
return &apiHandler[data]{
|
|
|
|
s: s,
|
|
|
|
w: w,
|
|
|
|
r: r,
|
|
|
|
permissionCheck: permissionCheck,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// alwaysAllowed can be passed as the permissionCheck argument to newHandler
|
|
|
|
// for requests that are always allowed to complete regardless of a peer's
|
|
|
|
// capabilities.
|
|
|
|
func alwaysAllowed[data any](_ data, _ peerCapabilities) bool { return true }
|
|
|
|
|
|
|
|
func (a *apiHandler[data]) getPeer() (peerCapabilities, error) {
|
|
|
|
// TODO(tailscale/corp#16695,sonia): We also call StatusWithoutPeers and
|
|
|
|
// WhoIs when originally checking for a session from authorizeRequest.
|
|
|
|
// Would be nice if we could pipe those through to here so we don't end
|
|
|
|
// up having to re-call them to grab the peer capabilities.
|
|
|
|
status, err := a.s.lc.StatusWithoutPeers(a.r.Context())
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
whois, err := a.s.lc.WhoIs(a.r.Context(), a.r.RemoteAddr)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
peer, err := toPeerCapabilities(status, whois)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return peer, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
type noBodyData any // empty type, for use from serveAPI for endpoints with empty body
|
|
|
|
|
|
|
|
// handle runs the given handler if the source peer satisfies the
|
|
|
|
// constraints for running this request.
|
|
|
|
//
|
|
|
|
// handle is expected for use when `data` type is empty, or set to
|
|
|
|
// `noBodyData` in practice. For requests that expect JSON body data
|
|
|
|
// to be attached, use handleJSON instead.
|
|
|
|
func (a *apiHandler[data]) handle(h http.HandlerFunc) {
|
|
|
|
peer, err := a.getPeer()
|
|
|
|
if err != nil {
|
|
|
|
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
var body data // not used
|
|
|
|
if !a.permissionCheck(body, peer) {
|
|
|
|
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
h(a.w, a.r)
|
|
|
|
}
|
|
|
|
|
|
|
|
// handleJSON manages decoding the request's body JSON and passing
|
|
|
|
// it on to the provided function if the source peer satisfies the
|
|
|
|
// constraints for running this request.
|
|
|
|
func (a *apiHandler[data]) handleJSON(h func(ctx context.Context, data data) error) {
|
|
|
|
defer a.r.Body.Close()
|
|
|
|
var body data
|
|
|
|
if err := json.NewDecoder(a.r.Body).Decode(&body); err != nil {
|
|
|
|
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
peer, err := a.getPeer()
|
|
|
|
if err != nil {
|
|
|
|
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if !a.permissionCheck(body, peer) {
|
|
|
|
http.Error(a.w, "not allowed", http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := h(a.r.Context(), body); err != nil {
|
|
|
|
http.Error(a.w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
a.w.WriteHeader(http.StatusOK)
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
if r.Method == httpm.PATCH {
|
|
|
|
// Enforce that PATCH requests are always application/json.
|
|
|
|
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
|
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
w.Header().Set("X-CSRF-Token", csrf.Token(r))
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/api")
|
|
|
|
switch {
|
|
|
|
case path == "/data" && r.Method == httpm.GET:
|
|
|
|
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
|
handle(s.serveGetNodeData)
|
|
|
|
return
|
|
|
|
case path == "/exit-nodes" && r.Method == httpm.GET:
|
|
|
|
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
|
handle(s.serveGetExitNodes)
|
|
|
|
return
|
|
|
|
case path == "/routes" && r.Method == httpm.POST:
|
|
|
|
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
|
|
|
|
if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
|
|
|
|
return false
|
|
|
|
} else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
newHandler[postRoutesRequest](s, w, r, peerAllowed).
|
|
|
|
handleJSON(s.servePostRoutes)
|
|
|
|
return
|
|
|
|
case path == "/device-details-click" && r.Method == httpm.POST:
|
|
|
|
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
|
handle(s.serveDeviceDetailsClick)
|
|
|
|
return
|
|
|
|
case path == "/local/v0/logout" && r.Method == httpm.POST:
|
|
|
|
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
|
|
|
return peer.canEdit(capFeatureAccount)
|
|
|
|
}
|
|
|
|
newHandler[noBodyData](s, w, r, peerAllowed).
|
|
|
|
handle(s.proxyRequestToLocalAPI)
|
|
|
|
return
|
|
|
|
case path == "/local/v0/prefs" && r.Method == httpm.PATCH:
|
|
|
|
peerAllowed := func(data maskedPrefs, peer peerCapabilities) bool {
|
|
|
|
if data.RunSSHSet && !peer.canEdit(capFeatureSSH) {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
newHandler[maskedPrefs](s, w, r, peerAllowed).
|
|
|
|
handleJSON(s.serveUpdatePrefs)
|
|
|
|
return
|
|
|
|
case path == "/local/v0/update/check" && r.Method == httpm.GET:
|
|
|
|
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
|
handle(s.proxyRequestToLocalAPI)
|
|
|
|
return
|
|
|
|
case path == "/local/v0/update/check" && r.Method == httpm.POST:
|
|
|
|
peerAllowed := func(_ noBodyData, peer peerCapabilities) bool {
|
|
|
|
return peer.canEdit(capFeatureAccount)
|
|
|
|
}
|
|
|
|
newHandler[noBodyData](s, w, r, peerAllowed).
|
|
|
|
handle(s.proxyRequestToLocalAPI)
|
|
|
|
return
|
|
|
|
case path == "/local/v0/update/progress" && r.Method == httpm.POST:
|
|
|
|
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
|
handle(s.proxyRequestToLocalAPI)
|
|
|
|
return
|
|
|
|
case path == "/local/v0/upload-client-metrics" && r.Method == httpm.POST:
|
|
|
|
newHandler[noBodyData](s, w, r, alwaysAllowed).
|
|
|
|
handle(s.proxyRequestToLocalAPI)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
|
|
|
}
|
|
|
|
|
|
|
|
type authResponse struct {
|
|
|
|
ServerMode ServerMode `json:"serverMode"`
|
|
|
|
Authorized bool `json:"authorized"` // has an authorized management session
|
|
|
|
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
|
|
|
|
NeedsSynoAuth bool `json:"needsSynoAuth,omitempty"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// viewerIdentity is the Tailscale identity of the source node
|
|
|
|
// connected to this web client.
|
|
|
|
type viewerIdentity struct {
|
|
|
|
LoginName string `json:"loginName"`
|
|
|
|
NodeName string `json:"nodeName"`
|
|
|
|
NodeIP string `json:"nodeIP"`
|
|
|
|
ProfilePicURL string `json:"profilePicUrl,omitempty"`
|
|
|
|
Capabilities peerCapabilities `json:"capabilities"` // features peer is allowed to edit
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
resp.ServerMode = s.mode
|
|
|
|
session, whois, status, sErr := s.getSession(r)
|
|
|
|
var caps peerCapabilities
|
|
|
|
|
|
|
|
if whois != nil {
|
|
|
|
var err error
|
|
|
|
caps, err = toPeerCapabilities(status, whois)
|
|
|
|
if err != nil {
|
|
|
|
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
resp.ViewerIdentity = &viewerIdentity{
|
|
|
|
LoginName: whois.UserProfile.LoginName,
|
|
|
|
NodeName: whois.Node.Name,
|
|
|
|
ProfilePicURL: whois.UserProfile.ProfilePicURL,
|
|
|
|
Capabilities: caps,
|
|
|
|
}
|
|
|
|
if addrs := whois.Node.Addresses; len(addrs) > 0 {
|
|
|
|
resp.ViewerIdentity.NodeIP = addrs[0].Addr().String()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// First verify platform auth.
|
|
|
|
// If platform auth is needed, this should happen first.
|
|
|
|
if s.mode == LoginServerMode || s.mode == ReadOnlyServerMode {
|
|
|
|
switch distro.Get() {
|
|
|
|
case distro.Synology:
|
|
|
|
authorized, err := authorizeSynology(r)
|
|
|
|
if err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if !authorized {
|
|
|
|
resp.NeedsSynoAuth = true
|
|
|
|
writeJSON(w, resp)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
case distro.QNAP:
|
|
|
|
if _, err := authorizeQNAP(r); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
// no additional auth for this distro
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
switch {
|
|
|
|
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
|
|
|
resp.Authorized = false // restricted to the readonly view
|
|
|
|
case sErr != nil && errors.Is(sErr, errNotOwner):
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
|
|
|
|
resp.Authorized = false // restricted to the readonly view
|
|
|
|
case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
|
|
|
|
resp.Authorized = false // restricted to the readonly view
|
|
|
|
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
|
|
|
|
resp.Authorized = false // restricted to the readonly view
|
|
|
|
case sErr != nil && !errors.Is(sErr, errNoSession):
|
|
|
|
// Any other error.
|
|
|
|
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
case session.isAuthorized(s.timeNow()):
|
|
|
|
if whois.Node.StableID == status.Self.ID {
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_managing_local", 1)
|
|
|
|
} else {
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
|
|
|
|
}
|
|
|
|
// User has a valid session. They're now authorized to edit if they
|
|
|
|
// have any edit capabilities. In practice, they won't be sent through
|
|
|
|
// the auth flow if they don't have edit caps, but their ACL granted
|
|
|
|
// permissions may change at any time. The frontend views and backend
|
|
|
|
// endpoints are always restricted to their current capabilities in
|
|
|
|
// addition to a valid session.
|
|
|
|
//
|
|
|
|
// But, we also check the caps here for a better user experience on
|
|
|
|
// the frontend login toggle, which uses resp.Authorized to display
|
|
|
|
// "viewing" vs "managing" copy. If they don't have caps, we want to
|
|
|
|
// display "viewing" even if they have a valid session.
|
|
|
|
resp.Authorized = !caps.isEmpty()
|
|
|
|
default:
|
|
|
|
if whois == nil || (whois.Node.StableID == status.Self.ID) {
|
|
|
|
// whois being nil implies local as the request did not come over Tailscale.
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
|
|
|
} else {
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
|
|
|
|
}
|
|
|
|
resp.Authorized = false // not yet authorized
|
|
|
|
}
|
|
|
|
|
|
|
|
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: "/",
|
|
|
|
HttpOnly: true,
|
|
|
|
SameSite: http.SameSiteStrictMode,
|
|
|
|
Expires: session.expires(),
|
|
|
|
// We can't set Secure to true because we serve over HTTP
|
|
|
|
// (but only on Tailscale IPs, hence over encrypted
|
|
|
|
// connections that a LAN-local attacker cannot sniff).
|
|
|
|
// In the future, we could support HTTPS requests using
|
|
|
|
// the full MagicDNS hostname, and could set this.
|
|
|
|
// Secure: true,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type nodeData struct {
|
|
|
|
ID tailcfg.StableNodeID
|
|
|
|
Status string
|
|
|
|
DeviceName string
|
|
|
|
TailnetName string // TLS cert name
|
|
|
|
DomainName string
|
|
|
|
IPv4 string
|
|
|
|
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
|
|
|
|
|
|
|
|
UsingExitNode *exitNode
|
|
|
|
AdvertisingExitNode bool
|
|
|
|
AdvertisingExitNodeApproved bool // whether running this node as an exit node has been approved by an admin
|
|
|
|
AdvertisedRoutes []subnetRoute // excludes exit node routes
|
|
|
|
RunningSSHServer bool
|
|
|
|
|
|
|
|
ClientVersion *tailcfg.ClientVersion
|
|
|
|
|
|
|
|
// whether tailnet ACLs allow access to port 5252 on this device
|
|
|
|
ACLAllowsAnyIncomingTraffic bool
|
|
|
|
|
|
|
|
ControlAdminURL string
|
|
|
|
LicensesURL string
|
|
|
|
|
|
|
|
// Features is the set of available features for use on the
|
|
|
|
// current platform. e.g. "ssh", "advertise-exit-node", etc.
|
|
|
|
// Map value is true if the given feature key is available.
|
|
|
|
//
|
|
|
|
// See web.availableFeatures func for population of this field.
|
|
|
|
// Contents are expected to match values defined in node-data.ts
|
|
|
|
// on the frontend.
|
|
|
|
Features map[string]bool
|
|
|
|
}
|
|
|
|
|
|
|
|
type subnetRoute struct {
|
|
|
|
Route string
|
|
|
|
Approved bool // approved by control server
|
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
filterRules, _ := s.lc.DebugPacketFilterRules(r.Context())
|
|
|
|
data := &nodeData{
|
|
|
|
ID: st.Self.ID,
|
|
|
|
Status: st.BackendState,
|
|
|
|
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
|
|
|
|
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"),
|
|
|
|
RunningSSHServer: prefs.RunSSH,
|
|
|
|
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
|
|
|
|
ControlAdminURL: prefs.AdminPageURL(),
|
|
|
|
LicensesURL: licenses.LicensesURL(),
|
|
|
|
Features: availableFeatures(),
|
|
|
|
|
|
|
|
ACLAllowsAnyIncomingTraffic: s.aclsAllowAccess(filterRules),
|
|
|
|
}
|
|
|
|
|
|
|
|
ipv4, ipv6 := s.selfNodeAddresses(r, st)
|
|
|
|
data.IPv4 = ipv4.String()
|
|
|
|
data.IPv6 = ipv6.String()
|
|
|
|
|
|
|
|
if hostinfo.GetEnvType() == hostinfo.HomeAssistantAddOn && data.URLPrefix == "" {
|
|
|
|
// X-Ingress-Path is the path prefix in use for Home Assistant
|
|
|
|
// https://developers.home-assistant.io/docs/add-ons/presentation#ingress
|
|
|
|
data.URLPrefix = r.Header.Get("X-Ingress-Path")
|
|
|
|
}
|
|
|
|
|
|
|
|
cv, err := s.lc.CheckUpdate(r.Context())
|
|
|
|
if err != nil {
|
|
|
|
s.logf("could not check for updates: %v", err)
|
|
|
|
} else {
|
|
|
|
data.ClientVersion = cv
|
|
|
|
}
|
|
|
|
|
|
|
|
if st.CurrentTailnet != nil {
|
|
|
|
data.TailnetName = st.CurrentTailnet.MagicDNSSuffix
|
|
|
|
data.DomainName = st.CurrentTailnet.Name
|
|
|
|
}
|
|
|
|
if st.Self.Tags != nil {
|
|
|
|
data.Tags = st.Self.Tags.AsSlice()
|
|
|
|
}
|
|
|
|
if st.Self.KeyExpiry != nil {
|
|
|
|
data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
|
|
|
|
}
|
|
|
|
|
|
|
|
routeApproved := func(route netip.Prefix) bool {
|
|
|
|
if st.Self == nil || st.Self.AllowedIPs == nil {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
return st.Self.AllowedIPs.ContainsFunc(func(p netip.Prefix) bool {
|
|
|
|
return p == route
|
|
|
|
})
|
|
|
|
}
|
|
|
|
data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6)
|
|
|
|
|
|
|
|
for _, r := range prefs.AdvertiseRoutes {
|
|
|
|
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
|
|
|
data.AdvertisingExitNode = true
|
|
|
|
} else {
|
|
|
|
data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{
|
|
|
|
Route: r.String(),
|
|
|
|
Approved: routeApproved(r),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if e := st.ExitNodeStatus; e != nil {
|
|
|
|
data.UsingExitNode = &exitNode{
|
|
|
|
ID: e.ID,
|
|
|
|
Online: e.Online,
|
|
|
|
}
|
|
|
|
for _, ps := range st.Peer {
|
|
|
|
if ps.ID == e.ID {
|
|
|
|
data.UsingExitNode.Name = ps.DNSName
|
|
|
|
data.UsingExitNode.Location = ps.Location
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if data.UsingExitNode.Name == "" {
|
|
|
|
// Falling back to TailscaleIP/StableNodeID when the peer
|
|
|
|
// is no longer included in status.
|
|
|
|
if len(e.TailscaleIPs) > 0 {
|
|
|
|
data.UsingExitNode.Name = e.TailscaleIPs[0].Addr().String()
|
|
|
|
} else {
|
|
|
|
data.UsingExitNode.Name = string(e.ID)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
writeJSON(w, *data)
|
|
|
|
}
|
|
|
|
|
|
|
|
func availableFeatures() map[string]bool {
|
|
|
|
env := hostinfo.GetEnvType()
|
|
|
|
features := map[string]bool{
|
|
|
|
"advertise-exit-node": true, // available on all platforms
|
|
|
|
"advertise-routes": true, // available on all platforms
|
|
|
|
"use-exit-node": canUseExitNode(env) == nil,
|
|
|
|
"ssh": envknob.CanRunTailscaleSSH() == nil,
|
|
|
|
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
|
|
|
|
}
|
|
|
|
if env == hostinfo.HomeAssistantAddOn {
|
|
|
|
// Setting SSH on Home Assistant causes trouble on startup
|
|
|
|
// (since the flag is not being passed to `tailscale up`).
|
|
|
|
// Although Tailscale SSH does work here,
|
|
|
|
// it's not terribly useful since it's running in a separate container.
|
|
|
|
features["ssh"] = false
|
|
|
|
}
|
|
|
|
return features
|
|
|
|
}
|
|
|
|
|
|
|
|
func canUseExitNode(env hostinfo.EnvType) error {
|
|
|
|
switch dist := distro.Get(); dist {
|
|
|
|
case distro.Synology, // see https://github.com/tailscale/tailscale/issues/1995
|
|
|
|
distro.QNAP,
|
|
|
|
distro.Unraid:
|
|
|
|
return fmt.Errorf("Tailscale exit nodes cannot be used on %s.", dist)
|
|
|
|
}
|
|
|
|
if env == hostinfo.HomeAssistantAddOn {
|
|
|
|
return errors.New("Tailscale exit nodes cannot be used on Home Assistant.")
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// aclsAllowAccess returns whether tailnet ACLs (as expressed in the provided filter rules)
|
|
|
|
// permit any devices to access the local web client.
|
|
|
|
// This does not currently check whether a specific device can connect, just any device.
|
|
|
|
func (s *Server) aclsAllowAccess(rules []tailcfg.FilterRule) bool {
|
|
|
|
for _, rule := range rules {
|
|
|
|
for _, dp := range rule.DstPorts {
|
|
|
|
if dp.Ports.Contains(ListenPort) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
type exitNode struct {
|
|
|
|
ID tailcfg.StableNodeID
|
|
|
|
Name string
|
|
|
|
Location *tailcfg.Location
|
|
|
|
Online bool
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) serveGetExitNodes(w http.ResponseWriter, r *http.Request) {
|
|
|
|
st, err := s.lc.Status(r.Context())
|
|
|
|
if err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
var exitNodes []*exitNode
|
|
|
|
for _, ps := range st.Peer {
|
|
|
|
if !ps.ExitNodeOption {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
exitNodes = append(exitNodes, &exitNode{
|
|
|
|
ID: ps.ID,
|
|
|
|
Name: ps.DNSName,
|
|
|
|
Location: ps.Location,
|
|
|
|
Online: ps.Online,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
writeJSON(w, exitNodes)
|
|
|
|
}
|
|
|
|
|
|
|
|
// maskedPrefs is the subset of ipn.MaskedPrefs that are
|
|
|
|
// allowed to be editable via the web UI.
|
|
|
|
type maskedPrefs struct {
|
|
|
|
RunSSHSet bool
|
|
|
|
RunSSH bool
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) serveUpdatePrefs(ctx context.Context, prefs maskedPrefs) error {
|
|
|
|
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
|
|
|
RunSSHSet: prefs.RunSSHSet,
|
|
|
|
Prefs: ipn.Prefs{
|
|
|
|
RunSSH: prefs.RunSSH,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
type postRoutesRequest struct {
|
|
|
|
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
|
|
|
|
SetRoutes bool // when set, AdvertiseRoutes value is applied
|
|
|
|
UseExitNode tailcfg.StableNodeID
|
|
|
|
AdvertiseExitNode bool
|
|
|
|
AdvertiseRoutes []string
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) servePostRoutes(ctx context.Context, data postRoutesRequest) error {
|
|
|
|
prefs, err := s.lc.GetPrefs(ctx)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
var currNonExitRoutes []string
|
|
|
|
var currAdvertisingExitNode bool
|
|
|
|
for _, r := range prefs.AdvertiseRoutes {
|
|
|
|
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
|
|
|
currAdvertisingExitNode = true
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
currNonExitRoutes = append(currNonExitRoutes, r.String())
|
|
|
|
}
|
|
|
|
// Set non-edited fields to their current values.
|
|
|
|
if data.SetExitNode {
|
|
|
|
data.AdvertiseRoutes = currNonExitRoutes
|
|
|
|
} else if data.SetRoutes {
|
|
|
|
data.AdvertiseExitNode = currAdvertisingExitNode
|
|
|
|
data.UseExitNode = prefs.ExitNodeID
|
|
|
|
}
|
|
|
|
|
|
|
|
// Calculate routes.
|
|
|
|
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
|
|
|
routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
hasExitNodeRoute := func(all []netip.Prefix) bool {
|
|
|
|
return slices.Contains(all, exitNodeRouteV4) ||
|
|
|
|
slices.Contains(all, exitNodeRouteV6)
|
|
|
|
}
|
|
|
|
|
|
|
|
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
|
|
|
|
return errors.New("cannot use and advertise exit node at same time")
|
|
|
|
}
|
|
|
|
|
|
|
|
// Make prefs update.
|
|
|
|
p := &ipn.MaskedPrefs{
|
|
|
|
AdvertiseRoutesSet: true,
|
|
|
|
ExitNodeIDSet: true,
|
|
|
|
Prefs: ipn.Prefs{
|
|
|
|
ExitNodeID: data.UseExitNode,
|
|
|
|
AdvertiseRoutes: routes,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
_, err = s.lc.EditPrefs(ctx, p)
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// tailscaleUp starts the daemon with the provided options.
|
|
|
|
// If reauthentication has been requested, an authURL is returned to complete device registration.
|
|
|
|
func (s *Server) tailscaleUp(ctx context.Context, st *ipnstate.Status, opt tailscaleUpOptions) (authURL string, retErr error) {
|
|
|
|
origAuthURL := st.AuthURL
|
|
|
|
isRunning := st.BackendState == ipn.Running.String()
|
|
|
|
|
|
|
|
if !opt.Reauthenticate {
|
|
|
|
switch {
|
|
|
|
case origAuthURL != "":
|
|
|
|
return origAuthURL, nil
|
|
|
|
case isRunning:
|
|
|
|
return "", nil
|
|
|
|
case st.BackendState == ipn.Stopped.String():
|
|
|
|
// stopped and not reauthenticating, so just start running
|
|
|
|
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
|
|
|
Prefs: ipn.Prefs{
|
|
|
|
WantRunning: true,
|
|
|
|
},
|
|
|
|
WantRunningSet: true,
|
|
|
|
})
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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 {
|
|
|
|
ipnOptions := ipn.Options{AuthKey: opt.AuthKey}
|
|
|
|
if opt.ControlURL != "" {
|
|
|
|
_, err := s.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
|
|
|
|
Prefs: ipn.Prefs{
|
|
|
|
ControlURL: opt.ControlURL,
|
|
|
|
},
|
|
|
|
ControlURLSet: true,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
s.logf("edit prefs: %v", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err := s.lc.Start(ctx, ipnOptions); err != nil {
|
|
|
|
s.logf("start: %v", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if opt.Reauthenticate {
|
|
|
|
if err := s.lc.StartLoginInteractive(ctx); err != nil {
|
|
|
|
s.logf("startLogin: %v", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
for {
|
|
|
|
n, err := watcher.Next()
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
if n.State != nil && *n.State == ipn.Running {
|
|
|
|
return "", nil
|
|
|
|
}
|
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type tailscaleUpOptions struct {
|
|
|
|
// If true, force reauthentication of the client.
|
|
|
|
// Otherwise simply reconnect, the same as running `tailscale up`.
|
|
|
|
Reauthenticate bool
|
|
|
|
|
|
|
|
ControlURL string
|
|
|
|
AuthKey string
|
|
|
|
}
|
|
|
|
|
|
|
|
// serveTailscaleUp serves requests to /api/up.
|
|
|
|
// If the user needs to authenticate, an authURL is provided in the response.
|
|
|
|
func (s *Server) serveTailscaleUp(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 opt tailscaleUpOptions
|
|
|
|
type mi map[string]any
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&opt); err != nil {
|
|
|
|
w.WriteHeader(400)
|
|
|
|
json.NewEncoder(w).Encode(mi{"error": err.Error()})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
s.logf("tailscaleUp(reauth=%v) ...", opt.Reauthenticate)
|
|
|
|
url, err := s.tailscaleUp(r.Context(), st, opt)
|
|
|
|
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, "{}")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// serveDeviceDetailsClick increments the web_client_device_details_click metric
|
|
|
|
// by one.
|
|
|
|
//
|
|
|
|
// Metric logging from the frontend typically is proxied to the localapi. This event
|
|
|
|
// has been special cased as access to the localapi is gated upon having a valid
|
|
|
|
// session which is not always the case when we want to be logging this metric (e.g.,
|
|
|
|
// when in readonly mode).
|
|
|
|
//
|
|
|
|
// Other metrics should not be logged in this way without a good reason.
|
|
|
|
func (s *Server) serveDeviceDetailsClick(w http.ResponseWriter, r *http.Request) {
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_device_details_click", 1)
|
|
|
|
|
|
|
|
io.WriteString(w, "{}")
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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/.
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
}
|