2023-08-08 23:58:45 +00:00
|
|
|
// Copyright (c) Tailscale Inc & AUTHORS
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
// Package web provides the Tailscale client for web.
|
|
|
|
package web
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2023-08-16 22:52:31 +00:00
|
|
|
"crypto/rand"
|
2023-08-08 23:58:45 +00:00
|
|
|
"encoding/json"
|
2023-10-05 18:48:45 +00:00
|
|
|
"errors"
|
2023-08-08 23:58:45 +00:00
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"log"
|
|
|
|
"net/http"
|
|
|
|
"net/netip"
|
|
|
|
"os"
|
2023-08-23 22:22:24 +00:00
|
|
|
"path/filepath"
|
2023-08-28 20:44:48 +00:00
|
|
|
"slices"
|
2023-08-08 23:58:45 +00:00
|
|
|
"strings"
|
2023-10-04 14:35:19 +00:00
|
|
|
"sync"
|
|
|
|
"time"
|
2023-08-08 23:58:45 +00:00
|
|
|
|
2023-08-16 22:52:31 +00:00
|
|
|
"github.com/gorilla/csrf"
|
2023-08-08 23:58:45 +00:00
|
|
|
"tailscale.com/client/tailscale"
|
2023-08-28 20:44:48 +00:00
|
|
|
"tailscale.com/client/tailscale/apitype"
|
2023-11-30 18:01:29 +00:00
|
|
|
"tailscale.com/clientupdate"
|
2023-08-08 23:58:45 +00:00
|
|
|
"tailscale.com/envknob"
|
2023-12-09 00:09:13 +00:00
|
|
|
"tailscale.com/hostinfo"
|
2023-08-08 23:58:45 +00:00
|
|
|
"tailscale.com/ipn"
|
|
|
|
"tailscale.com/ipn/ipnstate"
|
|
|
|
"tailscale.com/licenses"
|
|
|
|
"tailscale.com/net/netutil"
|
2023-11-03 03:05:40 +00:00
|
|
|
"tailscale.com/net/tsaddr"
|
2023-08-08 23:58:45 +00:00
|
|
|
"tailscale.com/tailcfg"
|
2023-10-11 18:35:22 +00:00
|
|
|
"tailscale.com/types/logger"
|
2023-08-25 15:27:22 +00:00
|
|
|
"tailscale.com/util/httpm"
|
2023-12-08 22:39:15 +00:00
|
|
|
"tailscale.com/version"
|
2023-08-08 23:58:45 +00:00
|
|
|
"tailscale.com/version/distro"
|
|
|
|
)
|
|
|
|
|
2023-11-03 03:05:40 +00:00
|
|
|
// 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
|
|
|
|
|
2023-08-09 17:14:03 +00:00
|
|
|
// Server is the backend server for a Tailscale web client.
|
|
|
|
type Server struct {
|
2023-11-02 19:13:22 +00:00
|
|
|
mode ServerMode
|
|
|
|
|
2023-10-11 18:35:22 +00:00
|
|
|
logf logger.Logf
|
2023-10-19 20:13:40 +00:00
|
|
|
lc *tailscale.LocalClient
|
|
|
|
timeNow func() time.Time
|
2023-08-10 17:58:59 +00:00
|
|
|
|
2023-11-02 22:19:16 +00:00
|
|
|
// devMode indicates that the server run with frontend assets
|
|
|
|
// served by a Vite dev server, allowing for local development
|
|
|
|
// on the web client frontend.
|
2023-11-02 19:13:22 +00:00
|
|
|
devMode bool
|
2023-08-23 22:22:24 +00:00
|
|
|
cgiMode bool
|
2023-08-31 21:27:41 +00:00
|
|
|
pathPrefix string
|
2023-09-08 19:30:07 +00:00
|
|
|
|
|
|
|
apiHandler http.Handler // serves api endpoints; csrf-protected
|
2023-10-11 18:35:22 +00:00
|
|
|
assetsHandler http.Handler // serves frontend assets
|
|
|
|
assetsCleanup func() // called from Server.Shutdown
|
2023-10-04 14:35:19 +00:00
|
|
|
|
|
|
|
// 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).
|
2023-10-19 20:13:40 +00:00
|
|
|
browserSessions sync.Map
|
2023-11-16 22:53:46 +00:00
|
|
|
|
|
|
|
// 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)
|
2023-10-04 14:35:19 +00:00
|
|
|
}
|
|
|
|
|
2023-11-02 19:13:22 +00:00
|
|
|
// 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"
|
|
|
|
)
|
|
|
|
|
2023-10-12 21:02:20 +00:00
|
|
|
var (
|
|
|
|
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
|
|
|
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
|
|
|
)
|
|
|
|
|
2023-08-23 22:22:24 +00:00
|
|
|
// ServerOpts contains options for constructing a new Server.
|
|
|
|
type ServerOpts struct {
|
2023-11-02 19:13:22 +00:00
|
|
|
// Mode specifies the mode of web client being constructed.
|
|
|
|
Mode ServerMode
|
|
|
|
|
2023-08-23 22:22:24 +00:00
|
|
|
// CGIMode indicates if the server is running as a CGI script.
|
|
|
|
CGIMode bool
|
|
|
|
|
2023-08-31 21:27:41 +00:00
|
|
|
// PathPrefix is the URL prefix added to requests by CGI or reverse proxy.
|
|
|
|
PathPrefix string
|
2023-08-24 19:56:09 +00:00
|
|
|
|
2023-08-23 22:22:24 +00:00
|
|
|
// LocalClient is the tailscale.LocalClient to use for this web server.
|
|
|
|
// If nil, a new one will be created.
|
|
|
|
LocalClient *tailscale.LocalClient
|
2023-10-19 20:13:40 +00:00
|
|
|
|
|
|
|
// TimeNow optionally provides a time function.
|
|
|
|
// time.Now is used as default.
|
|
|
|
TimeNow func() time.Time
|
2023-10-11 18:35:22 +00:00
|
|
|
|
2023-11-02 19:13:22 +00:00
|
|
|
// Logf optionally provides a logger function.
|
|
|
|
// log.Printf is used as default.
|
2023-10-11 18:35:22 +00:00
|
|
|
Logf logger.Logf
|
2023-11-16 22:53:46 +00:00
|
|
|
|
|
|
|
// 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)
|
2023-08-23 22:22:24 +00:00
|
|
|
}
|
|
|
|
|
2023-08-09 17:14:03 +00:00
|
|
|
// NewServer constructs a new Tailscale web client server.
|
2023-10-11 18:35:22 +00:00
|
|
|
// 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) {
|
2023-11-02 19:13:22 +00:00
|
|
|
switch opts.Mode {
|
2023-11-15 21:50:03 +00:00
|
|
|
case LoginServerMode, ManageServerMode:
|
2023-11-02 19:13:22 +00:00
|
|
|
// valid types
|
|
|
|
case "":
|
|
|
|
return nil, fmt.Errorf("must specify a Mode")
|
|
|
|
default:
|
|
|
|
return nil, fmt.Errorf("invalid Mode provided")
|
|
|
|
}
|
2023-08-23 22:22:24 +00:00
|
|
|
if opts.LocalClient == nil {
|
|
|
|
opts.LocalClient = &tailscale.LocalClient{}
|
2023-08-09 17:14:03 +00:00
|
|
|
}
|
2023-08-10 17:58:59 +00:00
|
|
|
s = &Server{
|
2023-11-16 22:53:46 +00:00
|
|
|
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 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")
|
|
|
|
}
|
2023-10-19 20:13:40 +00:00
|
|
|
}
|
|
|
|
if s.timeNow == nil {
|
|
|
|
s.timeNow = time.Now
|
2023-08-09 17:14:03 +00:00
|
|
|
}
|
2023-10-11 18:35:22 +00:00
|
|
|
if s.logf == nil {
|
|
|
|
s.logf = log.Printf
|
|
|
|
}
|
2023-11-02 22:19:16 +00:00
|
|
|
s.assetsHandler, s.assetsCleanup = assetsHandler(s.devMode)
|
2023-08-24 20:24:57 +00:00
|
|
|
|
2023-10-11 18:35:22 +00:00
|
|
|
var metric string // clientmetric to report on startup
|
|
|
|
|
2023-08-22 23:14:00 +00:00
|
|
|
// 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))
|
2023-11-02 19:13:22 +00:00
|
|
|
if s.mode == LoginServerMode {
|
2023-09-26 19:57:40 +00:00
|
|
|
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
2023-10-11 18:35:22 +00:00
|
|
|
metric = "web_login_client_initialization"
|
2023-09-26 19:57:40 +00:00
|
|
|
} else {
|
|
|
|
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
|
2023-10-11 18:35:22 +00:00
|
|
|
metric = "web_client_initialization"
|
2023-09-26 19:57:40 +00:00
|
|
|
}
|
2023-08-22 23:14:00 +00:00
|
|
|
|
2023-10-11 18:35:22 +00:00
|
|
|
// Don't block startup on reporting metric.
|
|
|
|
// Report in separate go routine with 5 second timeout.
|
2023-10-11 18:35:22 +00:00
|
|
|
go func() {
|
2023-10-11 18:35:22 +00:00
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
2023-10-11 18:35:22 +00:00
|
|
|
defer cancel()
|
|
|
|
s.lc.IncrementCounter(ctx, metric, 1)
|
|
|
|
}()
|
|
|
|
|
2023-10-11 18:35:22 +00:00
|
|
|
return s, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) Shutdown() {
|
2023-11-02 19:13:22 +00:00
|
|
|
s.logf("web.Server: shutting down")
|
2023-10-11 18:35:22 +00:00
|
|
|
if s.assetsCleanup != nil {
|
|
|
|
s.assetsCleanup()
|
|
|
|
}
|
2023-08-09 17:14:03 +00:00
|
|
|
}
|
2023-08-08 23:58:45 +00:00
|
|
|
|
2023-08-24 19:46:51 +00:00
|
|
|
// ServeHTTP processes all requests for the Tailscale web client.
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
2023-08-24 19:56:09 +00:00
|
|
|
handler := s.serve
|
|
|
|
|
2023-08-31 21:27:41 +00:00
|
|
|
// 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>
2023-12-08 22:42:48 +00:00
|
|
|
if s.cgiMode && s.pathPrefix != "" {
|
2023-08-31 21:27:41 +00:00
|
|
|
handler = enforcePrefix(s.pathPrefix, handler)
|
2023-08-24 19:56:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
handler(w, r)
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
|
|
|
|
2023-09-26 19:57:40 +00:00
|
|
|
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
2023-11-03 03:05:40 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-11-04 00:27:49 +00:00
|
|
|
if !s.devMode {
|
2023-12-12 03:33:38 +00:00
|
|
|
// 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"
|
|
|
|
|
2023-11-04 00:27:49 +00:00
|
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
2023-12-12 03:33:38 +00:00
|
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src * data:; script-src 'self' '"+indexScriptHash+"'")
|
2023-11-04 00:27:49 +00:00
|
|
|
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
|
|
|
|
}
|
2023-11-03 03:05:40 +00:00
|
|
|
}
|
|
|
|
|
2023-09-26 19:57:40 +00:00
|
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
2023-11-03 17:38:01 +00:00
|
|
|
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
|
2023-11-02 18:28:07 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
if ok := s.authorizeRequest(w, r); !ok {
|
|
|
|
http.Error(w, "not authorized", http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
2023-09-26 19:57:40 +00:00
|
|
|
// Pass API requests through to the API handler.
|
|
|
|
s.apiHandler.ServeHTTP(w, r)
|
|
|
|
return
|
2023-08-24 21:40:17 +00:00
|
|
|
}
|
2023-09-26 19:57:40 +00:00
|
|
|
s.assetsHandler.ServeHTTP(w, r)
|
|
|
|
}
|
2023-08-24 21:40:17 +00:00
|
|
|
|
2023-11-03 03:05:40 +00:00
|
|
|
// 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)
|
2023-11-03 21:46:53 +00:00
|
|
|
if r.Host == ipv4ServiceHost || r.Host == ipv6ServiceHost {
|
2023-11-03 03:05:40 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-10-17 20:07:37 +00:00
|
|
|
// authorizeRequest reports whether the request from the web client
|
|
|
|
// is authorized to be completed.
|
2023-09-26 19:57:40 +00:00
|
|
|
// It reports true if the request is authorized, and false otherwise.
|
2023-10-17 20:07:37 +00:00
|
|
|
// authorizeRequest manages writing out any relevant authorization
|
2023-09-26 19:57:40 +00:00
|
|
|
// errors to the ResponseWriter itself.
|
2023-10-17 20:07:37 +00:00
|
|
|
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
2023-11-02 19:13:22 +00:00
|
|
|
if s.mode == ManageServerMode { // client using tailscale auth
|
2023-12-08 20:15:57 +00:00
|
|
|
session, _, _, err := s.getSession(r)
|
2023-10-17 20:07:37 +00:00
|
|
|
switch {
|
2023-11-29 18:16:32 +00:00
|
|
|
case errors.Is(err, errNotUsingTailscale):
|
2023-10-17 20:07:37 +00:00
|
|
|
// 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:
|
2023-11-29 18:16:32 +00:00
|
|
|
// Readonly endpoint allowed without valid browser session.
|
2023-10-17 20:07:37 +00:00
|
|
|
return true
|
2023-12-11 17:50:06 +00:00
|
|
|
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
|
2023-10-17 20:07:37 +00:00
|
|
|
case strings.HasPrefix(r.URL.Path, "/api/"):
|
|
|
|
// All other /api/ endpoints require a valid browser session.
|
2023-10-23 16:36:21 +00:00
|
|
|
if err != nil || !session.isAuthorized(s.timeNow()) {
|
2023-10-17 20:07:37 +00:00
|
|
|
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.
|
2023-11-02 18:28:07 +00:00
|
|
|
switch distro.Get() {
|
|
|
|
case distro.Synology:
|
2023-11-09 21:19:22 +00:00
|
|
|
authorized, _ := authorizeSynology(r)
|
|
|
|
return authorized
|
2023-11-02 18:28:07 +00:00
|
|
|
case distro.QNAP:
|
2023-11-09 21:19:22 +00:00
|
|
|
authorized, _ := authorizeQNAP(r)
|
|
|
|
return authorized
|
2023-10-17 20:07:37 +00:00
|
|
|
default:
|
|
|
|
return true // no additional auth for this distro
|
2023-08-24 21:40:17 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-09-26 19:57:40 +00:00
|
|
|
// 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))
|
2023-11-17 01:23:35 +00:00
|
|
|
switch {
|
|
|
|
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
|
2023-09-26 19:57:40 +00:00
|
|
|
s.serveGetNodeData(w, r)
|
2023-11-17 01:23:35 +00:00
|
|
|
case r.URL.Path == "/api/up" && r.Method == httpm.POST:
|
|
|
|
s.serveTailscaleUp(w, r)
|
2023-12-11 17:50:06 +00:00
|
|
|
case r.URL.Path == "/api/device-details-click" && r.Method == httpm.POST:
|
|
|
|
s.serveDeviceDetailsClick(w, r)
|
2023-11-17 01:23:35 +00:00
|
|
|
default:
|
|
|
|
http.Error(w, "invalid endpoint or method", http.StatusNotFound)
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
2023-08-15 15:38:13 +00:00
|
|
|
}
|
2023-08-08 23:58:45 +00:00
|
|
|
|
2023-11-01 19:54:27 +00:00
|
|
|
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
|
|
|
|
)
|
|
|
|
|
2023-10-05 18:48:45 +00:00
|
|
|
type authResponse struct {
|
2023-11-09 21:19:22 +00:00
|
|
|
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
|
|
|
|
CanManageNode bool `json:"canManageNode"`
|
|
|
|
ViewerIdentity *viewerIdentity `json:"viewerIdentity,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"`
|
2023-10-05 18:48:45 +00:00
|
|
|
}
|
|
|
|
|
2023-11-02 18:28:07 +00:00
|
|
|
// 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) {
|
2023-10-05 18:48:45 +00:00
|
|
|
var resp authResponse
|
2023-12-08 21:46:54 +00:00
|
|
|
session, whois, status, sErr := s.getSession(r)
|
2023-10-05 18:48:45 +00:00
|
|
|
|
2023-12-08 21:46:54 +00:00
|
|
|
if whois != nil {
|
|
|
|
resp.ViewerIdentity = &viewerIdentity{
|
|
|
|
LoginName: whois.UserProfile.LoginName,
|
|
|
|
NodeName: whois.Node.Name,
|
|
|
|
ProfilePicURL: whois.UserProfile.ProfilePicURL,
|
|
|
|
}
|
|
|
|
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 {
|
2023-11-02 18:28:07 +00:00
|
|
|
switch distro.Get() {
|
|
|
|
case distro.Synology:
|
2023-11-09 21:19:22 +00:00
|
|
|
authorized, err := authorizeSynology(r)
|
2023-11-02 18:28:07 +00:00
|
|
|
if err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
2023-11-09 21:19:22 +00:00
|
|
|
if !authorized {
|
|
|
|
resp.AuthNeeded = synoAuth
|
2023-12-08 21:46:54 +00:00
|
|
|
writeJSON(w, resp)
|
|
|
|
return
|
2023-11-09 21:19:22 +00:00
|
|
|
}
|
2023-11-02 18:28:07 +00:00
|
|
|
case distro.QNAP:
|
2023-11-09 21:19:22 +00:00
|
|
|
if _, err := authorizeQNAP(r); err != nil {
|
2023-11-02 18:28:07 +00:00
|
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
default:
|
2023-11-09 21:19:22 +00:00
|
|
|
// no additional auth for this distro
|
2023-11-02 18:28:07 +00:00
|
|
|
}
|
2023-12-08 21:46:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
switch {
|
2023-12-09 00:09:13 +00:00
|
|
|
case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
|
|
|
|
// Restricted to the readonly view, no auth action to take.
|
2023-12-11 20:50:15 +00:00
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
2023-12-09 00:09:13 +00:00
|
|
|
resp.AuthNeeded = ""
|
2023-12-08 21:46:54 +00:00
|
|
|
case sErr != nil && errors.Is(sErr, errNotOwner):
|
2023-12-08 20:15:57 +00:00
|
|
|
// Restricted to the readonly view, no auth action to take.
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
|
|
|
|
resp.AuthNeeded = ""
|
2023-12-08 21:46:54 +00:00
|
|
|
case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
|
2023-12-08 20:15:57 +00:00
|
|
|
// Restricted to the readonly view, no auth action to take.
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
|
|
|
|
resp.AuthNeeded = ""
|
2023-12-08 21:46:54 +00:00
|
|
|
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
|
2023-12-08 20:15:57 +00:00
|
|
|
// Restricted to the readonly view, no auth action to take.
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
|
2023-11-09 21:19:22 +00:00
|
|
|
resp.AuthNeeded = ""
|
2023-12-08 21:46:54 +00:00
|
|
|
case sErr != nil && !errors.Is(sErr, errNoSession):
|
2023-11-03 17:38:01 +00:00
|
|
|
// Any other error.
|
2023-12-08 21:46:54 +00:00
|
|
|
http.Error(w, sErr.Error(), http.StatusInternalServerError)
|
2023-11-03 17:38:01 +00:00
|
|
|
return
|
|
|
|
case session.isAuthorized(s.timeNow()):
|
2023-12-08 20:15:57 +00:00
|
|
|
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)
|
|
|
|
}
|
2023-11-09 21:19:22 +00:00
|
|
|
resp.CanManageNode = true
|
|
|
|
resp.AuthNeeded = ""
|
2023-11-03 17:38:01 +00:00
|
|
|
default:
|
2023-12-11 20:50:15 +00:00
|
|
|
// whois being nil implies local as the request did not come over Tailscale
|
|
|
|
if whois == nil || (whois.Node.StableID == status.Self.ID) {
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
|
|
|
|
} else {
|
|
|
|
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
|
|
|
|
}
|
2023-11-09 21:19:22 +00:00
|
|
|
resp.AuthNeeded = tailscaleAuth
|
2023-11-03 17:38:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
2023-12-08 20:15:57 +00:00
|
|
|
session, whois, _, err := s.getSession(r)
|
2023-11-03 17:38:01 +00:00
|
|
|
if err != nil && !errors.Is(err, errNoSession) {
|
|
|
|
// Source associated with request not allowed to create
|
|
|
|
// a session for this web client.
|
2023-10-18 20:45:25 +00:00
|
|
|
http.Error(w, err.Error(), http.StatusUnauthorized)
|
|
|
|
return
|
2023-11-03 17:38:01 +00:00
|
|
|
}
|
|
|
|
if session == nil {
|
2023-10-18 20:45:25 +00:00
|
|
|
// Create a new session.
|
2023-11-03 17:38:01 +00:00
|
|
|
// If one already existed, we return that authURL rather than creating a new one.
|
|
|
|
session, err = s.newSession(r.Context(), whois)
|
2023-10-18 20:45:25 +00:00
|
|
|
if err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
// Set the cookie on browser.
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
2023-12-08 18:19:13 +00:00
|
|
|
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,
|
2023-10-18 20:45:25 +00:00
|
|
|
})
|
2023-10-05 18:48:45 +00:00
|
|
|
}
|
|
|
|
|
2023-11-03 17:38:01 +00:00
|
|
|
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) {
|
2023-12-08 20:15:57 +00:00
|
|
|
session, _, _, err := s.getSession(r)
|
2023-11-03 17:38:01 +00:00
|
|
|
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)
|
2023-10-05 18:48:45 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-25 15:27:22 +00:00
|
|
|
// 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")
|
2023-08-28 20:44:48 +00:00
|
|
|
switch {
|
2023-11-28 01:23:41 +00:00
|
|
|
case path == "/data" && r.Method == httpm.GET:
|
|
|
|
s.serveGetNodeData(w, r)
|
2023-08-25 15:27:22 +00:00
|
|
|
return
|
2023-11-15 20:23:15 +00:00
|
|
|
case path == "/exit-nodes" && r.Method == httpm.GET:
|
|
|
|
s.serveGetExitNodes(w, r)
|
|
|
|
return
|
2023-11-28 01:23:41 +00:00
|
|
|
case path == "/routes" && r.Method == httpm.POST:
|
|
|
|
s.servePostRoutes(w, r)
|
|
|
|
return
|
2023-12-11 17:50:06 +00:00
|
|
|
case path == "/device-details-click" && r.Method == httpm.POST:
|
|
|
|
s.serveDeviceDetailsClick(w, r)
|
|
|
|
return
|
2023-08-28 20:44:48 +00:00
|
|
|
case strings.HasPrefix(path, "/local/"):
|
|
|
|
s.proxyRequestToLocalAPI(w, r)
|
|
|
|
return
|
2023-08-25 15:27:22 +00:00
|
|
|
}
|
|
|
|
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
|
|
|
}
|
|
|
|
|
2023-08-15 15:38:13 +00:00
|
|
|
type nodeData struct {
|
2023-11-08 22:33:27 +00:00
|
|
|
ID tailcfg.StableNodeID
|
|
|
|
Status string
|
|
|
|
DeviceName string
|
|
|
|
TailnetName string // TLS cert name
|
2023-11-09 21:19:22 +00:00
|
|
|
DomainName string
|
2023-12-05 15:09:33 +00:00
|
|
|
IPv4 string
|
2023-11-08 22:33:27 +00:00
|
|
|
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
|
|
|
|
|
2023-12-11 20:40:29 +00:00
|
|
|
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
|
2023-11-08 22:33:27 +00:00
|
|
|
|
2023-11-15 21:04:44 +00:00
|
|
|
ClientVersion *tailcfg.ClientVersion
|
|
|
|
|
2023-11-30 00:40:41 +00:00
|
|
|
// whether tailnet ACLs allow access to port 5252 on this device
|
|
|
|
ACLAllowsAnyIncomingTraffic bool
|
|
|
|
|
2023-11-29 20:58:56 +00:00
|
|
|
ControlAdminURL string
|
|
|
|
LicensesURL string
|
2023-11-30 18:01:29 +00:00
|
|
|
|
|
|
|
// 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
|
2023-08-15 15:38:13 +00:00
|
|
|
}
|
|
|
|
|
2023-11-28 01:23:41 +00:00
|
|
|
type subnetRoute struct {
|
|
|
|
Route string
|
|
|
|
Approved bool // approved by control server
|
|
|
|
}
|
|
|
|
|
2023-09-27 17:27:59 +00:00
|
|
|
func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
|
|
|
st, err := s.lc.Status(r.Context())
|
2023-08-08 23:58:45 +00:00
|
|
|
if err != nil {
|
2023-09-27 17:27:59 +00:00
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
2023-09-27 17:27:59 +00:00
|
|
|
prefs, err := s.lc.GetPrefs(r.Context())
|
2023-08-08 23:58:45 +00:00
|
|
|
if err != nil {
|
2023-09-27 17:27:59 +00:00
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
2023-12-08 19:58:48 +00:00
|
|
|
filterRules, _ := s.lc.DebugPacketFilterRules(r.Context())
|
2023-08-15 15:38:13 +00:00
|
|
|
data := &nodeData{
|
2023-11-13 19:54:24 +00:00
|
|
|
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, "/"),
|
2023-11-29 20:58:56 +00:00
|
|
|
ControlAdminURL: prefs.AdminPageURL(),
|
2023-11-13 19:54:24 +00:00
|
|
|
LicensesURL: licenses.LicensesURL(),
|
2023-11-30 18:01:29 +00:00
|
|
|
Features: availableFeatures(),
|
2023-11-30 00:40:41 +00:00
|
|
|
|
|
|
|
ACLAllowsAnyIncomingTraffic: s.aclsAllowAccess(filterRules),
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
2023-11-29 20:58:56 +00:00
|
|
|
|
2023-12-09 00:09:13 +00:00
|
|
|
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")
|
|
|
|
}
|
|
|
|
|
2023-11-15 21:04:44 +00:00
|
|
|
cv, err := s.lc.CheckUpdate(r.Context())
|
|
|
|
if err != nil {
|
|
|
|
s.logf("could not check for updates: %v", err)
|
|
|
|
} else {
|
|
|
|
data.ClientVersion = cv
|
|
|
|
}
|
2023-11-08 22:33:27 +00:00
|
|
|
for _, ip := range st.TailscaleIPs {
|
|
|
|
if ip.Is4() {
|
2023-12-05 15:09:33 +00:00
|
|
|
data.IPv4 = ip.String()
|
2023-11-08 22:33:27 +00:00
|
|
|
} else if ip.Is6() {
|
|
|
|
data.IPv6 = ip.String()
|
|
|
|
}
|
2023-12-05 15:09:33 +00:00
|
|
|
if data.IPv4 != "" && data.IPv6 != "" {
|
2023-11-08 22:33:27 +00:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
2023-11-14 19:14:18 +00:00
|
|
|
if st.CurrentTailnet != nil {
|
|
|
|
data.TailnetName = st.CurrentTailnet.MagicDNSSuffix
|
|
|
|
data.DomainName = st.CurrentTailnet.Name
|
|
|
|
}
|
2023-11-08 22:33:27 +00:00
|
|
|
if st.Self.Tags != nil {
|
|
|
|
data.Tags = st.Self.Tags.AsSlice()
|
|
|
|
}
|
|
|
|
if st.Self.KeyExpiry != nil {
|
|
|
|
data.KeyExpiry = st.Self.KeyExpiry.Format(time.RFC3339)
|
|
|
|
}
|
2023-11-28 01:23:41 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
})
|
|
|
|
}
|
2023-12-11 20:40:29 +00:00
|
|
|
data.AdvertisingExitNodeApproved = routeApproved(exitNodeRouteV4) || routeApproved(exitNodeRouteV6)
|
|
|
|
|
2023-08-08 23:58:45 +00:00
|
|
|
for _, r := range prefs.AdvertiseRoutes {
|
|
|
|
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
2023-11-28 01:23:41 +00:00
|
|
|
data.AdvertisingExitNode = true
|
2023-08-08 23:58:45 +00:00
|
|
|
} else {
|
2023-11-28 01:23:41 +00:00
|
|
|
data.AdvertisedRoutes = append(data.AdvertisedRoutes, subnetRoute{
|
|
|
|
Route: r.String(),
|
|
|
|
Approved: routeApproved(r),
|
|
|
|
})
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
|
|
|
}
|
2023-11-15 20:23:15 +00:00
|
|
|
if e := st.ExitNodeStatus; e != nil {
|
2023-11-28 01:23:41 +00:00
|
|
|
data.UsingExitNode = &exitNode{
|
|
|
|
ID: e.ID,
|
|
|
|
Online: e.Online,
|
2023-11-15 20:23:15 +00:00
|
|
|
}
|
|
|
|
for _, ps := range st.Peer {
|
|
|
|
if ps.ID == e.ID {
|
2023-11-28 01:23:41 +00:00
|
|
|
data.UsingExitNode.Name = ps.DNSName
|
|
|
|
data.UsingExitNode.Location = ps.Location
|
2023-11-15 20:23:15 +00:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
2023-11-28 01:23:41 +00:00
|
|
|
if data.UsingExitNode.Name == "" {
|
2023-11-15 20:23:15 +00:00
|
|
|
// Falling back to TailscaleIP/StableNodeID when the peer
|
|
|
|
// is no longer included in status.
|
|
|
|
if len(e.TailscaleIPs) > 0 {
|
2023-11-28 01:23:41 +00:00
|
|
|
data.UsingExitNode.Name = e.TailscaleIPs[0].Addr().String()
|
2023-11-15 20:23:15 +00:00
|
|
|
} else {
|
2023-11-28 01:23:41 +00:00
|
|
|
data.UsingExitNode.Name = string(e.ID)
|
2023-11-15 20:23:15 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-11-03 17:38:01 +00:00
|
|
|
writeJSON(w, *data)
|
2023-08-15 15:38:13 +00:00
|
|
|
}
|
|
|
|
|
2023-11-30 18:01:29 +00:00
|
|
|
func availableFeatures() map[string]bool {
|
2023-12-12 00:47:49 +00:00
|
|
|
env := hostinfo.GetEnvType()
|
2023-12-09 00:09:13 +00:00
|
|
|
features := map[string]bool{
|
2023-12-12 00:47:49 +00:00
|
|
|
"advertise-exit-node": true, // available on all platforms
|
|
|
|
"advertise-routes": true, // available on all platforms
|
|
|
|
"use-exit-node": canUseExitNode(env) == nil,
|
2023-11-30 18:01:29 +00:00
|
|
|
"ssh": envknob.CanRunTailscaleSSH() == nil,
|
2023-12-08 22:39:15 +00:00
|
|
|
"auto-update": version.IsUnstableBuild() && clientupdate.CanAutoUpdate(),
|
2023-11-30 18:01:29 +00:00
|
|
|
}
|
2023-12-12 00:47:49 +00:00
|
|
|
if env == hostinfo.HomeAssistantAddOn {
|
2023-12-09 00:09:13 +00:00
|
|
|
// 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
|
2023-11-30 18:01:29 +00:00
|
|
|
}
|
|
|
|
|
2023-12-12 00:47:49 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-11-30 00:40:41 +00:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2023-11-15 20:23:15 +00:00
|
|
|
type exitNode struct {
|
|
|
|
ID tailcfg.StableNodeID
|
|
|
|
Name string
|
|
|
|
Location *tailcfg.Location
|
2023-11-28 01:23:41 +00:00
|
|
|
Online bool
|
2023-11-15 20:23:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
2023-12-04 19:58:14 +00:00
|
|
|
Online: ps.Online,
|
2023-11-15 20:23:15 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
writeJSON(w, exitNodes)
|
|
|
|
}
|
|
|
|
|
2023-11-28 01:23:41 +00:00
|
|
|
type postRoutesRequest struct {
|
2023-12-06 05:26:34 +00:00
|
|
|
SetExitNode bool // when set, UseExitNode and AdvertiseExitNode values are applied
|
|
|
|
SetRoutes bool // when set, AdvertiseRoutes value is applied
|
2023-11-28 01:23:41 +00:00
|
|
|
UseExitNode tailcfg.StableNodeID
|
2023-08-15 15:38:13 +00:00
|
|
|
AdvertiseExitNode bool
|
2023-12-06 05:26:34 +00:00
|
|
|
AdvertiseRoutes []string
|
2023-08-15 15:38:13 +00:00
|
|
|
}
|
|
|
|
|
2023-11-28 01:23:41 +00:00
|
|
|
func (s *Server) servePostRoutes(w http.ResponseWriter, r *http.Request) {
|
2023-08-15 15:38:13 +00:00
|
|
|
defer r.Body.Close()
|
|
|
|
|
2023-11-28 01:23:41 +00:00
|
|
|
var data postRoutesRequest
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
2023-08-15 15:38:13 +00:00
|
|
|
return
|
|
|
|
}
|
2023-12-06 05:26:34 +00:00
|
|
|
prefs, err := s.lc.GetPrefs(r.Context())
|
|
|
|
if err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
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
|
|
|
|
}
|
2023-08-15 15:38:13 +00:00
|
|
|
|
2023-11-28 01:23:41 +00:00
|
|
|
// Calculate routes.
|
|
|
|
routesStr := strings.Join(data.AdvertiseRoutes, ",")
|
|
|
|
routes, err := netutil.CalcAdvertiseRoutes(routesStr, data.AdvertiseExitNode)
|
|
|
|
if err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
return
|
|
|
|
}
|
2023-10-12 21:02:20 +00:00
|
|
|
|
2023-11-28 01:23:41 +00:00
|
|
|
hasExitNodeRoute := func(all []netip.Prefix) bool {
|
|
|
|
return slices.Contains(all, exitNodeRouteV4) ||
|
|
|
|
slices.Contains(all, exitNodeRouteV6)
|
2023-10-12 21:02:20 +00:00
|
|
|
}
|
|
|
|
|
2023-11-28 01:23:41 +00:00
|
|
|
if !data.UseExitNode.IsZero() && hasExitNodeRoute(routes) {
|
|
|
|
http.Error(w, "cannot use and advertise exit node at same time", http.StatusBadRequest)
|
2023-08-15 15:38:13 +00:00
|
|
|
return
|
|
|
|
}
|
2023-11-28 01:23:41 +00:00
|
|
|
|
|
|
|
// Make prefs update.
|
|
|
|
p := &ipn.MaskedPrefs{
|
2023-08-15 15:38:13 +00:00
|
|
|
AdvertiseRoutesSet: true,
|
2023-11-28 01:23:41 +00:00
|
|
|
ExitNodeIDSet: true,
|
|
|
|
Prefs: ipn.Prefs{
|
|
|
|
ExitNodeID: data.UseExitNode,
|
|
|
|
AdvertiseRoutes: routes,
|
|
|
|
},
|
2023-08-15 15:38:13 +00:00
|
|
|
}
|
2023-11-28 01:23:41 +00:00
|
|
|
if _, err := s.lc.EditPrefs(r.Context(), p); err != nil {
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
2023-08-15 15:38:13 +00:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-11-28 01:23:41 +00:00
|
|
|
w.WriteHeader(http.StatusOK)
|
2023-08-15 15:38:13 +00:00
|
|
|
}
|
|
|
|
|
2023-11-17 01:23:35 +00:00
|
|
|
// 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) {
|
2023-08-08 23:58:45 +00:00
|
|
|
origAuthURL := st.AuthURL
|
|
|
|
isRunning := st.BackendState == ipn.Running.String()
|
|
|
|
|
2023-11-17 01:23:35 +00:00
|
|
|
if !opt.Reauthenticate {
|
|
|
|
switch {
|
|
|
|
case origAuthURL != "":
|
2023-08-08 23:58:45 +00:00
|
|
|
return origAuthURL, nil
|
2023-11-17 01:23:35 +00:00
|
|
|
case isRunning:
|
2023-08-08 23:58:45 +00:00
|
|
|
return "", nil
|
2023-11-17 01:23:35 +00:00
|
|
|
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
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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()
|
2023-08-09 17:14:03 +00:00
|
|
|
watcher, err := s.lc.WatchIPNBus(watchCtx, 0)
|
2023-08-08 23:58:45 +00:00
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
defer watcher.Close()
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
if !isRunning {
|
2023-11-18 00:05:14 +00:00
|
|
|
ipnOptions := ipn.Options{AuthKey: opt.AuthKey}
|
|
|
|
if opt.ControlURL != "" {
|
|
|
|
ipnOptions.UpdatePrefs = &ipn.Prefs{ControlURL: opt.ControlURL}
|
|
|
|
}
|
|
|
|
if err := s.lc.Start(ctx, ipnOptions); err != nil {
|
|
|
|
s.logf("start: %v", err)
|
|
|
|
}
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
2023-11-17 01:23:35 +00:00
|
|
|
if opt.Reauthenticate {
|
2023-11-18 00:05:14 +00:00
|
|
|
if err := s.lc.StartLoginInteractive(ctx); err != nil {
|
|
|
|
s.logf("startLogin: %v", err)
|
|
|
|
}
|
2023-08-08 23:58:45 +00:00
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
for {
|
|
|
|
n, err := watcher.Next()
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
2023-11-18 00:05:14 +00:00
|
|
|
if n.State != nil && *n.State == ipn.Running {
|
|
|
|
return "", nil
|
|
|
|
}
|
2023-08-08 23:58:45 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-08-16 22:52:31 +00:00
|
|
|
|
2023-11-17 01:23:35 +00:00
|
|
|
type tailscaleUpOptions struct {
|
|
|
|
// If true, force reauthentication of the client.
|
|
|
|
// Otherwise simply reconnect, the same as running `tailscale up`.
|
|
|
|
Reauthenticate bool
|
2023-11-18 00:05:14 +00:00
|
|
|
|
|
|
|
ControlURL string
|
|
|
|
AuthKey string
|
2023-11-17 01:23:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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, "{}")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-11 17:50:06 +00:00
|
|
|
// 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, "{}")
|
|
|
|
}
|
|
|
|
|
2023-08-28 20:44:48 +00:00
|
|
|
// 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
|
|
|
|
}
|
2023-12-08 18:25:01 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
2023-08-28 20:44:48 +00:00
|
|
|
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.
|
|
|
|
var localapiAllowlist = []string{
|
|
|
|
"/v0/logout",
|
2023-11-13 19:54:24 +00:00
|
|
|
"/v0/prefs",
|
2023-11-15 21:04:44 +00:00
|
|
|
"/v0/update/check",
|
|
|
|
"/v0/update/install",
|
|
|
|
"/v0/update/progress",
|
2023-12-05 15:28:19 +00:00
|
|
|
"/v0/upload-client-metrics",
|
2023-08-28 20:44:48 +00:00
|
|
|
}
|
|
|
|
|
2023-08-23 22:22:24 +00:00
|
|
|
// 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 {
|
2023-08-30 18:20:02 +00:00
|
|
|
csrfFile := filepath.Join(os.TempDir(), "tailscale-web-csrf.key")
|
2023-08-23 22:22:24 +00:00
|
|
|
|
|
|
|
// 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
|
2023-08-16 22:52:31 +00:00
|
|
|
key := make([]byte, 32)
|
|
|
|
if _, err := rand.Read(key); err != nil {
|
2023-09-02 18:09:38 +00:00
|
|
|
log.Fatalf("error generating CSRF key: %v", err)
|
2023-08-16 22:52:31 +00:00
|
|
|
}
|
2023-08-23 22:22:24 +00:00
|
|
|
|
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-16 22:52:31 +00:00
|
|
|
return key
|
|
|
|
}
|
2023-08-24 19:56:09 +00:00
|
|
|
|
|
|
|
// 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 {
|
2023-08-31 21:27:41 +00:00
|
|
|
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 += "/"
|
|
|
|
}
|
|
|
|
|
2023-08-24 19:56:09 +00:00
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
if !strings.HasPrefix(r.URL.Path, prefix) {
|
|
|
|
http.Redirect(w, r, prefix, http.StatusFound)
|
|
|
|
return
|
|
|
|
}
|
2023-08-24 21:40:17 +00:00
|
|
|
prefix = strings.TrimSuffix(prefix, "/")
|
2023-08-24 19:56:09 +00:00
|
|
|
http.StripPrefix(prefix, h).ServeHTTP(w, r)
|
|
|
|
}
|
|
|
|
}
|
2023-11-03 17:38:01 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|