client/web: add ServerMode to web.Server

Adds a new Mode to the web server, indicating the specific
scenario the constructed server is intended to be run in. Also
starts filling this from the cli/web and ipn/ipnlocal callers.

From cli/web this gets filled conditionally based on whether the
preview web client node cap is set. If not set, the existing
"legacy" client is served. If set, both a login/lobby and full
management client are started (in "login" and "manage" modes
respectively).

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-11-02 15:13:22 -04:00 committed by Sonia Appasamy
parent 7145016414
commit 191e2ce719
5 changed files with 114 additions and 40 deletions

View File

@ -37,13 +37,13 @@
// 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 bool
tsDebugMode string
cgiMode bool
pathPrefix string
@ -65,6 +65,31 @@ type Server struct {
browserSessions sync.Map
}
// ServerMode specifies the mode of a running web.Server.
type ServerMode string
const (
// LoginServerMode serves a readonly login client for logging a
// node into a tailnet, and viewing a readonly interface of the
// node's current Tailscale settings.
//
// In this mode, API calls are authenticated via platform auth.
LoginServerMode ServerMode = "login"
// ManageServerMode serves a management client for editing tailscale
// settings of a node.
//
// This mode restricts the app to only being assessible over Tailscale,
// and API calls are authenticated via browser sessions associated with
// the source's Tailscale identity. If the source browser does not have
// a valid session, a readonly version of the app is displayed.
ManageServerMode ServerMode = "manage"
// LegacyServerMode serves the legacy web client, visible to users
// prior to release of tailscale/corp#14335.
LegacyServerMode ServerMode = "legacy"
)
var (
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
@ -72,6 +97,12 @@ type Server struct {
// ServerOpts contains options for constructing a new Server.
type ServerOpts struct {
// Mode specifies the mode of web client being constructed.
Mode ServerMode
// DevMode indicates that the server should be started with frontend
// assets served by a Vite dev server, allowing for local development
// on the web client frontend.
DevMode bool
// CGIMode indicates if the server is running as a CGI script.
@ -88,6 +119,8 @@ type ServerOpts struct {
// 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
}
@ -96,10 +129,19 @@ type ServerOpts struct {
// ctx is only required to live the duration of the NewServer call,
// and not the lifespan of the web server.
func NewServer(opts ServerOpts) (s *Server, err error) {
switch opts.Mode {
case LoginServerMode, ManageServerMode, LegacyServerMode:
// valid types
case "":
return nil, fmt.Errorf("must specify a Mode")
default:
return nil, fmt.Errorf("invalid Mode provided")
}
if opts.LocalClient == nil {
opts.LocalClient = &tailscale.LocalClient{}
}
s = &Server{
mode: opts.Mode,
logf: opts.Logf,
devMode: opts.DevMode,
lc: opts.LocalClient,
@ -113,7 +155,6 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
if s.logf == nil {
s.logf = log.Printf
}
s.tsDebugMode = s.debugMode()
s.assetsHandler, s.assetsCleanup = assetsHandler(opts.DevMode)
var metric string // clientmetric to report on startup
@ -124,9 +165,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
// The client is secured by limiting the interface it listens on,
// or by authenticating requests before they reach the web client.
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
if s.tsDebugMode == "login" {
// For the login client, we don't serve the full web client API,
// only the login endpoints.
if s.mode == LoginServerMode {
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
metric = "web_login_client_initialization"
} else {
@ -146,25 +185,12 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
}
func (s *Server) Shutdown() {
s.logf("web.Server: shutting down")
if s.assetsCleanup != nil {
s.assetsCleanup()
}
}
// debugMode returns the debug mode the web client is being run in.
// The empty string is returned in the case that this instance is
// not running in any debug mode.
func (s *Server) debugMode() string {
if !s.devMode {
return "" // debug modes only available in dev
}
switch mode := os.Getenv("TS_DEBUG_WEB_CLIENT_MODE"); mode {
case "login", "full": // valid debug modes
return mode
}
return ""
}
// ServeHTTP processes all requests for the Tailscale web client.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler := s.serve
@ -203,7 +229,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
// 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.tsDebugMode == "full" { // client using tailscale auth
if s.mode == ManageServerMode { // client using tailscale auth
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
switch {
case err != nil:
@ -256,11 +282,9 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
case httpm.GET:
// TODO(soniaappasamy): we may want a minimal node data response here
s.serveGetNodeData(w, r)
case httpm.POST:
// TODO(soniaappasamy): implement
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
http.Error(w, "invalid endpoint", http.StatusNotFound)
return
}
@ -404,6 +428,12 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
profile := st.User[st.Self.UserID]
deviceName := strings.Split(st.Self.DNSName, ".")[0]
versionShort := strings.Split(st.Version, "-")[0]
var debugMode string
if s.mode == ManageServerMode {
debugMode = "full"
} else if s.mode == LoginServerMode {
debugMode = "login"
}
data := &nodeData{
Profile: profile,
Status: st.BackendState,
@ -415,7 +445,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
IsUnraid: distro.Get() == distro.Unraid,
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
IPNVersion: versionShort,
DebugMode: s.tsDebugMode,
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
}
for _, r := range prefs.AdvertiseRoutes {
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {

View File

@ -337,8 +337,8 @@ func() *ipnstate.PeerStatus { return self },
go localapi.Serve(lal)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
tsDebugMode: "full",
timeNow: time.Now,
}
validCookie := "ts-cookie"
@ -428,8 +428,8 @@ func() *ipnstate.PeerStatus { return self },
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
s := &Server{
mode: ManageServerMode,
lc: &tailscale.LocalClient{Dial: lal.Dial},
tsDebugMode: "full",
timeNow: func() time.Time { return timeNow },
}

View File

@ -14,10 +14,13 @@
"net/http"
"net/http/cgi"
"os"
"os/signal"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/web"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/util/cmpx"
)
@ -76,11 +79,31 @@ func tlsConfigFromEnvironment() *tls.Config {
}
func runWeb(ctx context.Context, args []string) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
if len(args) > 0 {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
st, err := localClient.StatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
hasPreviewCap := st.Self.HasCap(tailcfg.CapabilityPreviewWebClient)
cliServerMode := web.LegacyServerMode
if hasPreviewCap {
cliServerMode = web.LoginServerMode
// Also start full client in tailscaled.
log.Printf("starting tailscaled web client at %s:5252\n", st.Self.TailscaleIPs[0])
if err := setRunWebClient(ctx, true); err != nil {
return fmt.Errorf("starting web client in tailscaled: %w", err)
}
}
webServer, err := web.NewServer(web.ServerOpts{
Mode: cliServerMode,
DevMode: webArgs.dev,
CGIMode: webArgs.cgi,
PathPrefix: webArgs.prefix,
@ -90,24 +113,35 @@ func runWeb(ctx context.Context, args []string) error {
log.Printf("tailscale.web: %v", err)
return err
}
defer webServer.Shutdown()
go func() {
select {
case <-ctx.Done():
// Shutdown the server.
webServer.Shutdown()
if hasPreviewCap && !webArgs.cgi {
log.Println("stopping tailscaled web client")
// When not in cgi mode, shut down the tailscaled
// web client on cli termination.
if err := setRunWebClient(context.Background(), false); err != nil {
log.Printf("stopping tailscaled web client: %v", err)
}
}
}
os.Exit(0)
}()
if webArgs.cgi {
if err := cgi.Serve(webServer); err != nil {
log.Printf("tailscale.cgi: %v", err)
return err
}
return nil
}
tlsConfig := tlsConfigFromEnvironment()
if tlsConfig != nil {
} else if tlsConfig := tlsConfigFromEnvironment(); tlsConfig != nil {
server := &http.Server{
Addr: webArgs.listen,
TLSConfig: tlsConfig,
Handler: webServer,
}
defer server.Shutdown(ctx)
log.Printf("web server running on: https://%s", server.Addr)
return server.ListenAndServeTLS("", "")
} else {
@ -116,6 +150,14 @@ func runWeb(ctx context.Context, args []string) error {
}
}
func setRunWebClient(ctx context.Context, val bool) error {
_, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{RunWebClient: val},
RunWebClientSet: true,
})
return err
}
// urlOfListenAddr parses a given listen address into a formatted URL
func urlOfListenAddr(addr string) string {
host, port, _ := net.SplitHostPort(addr)

View File

@ -52,6 +52,7 @@ func (b *LocalBackend) WebClientInit() (err error) {
b.logf("WebClientInit: initializing web ui")
if b.webClient.server, err = web.NewServer(web.ServerOpts{
Mode: web.ManageServerMode,
// TODO(sonia): allow passing back dev mode flag
LocalClient: b.webClient.lc,
Logf: b.logf,

View File

@ -31,6 +31,7 @@ func main() {
// Serve the Tailscale web client.
ws, err := web.NewServer(web.ServerOpts{
Mode: web.LegacyServerMode,
DevMode: *devMode,
LocalClient: lc,
})