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:
Brad Fitzpatrick authored and Brad Fitzpatrick committed 2026-09-28 13:26:52 -07:00
1 parent 48ec6284d3
commit b4e3a5299c
14 files changed
+297 -5

No files matched your search

+23 -1
View File
@@ -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.
+1 -1
View File
@@ -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+
+8
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
+34
View File
@@ -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)
+27
View File
@@ -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"
)
+3
View File
@@ -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"