diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index b3cdb3dbb..5f8d15456 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -38,24 +38,59 @@ import ( "tailscale.com/tailcfg" ) -var ( - // TailscaledSocket is the tailscaled Unix socket. It's used by the TailscaledDialer. - TailscaledSocket = paths.DefaultTailscaledSocket() +// defaultLocalClient is the default LocalClient when using the legacy +// package-level functions. +var defaultLocalClient LocalClient + +// LocalClient is a client to Tailscale's "local API", communicating with the +// Tailscale daemon on the local machine. Its API is not necessarily stable and +// subject to changes between releases. Some API calls have stricter +// compatibility guarantees, once they've been widely adopted. See method docs +// for details. +// +// Its zero value is valid to use. +// +// Any exported fields should be set before using methods on the type +// and not changed thereafter. +type LocalClient struct { + // Dial optionally specifies an alternate func that connects to the local + // machine's tailscaled or equivalent. If nil, a default is used. + Dial func(ctx context.Context, network, addr string) (net.Conn, error) + + // Socket specifies an alternate path to the local Tailscale socket. + // If empty, a platform-specific default is used. + Socket string + + // UseSocketOnly, if true, tries to only connect to tailscaled via the + // Unix socket and not via fallback mechanisms as done on macOS when + // connecting to the GUI client variants. + UseSocketOnly bool + + // tsClient does HTTP requests to the local Tailscale daemon. + // It's lazily initialized on first use. + tsClient *http.Client + tsClientOnce sync.Once +} - // TailscaledSocketSetExplicitly reports whether the user explicitly set TailscaledSocket. - TailscaledSocketSetExplicitly bool +func (lc *LocalClient) socket() string { + if lc.Socket != "" { + return lc.Socket + } + return paths.DefaultTailscaledSocket() +} - // TailscaledDialer is the DialContext func that connects to the local machine's - // tailscaled or equivalent. - TailscaledDialer = defaultDialer -) +func (lc *LocalClient) dialer() func(ctx context.Context, network, addr string) (net.Conn, error) { + if lc.Dial != nil { + return lc.Dial + } + return lc.defaultDialer +} -func defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) { +func (lc *LocalClient) defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) { if addr != "local-tailscaled.sock:80" { return nil, fmt.Errorf("unexpected URL address %q", addr) } - // TODO: make this part of a safesocket.ConnectionStrategy - if !TailscaledSocketSetExplicitly { + if !lc.UseSocketOnly { // On macOS, when dialing from non-sandboxed program to sandboxed GUI running // a TCP server on a random port, find the random port. For HTTP connections, // we don't send the token. It gets added in an HTTP Basic-Auth header. @@ -64,21 +99,13 @@ func defaultDialer(ctx context.Context, network, addr string) (net.Conn, error) return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port)) } } - s := safesocket.DefaultConnectionStrategy(TailscaledSocket) + s := safesocket.DefaultConnectionStrategy(lc.socket()) // The user provided a non-default tailscaled socket address. // Connect only to exactly what they provided. s.UseFallback(false) return safesocket.Connect(s) } -var ( - // tsClient does HTTP requests to the local Tailscale daemon. - // We lazily initialize the client in case the caller wants to - // override TailscaledDialer. - tsClient *http.Client - tsClientOnce sync.Once -) - // DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon. // // URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4. @@ -88,22 +115,22 @@ var ( // authenticating to the local Tailscale daemon vary by platform. // // DoLocalRequest may mutate the request to add Authorization headers. -func DoLocalRequest(req *http.Request) (*http.Response, error) { - tsClientOnce.Do(func() { - tsClient = &http.Client{ +func (lc *LocalClient) DoLocalRequest(req *http.Request) (*http.Response, error) { + lc.tsClientOnce.Do(func() { + lc.tsClient = &http.Client{ Transport: &http.Transport{ - DialContext: TailscaledDialer, + DialContext: lc.dialer(), }, } }) if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil { req.SetBasicAuth("", token) } - return tsClient.Do(req) + return lc.tsClient.Do(req) } -func doLocalRequestNiceError(req *http.Request) (*http.Response, error) { - res, err := DoLocalRequest(req) +func (lc *LocalClient) doLocalRequestNiceError(req *http.Request) (*http.Response, error) { + res, err := lc.DoLocalRequest(req) if err == nil { if server := res.Header.Get("Tailscale-Version"); server != "" && server != ipn.IPCVersion() && onVersionMismatch != nil { onVersionMismatch(ipn.IPCVersion(), server) @@ -169,12 +196,12 @@ func SetVersionMismatchHandler(f func(clientVer, serverVer string)) { onVersionMismatch = f } -func send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) { +func (lc *LocalClient) send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body) if err != nil { return nil, err } - res, err := doLocalRequestNiceError(req) + res, err := lc.doLocalRequestNiceError(req) if err != nil { return nil, err } @@ -190,13 +217,20 @@ func send(ctx context.Context, method, path string, wantStatus int, body io.Read return slurp, nil } -func get200(ctx context.Context, path string) ([]byte, error) { - return send(ctx, "GET", path, 200, nil) +func (lc *LocalClient) get200(ctx context.Context, path string) ([]byte, error) { + return lc.send(ctx, "GET", path, 200, nil) } // WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port. +// +// Deprecated: use LocalClient.WhoIs. func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) { - body, err := get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)) + return defaultLocalClient.WhoIs(ctx, remoteAddr) +} + +// WhoIs returns the owner of the remoteAddr, which must be an IP or IP:port. +func (lc *LocalClient) WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, error) { + body, err := lc.get200(ctx, "/localapi/v0/whois?addr="+url.QueryEscape(remoteAddr)) if err != nil { return nil, err } @@ -211,18 +245,18 @@ func WhoIs(ctx context.Context, remoteAddr string) (*apitype.WhoIsResponse, erro } // Goroutines returns a dump of the Tailscale daemon's current goroutines. -func Goroutines(ctx context.Context) ([]byte, error) { - return get200(ctx, "/localapi/v0/goroutines") +func (lc *LocalClient) Goroutines(ctx context.Context) ([]byte, error) { + return lc.get200(ctx, "/localapi/v0/goroutines") } // DaemonMetrics returns the Tailscale daemon's metrics in // the Prometheus text exposition format. -func DaemonMetrics(ctx context.Context) ([]byte, error) { - return get200(ctx, "/localapi/v0/metrics") +func (lc *LocalClient) DaemonMetrics(ctx context.Context) ([]byte, error) { + return lc.get200(ctx, "/localapi/v0/metrics") } // Profile returns a pprof profile of the Tailscale daemon. -func Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) { +func (lc *LocalClient) Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) { var secArg string if sec < 0 || sec > 300 { return nil, errors.New("duration out of range") @@ -230,12 +264,12 @@ func Profile(ctx context.Context, pprofType string, sec int) ([]byte, error) { if sec != 0 || pprofType == "profile" { secArg = fmt.Sprint(sec) } - return get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg)) + return lc.get200(ctx, fmt.Sprintf("/localapi/v0/profile?name=%s&seconds=%v", url.QueryEscape(pprofType), secArg)) } // BugReport logs and returns a log marker that can be shared by the user with support. -func BugReport(ctx context.Context, note string) (string, error) { - body, err := send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil) +func (lc *LocalClient) BugReport(ctx context.Context, note string) (string, error) { + body, err := lc.send(ctx, "POST", "/localapi/v0/bugreport?note="+url.QueryEscape(note), 200, nil) if err != nil { return "", err } @@ -244,8 +278,8 @@ func BugReport(ctx context.Context, note string) (string, error) { // DebugAction invokes a debug action, such as "rebind" or "restun". // These are development tools and subject to change or removal over time. -func DebugAction(ctx context.Context, action string) error { - body, err := send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil) +func (lc *LocalClient) DebugAction(ctx context.Context, action string) error { + body, err := lc.send(ctx, "POST", "/localapi/v0/debug?action="+url.QueryEscape(action), 200, nil) if err != nil { return fmt.Errorf("error %w: %s", err, body) } @@ -254,16 +288,26 @@ func DebugAction(ctx context.Context, action string) error { // Status returns the Tailscale daemon's status. func Status(ctx context.Context) (*ipnstate.Status, error) { - return status(ctx, "") + return defaultLocalClient.Status(ctx) +} + +// Status returns the Tailscale daemon's status. +func (lc *LocalClient) Status(ctx context.Context) (*ipnstate.Status, error) { + return lc.status(ctx, "") } // StatusWithoutPeers returns the Tailscale daemon's status, without the peer info. func StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { - return status(ctx, "?peers=false") + return defaultLocalClient.StatusWithoutPeers(ctx) } -func status(ctx context.Context, queryString string) (*ipnstate.Status, error) { - body, err := get200(ctx, "/localapi/v0/status"+queryString) +// StatusWithoutPeers returns the Tailscale daemon's status, without the peer info. +func (lc *LocalClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { + return lc.status(ctx, "?peers=false") +} + +func (lc *LocalClient) status(ctx context.Context, queryString string) (*ipnstate.Status, error) { + body, err := lc.get200(ctx, "/localapi/v0/status"+queryString) if err != nil { return nil, err } @@ -277,8 +321,8 @@ func status(ctx context.Context, queryString string) (*ipnstate.Status, error) { // IDToken is a request to get an OIDC ID token for an audience. // The token can be presented to any resource provider which offers OIDC // Federation. -func IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) { - body, err := get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud)) +func (lc *LocalClient) IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) { + body, err := lc.get200(ctx, "/localapi/v0/id-token?aud="+url.QueryEscape(aud)) if err != nil { return nil, err } @@ -289,8 +333,8 @@ func IDToken(ctx context.Context, aud string) (*tailcfg.TokenResponse, error) { return tr, nil } -func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { - body, err := get200(ctx, "/localapi/v0/files/") +func (lc *LocalClient) WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { + body, err := lc.get200(ctx, "/localapi/v0/files/") if err != nil { return nil, err } @@ -301,17 +345,17 @@ func WaitingFiles(ctx context.Context) ([]apitype.WaitingFile, error) { return wfs, nil } -func DeleteWaitingFile(ctx context.Context, baseName string) error { - _, err := send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil) +func (lc *LocalClient) DeleteWaitingFile(ctx context.Context, baseName string) error { + _, err := lc.send(ctx, "DELETE", "/localapi/v0/files/"+url.PathEscape(baseName), http.StatusNoContent, nil) return err } -func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) { +func (lc *LocalClient) GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, size int64, err error) { req, err := http.NewRequestWithContext(ctx, "GET", "http://local-tailscaled.sock/localapi/v0/files/"+url.PathEscape(baseName), nil) if err != nil { return nil, 0, err } - res, err := doLocalRequestNiceError(req) + res, err := lc.doLocalRequestNiceError(req) if err != nil { return nil, 0, err } @@ -327,8 +371,8 @@ func GetWaitingFile(ctx context.Context, baseName string) (rc io.ReadCloser, siz return res.Body, res.ContentLength, nil } -func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) { - body, err := get200(ctx, "/localapi/v0/file-targets") +func (lc *LocalClient) FileTargets(ctx context.Context) ([]apitype.FileTarget, error) { + body, err := lc.get200(ctx, "/localapi/v0/file-targets") if err != nil { return nil, err } @@ -343,7 +387,7 @@ func FileTargets(ctx context.Context) ([]apitype.FileTarget, error) { // // A size of -1 means unknown. // The name parameter is the original filename, not escaped. -func PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error { +func (lc *LocalClient) PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name string, r io.Reader) error { req, err := http.NewRequestWithContext(ctx, "PUT", "http://local-tailscaled.sock/localapi/v0/file-put/"+string(target)+"/"+url.PathEscape(name), r) if err != nil { return err @@ -351,7 +395,7 @@ func PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name if size != -1 { req.ContentLength = size } - res, err := doLocalRequestNiceError(req) + res, err := lc.doLocalRequestNiceError(req) if err != nil { return err } @@ -363,8 +407,11 @@ func PushFile(ctx context.Context, target tailcfg.StableNodeID, size int64, name return bestError(fmt.Errorf("%s: %s", res.Status, all), all) } -func CheckIPForwarding(ctx context.Context) error { - body, err := get200(ctx, "/localapi/v0/check-ip-forwarding") +// CheckIPForwarding asks the local Tailscale daemon whether it looks like the +// machine is properly configured to forward IP packets as a subnet router +// or exit node. +func (lc *LocalClient) CheckIPForwarding(ctx context.Context) error { + body, err := lc.get200(ctx, "/localapi/v0/check-ip-forwarding") if err != nil { return err } @@ -386,17 +433,17 @@ func CheckIPForwarding(ctx context.Context) error { // work. Currently (2022-04-18) this only checks for SSH server compatibility. // Note that EditPrefs does the same validation as this, so call CheckPrefs before // EditPrefs is not necessary. -func CheckPrefs(ctx context.Context, p *ipn.Prefs) error { +func (lc *LocalClient) CheckPrefs(ctx context.Context, p *ipn.Prefs) error { pj, err := json.Marshal(p) if err != nil { return err } - _, err = send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj)) + _, err = lc.send(ctx, "POST", "/localapi/v0/check-prefs", http.StatusOK, bytes.NewReader(pj)) return err } -func GetPrefs(ctx context.Context) (*ipn.Prefs, error) { - body, err := get200(ctx, "/localapi/v0/prefs") +func (lc *LocalClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) { + body, err := lc.get200(ctx, "/localapi/v0/prefs") if err != nil { return nil, err } @@ -407,12 +454,12 @@ func GetPrefs(ctx context.Context) (*ipn.Prefs, error) { return &p, nil } -func EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { +func (lc *LocalClient) EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { mpj, err := json.Marshal(mp) if err != nil { return nil, err } - body, err := send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj)) + body, err := lc.send(ctx, "PATCH", "/localapi/v0/prefs", http.StatusOK, bytes.NewReader(mpj)) if err != nil { return nil, err } @@ -423,8 +470,8 @@ func EditPrefs(ctx context.Context, mp *ipn.MaskedPrefs) (*ipn.Prefs, error) { return &p, nil } -func Logout(ctx context.Context) error { - _, err := send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil) +func (lc *LocalClient) Logout(ctx context.Context) error { + _, err := lc.send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil) return err } @@ -442,11 +489,11 @@ func Logout(ctx context.Context) error { // This is a low-level interface; it's expected that most Tailscale // users use a higher level interface to getting/using TLS // certificates. -func SetDNS(ctx context.Context, name, value string) error { +func (lc *LocalClient) SetDNS(ctx context.Context, name, value string) error { v := url.Values{} v.Set("name", name) v.Set("value", value) - _, err := send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil) + _, err := lc.send(ctx, "POST", "/localapi/v0/set-dns?"+v.Encode(), 200, nil) return err } @@ -456,7 +503,7 @@ func SetDNS(ctx context.Context, name, value string) error { // tailscaled), a FQDN, or an IP address. // // The ctx is only used for the duration of the call, not the lifetime of the net.Conn. -func DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) { +func (lc *LocalClient) DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) { connCh := make(chan net.Conn, 1) trace := httptrace.ClientTrace{ GotConn: func(info httptrace.GotConnInfo) { @@ -474,7 +521,7 @@ func DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) { "Dial-Host": []string{host}, "Dial-Port": []string{fmt.Sprint(port)}, } - res, err := DoLocalRequest(req) + res, err := lc.DoLocalRequest(req) if err != nil { return nil, err } @@ -506,9 +553,9 @@ func DialTCP(ctx context.Context, host string, port uint16) (net.Conn, error) { // CurrentDERPMap returns the current DERPMap that is being used by the local tailscaled. // It is intended to be used with netcheck to see availability of DERPs. -func CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) { +func (lc *LocalClient) CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) { var derpMap tailcfg.DERPMap - res, err := send(ctx, "GET", "/localapi/v0/derpmap", 200, nil) + res, err := lc.send(ctx, "GET", "/localapi/v0/derpmap", 200, nil) if err != nil { return nil, err } @@ -521,8 +568,19 @@ func CurrentDERPMap(ctx context.Context) (*tailcfg.DERPMap, error) { // CertPair returns a cert and private key for the provided DNS domain. // // It returns a cached certificate from disk if it's still valid. +// +// Deprecated: use LocalClient.CertPair. func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) { - res, err := send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil) + return defaultLocalClient.CertPair(ctx, domain) +} + +// CertPair returns a cert and private key for the provided DNS domain. +// +// It returns a cached certificate from disk if it's still valid. +// +// API maturity: this is considered a stable API. +func (lc *LocalClient) CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) { + res, err := lc.send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil) if err != nil { return nil, nil, err } @@ -546,7 +604,21 @@ func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err e // // It's the right signature to use as the value of // tls.Config.GetCertificate. +// +// Deprecated: use LocalClient.GetCertificate. func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { + return defaultLocalClient.GetCertificate(hi) +} + +// GetCertificate fetches a TLS certificate for the TLS ClientHello in hi. +// +// It returns a cached certificate from disk if it's still valid. +// +// It's the right signature to use as the value of +// tls.Config.GetCertificate. +// +// API maturity: this is considered a stable API. +func (lc *LocalClient) GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { if hi == nil || hi.ServerName == "" { return nil, errors.New("no SNI ServerName") } @@ -555,11 +627,11 @@ func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { name := hi.ServerName if !strings.Contains(name, ".") { - if v, ok := ExpandSNIName(ctx, name); ok { + if v, ok := lc.ExpandSNIName(ctx, name); ok { name = v } } - certPEM, keyPEM, err := CertPair(ctx, name) + certPEM, keyPEM, err := lc.CertPair(ctx, name) if err != nil { return nil, err } @@ -571,7 +643,14 @@ func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { } // ExpandSNIName expands bare label name into the the most likely actual TLS cert name. +// +// Deprecated: use LocalClient.ExpandSNIName. func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) { + return defaultLocalClient.ExpandSNIName(ctx, name) +} + +// ExpandSNIName expands bare label name into the the most likely actual TLS cert name. +func (lc *LocalClient) ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) { st, err := StatusWithoutPeers(ctx) if err != nil { return "", false diff --git a/cmd/proxy-to-grafana/proxy-to-grafana.go b/cmd/proxy-to-grafana/proxy-to-grafana.go index b81f1dec5..7e5d580c7 100644 --- a/cmd/proxy-to-grafana/proxy-to-grafana.go +++ b/cmd/proxy-to-grafana/proxy-to-grafana.go @@ -62,6 +62,12 @@ func main() { Hostname: *hostname, } + // TODO(bradfitz,maisem): move this to a method on tsnet.Server probably. + if err := ts.Start(); err != nil { + log.Fatalf("Error starting tsnet.Server: %v", err) + } + localClient, _ := ts.LocalClient() + url, err := url.Parse(fmt.Sprintf("http://%s", *backendAddr)) if err != nil { log.Fatalf("couldn't parse backend address: %v", err) @@ -71,7 +77,7 @@ func main() { originalDirector := proxy.Director proxy.Director = func(req *http.Request) { originalDirector(req) - modifyRequest(req) + modifyRequest(req, localClient) } var ln net.Listener @@ -84,7 +90,7 @@ func main() { go func() { // wait for tailscale to start before trying to fetch cert names for i := 0; i < 60; i++ { - st, err := tailscale.Status(context.Background()) + st, err := localClient.Status(context.Background()) if err != nil { log.Printf("error retrieving tailscale status; retrying: %v", err) } else { @@ -100,7 +106,7 @@ func main() { if err != nil { log.Fatal(err) } - name, ok := tailscale.ExpandSNIName(context.Background(), *hostname) + name, ok := localClient.ExpandSNIName(context.Background(), *hostname) if !ok { log.Fatalf("can't get hostname for https redirect") } @@ -120,14 +126,14 @@ func main() { log.Fatal(http.Serve(ln, proxy)) } -func modifyRequest(req *http.Request) { +func modifyRequest(req *http.Request, localClient *tailscale.LocalClient) { // with enable_login_token set to true, we get a cookie that handles // auth for paths that are not /login if req.URL.Path != "/login" { return } - user, err := getTailscaleUser(req.Context(), req.RemoteAddr) + user, err := getTailscaleUser(req.Context(), localClient, req.RemoteAddr) if err != nil { log.Printf("error getting Tailscale user: %v", err) return @@ -137,8 +143,8 @@ func modifyRequest(req *http.Request) { req.Header.Set("X-Webauth-Name", user.DisplayName) } -func getTailscaleUser(ctx context.Context, ipPort string) (*tailcfg.UserProfile, error) { - whois, err := tailscale.WhoIs(ctx, ipPort) +func getTailscaleUser(ctx context.Context, localClient *tailscale.LocalClient, ipPort string) (*tailcfg.UserProfile, error) { + whois, err := localClient.WhoIs(ctx, ipPort) if err != nil { return nil, fmt.Errorf("failed to identify remote host: %w", err) } diff --git a/cmd/tailscale/cli/bugreport.go b/cmd/tailscale/cli/bugreport.go index 5de2f134a..f22193131 100644 --- a/cmd/tailscale/cli/bugreport.go +++ b/cmd/tailscale/cli/bugreport.go @@ -9,7 +9,6 @@ import ( "errors" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" ) var bugReportCmd = &ffcli.Command{ @@ -28,7 +27,7 @@ func runBugReport(ctx context.Context, args []string) error { default: return errors.New("unknown argumets") } - logMarker, err := tailscale.BugReport(ctx, note) + logMarker, err := localClient.BugReport(ctx, note) if err != nil { return err } diff --git a/cmd/tailscale/cli/cert.go b/cmd/tailscale/cli/cert.go index ac7284d8f..dd4b5b4ab 100644 --- a/cmd/tailscale/cli/cert.go +++ b/cmd/tailscale/cli/cert.go @@ -50,7 +50,7 @@ func runCert(ctx context.Context, args []string) error { }, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.TLS != nil && !strings.Contains(r.Host, ".") && r.Method == "GET" { - if v, ok := tailscale.ExpandSNIName(r.Context(), r.Host); ok { + if v, ok := localClient.ExpandSNIName(r.Context(), r.Host); ok { http.Redirect(w, r, "https://"+v+r.URL.Path, http.StatusTemporaryRedirect) return } @@ -64,7 +64,7 @@ func runCert(ctx context.Context, args []string) error { if len(args) != 1 { var hint bytes.Buffer - if st, err := tailscale.Status(ctx); err == nil { + if st, err := localClient.Status(ctx); err == nil { if st.BackendState != ipn.Running.String() { fmt.Fprintf(&hint, "\nTailscale is not running.\n") } else if len(st.CertDomains) == 0 { diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index a98c25158..a73fc71a1 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -125,6 +125,8 @@ func CleanUpArgs(args []string) []string { return out } +var localClient tailscale.LocalClient + // Run runs the CLI. The args do not include the binary name. func Run(args []string) (err error) { if len(args) == 1 && (args[0] == "-V" || args[0] == "--version") { @@ -193,10 +195,10 @@ change in the future. return err } - tailscale.TailscaledSocket = rootArgs.socket + localClient.Socket = rootArgs.socket rootfs.Visit(func(f *flag.Flag) { if f.Name == "socket" { - tailscale.TailscaledSocketSetExplicitly = true + localClient.UseSocketOnly = true } }) diff --git a/cmd/tailscale/cli/debug.go b/cmd/tailscale/cli/debug.go index dcbe47afa..c23d976af 100644 --- a/cmd/tailscale/cli/debug.go +++ b/cmd/tailscale/cli/debug.go @@ -23,7 +23,6 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "inet.af/netaddr" - "tailscale.com/client/tailscale" "tailscale.com/hostinfo" "tailscale.com/ipn" "tailscale.com/net/tsaddr" @@ -155,7 +154,7 @@ func runDebug(ctx context.Context, args []string) error { if out := debugArgs.cpuFile; out != "" { usedFlag = true // TODO(bradfitz): add "profile" subcommand log.Printf("Capturing CPU profile for %v seconds ...", debugArgs.cpuSec) - if v, err := tailscale.Profile(ctx, "profile", debugArgs.cpuSec); err != nil { + if v, err := localClient.Profile(ctx, "profile", debugArgs.cpuSec); err != nil { return err } else { if err := writeProfile(out, v); err != nil { @@ -167,7 +166,7 @@ func runDebug(ctx context.Context, args []string) error { if out := debugArgs.memFile; out != "" { usedFlag = true // TODO(bradfitz): add "profile" subcommand log.Printf("Capturing memory profile ...") - if v, err := tailscale.Profile(ctx, "heap", 0); err != nil { + if v, err := localClient.Profile(ctx, "heap", 0); err != nil { return err } else { if err := writeProfile(out, v); err != nil { @@ -179,7 +178,7 @@ func runDebug(ctx context.Context, args []string) error { if debugArgs.file != "" { usedFlag = true // TODO(bradfitz): add "file" subcommand if debugArgs.file == "get" { - wfs, err := tailscale.WaitingFiles(ctx) + wfs, err := localClient.WaitingFiles(ctx) if err != nil { fatalf("%v\n", err) } @@ -190,9 +189,9 @@ func runDebug(ctx context.Context, args []string) error { } delete := strings.HasPrefix(debugArgs.file, "delete:") if delete { - return tailscale.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:")) + return localClient.DeleteWaitingFile(ctx, strings.TrimPrefix(debugArgs.file, "delete:")) } - rc, size, err := tailscale.GetWaitingFile(ctx, debugArgs.file) + rc, size, err := localClient.GetWaitingFile(ctx, debugArgs.file) if err != nil { return err } @@ -227,7 +226,7 @@ var prefsArgs struct { } func runPrefs(ctx context.Context, args []string) error { - prefs, err := tailscale.GetPrefs(ctx) + prefs, err := localClient.GetPrefs(ctx) if err != nil { return err } @@ -261,7 +260,7 @@ func runWatchIPN(ctx context.Context, args []string) error { } func runDERPMap(ctx context.Context, args []string) error { - dm, err := tailscale.CurrentDERPMap(ctx) + dm, err := localClient.CurrentDERPMap(ctx) if err != nil { return fmt.Errorf( "failed to get local derp map, instead `curl %s/derpmap/default`: %w", ipn.DefaultControlURL, err, @@ -278,7 +277,7 @@ func localAPIAction(action string) func(context.Context, []string) error { if len(args) > 0 { return errors.New("unexpected arguments") } - return tailscale.DebugAction(ctx, action) + return localClient.DebugAction(ctx, action) } } @@ -319,7 +318,7 @@ func runHostinfo(ctx context.Context, args []string) error { } func runDaemonGoroutines(ctx context.Context, args []string) error { - goroutines, err := tailscale.Goroutines(ctx) + goroutines, err := localClient.Goroutines(ctx) if err != nil { return err } @@ -334,7 +333,7 @@ var metricsArgs struct { func runDaemonMetrics(ctx context.Context, args []string) error { last := map[string]int64{} for { - out, err := tailscale.DaemonMetrics(ctx) + out, err := localClient.DaemonMetrics(ctx) if err != nil { return err } diff --git a/cmd/tailscale/cli/down.go b/cmd/tailscale/cli/down.go index 30a2d2f9b..8feac33cc 100644 --- a/cmd/tailscale/cli/down.go +++ b/cmd/tailscale/cli/down.go @@ -9,7 +9,6 @@ import ( "fmt" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" "tailscale.com/ipn" ) @@ -26,7 +25,7 @@ func runDown(ctx context.Context, args []string) error { return fmt.Errorf("too many non-flag arguments: %q", args) } - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { return fmt.Errorf("error fetching current status: %w", err) } @@ -34,7 +33,7 @@ func runDown(ctx context.Context, args []string) error { fmt.Fprintf(Stderr, "Tailscale was already stopped.\n") return nil } - _, err = tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{ + _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ Prefs: ipn.Prefs{ WantRunning: false, }, diff --git a/cmd/tailscale/cli/file.go b/cmd/tailscale/cli/file.go index a841e5de3..1dc5b9d38 100644 --- a/cmd/tailscale/cli/file.go +++ b/cmd/tailscale/cli/file.go @@ -24,7 +24,6 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "golang.org/x/time/rate" "inet.af/netaddr" - "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" "tailscale.com/envknob" "tailscale.com/ipn" @@ -157,7 +156,7 @@ func runCp(ctx context.Context, args []string) error { if cpArgs.verbose { log.Printf("sending %q to %v/%v/%v ...", name, target, ip, stableID) } - err := tailscale.PushFile(ctx, stableID, contentLength, name, fileContents) + err := localClient.PushFile(ctx, stableID, contentLength, name, fileContents) if err != nil { return err } @@ -173,7 +172,7 @@ func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNode if err != nil { return "", false, err } - fts, err := tailscale.FileTargets(ctx) + fts, err := localClient.FileTargets(ctx) if err != nil { return "", false, err } @@ -194,7 +193,7 @@ func getTargetStableID(ctx context.Context, ipStr string) (id tailcfg.StableNode // invalid file sharing target. func fileTargetErrorDetail(ctx context.Context, ip netaddr.IP) error { found := false - if st, err := tailscale.Status(ctx); err == nil && st.Self != nil { + if st, err := localClient.Status(ctx); err == nil && st.Self != nil { for _, peer := range st.Peer { for _, pip := range peer.TailscaleIPs { if pip == ip { @@ -261,7 +260,7 @@ func runCpTargets(ctx context.Context, args []string) error { if len(args) > 0 { return errors.New("invalid arguments with --targets") } - fts, err := tailscale.FileTargets(ctx) + fts, err := localClient.FileTargets(ctx) if err != nil { return err } @@ -385,7 +384,7 @@ func openFileOrSubstitute(dir, base string, action onConflict) (*os.File, error) } func receiveFile(ctx context.Context, wf apitype.WaitingFile, dir string) (targetFile string, size int64, err error) { - rc, size, err := tailscale.GetWaitingFile(ctx, wf.Name) + rc, size, err := localClient.GetWaitingFile(ctx, wf.Name) if err != nil { return "", 0, fmt.Errorf("opening inbox file %q: %w", wf.Name, err) } @@ -407,7 +406,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error { var err error var errs []error for len(errs) == 0 { - wfs, err = tailscale.WaitingFiles(ctx) + wfs, err = localClient.WaitingFiles(ctx) if err != nil { errs = append(errs, fmt.Errorf("getting WaitingFiles: %w", err)) break @@ -439,7 +438,7 @@ func runFileGetOneBatch(ctx context.Context, dir string) []error { if getArgs.verbose { printf("wrote %v as %v (%d bytes)\n", wf.Name, writtenFile, size) } - if err = tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil { + if err = localClient.DeleteWaitingFile(ctx, wf.Name); err != nil { errs = append(errs, fmt.Errorf("deleting %q from inbox: %v", wf.Name, err)) continue } @@ -503,7 +502,7 @@ func wipeInbox(ctx context.Context) error { if getArgs.wait { return errors.New("can't use --wait with /dev/null target") } - wfs, err := tailscale.WaitingFiles(ctx) + wfs, err := localClient.WaitingFiles(ctx) if err != nil { return fmt.Errorf("getting WaitingFiles: %w", err) } @@ -512,7 +511,7 @@ func wipeInbox(ctx context.Context) error { if getArgs.verbose { log.Printf("deleting %v ...", wf.Name) } - if err := tailscale.DeleteWaitingFile(ctx, wf.Name); err != nil { + if err := localClient.DeleteWaitingFile(ctx, wf.Name); err != nil { return fmt.Errorf("deleting %q: %v", wf.Name, err) } deleted++ diff --git a/cmd/tailscale/cli/id-token.go b/cmd/tailscale/cli/id-token.go index 84bb1df20..eabee4cc2 100644 --- a/cmd/tailscale/cli/id-token.go +++ b/cmd/tailscale/cli/id-token.go @@ -10,7 +10,6 @@ import ( "fmt" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" ) var idTokenCmd = &ffcli.Command{ @@ -25,7 +24,7 @@ func runIDToken(ctx context.Context, args []string) error { return errors.New("usage: id-token ") } - tr, err := tailscale.IDToken(ctx, args[0]) + tr, err := localClient.IDToken(ctx, args[0]) if err != nil { return err } diff --git a/cmd/tailscale/cli/ip.go b/cmd/tailscale/cli/ip.go index db5ed86a4..d4fe055d7 100644 --- a/cmd/tailscale/cli/ip.go +++ b/cmd/tailscale/cli/ip.go @@ -12,7 +12,6 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "inet.af/netaddr" - "tailscale.com/client/tailscale" "tailscale.com/ipn/ipnstate" ) @@ -59,7 +58,7 @@ func runIP(ctx context.Context, args []string) error { if !v4 && !v6 { v4, v6 = true, true } - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { return err } diff --git a/cmd/tailscale/cli/logout.go b/cmd/tailscale/cli/logout.go index 3511b9efd..0bce01fda 100644 --- a/cmd/tailscale/cli/logout.go +++ b/cmd/tailscale/cli/logout.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" ) var logoutCmd = &ffcli.Command{ @@ -30,5 +29,5 @@ func runLogout(ctx context.Context, args []string) error { if len(args) > 0 { return fmt.Errorf("too many non-flag arguments: %q", args) } - return tailscale.Logout(ctx) + return localClient.Logout(ctx) } diff --git a/cmd/tailscale/cli/nc.go b/cmd/tailscale/cli/nc.go index c8cec868c..1f9edb185 100644 --- a/cmd/tailscale/cli/nc.go +++ b/cmd/tailscale/cli/nc.go @@ -13,7 +13,6 @@ import ( "strconv" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" ) var ncCmd = &ffcli.Command{ @@ -24,7 +23,7 @@ var ncCmd = &ffcli.Command{ } func runNC(ctx context.Context, args []string) error { - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { return fixTailscaledConnectError(err) } @@ -45,7 +44,7 @@ func runNC(ctx context.Context, args []string) error { } // TODO(bradfitz): also add UDP too, via flag? - c, err := tailscale.DialTCP(ctx, hostOrIP, uint16(port)) + c, err := localClient.DialTCP(ctx, hostOrIP, uint16(port)) if err != nil { return fmt.Errorf("Dial(%q, %v): %w", hostOrIP, port, err) } diff --git a/cmd/tailscale/cli/netcheck.go b/cmd/tailscale/cli/netcheck.go index 30f7a58bf..41f353f98 100644 --- a/cmd/tailscale/cli/netcheck.go +++ b/cmd/tailscale/cli/netcheck.go @@ -18,7 +18,6 @@ import ( "time" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" "tailscale.com/envknob" "tailscale.com/ipn" "tailscale.com/net/netcheck" @@ -63,7 +62,7 @@ func runNetcheck(ctx context.Context, args []string) error { fmt.Fprintln(Stderr, "# Warning: this JSON format is not yet considered a stable interface") } - dm, err := tailscale.CurrentDERPMap(ctx) + dm, err := localClient.CurrentDERPMap(ctx) noRegions := dm != nil && len(dm.Regions) == 0 if noRegions { log.Printf("No DERP map from tailscaled; using default.") diff --git a/cmd/tailscale/cli/ping.go b/cmd/tailscale/cli/ping.go index d8bcf4ea1..6a45fbdc9 100644 --- a/cmd/tailscale/cli/ping.go +++ b/cmd/tailscale/cli/ping.go @@ -16,7 +16,6 @@ import ( "time" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" ) @@ -65,7 +64,7 @@ var pingArgs struct { } func runPing(ctx context.Context, args []string) error { - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { return fixTailscaledConnectError(err) } @@ -173,7 +172,7 @@ func tailscaleIPFromArg(ctx context.Context, hostOrIP string) (ip string, self b } // Otherwise, try to resolve it first from the network peer list. - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { return "", false, err } diff --git a/cmd/tailscale/cli/ssh.go b/cmd/tailscale/cli/ssh.go index 9b440df26..d1d0c2b78 100644 --- a/cmd/tailscale/cli/ssh.go +++ b/cmd/tailscale/cli/ssh.go @@ -20,7 +20,6 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "inet.af/netaddr" - "tailscale.com/client/tailscale" "tailscale.com/envknob" "tailscale.com/ipn/ipnstate" ) @@ -47,7 +46,7 @@ func runSSH(ctx context.Context, args []string) error { username = lu.Username } - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { return err } diff --git a/cmd/tailscale/cli/status.go b/cmd/tailscale/cli/status.go index 8798c4b38..af65ea71f 100644 --- a/cmd/tailscale/cli/status.go +++ b/cmd/tailscale/cli/status.go @@ -19,7 +19,6 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "github.com/toqueteos/webbrowser" "inet.af/netaddr" - "tailscale.com/client/tailscale" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/net/interfaces" @@ -73,9 +72,9 @@ func runStatus(ctx context.Context, args []string) error { if len(args) > 0 { return errors.New("unexpected non-flag arguments to 'tailscale status'") } - getStatus := tailscale.Status + getStatus := localClient.Status if !statusArgs.peers { - getStatus = tailscale.StatusWithoutPeers + getStatus = localClient.StatusWithoutPeers } st, err := getStatus(ctx) if err != nil { @@ -115,7 +114,7 @@ func runStatus(ctx context.Context, args []string) error { http.NotFound(w, r) return } - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { http.Error(w, err.Error(), 500) return diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index aed8c18b2..09df80b03 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -24,7 +24,6 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" qrcode "github.com/skip2/go-qrcode" "inet.af/netaddr" - "tailscale.com/client/tailscale" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/net/tsaddr" @@ -406,7 +405,7 @@ func runUp(ctx context.Context, args []string) error { fatalf("too many non-flag arguments: %q", args) } - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { return fixTailscaledConnectError(err) } @@ -447,12 +446,12 @@ func runUp(ctx context.Context, args []string) error { } if len(prefs.AdvertiseRoutes) > 0 { - if err := tailscale.CheckIPForwarding(context.Background()); err != nil { + if err := localClient.CheckIPForwarding(context.Background()); err != nil { warnf("%v", err) } } - curPrefs, err := tailscale.GetPrefs(ctx) + curPrefs, err := localClient.GetPrefs(ctx) if err != nil { return err } @@ -471,7 +470,7 @@ func runUp(ctx context.Context, args []string) error { fatalf("%s", err) } if justEditMP != nil { - _, err := tailscale.EditPrefs(ctx, justEditMP) + _, err := localClient.EditPrefs(ctx, justEditMP) return err } @@ -582,7 +581,7 @@ func runUp(ctx context.Context, args []string) error { // Special case: bare "tailscale up" means to just start // running, if there's ever been a login. if simpleUp { - _, err := tailscale.EditPrefs(ctx, &ipn.MaskedPrefs{ + _, err := localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ Prefs: ipn.Prefs{ WantRunning: true, }, @@ -592,7 +591,7 @@ func runUp(ctx context.Context, args []string) error { return err } } else { - if err := tailscale.CheckPrefs(ctx, prefs); err != nil { + if err := localClient.CheckPrefs(ctx, prefs); err != nil { return err } diff --git a/cmd/tailscale/cli/version.go b/cmd/tailscale/cli/version.go index 9f3e17594..eca3f5148 100644 --- a/cmd/tailscale/cli/version.go +++ b/cmd/tailscale/cli/version.go @@ -10,7 +10,6 @@ import ( "fmt" "github.com/peterbourgon/ff/v3/ffcli" - "tailscale.com/client/tailscale" "tailscale.com/version" ) @@ -41,7 +40,7 @@ func runVersion(ctx context.Context, args []string) error { printf("Client: %s\n", version.String()) - st, err := tailscale.StatusWithoutPeers(ctx) + st, err := localClient.StatusWithoutPeers(ctx) if err != nil { return err } diff --git a/cmd/tailscale/cli/web.go b/cmd/tailscale/cli/web.go index bde83010c..0cf7e6f69 100644 --- a/cmd/tailscale/cli/web.go +++ b/cmd/tailscale/cli/web.go @@ -28,7 +28,6 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "inet.af/netaddr" - "tailscale.com/client/tailscale" "tailscale.com/ipn" "tailscale.com/tailcfg" "tailscale.com/types/preftype" @@ -318,7 +317,7 @@ func webHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(mi{"error": err.Error()}) return } - prefs, err := tailscale.GetPrefs(r.Context()) + prefs, err := localClient.GetPrefs(r.Context()) if err != nil && !postData.Reauthenticate { w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(mi{"error": err.Error()}) @@ -348,12 +347,12 @@ func webHandler(w http.ResponseWriter, r *http.Request) { return } - st, err := tailscale.Status(r.Context()) + st, err := localClient.Status(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - prefs, err := tailscale.GetPrefs(r.Context()) + prefs, err := localClient.GetPrefs(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -406,7 +405,7 @@ func tailscaleUp(ctx context.Context, prefs *ipn.Prefs, forceReauth bool) (authU prefs.NetfilterMode = preftype.NetfilterOff } - st, err := tailscale.Status(ctx) + st, err := localClient.Status(ctx) if err != nil { return "", fmt.Errorf("can't fetch status: %v", err) } diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index efbbb91ac..0b0a51971 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -75,6 +75,7 @@ type Server struct { hostname string shutdownCtx context.Context shutdownCancel context.CancelFunc + localClient *tailscale.LocalClient mu sync.Mutex listeners map[listenKey]*listener @@ -90,6 +91,17 @@ func (s *Server) Dial(ctx context.Context, network, address string) (net.Conn, e return s.dialer.UserDial(ctx, network, address) } +// LocalClient returns a LocalClient that speaks to s. +// +// It will start the server if it has not been started yet. If the server's +// already been started successfully, it doesn't return an error. +func (s *Server) LocalClient() (*tailscale.LocalClient, error) { + if err := s.Start(); err != nil { + return nil, err + } + return s.localClient, nil +} + // Start connects the server to the tailnet. // Optional: any calls to Dial/Listen will also call Start. func (s *Server) Start() error { @@ -261,9 +273,7 @@ func (s *Server) start() error { // TODO(maisem): Rename nettest package to remove "test". lal := nettest.Listen("local-tailscaled.sock:80") s.localAPIListener = lal - - // Override the Tailscale client to use the in-process listener. - tailscale.TailscaledDialer = lal.Dial + s.localClient = &tailscale.LocalClient{Dial: lal.Dial} go func() { if err := http.Serve(lal, lah); err != nil { logf("localapi serve error: %v", err)