Compare commits

...

2 Commits

Author SHA1 Message Date
Sonia Appasamy
11b70e46b8 ipn/ipnlocal,client/web: add web client to tailscaled
Allows for serving the web interface from tailscaled, with the
ability to start and stop the server via localapi endpoints
(/web/start and /web/stop).

This will be used to run the new full management web client,
which will only be accessible over Tailscale (with an extra auth
check step over noise) from the daemon. This switch also allows
us to run the web interface securely without needing to manage
individual device environments (CGI, proxies, etc).

Updates tailscale/corp#14335

Co-authored-by: Will Norris <will@tailscale.com>
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
Signed-off-by: Will Norris <will@tailscale.com>
2023-10-24 20:00:30 -07:00
Sonia Appasamy
7f3599748a client/web: allow providing logger implementation
Also report metrics in separate go routine with a 5 second timeout.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-10-24 19:57:42 -07:00
7 changed files with 213 additions and 15 deletions

View File

@@ -32,12 +32,14 @@ import (
"tailscale.com/licenses"
"tailscale.com/net/netutil"
"tailscale.com/tailcfg"
"tailscale.com/types/logger"
"tailscale.com/util/httpm"
"tailscale.com/version/distro"
)
// Server is the backend server for a Tailscale web client.
type Server struct {
logf logger.Logf
lc *tailscale.LocalClient
timeNow func() time.Time
@@ -47,8 +49,9 @@ type Server struct {
cgiMode bool
pathPrefix string
assetsHandler http.Handler // serves frontend assets
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.
@@ -136,14 +139,20 @@ type ServerOpts struct {
// TimeNow optionally provides a time function.
// time.Now is used as default.
TimeNow func() time.Time
Logf logger.Logf
}
// NewServer constructs a new Tailscale web client server.
func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
// 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) {
if opts.LocalClient == nil {
opts.LocalClient = &tailscale.LocalClient{}
}
s = &Server{
logf: opts.Logf,
devMode: opts.DevMode,
lc: opts.LocalClient,
cgiMode: opts.CGIMode,
@@ -153,8 +162,13 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
if s.timeNow == nil {
s.timeNow = time.Now
}
if s.logf == nil {
s.logf = log.Printf
}
s.tsDebugMode = s.debugMode()
s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
s.assetsHandler, s.assetsCleanup = assetsHandler(opts.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
@@ -166,13 +180,26 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
// 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.lc.IncrementCounter(context.Background(), "web_login_client_initialization", 1)
metric = "web_login_client_initialization"
} else {
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1)
metric = "web_client_initialization"
}
return s, cleanup
// Report metric in separate go routine with 5 second timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
go func() {
defer cancel()
s.lc.IncrementCounter(ctx, metric, 1)
}()
return s, nil
}
func (s *Server) Shutdown() {
if s.assetsCleanup != nil {
s.assetsCleanup()
}
}
// debugMode returns the debug mode the web client is being run in.
@@ -645,7 +672,7 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
}
mp.Prefs.WantRunning = true
mp.Prefs.AdvertiseRoutes = routes
log.Printf("Doing edit: %v", mp.Pretty())
s.logf("Doing edit: %v", mp.Pretty())
if _, err := s.lc.EditPrefs(r.Context(), mp); err != nil {
w.WriteHeader(http.StatusInternalServerError)
@@ -661,9 +688,9 @@ func (s *Server) servePostNodeUpdate(w http.ResponseWriter, r *http.Request) {
if postData.ForceLogout {
logout = true
}
log.Printf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
s.logf("tailscaleUp(reauth=%v, logout=%v) ...", reauth, logout)
url, err := s.tailscaleUp(r.Context(), st, postData)
log.Printf("tailscaleUp = (URL %v, %v)", url != "", err)
s.logf("tailscaleUp = (URL %v, %v)", url != "", err)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(mi{"error": err.Error()})

View File

@@ -80,13 +80,17 @@ func runWeb(ctx context.Context, args []string) error {
return fmt.Errorf("too many non-flag arguments: %q", args)
}
webServer, cleanup := web.NewServer(web.ServerOpts{
webServer, err := web.NewServer(web.ServerOpts{
DevMode: webArgs.dev,
CGIMode: webArgs.cgi,
PathPrefix: webArgs.prefix,
LocalClient: &localClient,
})
defer cleanup()
if err != nil {
log.Printf("tailscale.web: %v", err)
return err
}
defer webServer.Shutdown()
if webArgs.cgi {
if err := cgi.Serve(webServer); err != nil {

View File

@@ -100,6 +100,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/google/nftables/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/clientupdate
github.com/gorilla/csrf from tailscale.com/client/web
github.com/gorilla/securecookie from github.com/gorilla/csrf
github.com/hdevalence/ed25519consensus from tailscale.com/tka+
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
@@ -133,6 +135,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L github.com/pierrec/lz4/v4/internal/lz4errors from github.com/pierrec/lz4/v4+
L github.com/pierrec/lz4/v4/internal/lz4stream from github.com/pierrec/lz4/v4
L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream
github.com/pkg/errors from github.com/gorilla/csrf
LD github.com/pkg/sftp from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient
@@ -149,6 +152,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
github.com/tailscale/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+
github.com/tailscale/web-client-prebuilt from tailscale.com/client/web
💣 github.com/tailscale/wireguard-go/conn from github.com/tailscale/wireguard-go/device+
W 💣 github.com/tailscale/wireguard-go/conn/winrio from github.com/tailscale/wireguard-go/conn
💣 github.com/tailscale/wireguard-go/device from tailscale.com/net/tstun+
@@ -219,8 +223,9 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/ipn+
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
tailscale.com/client/tailscale from tailscale.com/derp
tailscale.com/client/tailscale from tailscale.com/derp+
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+
tailscale.com/client/web from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+
@@ -251,6 +256,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
tailscale.com/ipn/store/mem from tailscale.com/ipn/store+
L tailscale.com/kube from tailscale.com/ipn/store/kubestore
tailscale.com/licenses from tailscale.com/client/web
tailscale.com/log/filelogger from tailscale.com/logpolicy
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+
@@ -268,6 +274,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/net/dnsfallback from tailscale.com/control/controlclient+
tailscale.com/net/flowtrack from tailscale.com/net/packet+
💣 tailscale.com/net/interfaces from tailscale.com/control/controlclient+
tailscale.com/net/memnet from tailscale.com/ipn/ipnlocal
tailscale.com/net/netaddr from tailscale.com/ipn+
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
@@ -339,7 +346,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth
tailscale.com/util/groupmember from tailscale.com/ipn/ipnauth+
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+
tailscale.com/util/httpm from tailscale.com/client/tailscale+
@@ -469,6 +476,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
encoding/base32 from tailscale.com/tka+
encoding/base64 from encoding/json+
encoding/binary from compress/gzip+
encoding/gob from github.com/gorilla/securecookie
encoding/hex from crypto/x509+
encoding/json from expvar+
encoding/pem from crypto/tls+
@@ -483,6 +491,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
hash/fnv from tailscale.com/wgengine/magicsock+
hash/maphash from go4.org/mem
html from tailscale.com/ipn/ipnlocal+
html/template from github.com/gorilla/csrf
io from bufio+
io/fs from crypto/x509+
io/ioutil from github.com/godbus/dbus/v5+
@@ -527,6 +536,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
sync/atomic from context+
syscall from crypto/rand+
text/tabwriter from runtime/pprof
text/template from html/template
text/template/parse from html/template+
time from compress/gzip+
unicode from bytes+
unicode/utf16 from crypto/x509+

View File

@@ -205,6 +205,7 @@ type LocalBackend struct {
httpTestClient *http.Client // for controlclient. nil by default, used by tests.
ccGen clientGen // function for producing controlclient; lazily populated
sshServer SSHServer // or nil, initialized lazily.
web webServer
notify func(ipn.Notify)
cc controlclient.Client
ccAuto *controlclient.Auto // if cc is of type *controlclient.Auto
@@ -635,6 +636,7 @@ func (b *LocalBackend) Shutdown() {
b.sshServer.Shutdown()
b.sshServer = nil
}
b.webShutdownLocked()
b.closePeerAPIListenersLocked()
if b.debugSink != nil {
b.e.InstallCaptureHook(nil)

126
ipn/ipnlocal/web.go Normal file
View File

@@ -0,0 +1,126 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package ipnlocal
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"tailscale.com/client/tailscale"
"tailscale.com/client/web"
"tailscale.com/envknob"
"tailscale.com/net/memnet"
)
// webServer holds state for the web interface for managing
// this tailscale instance. The web interface is not used by
// default, but initialized by calling LocalBackend.WebOrInit.
type webServer struct {
ws *web.Server // or nil, initialized lazily
httpServer *http.Server // or nil, initialized lazily
// webServer maintains its own localapi server and localclient connected to it
localAPIListener net.Listener // in-memory, used by lc
localAPIServer *http.Server
lc *tailscale.LocalClient
wg sync.WaitGroup
}
// WebOrInit gets or initializes the web interface for
// managing this tailscaled instance.
func (b *LocalBackend) WebOrInit(localapiHandler http.Handler) (_ *web.Server, err error) {
if !envknob.Bool("TS_DEBUG_WEB_UI") {
return nil, errors.New("web ui flag unset")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.web.ws != nil {
return b.web.ws, nil
}
lal := memnet.Listen("local-tailscaled.sock:80")
b.web.localAPIListener = lal
b.web.localAPIServer = &http.Server{Handler: localapiHandler}
b.web.lc = &tailscale.LocalClient{Dial: lal.Dial}
go func() {
if err := b.web.localAPIServer.Serve(lal); err != nil {
b.logf("localapi serve error: %v", err)
}
}()
b.logf("WebOrInit: initializing web ui")
if b.web.ws, err = web.NewServer(web.ServerOpts{
// TODO(sonia): allow passing back dev mode flag
LocalClient: b.web.lc,
Logf: b.logf,
}); err != nil {
return nil, fmt.Errorf("web.NewServer: %w", err)
}
// Start up the server.
b.web.wg.Add(1)
go func() {
defer b.web.wg.Done()
addr := ":5252"
b.web.httpServer = &http.Server{
Addr: addr,
Handler: http.HandlerFunc(b.web.ws.ServeHTTP),
}
b.logf("WebOrInit: serving web ui on %s", addr)
if err := b.web.httpServer.ListenAndServe(); err != nil {
if err != http.ErrServerClosed {
b.logf("[unexpected] WebOrInit: %v", err)
}
}
}()
b.logf("WebOrInit: started web ui")
return b.web.ws, nil
}
// WebShutdown shuts down any running b.web servers and
// clears out b.web state (besides the b.web.lc field,
// which is left untouched because required for future
// web startups).
func (b *LocalBackend) WebShutdown() {
b.mu.Lock()
defer b.mu.Unlock()
b.webShutdownLocked()
}
// webShutdownLocked shuts down any running b.web servers
// and clears out b.web state (besides the b.web.lc field,
// which is left untouched because required for future web
// startups).
//
// b.mu must be held.
func (b *LocalBackend) webShutdownLocked() {
if b.web.ws != nil {
b.web.ws.Shutdown()
}
if b.web.httpServer != nil {
if err := b.web.httpServer.Shutdown(context.Background()); err != nil {
b.logf("[unexpected] webShutdownLocked: %v", err)
}
}
if b.web.localAPIServer != nil {
if err := b.web.localAPIServer.Shutdown(context.Background()); err != nil {
b.logf("[unexpected] webShutdownLocked: %v", err)
}
}
if b.web.localAPIListener != nil {
b.web.localAPIListener.Close()
}
b.web.ws = nil
b.web.httpServer = nil
b.web.wg.Wait()
b.logf("webShutdownLocked: shut down web ui")
}

View File

@@ -66,6 +66,7 @@ var handler = map[string]localAPIHandler{
"file-put/": (*Handler).serveFilePut,
"files/": (*Handler).serveFiles,
"profiles/": (*Handler).serveProfiles,
"web/": (*Handler).serveWeb,
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
// without a trailing slash:
@@ -2181,6 +2182,30 @@ func (h *Handler) serveDebugWebClient(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
}
func (h *Handler) serveWeb(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
switch r.URL.Path {
case "/localapi/v0/web/start":
_, err := h.b.WebOrInit(h)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
return
case "/localapi/v0/web/stop":
h.b.WebShutdown()
w.WriteHeader(http.StatusOK)
return
default:
http.Error(w, "invalid action", http.StatusBadRequest)
return
}
}
func defBool(a string, def bool) bool {
if a == "" {
return def

View File

@@ -30,11 +30,14 @@ func main() {
}
// Serve the Tailscale web client.
ws, cleanup := web.NewServer(web.ServerOpts{
ws, err := web.NewServer(web.ServerOpts{
DevMode: *devMode,
LocalClient: lc,
})
defer cleanup()
if err != nil {
log.Fatal(err)
}
defer ws.Shutdown()
log.Printf("Serving Tailscale web client on http://%s", *addr)
if err := http.ListenAndServe(*addr, ws); err != nil {
if err != http.ErrServerClosed {