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 as a long-lived service in environments
where the CLI version is restricted to CGI, allowing us to manage
certain auth state in memory.

ipn/ipnlocal/web is stubbed out in ipn/ipnlocal/web_stub for
ios builds to satisfy ios restriction from adding "text/template"
and "html/template" dependencies.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
pull/9987/head
Sonia Appasamy 8 months ago committed by Sonia Appasamy
parent 93aa8a8cff
commit 89953b015b

@ -49,8 +49,9 @@ type Server struct {
cgiMode bool cgiMode bool
pathPrefix string pathPrefix string
assetsHandler http.Handler // serves frontend assets
apiHandler http.Handler // serves api endpoints; csrf-protected 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 // browserSessions is an in-memory cache of browser sessions for the
// full management web client, which is only accessible over Tailscale. // full management web client, which is only accessible over Tailscale.
@ -143,7 +144,10 @@ type ServerOpts struct {
} }
// NewServer constructs a new Tailscale web client server. // 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 { if opts.LocalClient == nil {
opts.LocalClient = &tailscale.LocalClient{} opts.LocalClient = &tailscale.LocalClient{}
} }
@ -162,7 +166,7 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
s.logf = log.Printf s.logf = log.Printf
} }
s.tsDebugMode = s.debugMode() 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 var metric string // clientmetric to report on startup
@ -182,14 +186,21 @@ func NewServer(opts ServerOpts) (s *Server, cleanup func()) {
metric = "web_client_initialization" metric = "web_client_initialization"
} }
// Report metric in separate go routine with 5 second timeout. // Don't block startup on reporting metric.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // Report in separate go routine with 5 second timeout.
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
s.lc.IncrementCounter(ctx, metric, 1) s.lc.IncrementCounter(ctx, metric, 1)
}() }()
return s, cleanup 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. // debugMode returns the debug mode the web client is being run in.

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

@ -95,6 +95,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/internal/parseexprfunc from github.com/google/nftables+
L github.com/google/nftables/xt from github.com/google/nftables/expr+ L github.com/google/nftables/xt from github.com/google/nftables/expr+
github.com/google/uuid from tailscale.com/clientupdate 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+ github.com/hdevalence/ed25519consensus from tailscale.com/tka+
L 💣 github.com/illarion/gonotify from tailscale.com/net/dns L 💣 github.com/illarion/gonotify from tailscale.com/net/dns
L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun L github.com/insomniacslk/dhcp/dhcpv4 from tailscale.com/net/tstun
@ -128,6 +130,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/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/lz4stream from github.com/pierrec/lz4/v4
L github.com/pierrec/lz4/v4/internal/xxh32 from github.com/pierrec/lz4/v4/internal/lz4stream 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 from tailscale.com/ssh/tailssh
LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp LD github.com/pkg/sftp/internal/encoding/ssh/filexfer from github.com/pkg/sftp
W 💣 github.com/tailscale/certstore from tailscale.com/control/controlclient 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/goupnp/ssdp from github.com/tailscale/goupnp
github.com/tailscale/hujson from tailscale.com/ipn/conffile github.com/tailscale/hujson from tailscale.com/ipn/conffile
L 💣 github.com/tailscale/netlink from tailscale.com/wgengine/router+ 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+ 💣 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 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+ 💣 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 from tailscale.com/version
tailscale.com/atomicfile from tailscale.com/ipn+ tailscale.com/atomicfile from tailscale.com/ipn+
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled 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/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 from tailscale.com/ipn/ipnlocal
tailscale.com/clientupdate/distsign from tailscale.com/clientupdate tailscale.com/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+ 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 L tailscale.com/ipn/store/kubestore from tailscale.com/ipn/store
tailscale.com/ipn/store/mem 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 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/filelogger from tailscale.com/logpolicy
tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal tailscale.com/log/sockstatlog from tailscale.com/ipn/ipnlocal
tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+ tailscale.com/logpolicy from tailscale.com/cmd/tailscaled+
@ -339,7 +345,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+ L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics+
tailscale.com/util/dnsname from tailscale.com/hostinfo+ tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/goroutines from tailscale.com/ipn/ipnlocal 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/hashx from tailscale.com/util/deephash
tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+ tailscale.com/util/httphdr from tailscale.com/ipn/ipnlocal+
tailscale.com/util/httpm from tailscale.com/client/tailscale+ tailscale.com/util/httpm from tailscale.com/client/tailscale+
@ -468,6 +474,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
encoding/base32 from tailscale.com/tka+ encoding/base32 from tailscale.com/tka+
encoding/base64 from encoding/json+ encoding/base64 from encoding/json+
encoding/binary from compress/gzip+ encoding/binary from compress/gzip+
encoding/gob from github.com/gorilla/securecookie
encoding/hex from crypto/x509+ encoding/hex from crypto/x509+
encoding/json from expvar+ encoding/json from expvar+
encoding/pem from crypto/tls+ encoding/pem from crypto/tls+
@ -482,6 +489,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
hash/fnv from tailscale.com/wgengine/magicsock+ hash/fnv from tailscale.com/wgengine/magicsock+
hash/maphash from go4.org/mem hash/maphash from go4.org/mem
html from tailscale.com/ipn/ipnlocal+ html from tailscale.com/ipn/ipnlocal+
html/template from github.com/gorilla/csrf
io from bufio+ io from bufio+
io/fs from crypto/x509+ io/fs from crypto/x509+
io/ioutil from github.com/godbus/dbus/v5+ io/ioutil from github.com/godbus/dbus/v5+
@ -526,6 +534,8 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
sync/atomic from context+ sync/atomic from context+
syscall from crypto/rand+ syscall from crypto/rand+
text/tabwriter from runtime/pprof text/tabwriter from runtime/pprof
text/template from html/template
text/template/parse from html/template+
time from compress/gzip+ time from compress/gzip+
unicode from bytes+ unicode from bytes+
unicode/utf16 from crypto/x509+ unicode/utf16 from crypto/x509+

@ -29,6 +29,7 @@ import (
"syscall" "syscall"
"time" "time"
"tailscale.com/client/tailscale"
"tailscale.com/cmd/tailscaled/childproc" "tailscale.com/cmd/tailscaled/childproc"
"tailscale.com/control/controlclient" "tailscale.com/control/controlclient"
"tailscale.com/envknob" "tailscale.com/envknob"
@ -569,6 +570,9 @@ func getLocalBackend(ctx context.Context, logf logger.Logf, logID logid.PublicID
if root := lb.TailscaleVarRoot(); root != "" { if root := lb.TailscaleVarRoot(); root != "" {
dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"), logf) dnsfallback.SetCachePath(filepath.Join(root, "derpmap.cached.json"), logf)
} }
if envknob.Bool("TS_DEBUG_WEB_UI") {
lb.SetWebLocalClient(&tailscale.LocalClient{Socket: args.socketpath, UseSocketOnly: args.socketpath != ""})
}
configureTaildrop(logf, lb) configureTaildrop(logf, lb)
if err := ns.Start(lb); err != nil { if err := ns.Start(lb); err != nil {
log.Fatalf("failed to start netstack: %v", err) log.Fatalf("failed to start netstack: %v", err)

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

@ -0,0 +1,110 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !ios && !android
package ipnlocal
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"tailscale.com/client/tailscale"
"tailscale.com/client/web"
"tailscale.com/envknob"
)
// 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
// lc optionally specifies a LocalClient to use to connect
// to the localapi for this tailscaled instance.
// If nil, a default is used.
lc *tailscale.LocalClient
wg sync.WaitGroup
}
// SetWebLocalClient sets the b.web.lc function.
// If lc is provided as nil, b.web.lc is cleared out.
func (b *LocalBackend) SetWebLocalClient(lc *tailscale.LocalClient) {
b.mu.Lock()
defer b.mu.Unlock()
b.web.lc = lc
}
// WebInit initializes the web interface for managing
// this tailscaled instance. If the web interface is
// already running, WebInit is a no-op.
func (b *LocalBackend) WebInit() (err error) {
if !envknob.Bool("TS_DEBUG_WEB_UI") {
return errors.New("web ui flag unset")
}
b.mu.Lock()
defer b.mu.Unlock()
if b.web.ws != nil {
return nil
}
b.logf("WebInit: 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 fmt.Errorf("web.NewServer: %w", err)
}
// Start up the server.
b.web.wg.Add(1)
go func() {
defer b.web.wg.Done()
// TODO(sonia/will): only listen on Tailscale IP addresses
addr := ":5252"
b.web.httpServer = &http.Server{
Addr: addr,
Handler: http.HandlerFunc(b.web.ws.ServeHTTP),
}
b.logf("WebInit: serving web ui on %s", addr)
if err := b.web.httpServer.ListenAndServe(); err != nil {
if err != http.ErrServerClosed {
b.logf("[unexpected] WebInit: %v", err)
}
}
}()
b.logf("WebInit: started web ui")
return 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).
// WebShutdown obtains the b.mu lock.
func (b *LocalBackend) WebShutdown() {
b.mu.Lock()
webS := b.web.ws
httpS := b.web.httpServer
b.web.ws = nil
b.web.httpServer = nil
b.mu.Unlock() // release lock before shutdown
if webS != nil {
b.web.ws.Shutdown()
}
if httpS != nil {
if err := b.web.httpServer.Shutdown(context.Background()); err != nil {
b.logf("[unexpected] WebShutdown: %v", err)
}
}
b.web.wg.Wait()
b.logf("WebShutdown: shut down web ui")
}

@ -0,0 +1,22 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build ios || android
package ipnlocal
import (
"errors"
"tailscale.com/client/tailscale"
)
type webServer struct{}
func (b *LocalBackend) SetWebLocalClient(lc *tailscale.LocalClient) {}
func (b *LocalBackend) WebInit() error {
return errors.New("not implemented")
}
func (b *LocalBackend) WebShutdown() {}

@ -66,6 +66,7 @@ var handler = map[string]localAPIHandler{
"file-put/": (*Handler).serveFilePut, "file-put/": (*Handler).serveFilePut,
"files/": (*Handler).serveFiles, "files/": (*Handler).serveFiles,
"profiles/": (*Handler).serveProfiles, "profiles/": (*Handler).serveProfiles,
"web/": (*Handler).serveWeb,
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME // The other /localapi/v0/NAME handlers are exact matches and contain only NAME
// without a trailing slash: // without a trailing slash:
@ -2233,6 +2234,33 @@ func (h *Handler) serveDebugWebClient(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
} }
func (h *Handler) serveWeb(w http.ResponseWriter, r *http.Request) {
if !h.PermitWrite {
http.Error(w, "access denied", http.StatusForbidden)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
switch r.URL.Path {
case "/localapi/v0/web/start":
if err := h.b.WebInit(); 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 { func defBool(a string, def bool) bool {
if a == "" { if a == "" {
return def return def

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

@ -11,6 +11,7 @@ import (
// transitive deps when we run "go install tailscaled" in a child // transitive deps when we run "go install tailscaled" in a child
// process and can cache a prior success when a dependency changes. // process and can cache a prior success when a dependency changes.
_ "tailscale.com/chirp" _ "tailscale.com/chirp"
_ "tailscale.com/client/tailscale"
_ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/cmd/tailscaled/childproc"
_ "tailscale.com/control/controlclient" _ "tailscale.com/control/controlclient"
_ "tailscale.com/derp/derphttp" _ "tailscale.com/derp/derphttp"

@ -11,6 +11,7 @@ import (
// transitive deps when we run "go install tailscaled" in a child // transitive deps when we run "go install tailscaled" in a child
// process and can cache a prior success when a dependency changes. // process and can cache a prior success when a dependency changes.
_ "tailscale.com/chirp" _ "tailscale.com/chirp"
_ "tailscale.com/client/tailscale"
_ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/cmd/tailscaled/childproc"
_ "tailscale.com/control/controlclient" _ "tailscale.com/control/controlclient"
_ "tailscale.com/derp/derphttp" _ "tailscale.com/derp/derphttp"

@ -11,6 +11,7 @@ import (
// transitive deps when we run "go install tailscaled" in a child // transitive deps when we run "go install tailscaled" in a child
// process and can cache a prior success when a dependency changes. // process and can cache a prior success when a dependency changes.
_ "tailscale.com/chirp" _ "tailscale.com/chirp"
_ "tailscale.com/client/tailscale"
_ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/cmd/tailscaled/childproc"
_ "tailscale.com/control/controlclient" _ "tailscale.com/control/controlclient"
_ "tailscale.com/derp/derphttp" _ "tailscale.com/derp/derphttp"

@ -11,6 +11,7 @@ import (
// transitive deps when we run "go install tailscaled" in a child // transitive deps when we run "go install tailscaled" in a child
// process and can cache a prior success when a dependency changes. // process and can cache a prior success when a dependency changes.
_ "tailscale.com/chirp" _ "tailscale.com/chirp"
_ "tailscale.com/client/tailscale"
_ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/cmd/tailscaled/childproc"
_ "tailscale.com/control/controlclient" _ "tailscale.com/control/controlclient"
_ "tailscale.com/derp/derphttp" _ "tailscale.com/derp/derphttp"

@ -18,6 +18,7 @@ import (
_ "golang.org/x/sys/windows/svc/mgr" _ "golang.org/x/sys/windows/svc/mgr"
_ "golang.zx2c4.com/wintun" _ "golang.zx2c4.com/wintun"
_ "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg" _ "golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
_ "tailscale.com/client/tailscale"
_ "tailscale.com/cmd/tailscaled/childproc" _ "tailscale.com/cmd/tailscaled/childproc"
_ "tailscale.com/control/controlclient" _ "tailscale.com/control/controlclient"
_ "tailscale.com/derp/derphttp" _ "tailscale.com/derp/derphttp"

Loading…
Cancel
Save