mirror of
https://github.com/tailscale/tailscale.git
synced 2026-10-23 03:37:19 +09:00
cmd/tailscaled, safesocket: add --windows-mode=dev
This adds a tailscaled string flag "--windows-mode" which accepts two possible values: the empty string (default) to get the normal behavior (tailscaled running as an admin, usually as a service), and "dev", to make the safesocket named pipe path be at a location that regular users (non-admins) can create. It then modifies the safesocket client side (as used by the CLI) to try the dev mode path too on failure. This lets people work on tailscaled.exe+tailscale.exe in a terminal easily during development, as either an admin or non-admin. (This used to work prior to the move away from TCP localhost to named pipes for safesocket on Windows) Updates #2791 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com> Change-Id: I9c1e3a5b7d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c
This commit is contained in:
1 parent
48ec6284d3
commit
b4e3a5299c
14 files changed
+297
-5
No files matched your search
+23
-1
@@ -20,6 +20,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"iter"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -129,7 +130,28 @@ func (lc *Client) defaultDialer(ctx context.Context, network, addr string) (net.
|
||||
return d.DialContext(ctx, "tcp", "127.0.0.1:"+strconv.Itoa(port))
|
||||
}
|
||||
}
|
||||
return safesocket.ConnectContext(ctx, lc.socket())
|
||||
sock := lc.socket()
|
||||
c, err := safesocket.ConnectContext(ctx, sock)
|
||||
if runtime.GOOS == "windows" && err != nil && sock == paths.DefaultTailscaledSocket() && errors.Is(err, fs.ErrNotExist) {
|
||||
// Nothing is listening on the default pipe. A tailscaled the
|
||||
// current user started with --windows-mode=dev listens on a
|
||||
// per-user pipe instead; try that before giving up. That pipe
|
||||
// is not under the administrators-only prefix, so insist that
|
||||
// it is owned by the current user and not some other user's
|
||||
// process squatting on the name.
|
||||
if dev := paths.WindowsDevTailscaledSocket(); dev != "" {
|
||||
c, err2 := safesocket.ConnectCurrentUserContext(ctx, dev)
|
||||
if err2 == nil {
|
||||
return c, nil
|
||||
}
|
||||
if !errors.Is(err2, fs.ErrNotExist) {
|
||||
// Something is listening there but we won't use it,
|
||||
// which is worth more than the default pipe's absence.
|
||||
return nil, fmt.Errorf("connecting to %s: %w", dev, err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon.
|
||||
|
||||
@@ -410,7 +410,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
|
||||
os from crypto/internal/sysrand+
|
||||
os/exec from tailscale.com/client/local+
|
||||
os/signal from tailscale.com/cmd/derper
|
||||
W os/user from tailscale.com/util/winutil
|
||||
os/user from tailscale.com/util/winutil+
|
||||
path from github.com/prometheus/client_golang/prometheus/internal+
|
||||
path/filepath from crypto/x509+
|
||||
reflect from encoding/asn1+
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -46,6 +48,12 @@ func fixTailscaledConnectErrorImpl(origErr error) error {
|
||||
}
|
||||
}
|
||||
if foundProc == nil {
|
||||
if !errors.Is(origErr, fs.ErrNotExist) {
|
||||
// The socket is there, or something else went wrong, so the
|
||||
// error itself is informative: a Windows named pipe owned by
|
||||
// another user, for example. Don't hide it.
|
||||
return fmt.Errorf("failed to connect to local tailscaled (no tailscaled process found): %w", origErr)
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return fmt.Errorf("failed to connect to local tailscaled process; is the Tailscale service running?")
|
||||
|
||||
@@ -54,6 +54,7 @@ import (
|
||||
"tailscale.com/paths"
|
||||
"tailscale.com/safesocket"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tsconst"
|
||||
"tailscale.com/tsd"
|
||||
"tailscale.com/types/flagtype"
|
||||
"tailscale.com/types/key"
|
||||
@@ -134,6 +135,7 @@ var args struct {
|
||||
encryptState boolFlag
|
||||
statedir string
|
||||
socketpath string
|
||||
windowsMode tsconst.WindowsMode // Windows only
|
||||
birdSocketPath string
|
||||
verbose int
|
||||
socksAddr string // listen address for SOCKS5 server
|
||||
@@ -219,6 +221,12 @@ func main() {
|
||||
}
|
||||
flag.StringVar(&args.statedir, "statedir", "", "path to directory for storage of config state, TLS certs, temporary incoming Taildrop files, etc. If empty, it's derived from --state when possible.")
|
||||
flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket")
|
||||
if runtime.GOOS == "windows" {
|
||||
flag.Func("windows-mode", `how tailscaled is being run: "" (the default) is the Tailscale service or an administrator standing in for it, which must use the default --socket; "dev" is a developer running tailscaled by hand, which listens on \\.\pipe\tailscale-<user SID> by default and only accepts connections from that user`, func(s string) error {
|
||||
args.windowsMode = tsconst.WindowsMode(s)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if buildfeatures.HasBird {
|
||||
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
|
||||
}
|
||||
@@ -287,6 +295,12 @@ store state on filesystem.`)
|
||||
log.SetFlags(0)
|
||||
log.Fatalf("--socket is required")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if err := applyWindowsMode(); err != nil {
|
||||
log.SetFlags(0)
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if buildfeatures.HasBird && args.birdSocketPath != "" && !wgengine.HookNewBird.IsSet() {
|
||||
log.SetFlags(0)
|
||||
@@ -339,6 +353,38 @@ store state on filesystem.`)
|
||||
}
|
||||
}
|
||||
|
||||
// applyWindowsMode validates --windows-mode and --socket on Windows and, in
|
||||
// dev mode, applies the per-user default socket.
|
||||
//
|
||||
// Clients trust the default socket because only administrators can create
|
||||
// pipes under its prefix. A tailscaled listening anywhere else could be any
|
||||
// user's, so it must say so with --windows-mode=dev, which also makes its
|
||||
// pipe accessible to that user alone; see [safesocket.ListenCurrentUser].
|
||||
func applyWindowsMode() error {
|
||||
switch args.windowsMode {
|
||||
case tsconst.WindowsModeDefault:
|
||||
if !paths.IsWindowsProtectedPipe(args.socketpath) {
|
||||
return fmt.Errorf("--socket=%q is not under %s, the prefix only administrators can create pipes in; the Tailscale service must use the default socket, and a developer running tailscaled by hand must pass --windows-mode=dev", args.socketpath, paths.WindowsProtectedPipePrefix)
|
||||
}
|
||||
case tsconst.WindowsModeDev:
|
||||
socketSet := false
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "socket" {
|
||||
socketSet = true
|
||||
}
|
||||
})
|
||||
if !socketSet {
|
||||
args.socketpath = paths.WindowsDevTailscaledSocket()
|
||||
if args.socketpath == "" {
|
||||
return errors.New("--windows-mode=dev: can't determine the current user for the default --socket")
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid --windows-mode=%q; valid values are %q (the default, for the Tailscale service) and %q", args.windowsMode, tsconst.WindowsModeDefault, tsconst.WindowsModeDev)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func trySynologyMigration(p string) error {
|
||||
if runtime.GOOS != "linux" || distro.Get() != distro.Synology {
|
||||
return nil
|
||||
@@ -553,7 +599,14 @@ var sigPipe os.Signal // set by sigpipe.go
|
||||
|
||||
// logID may be the zero value if logging is not in use.
|
||||
func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID, sys *tsd.System) error {
|
||||
ln, err := safesocket.Listen(args.socketpath)
|
||||
listen := safesocket.Listen
|
||||
if runtime.GOOS == "windows" && args.windowsMode == tsconst.WindowsModeDev {
|
||||
// The developer's pipe is owned by and open only to the developer,
|
||||
// which lets their CLI tell it apart from another user's pipe of
|
||||
// the same name. See safesocket.ConnectCurrentUserContext.
|
||||
listen = safesocket.ListenCurrentUser
|
||||
}
|
||||
ln, err := listen(args.socketpath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("safesocket.Listen: %v", err)
|
||||
}
|
||||
|
||||
+32
-1
@@ -8,8 +8,10 @@ package paths
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/version/distro"
|
||||
@@ -19,11 +21,40 @@ import (
|
||||
// containing a directory we can read/write in.
|
||||
var AppSharedDir syncs.AtomicValue[string]
|
||||
|
||||
// WindowsProtectedPipePrefix is the prefix of the Windows named pipe names
|
||||
// that only administrators may create. tailscaled's default socket is under
|
||||
// it, which is what lets clients trust that whatever answers there is the
|
||||
// Tailscale service (or an administrator's tailscaled) and not another user's.
|
||||
const WindowsProtectedPipePrefix = `\\.\pipe\ProtectedPrefix\Administrators\`
|
||||
|
||||
// IsWindowsProtectedPipe reports whether path is a named pipe name under
|
||||
// [WindowsProtectedPipePrefix]. Pipe names are case-insensitive.
|
||||
func IsWindowsProtectedPipe(path string) bool {
|
||||
return len(path) > len(WindowsProtectedPipePrefix) &&
|
||||
strings.EqualFold(path[:len(WindowsProtectedPipePrefix)], WindowsProtectedPipePrefix)
|
||||
}
|
||||
|
||||
// WindowsDevTailscaledSocket returns the named pipe that a tailscaled started
|
||||
// by the current user with --windows-mode=dev listens on by default:
|
||||
// \\.\pipe\tailscale-<SID>, with the user's SID rather than their name, which
|
||||
// need not be unique once local and domain accounts are in play. It returns
|
||||
// "" on other platforms or if the current user is unknown.
|
||||
func WindowsDevTailscaledSocket() string {
|
||||
if runtime.GOOS != "windows" {
|
||||
return ""
|
||||
}
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return `\\.\pipe\tailscale-` + u.Uid
|
||||
}
|
||||
|
||||
// DefaultTailscaledSocket returns the path to the tailscaled Unix socket
|
||||
// or the empty string if there's no reasonable default.
|
||||
func DefaultTailscaledSocket() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return `\\.\pipe\ProtectedPrefix\Administrators\Tailscale\tailscaled`
|
||||
return WindowsProtectedPipePrefix + `Tailscale\tailscaled`
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return "/var/run/tailscaled.socket"
|
||||
|
||||
+110
-1
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/tailscale/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
@@ -27,6 +28,80 @@ func connect(ctx context.Context, path string) (net.Conn, error) {
|
||||
return winio.DialPipeAccessImpLevel(ctx, path, windows.GENERIC_READ|windows.GENERIC_WRITE, winio.PipeImpLevelIdentification)
|
||||
}
|
||||
|
||||
// connectCurrentUser connects to the pipe at path and then verifies that the
|
||||
// pipe could only have been created, and can only be served, by the current
|
||||
// user, closing the connection and returning an error if not.
|
||||
func connectCurrentUser(ctx context.Context, path string) (net.Conn, error) {
|
||||
c, err := ConnectContext(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkPipeExclusiveToCurrentUser(c); err != nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// checkPipeExclusiveToCurrentUser reports an error unless the named pipe c is
|
||||
// connected to is owned by the current user and its DACL grants access to no
|
||||
// one but the current user.
|
||||
//
|
||||
// A named pipe name is shared by all of its instances, and a client connecting
|
||||
// to the name is served by whichever instance is waiting. The first instance's
|
||||
// security descriptor governs the rest: creating another instance needs
|
||||
// FILE_CREATE_PIPE_INSTANCE, which the DACL grants along with write access.
|
||||
// So the server on the other end of c is the current user's if and only if the
|
||||
// pipe was created by the current user (the owner: only administrators may
|
||||
// set an owner other than themselves) and no one else may create instances
|
||||
// (the DACL). Checking the owner alone would let another user serve
|
||||
// connections on a pipe the current user created with a permissive DACL.
|
||||
func checkPipeExclusiveToCurrentUser(c net.Conn) error {
|
||||
pc, ok := c.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected pipe conn type %T", c)
|
||||
}
|
||||
me, err := currentUserSID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// GENERIC_READ on the pipe includes READ_CONTROL, so we may read its
|
||||
// security descriptor.
|
||||
sd, err := windows.GetSecurityInfo(windows.Handle(pc.Fd()), windows.SE_KERNEL_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting the pipe's security descriptor: %w", err)
|
||||
}
|
||||
owner, _, err := sd.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting the pipe's owner: %w", err)
|
||||
}
|
||||
if !owner.Equals(me) {
|
||||
return fmt.Errorf("named pipe is owned by %v, not by the current user %v; refusing to talk to it", owner, me)
|
||||
}
|
||||
dacl, _, err := sd.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting the pipe's DACL: %w", err)
|
||||
}
|
||||
if dacl == nil {
|
||||
return errors.New("named pipe has no DACL, so any user may serve it; refusing to talk to it")
|
||||
}
|
||||
for i := range uint32(dacl.AceCount) {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, i, &ace); err != nil {
|
||||
return fmt.Errorf("reading the pipe's DACL: %w", err)
|
||||
}
|
||||
if ace.Header.AceType != windows.ACCESS_ALLOWED_ACE_TYPE {
|
||||
continue // a deny ACE can't let anyone else in
|
||||
}
|
||||
sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
|
||||
if !sid.Equals(me) {
|
||||
return fmt.Errorf("named pipe grants access to %v, not only to the current user %v, so another user could serve it; refusing to talk to it", sid, me)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// windowsSDDL is the Security Descriptor set on the namedpipe.
|
||||
// It provides read/write access to all users and the local system.
|
||||
//
|
||||
@@ -36,11 +111,45 @@ func connect(ctx context.Context, path string) (net.Conn, error) {
|
||||
// administrators may assign that SID as an owner.
|
||||
const windowsSDDL = "D:P(A;;GWGR;;;BU)(A;;GWGR;;;SY)"
|
||||
|
||||
func init() {
|
||||
listenCurrentUserHook = listenCurrentUser
|
||||
connectCurrentUserHook = connectCurrentUser
|
||||
}
|
||||
|
||||
func listen(path string) (net.Listener, error) {
|
||||
return listenSDDL(path, windowsSDDL)
|
||||
}
|
||||
|
||||
// currentUserSID returns the SID of the user this process runs as.
|
||||
func currentUserSID() (*windows.SID, error) {
|
||||
tu, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting the current user's SID: %w", err)
|
||||
}
|
||||
return tu.User.Sid, nil
|
||||
}
|
||||
|
||||
// listenCurrentUser is like listen, but the pipe is explicitly owned by the
|
||||
// current user, and only that user may open it.
|
||||
//
|
||||
// The owner is what a connecting client of the same user checks; see
|
||||
// connectCurrentUser. Only administrators may set an owner other than
|
||||
// themselves, so a pipe owned by user X was created by X or by an
|
||||
// administrator.
|
||||
func listenCurrentUser(path string) (net.Listener, error) {
|
||||
sid, err := currentUserSID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sddl := fmt.Sprintf("O:%sD:P(A;;GWGR;;;%s)", sid, sid)
|
||||
return listenSDDL(path, sddl)
|
||||
}
|
||||
|
||||
func listenSDDL(path, sddl string) (net.Listener, error) {
|
||||
lc, err := winio.ListenPipe(
|
||||
path,
|
||||
&winio.PipeConfig{
|
||||
SecurityDescriptor: windowsSDDL,
|
||||
SecurityDescriptor: sddl,
|
||||
InputBufferSize: 256 * 1024,
|
||||
OutputBufferSize: 256 * 1024,
|
||||
},
|
||||
|
||||
@@ -89,6 +89,40 @@ func Listen(path string) (net.Listener, error) {
|
||||
return listen(path)
|
||||
}
|
||||
|
||||
// Windows-only implementations of ListenCurrentUser and
|
||||
// ConnectCurrentUserContext, set by pipe_windows.go. Elsewhere they are nil
|
||||
// and the functions behave like Listen and ConnectContext.
|
||||
var (
|
||||
listenCurrentUserHook func(path string) (net.Listener, error)
|
||||
connectCurrentUserHook func(ctx context.Context, path string) (net.Conn, error)
|
||||
)
|
||||
|
||||
// ListenCurrentUser is like Listen, but on Windows the named pipe is owned
|
||||
// by, and accessible only to, the user running this process, so that a
|
||||
// client can verify with ConnectCurrentUserContext that it reached a server
|
||||
// running as itself. It is for a tailscaled run by a user for their own use
|
||||
// (tailscaled --windows-mode=dev) rather than by the system. On other
|
||||
// platforms it is Listen.
|
||||
func ListenCurrentUser(path string) (net.Listener, error) {
|
||||
if f := listenCurrentUserHook; f != nil {
|
||||
return f(path)
|
||||
}
|
||||
return listen(path)
|
||||
}
|
||||
|
||||
// ConnectCurrentUserContext is like ConnectContext, but on Windows it fails
|
||||
// if the named pipe's owner is not the user running this process. Only
|
||||
// administrators may make a pipe owned by anyone but themselves, so this
|
||||
// stops another user from standing up a pipe of the expected name and
|
||||
// impersonating the user's own tailscaled. On other platforms it is
|
||||
// ConnectContext.
|
||||
func ConnectCurrentUserContext(ctx context.Context, path string) (net.Conn, error) {
|
||||
if f := connectCurrentUserHook; f != nil {
|
||||
return f(ctx, path)
|
||||
}
|
||||
return ConnectContext(ctx, path)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTokenNotFound = errors.New("no token found")
|
||||
ErrNoTokenOnOS = errors.New("no token on " + runtime.GOOS)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package tsconst
|
||||
|
||||
// WindowsMode is how tailscaled is being run on Windows, as given by its
|
||||
// --windows-mode flag. It decides where tailscaled listens for the CLI and
|
||||
// GUI and who may connect.
|
||||
type WindowsMode string
|
||||
|
||||
const (
|
||||
// WindowsModeDefault is the default: tailscaled is the Tailscale service
|
||||
// (running as LocalSystem), or an administrator running tailscaled.exe by
|
||||
// hand in its place. It must listen on the default named pipe under
|
||||
// \\.\pipe\ProtectedPrefix\Administrators\, which only administrators
|
||||
// can create; that is what lets clients trust it. It serves every local
|
||||
// user, with per-user access decided by tailscaled itself.
|
||||
WindowsModeDefault WindowsMode = ""
|
||||
|
||||
// WindowsModeDev is a developer running tailscaled by hand, possibly
|
||||
// without administrator rights. It listens by default on a per-user
|
||||
// named pipe, \\.\pipe\tailscale-<SID>, which is created owned by and
|
||||
// accessible to that user alone, and the CLI falls back to that pipe
|
||||
// when the default one doesn't exist. Nothing about it is trusted by
|
||||
// other users, and it can't serve them.
|
||||
WindowsModeDev WindowsMode = "dev"
|
||||
)
|
||||
@@ -1002,6 +1002,9 @@ func (n *TestNode) StartDaemonAsIPNGOOS(ipnGOOS string) *Daemon {
|
||||
// On Windows, tailscaled defaults to a fixed port (41641).
|
||||
// Force a random port to avoid collisions when running multiple test nodes.
|
||||
cmd.Args = append(cmd.Args, "--port=0")
|
||||
// The test's per-node pipe isn't under the administrators-only
|
||||
// prefix that tailscaled otherwise insists on.
|
||||
cmd.Args = append(cmd.Args, "--windows-mode=dev")
|
||||
}
|
||||
if *verboseTailscaled {
|
||||
cmd.Args = append(cmd.Args, "-verbose=2")
|
||||
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
_ "tailscale.com/safesocket"
|
||||
_ "tailscale.com/syncs"
|
||||
_ "tailscale.com/tailcfg"
|
||||
_ "tailscale.com/tsconst"
|
||||
_ "tailscale.com/tsd"
|
||||
_ "tailscale.com/tsweb/varz"
|
||||
_ "tailscale.com/types/flagtype"
|
||||
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
_ "tailscale.com/safesocket"
|
||||
_ "tailscale.com/syncs"
|
||||
_ "tailscale.com/tailcfg"
|
||||
_ "tailscale.com/tsconst"
|
||||
_ "tailscale.com/tsd"
|
||||
_ "tailscale.com/tsweb/varz"
|
||||
_ "tailscale.com/types/flagtype"
|
||||
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
_ "tailscale.com/safesocket"
|
||||
_ "tailscale.com/syncs"
|
||||
_ "tailscale.com/tailcfg"
|
||||
_ "tailscale.com/tsconst"
|
||||
_ "tailscale.com/tsd"
|
||||
_ "tailscale.com/tsweb/varz"
|
||||
_ "tailscale.com/types/flagtype"
|
||||
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
_ "tailscale.com/safesocket"
|
||||
_ "tailscale.com/syncs"
|
||||
_ "tailscale.com/tailcfg"
|
||||
_ "tailscale.com/tsconst"
|
||||
_ "tailscale.com/tsd"
|
||||
_ "tailscale.com/tsweb/varz"
|
||||
_ "tailscale.com/types/flagtype"
|
||||
|
||||
@@ -52,6 +52,7 @@ import (
|
||||
_ "tailscale.com/safesocket"
|
||||
_ "tailscale.com/syncs"
|
||||
_ "tailscale.com/tailcfg"
|
||||
_ "tailscale.com/tsconst"
|
||||
_ "tailscale.com/tsconst/wintun"
|
||||
_ "tailscale.com/tsd"
|
||||
_ "tailscale.com/tsweb/varz"
|
||||
|
||||
Reference in new issue
Block a user