mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-18 02:48:40 +00:00
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:
parent
7145016414
commit
191e2ce719
@ -37,13 +37,13 @@ import (
|
|||||||
|
|
||||||
// Server is the backend server for a Tailscale web client.
|
// Server is the backend server for a Tailscale web client.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
mode ServerMode
|
||||||
|
|
||||||
logf logger.Logf
|
logf logger.Logf
|
||||||
lc *tailscale.LocalClient
|
lc *tailscale.LocalClient
|
||||||
timeNow func() time.Time
|
timeNow func() time.Time
|
||||||
|
|
||||||
devMode bool
|
devMode bool
|
||||||
tsDebugMode string
|
|
||||||
|
|
||||||
cgiMode bool
|
cgiMode bool
|
||||||
pathPrefix string
|
pathPrefix string
|
||||||
|
|
||||||
@ -65,6 +65,31 @@ type Server struct {
|
|||||||
browserSessions sync.Map
|
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 (
|
var (
|
||||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||||
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
exitNodeRouteV6 = netip.MustParsePrefix("::/0")
|
||||||
@ -72,6 +97,12 @@ var (
|
|||||||
|
|
||||||
// ServerOpts contains options for constructing a new Server.
|
// ServerOpts contains options for constructing a new Server.
|
||||||
type ServerOpts struct {
|
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
|
DevMode bool
|
||||||
|
|
||||||
// CGIMode indicates if the server is running as a CGI script.
|
// CGIMode indicates if the server is running as a CGI script.
|
||||||
@ -88,6 +119,8 @@ type ServerOpts struct {
|
|||||||
// time.Now is used as default.
|
// time.Now is used as default.
|
||||||
TimeNow func() time.Time
|
TimeNow func() time.Time
|
||||||
|
|
||||||
|
// Logf optionally provides a logger function.
|
||||||
|
// log.Printf is used as default.
|
||||||
Logf logger.Logf
|
Logf logger.Logf
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,10 +129,19 @@ type ServerOpts struct {
|
|||||||
// ctx is only required to live the duration of the NewServer call,
|
// ctx is only required to live the duration of the NewServer call,
|
||||||
// and not the lifespan of the web server.
|
// and not the lifespan of the web server.
|
||||||
func NewServer(opts ServerOpts) (s *Server, err error) {
|
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 {
|
if opts.LocalClient == nil {
|
||||||
opts.LocalClient = &tailscale.LocalClient{}
|
opts.LocalClient = &tailscale.LocalClient{}
|
||||||
}
|
}
|
||||||
s = &Server{
|
s = &Server{
|
||||||
|
mode: opts.Mode,
|
||||||
logf: opts.Logf,
|
logf: opts.Logf,
|
||||||
devMode: opts.DevMode,
|
devMode: opts.DevMode,
|
||||||
lc: opts.LocalClient,
|
lc: opts.LocalClient,
|
||||||
@ -113,7 +155,6 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
|||||||
if s.logf == nil {
|
if s.logf == nil {
|
||||||
s.logf = log.Printf
|
s.logf = log.Printf
|
||||||
}
|
}
|
||||||
s.tsDebugMode = s.debugMode()
|
|
||||||
s.assetsHandler, s.assetsCleanup = assetsHandler(opts.DevMode)
|
s.assetsHandler, s.assetsCleanup = assetsHandler(opts.DevMode)
|
||||||
|
|
||||||
var metric string // clientmetric to report on startup
|
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,
|
// The client is secured by limiting the interface it listens on,
|
||||||
// or by authenticating requests before they reach the web client.
|
// or by authenticating requests before they reach the web client.
|
||||||
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
|
||||||
if s.tsDebugMode == "login" {
|
if s.mode == LoginServerMode {
|
||||||
// For the login client, we don't serve the full web client API,
|
|
||||||
// only the login endpoints.
|
|
||||||
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
|
||||||
metric = "web_login_client_initialization"
|
metric = "web_login_client_initialization"
|
||||||
} else {
|
} else {
|
||||||
@ -146,25 +185,12 @@ func NewServer(opts ServerOpts) (s *Server, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Shutdown() {
|
func (s *Server) Shutdown() {
|
||||||
|
s.logf("web.Server: shutting down")
|
||||||
if s.assetsCleanup != nil {
|
if s.assetsCleanup != nil {
|
||||||
s.assetsCleanup()
|
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.
|
// ServeHTTP processes all requests for the Tailscale web client.
|
||||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
handler := s.serve
|
handler := s.serve
|
||||||
@ -203,7 +229,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
|
|||||||
// authorizeRequest manages writing out any relevant authorization
|
// authorizeRequest manages writing out any relevant authorization
|
||||||
// errors to the ResponseWriter itself.
|
// errors to the ResponseWriter itself.
|
||||||
func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
|
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)
|
_, err := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
@ -256,11 +282,9 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
case httpm.GET:
|
case httpm.GET:
|
||||||
// TODO(soniaappasamy): we may want a minimal node data response here
|
// TODO(soniaappasamy): we may want a minimal node data response here
|
||||||
s.serveGetNodeData(w, r)
|
s.serveGetNodeData(w, r)
|
||||||
case httpm.POST:
|
return
|
||||||
// TODO(soniaappasamy): implement
|
|
||||||
default:
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
}
|
}
|
||||||
|
http.Error(w, "invalid endpoint", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -404,6 +428,12 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
|||||||
profile := st.User[st.Self.UserID]
|
profile := st.User[st.Self.UserID]
|
||||||
deviceName := strings.Split(st.Self.DNSName, ".")[0]
|
deviceName := strings.Split(st.Self.DNSName, ".")[0]
|
||||||
versionShort := strings.Split(st.Version, "-")[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{
|
data := &nodeData{
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
Status: st.BackendState,
|
Status: st.BackendState,
|
||||||
@ -415,7 +445,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
|||||||
IsUnraid: distro.Get() == distro.Unraid,
|
IsUnraid: distro.Get() == distro.Unraid,
|
||||||
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
||||||
IPNVersion: versionShort,
|
IPNVersion: versionShort,
|
||||||
DebugMode: s.tsDebugMode,
|
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
|
||||||
}
|
}
|
||||||
for _, r := range prefs.AdvertiseRoutes {
|
for _, r := range prefs.AdvertiseRoutes {
|
||||||
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
if r == exitNodeRouteV4 || r == exitNodeRouteV6 {
|
||||||
|
@ -337,9 +337,9 @@ func TestAuthorizeRequest(t *testing.T) {
|
|||||||
go localapi.Serve(lal)
|
go localapi.Serve(lal)
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
mode: ManageServerMode,
|
||||||
tsDebugMode: "full",
|
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||||
timeNow: time.Now,
|
timeNow: time.Now,
|
||||||
}
|
}
|
||||||
validCookie := "ts-cookie"
|
validCookie := "ts-cookie"
|
||||||
s.browserSessions.Store(validCookie, &browserSession{
|
s.browserSessions.Store(validCookie, &browserSession{
|
||||||
@ -428,9 +428,9 @@ func TestServeTailscaleAuth(t *testing.T) {
|
|||||||
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
|
sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2)
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
mode: ManageServerMode,
|
||||||
tsDebugMode: "full",
|
lc: &tailscale.LocalClient{Dial: lal.Dial},
|
||||||
timeNow: func() time.Time { return timeNow },
|
timeNow: func() time.Time { return timeNow },
|
||||||
}
|
}
|
||||||
|
|
||||||
successCookie := "ts-cookie-success"
|
successCookie := "ts-cookie-success"
|
||||||
|
@ -14,10 +14,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cgi"
|
"net/http/cgi"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"tailscale.com/client/web"
|
"tailscale.com/client/web"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/util/cmpx"
|
"tailscale.com/util/cmpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -76,11 +79,31 @@ func tlsConfigFromEnvironment() *tls.Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runWeb(ctx context.Context, args []string) error {
|
func runWeb(ctx context.Context, args []string) error {
|
||||||
|
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
return fmt.Errorf("too many non-flag arguments: %q", args)
|
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{
|
webServer, err := web.NewServer(web.ServerOpts{
|
||||||
|
Mode: cliServerMode,
|
||||||
DevMode: webArgs.dev,
|
DevMode: webArgs.dev,
|
||||||
CGIMode: webArgs.cgi,
|
CGIMode: webArgs.cgi,
|
||||||
PathPrefix: webArgs.prefix,
|
PathPrefix: webArgs.prefix,
|
||||||
@ -90,24 +113,35 @@ func runWeb(ctx context.Context, args []string) error {
|
|||||||
log.Printf("tailscale.web: %v", err)
|
log.Printf("tailscale.web: %v", err)
|
||||||
return 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 webArgs.cgi {
|
||||||
if err := cgi.Serve(webServer); err != nil {
|
if err := cgi.Serve(webServer); err != nil {
|
||||||
log.Printf("tailscale.cgi: %v", err)
|
log.Printf("tailscale.cgi: %v", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
} else if tlsConfig := tlsConfigFromEnvironment(); tlsConfig != nil {
|
||||||
|
|
||||||
tlsConfig := tlsConfigFromEnvironment()
|
|
||||||
if tlsConfig != nil {
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: webArgs.listen,
|
Addr: webArgs.listen,
|
||||||
TLSConfig: tlsConfig,
|
TLSConfig: tlsConfig,
|
||||||
Handler: webServer,
|
Handler: webServer,
|
||||||
}
|
}
|
||||||
|
defer server.Shutdown(ctx)
|
||||||
log.Printf("web server running on: https://%s", server.Addr)
|
log.Printf("web server running on: https://%s", server.Addr)
|
||||||
return server.ListenAndServeTLS("", "")
|
return server.ListenAndServeTLS("", "")
|
||||||
} else {
|
} 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
|
// urlOfListenAddr parses a given listen address into a formatted URL
|
||||||
func urlOfListenAddr(addr string) string {
|
func urlOfListenAddr(addr string) string {
|
||||||
host, port, _ := net.SplitHostPort(addr)
|
host, port, _ := net.SplitHostPort(addr)
|
||||||
|
@ -52,6 +52,7 @@ func (b *LocalBackend) WebClientInit() (err error) {
|
|||||||
|
|
||||||
b.logf("WebClientInit: initializing web ui")
|
b.logf("WebClientInit: initializing web ui")
|
||||||
if b.webClient.server, err = web.NewServer(web.ServerOpts{
|
if b.webClient.server, err = web.NewServer(web.ServerOpts{
|
||||||
|
Mode: web.ManageServerMode,
|
||||||
// TODO(sonia): allow passing back dev mode flag
|
// TODO(sonia): allow passing back dev mode flag
|
||||||
LocalClient: b.webClient.lc,
|
LocalClient: b.webClient.lc,
|
||||||
Logf: b.logf,
|
Logf: b.logf,
|
||||||
|
@ -31,6 +31,7 @@ func main() {
|
|||||||
|
|
||||||
// Serve the Tailscale web client.
|
// Serve the Tailscale web client.
|
||||||
ws, err := web.NewServer(web.ServerOpts{
|
ws, err := web.NewServer(web.ServerOpts{
|
||||||
|
Mode: web.LegacyServerMode,
|
||||||
DevMode: *devMode,
|
DevMode: *devMode,
|
||||||
LocalClient: lc,
|
LocalClient: lc,
|
||||||
})
|
})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user