├── go.mod ├── README.md ├── internal ├── safesocket │ ├── safesocket_test.go │ ├── safesocket_ps.go │ ├── pipe_windows.go │ ├── basic_test.go │ ├── safesocket_darwin.go │ ├── safesocket.go │ └── unixsocket.go └── paths │ ├── paths_unix.go │ ├── paths.go │ └── paths_windows.go ├── LICENSE ├── go.sum └── tscert.go /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale/tscert 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/Microsoft/go-winio v0.6.0 7 | github.com/mitchellh/go-ps v1.0.0 8 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f 9 | ) 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tscert 2 | 3 | This is a stripped down version of the 4 | `tailscale.com/client/tailscale` Go package but with minimal 5 | dependencies and supporting older versions of Go. 6 | 7 | It's meant for use by Caddy, so they don't need to depend on Go 1.17 yet. 8 | Also, it has the nice side effect of not polluting their `go.sum` file 9 | because `tailscale.com` is a somewhat large module. 10 | 11 | ## Docs 12 | 13 | See https://pkg.go.dev/github.com/tailscale/tscert 14 | -------------------------------------------------------------------------------- /internal/safesocket/safesocket_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package safesocket 6 | 7 | import "testing" 8 | 9 | func TestLocalTCPPortAndToken(t *testing.T) { 10 | // Just test that it compiles for now (is available on all platforms). 11 | port, token, err := LocalTCPPortAndToken() 12 | t.Logf("got %v, %s, %v", port, token, err) 13 | } 14 | -------------------------------------------------------------------------------- /internal/safesocket/safesocket_ps.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build linux || windows || darwin || freebsd 6 | // +build linux windows darwin freebsd 7 | 8 | package safesocket 9 | 10 | import ( 11 | "strings" 12 | 13 | ps "github.com/mitchellh/go-ps" 14 | ) 15 | 16 | func init() { 17 | tailscaledProcExists = func() bool { 18 | procs, err := ps.Processes() 19 | if err != nil { 20 | return false 21 | } 22 | for _, proc := range procs { 23 | name := proc.Executable() 24 | const tailscaled = "tailscaled" 25 | if len(name) < len(tailscaled) { 26 | continue 27 | } 28 | // Do case insensitive comparison for Windows, 29 | // notably, and ignore any ".exe" suffix. 30 | if strings.EqualFold(name[:len(tailscaled)], tailscaled) { 31 | return true 32 | } 33 | } 34 | return false 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/safesocket/pipe_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package safesocket 6 | 7 | import ( 8 | "fmt" 9 | "net" 10 | "syscall" 11 | 12 | "github.com/Microsoft/go-winio" 13 | ) 14 | 15 | func connect(s *ConnectionStrategy) (net.Conn, error) { 16 | return winio.DialPipe(s.path, nil) 17 | } 18 | 19 | func setFlags(network, address string, c syscall.RawConn) error { 20 | return c.Control(func(fd uintptr) { 21 | syscall.SetsockoptInt(syscall.Handle(fd), syscall.SOL_SOCKET, 22 | syscall.SO_REUSEADDR, 1) 23 | }) 24 | } 25 | 26 | // windowsSDDL is the Security Descriptor set on the namedpipe. 27 | // It provides read/write access to all users and the local system. 28 | const windowsSDDL = "O:BAG:BAD:PAI(A;OICI;GWGR;;;BU)(A;OICI;GWGR;;;SY)" 29 | 30 | func listen(path string, port uint16) (_ net.Listener, gotPort uint16, _ error) { 31 | lc, err := winio.ListenPipe( 32 | path, 33 | &winio.PipeConfig{ 34 | SecurityDescriptor: windowsSDDL, 35 | InputBufferSize: 256 * 1024, 36 | OutputBufferSize: 256 * 1024, 37 | }, 38 | ) 39 | if err != nil { 40 | return nil, 0, fmt.Errorf("namedpipe.Listen: %w", err) 41 | } 42 | return lc, 0, nil 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020 Tailscale & AUTHORS. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /internal/safesocket/basic_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package safesocket 6 | 7 | import ( 8 | "fmt" 9 | "path/filepath" 10 | "runtime" 11 | "testing" 12 | ) 13 | 14 | func TestBasics(t *testing.T) { 15 | // Make the socket in a temp dir rather than the cwd 16 | // so that the test can be run from a mounted filesystem (#2367). 17 | dir := t.TempDir() 18 | var sock string 19 | if runtime.GOOS != "windows" { 20 | sock = filepath.Join(dir, "test") 21 | } else { 22 | sock = fmt.Sprintf(`\\.\pipe\tailscale-test`) 23 | } 24 | 25 | l, port, err := Listen(sock, 0) 26 | if err != nil { 27 | t.Fatal(err) 28 | } 29 | 30 | errs := make(chan error, 2) 31 | 32 | go func() { 33 | s, err := l.Accept() 34 | if err != nil { 35 | errs <- err 36 | return 37 | } 38 | l.Close() 39 | s.Write([]byte("hello")) 40 | 41 | b := make([]byte, 1024) 42 | n, err := s.Read(b) 43 | if err != nil { 44 | errs <- err 45 | return 46 | } 47 | t.Logf("server read %d bytes.", n) 48 | if string(b[:n]) != "world" { 49 | errs <- fmt.Errorf("got %#v, expected %#v\n", string(b[:n]), "world") 50 | return 51 | } 52 | s.Close() 53 | errs <- nil 54 | }() 55 | 56 | go func() { 57 | s := DefaultConnectionStrategy(sock) 58 | s.UsePort(port) 59 | c, err := Connect(s) 60 | if err != nil { 61 | errs <- err 62 | return 63 | } 64 | c.Write([]byte("world")) 65 | b := make([]byte, 1024) 66 | n, err := c.Read(b) 67 | if err != nil { 68 | errs <- err 69 | return 70 | } 71 | if string(b[:n]) != "hello" { 72 | errs <- fmt.Errorf("got %#v, expected %#v\n", string(b[:n]), "hello") 73 | } 74 | c.Close() 75 | errs <- nil 76 | }() 77 | 78 | for i := 0; i < 2; i++ { 79 | if err := <-errs; err != nil { 80 | t.Fatal(err) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/paths/paths_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build !windows && !js 6 | // +build !windows,!js 7 | 8 | package paths 9 | 10 | import ( 11 | "fmt" 12 | "os" 13 | "path/filepath" 14 | "runtime" 15 | 16 | "golang.org/x/sys/unix" 17 | ) 18 | 19 | func init() { 20 | stateFileFunc = stateFileUnix 21 | } 22 | 23 | func statePath() string { 24 | switch runtime.GOOS { 25 | case "linux": 26 | if fi, err := os.Stat("/gokrazy"); err == nil && fi.IsDir() { 27 | return "/perm/tailscaled/tailscaled.state" 28 | } 29 | 30 | return "/var/lib/tailscale/tailscaled.state" 31 | case "freebsd", "openbsd": 32 | return "/var/db/tailscale/tailscaled.state" 33 | case "darwin": 34 | return "/Library/Tailscale/tailscaled.state" 35 | default: 36 | return "" 37 | } 38 | } 39 | 40 | func stateFileUnix() string { 41 | path := statePath() 42 | if path == "" { 43 | return "" 44 | } 45 | 46 | try := path 47 | for i := 0; i < 3; i++ { // check writability of the file, /var/lib/tailscale, and /var/lib 48 | err := unix.Access(try, unix.O_RDWR) 49 | if err == nil { 50 | return path 51 | } 52 | try = filepath.Dir(try) 53 | } 54 | 55 | if os.Getuid() == 0 { 56 | return "" 57 | } 58 | 59 | // For non-root users, fall back to $XDG_DATA_HOME/tailscale/*. 60 | return filepath.Join(xdgDataHome(), "tailscale", "tailscaled.state") 61 | } 62 | 63 | func xdgDataHome() string { 64 | if e := os.Getenv("XDG_DATA_HOME"); e != "" { 65 | return e 66 | } 67 | return filepath.Join(os.Getenv("HOME"), ".local/share") 68 | } 69 | 70 | func ensureStateDirPerms(dir string) error { 71 | if filepath.Base(dir) != "tailscale" { 72 | return nil 73 | } 74 | fi, err := os.Stat(dir) 75 | if err != nil { 76 | return err 77 | } 78 | if !fi.IsDir() { 79 | return fmt.Errorf("expected %q to be a directory; is %v", dir, fi.Mode()) 80 | } 81 | const perm = 0700 82 | if fi.Mode().Perm() == perm { 83 | // Already correct. 84 | return nil 85 | } 86 | return os.Chmod(dir, perm) 87 | } 88 | 89 | // LegacyStateFilePath is not applicable to UNIX; it is just stubbed out. 90 | func LegacyStateFilePath() string { 91 | return "" 92 | } 93 | -------------------------------------------------------------------------------- /internal/paths/paths.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package paths returns platform and user-specific default paths to 6 | // Tailscale files and directories. 7 | package paths 8 | 9 | import ( 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "sync/atomic" 14 | ) 15 | 16 | // AppSharedDir is a string set by the iOS or Android app on start 17 | // containing a directory we can read/write in. 18 | var AppSharedDir atomic.Value 19 | 20 | // DefaultTailscaledSocket returns the path to the tailscaled Unix socket 21 | // or the empty string if there's no reasonable default. 22 | func DefaultTailscaledSocket() string { 23 | if socket := os.Getenv("TS_SOCKET"); socket != "" { 24 | return socket 25 | } 26 | if runtime.GOOS == "windows" { 27 | return `\\.\pipe\ProtectedPrefix\Administrators\Tailscale\tailscaled` 28 | } 29 | if runtime.GOOS == "darwin" { 30 | return "/var/run/tailscaled.socket" 31 | } 32 | if fi, err := os.Stat("/gokrazy"); err == nil && fi.IsDir() { 33 | return "/perm/tailscaled/tailscaled.sock" 34 | } 35 | if fi, err := os.Stat("/var/run"); err == nil && fi.IsDir() { 36 | return "/var/run/tailscale/tailscaled.sock" 37 | } 38 | return "tailscaled.sock" 39 | } 40 | 41 | var stateFileFunc func() string 42 | 43 | // DefaultTailscaledStateFile returns the default path to the 44 | // tailscaled state file, or the empty string if there's no reasonable 45 | // default value. 46 | func DefaultTailscaledStateFile() string { 47 | if statedir := os.Getenv("TS_STATE_DIR"); statedir != "" { 48 | return filepath.Join(statedir, "tailscaled.state") 49 | } 50 | if f := stateFileFunc; f != nil { 51 | return f() 52 | } 53 | if runtime.GOOS == "windows" { 54 | return filepath.Join(os.Getenv("ProgramData"), "Tailscale", "server-state.conf") 55 | } 56 | return "" 57 | } 58 | 59 | // MkStateDir ensures that dirPath, the daemon's configurtaion directory 60 | // containing machine keys etc, both exists and has the correct permissions. 61 | // We want it to only be accessible to the user the daemon is running under. 62 | func MkStateDir(dirPath string) error { 63 | if err := os.MkdirAll(dirPath, 0700); err != nil { 64 | return err 65 | } 66 | 67 | return ensureStateDirPerms(dirPath) 68 | } 69 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= 2 | github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 5 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 8 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 9 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 10 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 11 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 12 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= 13 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 14 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 15 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 16 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 17 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 18 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 19 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 20 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= 25 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 26 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 27 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 28 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 29 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 30 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 31 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 32 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 33 | golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU= 34 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 35 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 36 | -------------------------------------------------------------------------------- /internal/safesocket/safesocket_darwin.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package safesocket 6 | 7 | import ( 8 | "bufio" 9 | "bytes" 10 | "errors" 11 | "fmt" 12 | "io/ioutil" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "strconv" 17 | "strings" 18 | ) 19 | 20 | func init() { 21 | localTCPPortAndToken = localTCPPortAndTokenDarwin 22 | } 23 | 24 | // localTCPPortAndTokenMacsys returns the localhost TCP port number and auth token 25 | // from /Library/Tailscale. 26 | // 27 | // In that case the files are: 28 | // /Library/Tailscale/ipnport => $port (symlink with localhost port number target) 29 | // /Library/Tailscale/sameuserproof-$port is a file with auth 30 | func localTCPPortAndTokenMacsys() (port int, token string, err error) { 31 | 32 | const dir = "/Library/Tailscale" 33 | portStr, err := os.Readlink(filepath.Join(dir, "ipnport")) 34 | if err != nil { 35 | return 0, "", err 36 | } 37 | port, err = strconv.Atoi(portStr) 38 | if err != nil { 39 | return 0, "", err 40 | } 41 | authb, err := os.ReadFile(filepath.Join(dir, "sameuserproof-"+portStr)) 42 | if err != nil { 43 | return 0, "", err 44 | } 45 | auth := strings.TrimSpace(string(authb)) 46 | if auth == "" { 47 | return 0, "", errors.New("empty auth token in sameuserproof file") 48 | } 49 | return port, auth, nil 50 | } 51 | 52 | func localTCPPortAndTokenDarwin() (port int, token string, err error) { 53 | // There are two ways this binary can be run: as the Mac App Store sandboxed binary, 54 | // or a normal binary that somebody built or download and are being run from outside 55 | // the sandbox. Detect which way we're running and then figure out how to connect 56 | // to the local daemon. 57 | 58 | if dir := os.Getenv("TS_MACOS_CLI_SHARED_DIR"); dir != "" { 59 | // First see if we're running as the non-AppStore "macsys" variant. 60 | if strings.Contains(os.Getenv("HOME"), "/Containers/io.tailscale.ipn.macsys/") { 61 | if port, token, err := localTCPPortAndTokenMacsys(); err == nil { 62 | return port, token, nil 63 | } 64 | } 65 | 66 | // The current binary (this process) is sandboxed. The user is 67 | // running the CLI via /Applications/Tailscale.app/Contents/MacOS/Tailscale 68 | // which sets the TS_MACOS_CLI_SHARED_DIR environment variable. 69 | fis, err := ioutil.ReadDir(dir) 70 | if err != nil { 71 | return 0, "", err 72 | } 73 | for _, fi := range fis { 74 | name := filepath.Base(fi.Name()) 75 | // Look for name like "sameuserproof-61577-2ae2ec9e0aa2005784f1" 76 | // to extract out the port number and token. 77 | if strings.HasPrefix(name, "sameuserproof-") { 78 | f := strings.SplitN(name, "-", 3) 79 | if len(f) == 3 { 80 | if port, err := strconv.Atoi(f[1]); err == nil { 81 | return port, f[2], nil 82 | } 83 | } 84 | } 85 | } 86 | return 0, "", fmt.Errorf("failed to find sandboxed sameuserproof-* file in TS_MACOS_CLI_SHARED_DIR %q", dir) 87 | } 88 | 89 | // The current process is running outside the sandbox, so use 90 | // lsof to find the IPNExtension (the Mac App Store variant). 91 | 92 | cmd := exec.Command("lsof", 93 | "-n", // numeric sockets; don't do DNS lookups, etc 94 | "-a", // logical AND remaining options 95 | fmt.Sprintf("-u%d", os.Getuid()), // process of same user only 96 | "-c", "IPNExtension", // starting with IPNExtension 97 | "-F", // machine-readable output 98 | ) 99 | out, err := cmd.Output() 100 | if err != nil { 101 | // Before returning an error, see if we're running the 102 | // macsys variant at the normal location. 103 | if port, token, err := localTCPPortAndTokenMacsys(); err == nil { 104 | return port, token, nil 105 | } 106 | 107 | return 0, "", fmt.Errorf("failed to run '%s' looking for IPNExtension: %w", cmd, err) 108 | } 109 | bs := bufio.NewScanner(bytes.NewReader(out)) 110 | subStr := []byte(".tailscale.ipn.macos/sameuserproof-") 111 | for bs.Scan() { 112 | line := bs.Bytes() 113 | i := bytes.Index(line, subStr) 114 | if i == -1 { 115 | continue 116 | } 117 | f := strings.SplitN(string(line[i+len(subStr):]), "-", 2) 118 | if len(f) != 2 { 119 | continue 120 | } 121 | portStr, token := f[0], f[1] 122 | port, err := strconv.Atoi(portStr) 123 | if err != nil { 124 | return 0, "", fmt.Errorf("invalid port %q found in lsof", portStr) 125 | } 126 | return port, token, nil 127 | } 128 | 129 | // Before returning an error, see if we're running the 130 | // macsys variant at the normal location. 131 | if port, token, err := localTCPPortAndTokenMacsys(); err == nil { 132 | return port, token, nil 133 | } 134 | return 0, "", ErrTokenNotFound 135 | } 136 | -------------------------------------------------------------------------------- /internal/paths/paths_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package paths 6 | 7 | import ( 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "unsafe" 12 | 13 | "golang.org/x/sys/windows" 14 | ) 15 | 16 | func getTokenInfo(token windows.Token, infoClass uint32) ([]byte, error) { 17 | var desiredLen uint32 18 | err := windows.GetTokenInformation(token, infoClass, nil, 0, &desiredLen) 19 | if err != nil && err != windows.ERROR_INSUFFICIENT_BUFFER { 20 | return nil, err 21 | } 22 | 23 | buf := make([]byte, desiredLen) 24 | actualLen := desiredLen 25 | err = windows.GetTokenInformation(token, infoClass, &buf[0], desiredLen, &actualLen) 26 | return buf, err 27 | } 28 | 29 | func getTokenUserInfo(token windows.Token) (*windows.Tokenuser, error) { 30 | buf, err := getTokenInfo(token, windows.TokenUser) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return (*windows.Tokenuser)(unsafe.Pointer(&buf[0])), nil 36 | } 37 | 38 | func getTokenPrimaryGroupInfo(token windows.Token) (*windows.Tokenprimarygroup, error) { 39 | buf, err := getTokenInfo(token, windows.TokenPrimaryGroup) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return (*windows.Tokenprimarygroup)(unsafe.Pointer(&buf[0])), nil 45 | } 46 | 47 | type userSids struct { 48 | User *windows.SID 49 | PrimaryGroup *windows.SID 50 | } 51 | 52 | func getCurrentUserSids() (*userSids, error) { 53 | token, err := windows.OpenCurrentProcessToken() 54 | if err != nil { 55 | return nil, err 56 | } 57 | defer token.Close() 58 | 59 | userInfo, err := getTokenUserInfo(token) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | primaryGroup, err := getTokenPrimaryGroupInfo(token) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return &userSids{userInfo.User.Sid, primaryGroup.PrimaryGroup}, nil 70 | } 71 | 72 | // ensureStateDirPerms applies a restrictive ACL to the directory specified by dirPath. 73 | // It sets the following security attributes on the directory: 74 | // Owner: The user for the current process; 75 | // Primary Group: The primary group for the current process; 76 | // DACL: Full control to the current user and to the Administrators group. 77 | // (We include Administrators so that admin users may still access logs; 78 | // granting access exclusively to LocalSystem would require admins to use 79 | // special tools to access the Log directory) 80 | // Inheritance: The directory does not inherit the ACL from its parent. 81 | // However, any directories and/or files created within this 82 | // directory *do* inherit the ACL that we are setting. 83 | func ensureStateDirPerms(dirPath string) error { 84 | fi, err := os.Stat(dirPath) 85 | if err != nil { 86 | return err 87 | } 88 | if !fi.IsDir() { 89 | return os.ErrInvalid 90 | } 91 | if strings.ToLower(filepath.Base(dirPath)) != "tailscale" { 92 | return nil 93 | } 94 | 95 | // We need the info for our current user as SIDs 96 | sids, err := getCurrentUserSids() 97 | if err != nil { 98 | return err 99 | } 100 | 101 | // We also need the SID for the Administrators group so that admins may 102 | // easily access logs. 103 | adminGroupSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // Munge the SIDs into the format required by EXPLICIT_ACCESS. 109 | userTrustee := windows.TRUSTEE{nil, windows.NO_MULTIPLE_TRUSTEE, 110 | windows.TRUSTEE_IS_SID, windows.TRUSTEE_IS_USER, 111 | windows.TrusteeValueFromSID(sids.User)} 112 | 113 | adminTrustee := windows.TRUSTEE{nil, windows.NO_MULTIPLE_TRUSTEE, 114 | windows.TRUSTEE_IS_SID, windows.TRUSTEE_IS_WELL_KNOWN_GROUP, 115 | windows.TrusteeValueFromSID(adminGroupSid)} 116 | 117 | // We declare our access rights via this array of EXPLICIT_ACCESS structures. 118 | // We set full access to our user and to Administrators. 119 | // We configure the DACL such that any files or directories created within 120 | // dirPath will also inherit this DACL. 121 | explicitAccess := []windows.EXPLICIT_ACCESS{ 122 | { 123 | windows.GENERIC_ALL, 124 | windows.SET_ACCESS, 125 | windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 126 | userTrustee, 127 | }, 128 | { 129 | windows.GENERIC_ALL, 130 | windows.SET_ACCESS, 131 | windows.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 132 | adminTrustee, 133 | }, 134 | } 135 | 136 | dacl, err := windows.ACLFromEntries(explicitAccess, nil) 137 | if err != nil { 138 | return err 139 | } 140 | 141 | // We now reset the file's owner, primary group, and DACL. 142 | // We also must pass PROTECTED_DACL_SECURITY_INFORMATION so that our new ACL 143 | // does not inherit any ACL entries from the parent directory. 144 | const flags = windows.OWNER_SECURITY_INFORMATION | 145 | windows.GROUP_SECURITY_INFORMATION | 146 | windows.DACL_SECURITY_INFORMATION | 147 | windows.PROTECTED_DACL_SECURITY_INFORMATION 148 | return windows.SetNamedSecurityInfo(dirPath, windows.SE_FILE_OBJECT, flags, 149 | sids.User, sids.PrimaryGroup, dacl, nil) 150 | } 151 | 152 | // LegacyStateFilePath returns the legacy path to the state file when it was stored under the 153 | // current user's %LocalAppData%. 154 | func LegacyStateFilePath() string { 155 | return filepath.Join(os.Getenv("LocalAppData"), "Tailscale", "server-state.conf") 156 | } 157 | -------------------------------------------------------------------------------- /internal/safesocket/safesocket.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package safesocket creates either a Unix socket, if possible, or 6 | // otherwise a localhost TCP connection. 7 | package safesocket 8 | 9 | import ( 10 | "errors" 11 | "net" 12 | "runtime" 13 | "time" 14 | ) 15 | 16 | // WindowsLocalPort is the default localhost TCP port 17 | // used by safesocket on Windows. 18 | const WindowsLocalPort = 41112 19 | 20 | type closeable interface { 21 | CloseRead() error 22 | CloseWrite() error 23 | } 24 | 25 | // ConnCloseRead calls c's CloseRead method. c is expected to be 26 | // either a UnixConn or TCPConn as returned from this package. 27 | func ConnCloseRead(c net.Conn) error { 28 | return c.(closeable).CloseRead() 29 | } 30 | 31 | // ConnCloseWrite calls c's CloseWrite method. c is expected to be 32 | // either a UnixConn or TCPConn as returned from this package. 33 | func ConnCloseWrite(c net.Conn) error { 34 | return c.(closeable).CloseWrite() 35 | } 36 | 37 | var processStartTime = time.Now() 38 | var tailscaledProcExists = func() bool { return false } // set by safesocket_ps.go 39 | 40 | // tailscaledStillStarting reports whether tailscaled is probably 41 | // still starting up. That is, it reports whether the caller should 42 | // keep retrying to connect. 43 | func tailscaledStillStarting() bool { 44 | d := time.Since(processStartTime) 45 | if d < 2*time.Second { 46 | // Without even checking the process table, assume 47 | // that for the first two seconds that tailscaled is 48 | // probably still starting. That is, assume they're 49 | // running "tailscaled & tailscale up ...." and make 50 | // the tailscale client block for a bit for tailscaled 51 | // to start accepting on the socket. 52 | return true 53 | } 54 | if d > 5*time.Second { 55 | return false 56 | } 57 | return tailscaledProcExists() 58 | } 59 | 60 | // A ConnectionStrategy is a plan for how to connect to tailscaled or equivalent (e.g. IPNExtension on macOS). 61 | type ConnectionStrategy struct { 62 | // For now, a ConnectionStrategy is just a unix socket path, a TCP port, 63 | // and a flag indicating whether to try fallback connections options. 64 | path string 65 | port uint16 66 | fallback bool 67 | // Longer term, a ConnectionStrategy should be an ordered list of things to attempt, 68 | // with just the information required to connection for each. 69 | // 70 | // We have at least these cases to consider (see issue 3530): 71 | // 72 | // tailscale sandbox | tailscaled sandbox | OS | connection 73 | // ------------------|--------------------|---------|----------- 74 | // no | no | unix | unix socket 75 | // no | no | Windows | TCP/port 76 | // no | no | wasm | memconn 77 | // no | Network Extension | macOS | TCP/port/token, port/token from lsof 78 | // no | System Extension | macOS | TCP/port/token, port/token from lsof 79 | // yes | Network Extension | macOS | TCP/port/token, port/token from readdir 80 | // yes | System Extension | macOS | TCP/port/token, port/token from readdir 81 | // 82 | // Note e.g. that port is only relevant as an input to Connect on Windows, 83 | // that path is not relevant to Windows, and that neither matters to wasm. 84 | } 85 | 86 | // DefaultConnectionStrategy returns a default connection strategy. 87 | // The default strategy is to attempt to connect in as many ways as possible. 88 | // It uses path as the unix socket path, when applicable, 89 | // and defaults to WindowsLocalPort for the TCP port when applicable. 90 | // It falls back to auto-discovery across sandbox boundaries on macOS. 91 | // TODO: maybe take no arguments, since path is irrelevant on Windows? Discussion in PR 3499. 92 | func DefaultConnectionStrategy(path string) *ConnectionStrategy { 93 | return &ConnectionStrategy{path: path, port: WindowsLocalPort, fallback: true} 94 | } 95 | 96 | // UsePort modifies s to use port for the TCP port when applicable. 97 | // UsePort is only applicable on Windows, and only then 98 | // when not using the default for Windows. 99 | func (s *ConnectionStrategy) UsePort(port uint16) { 100 | s.port = port 101 | } 102 | 103 | // UseFallback modifies s to set whether it should fall back 104 | // to connecting to the macOS GUI's tailscaled 105 | // if the Unix socket path wasn't reachable. 106 | func (s *ConnectionStrategy) UseFallback(b bool) { 107 | s.fallback = b 108 | } 109 | 110 | // ExactPath returns a connection strategy that only attempts to connect via path. 111 | func ExactPath(path string) *ConnectionStrategy { 112 | return &ConnectionStrategy{path: path, fallback: false} 113 | } 114 | 115 | // Connect connects to tailscaled using s 116 | func Connect(s *ConnectionStrategy) (net.Conn, error) { 117 | for { 118 | c, err := connect(s) 119 | if err != nil && tailscaledStillStarting() { 120 | time.Sleep(250 * time.Millisecond) 121 | continue 122 | } 123 | return c, err 124 | } 125 | } 126 | 127 | // Listen returns a listener either on Unix socket path (on Unix), or 128 | // the localhost port (on Windows). 129 | // If port is 0, the returned gotPort says which port was selected on Windows. 130 | func Listen(path string, port uint16) (_ net.Listener, gotPort uint16, _ error) { 131 | return listen(path, port) 132 | } 133 | 134 | var ( 135 | ErrTokenNotFound = errors.New("no token found") 136 | ErrNoTokenOnOS = errors.New("no token on " + runtime.GOOS) 137 | ) 138 | 139 | var localTCPPortAndToken func() (port int, token string, err error) 140 | 141 | // LocalTCPPortAndToken returns the port number and auth token to connect to 142 | // the local Tailscale daemon. It's currently only applicable on macOS 143 | // when tailscaled is being run in the Mac Sandbox from the App Store version 144 | // of Tailscale. 145 | func LocalTCPPortAndToken() (port int, token string, err error) { 146 | if localTCPPortAndToken == nil { 147 | return 0, "", ErrNoTokenOnOS 148 | } 149 | return localTCPPortAndToken() 150 | } 151 | 152 | // PlatformUsesPeerCreds reports whether the current platform uses peer credentials 153 | // to authenticate connections. 154 | func PlatformUsesPeerCreds() bool { return GOOSUsesPeerCreds(runtime.GOOS) } 155 | 156 | // GOOSUsesPeerCreds is like PlatformUsesPeerCreds but takes a 157 | // runtime.GOOS value instead of using the current one. 158 | func GOOSUsesPeerCreds(goos string) bool { 159 | switch goos { 160 | case "linux", "darwin", "freebsd": 161 | return true 162 | } 163 | return false 164 | } 165 | -------------------------------------------------------------------------------- /internal/safesocket/unixsocket.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | //go:build !windows && !js 6 | // +build !windows,!js 7 | 8 | package safesocket 9 | 10 | import ( 11 | "errors" 12 | "fmt" 13 | "io" 14 | "io/ioutil" 15 | "log" 16 | "net" 17 | "os" 18 | "os/exec" 19 | "path/filepath" 20 | "runtime" 21 | "strconv" 22 | "strings" 23 | ) 24 | 25 | // TODO(apenwarr): handle magic cookie auth 26 | func connect(s *ConnectionStrategy) (net.Conn, error) { 27 | if runtime.GOOS == "js" { 28 | return nil, errors.New("safesocket.Connect not yet implemented on js/wasm") 29 | } 30 | if runtime.GOOS == "darwin" && s.fallback && s.path == "" && s.port == 0 { 31 | return connectMacOSAppSandbox() 32 | } 33 | pipe, err := net.Dial("unix", s.path) 34 | if err != nil { 35 | if runtime.GOOS == "darwin" && s.fallback { 36 | extConn, extErr := connectMacOSAppSandbox() 37 | if extErr != nil { 38 | return nil, fmt.Errorf("safesocket: failed to connect to %v: %v; failed to connect to Tailscale IPNExtension: %v", s.path, err, extErr) 39 | } 40 | return extConn, nil 41 | } 42 | return nil, err 43 | } 44 | return pipe, nil 45 | } 46 | 47 | // TODO(apenwarr): handle magic cookie auth 48 | func listen(path string, port uint16) (ln net.Listener, _ uint16, err error) { 49 | // Unix sockets hang around in the filesystem even after nobody 50 | // is listening on them. (Which is really unfortunate but long- 51 | // entrenched semantics.) Try connecting first; if it works, then 52 | // the socket is still live, so let's not replace it. If it doesn't 53 | // work, then replace it. 54 | // 55 | // Note that there's a race condition between these two steps. A 56 | // "proper" daemon usually uses a dance involving pidfiles to first 57 | // ensure that no other instances of itself are running, but that's 58 | // beyond the scope of our simple socket library. 59 | c, err := net.Dial("unix", path) 60 | if err == nil { 61 | c.Close() 62 | if tailscaledRunningUnderLaunchd() { 63 | return nil, 0, fmt.Errorf("%v: address already in use; tailscaled already running under launchd (to stop, run: $ sudo launchctl stop com.tailscale.tailscaled)", path) 64 | } 65 | return nil, 0, fmt.Errorf("%v: address already in use", path) 66 | } 67 | _ = os.Remove(path) 68 | 69 | perm := socketPermissionsForOS() 70 | 71 | sockDir := filepath.Dir(path) 72 | if _, err := os.Stat(sockDir); os.IsNotExist(err) { 73 | os.MkdirAll(sockDir, 0755) // best effort 74 | 75 | // If we're on a platform where we want the socket 76 | // world-readable, open up the permissions on the 77 | // just-created directory too, in case a umask ate 78 | // it. This primarily affects running tailscaled by 79 | // hand as root in a shell, as there is no umask when 80 | // running under systemd. 81 | if perm == 0666 { 82 | if fi, err := os.Stat(sockDir); err == nil && fi.Mode()&0077 == 0 { 83 | if err := os.Chmod(sockDir, 0755); err != nil { 84 | log.Print(err) 85 | } 86 | } 87 | } 88 | } 89 | pipe, err := net.Listen("unix", path) 90 | if err != nil { 91 | return nil, 0, err 92 | } 93 | os.Chmod(path, perm) 94 | return pipe, 0, err 95 | } 96 | 97 | func tailscaledRunningUnderLaunchd() bool { 98 | if runtime.GOOS != "darwin" { 99 | return false 100 | } 101 | plist, err := exec.Command("launchctl", "list", "com.tailscale.tailscaled").Output() 102 | _ = plist // parse it? https://github.com/DHowett/go-plist if we need something. 103 | running := err == nil 104 | return running 105 | } 106 | 107 | // socketPermissionsForOS returns the permissions to use for the 108 | // tailscaled.sock. 109 | func socketPermissionsForOS() os.FileMode { 110 | if PlatformUsesPeerCreds() { 111 | return 0666 112 | } 113 | // Otherwise, root only. 114 | return 0600 115 | } 116 | 117 | // connectMacOSAppSandbox connects to the Tailscale Network Extension, 118 | // which is necessarily running within the macOS App Sandbox. Our 119 | // little dance to connect a regular user binary to the sandboxed 120 | // network extension is: 121 | // 122 | // * the sandboxed IPNExtension picks a random localhost:0 TCP port 123 | // to listen on 124 | // * it also picks a random hex string that acts as an auth token 125 | // * it then creates a file named "sameuserproof-$PORT-$TOKEN" and leaves 126 | // that file descriptor open forever. 127 | // 128 | // Then, we do different things depending on whether the user is 129 | // running cmd/tailscale that they built themselves (running as 130 | // themselves, outside the App Sandbox), or whether the user is 131 | // running the CLI via the GUI binary 132 | // (e.g. /Applications/Tailscale.app/Contents/MacOS/Tailscale ), 133 | // in which case we're running within the App Sandbox. 134 | // 135 | // If we're outside the App Sandbox: 136 | // 137 | // * then we come along here, running as the same UID, but outside 138 | // of the sandbox, and look for it. We can run lsof on our own processes, 139 | // but other users on the system can't. 140 | // * we parse out the localhost port number and the auth token 141 | // * we connect to TCP localhost:$PORT 142 | // * we send $TOKEN + "\n" 143 | // * server verifies $TOKEN, sends "#IPN\n" if okay. 144 | // * server is now protocol switched 145 | // * we return the net.Conn and the caller speaks the normal protocol 146 | // 147 | // If we're inside the App Sandbox, then TS_MACOS_CLI_SHARED_DIR has 148 | // been set to our shared directory. We now have to find the most 149 | // recent "sameuserproof" file (there should only be 1, but previous 150 | // versions of the macOS app didn't clean them up). 151 | func connectMacOSAppSandbox() (net.Conn, error) { 152 | // Are we running the Tailscale.app GUI binary as a CLI, running within the App Sandbox? 153 | if d := os.Getenv("TS_MACOS_CLI_SHARED_DIR"); d != "" { 154 | fis, err := ioutil.ReadDir(d) 155 | if err != nil { 156 | return nil, fmt.Errorf("reading TS_MACOS_CLI_SHARED_DIR: %w", err) 157 | } 158 | var best os.FileInfo 159 | for _, fi := range fis { 160 | if !strings.HasPrefix(fi.Name(), "sameuserproof-") || strings.Count(fi.Name(), "-") != 2 { 161 | continue 162 | } 163 | if best == nil || fi.ModTime().After(best.ModTime()) { 164 | best = fi 165 | } 166 | } 167 | if best == nil { 168 | return nil, fmt.Errorf("no sameuserproof token found in TS_MACOS_CLI_SHARED_DIR %q", d) 169 | } 170 | f := strings.SplitN(best.Name(), "-", 3) 171 | portStr, token := f[1], f[2] 172 | port, err := strconv.Atoi(portStr) 173 | if err != nil { 174 | return nil, fmt.Errorf("invalid port %q", portStr) 175 | } 176 | return connectMacTCP(port, token) 177 | } 178 | 179 | // Otherwise, assume we're running the cmd/tailscale binary from outside the 180 | // App Sandbox. 181 | port, token, err := LocalTCPPortAndToken() 182 | if err != nil { 183 | return nil, err 184 | } 185 | return connectMacTCP(port, token) 186 | } 187 | 188 | func connectMacTCP(port int, token string) (net.Conn, error) { 189 | c, err := net.Dial("tcp", "localhost:"+strconv.Itoa(port)) 190 | if err != nil { 191 | return nil, fmt.Errorf("error dialing IPNExtension: %w", err) 192 | } 193 | if _, err := io.WriteString(c, token+"\n"); err != nil { 194 | return nil, fmt.Errorf("error writing auth token: %w", err) 195 | } 196 | buf := make([]byte, 5) 197 | const authOK = "#IPN\n" 198 | if _, err := io.ReadFull(c, buf); err != nil { 199 | return nil, fmt.Errorf("error reading from IPNExtension post-auth: %w", err) 200 | } 201 | if string(buf) != authOK { 202 | return nil, fmt.Errorf("invalid response reading from IPNExtension post-auth") 203 | } 204 | return c, nil 205 | } 206 | -------------------------------------------------------------------------------- /tscert.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 Tailscale Inc & AUTHORS All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package tscert fetches HTTPS certs from the local machine's 6 | // Tailscale daemon (tailscaled). 7 | package tscert 8 | 9 | import ( 10 | "bytes" 11 | "context" 12 | "crypto/tls" 13 | "encoding/json" 14 | "errors" 15 | "fmt" 16 | "io" 17 | "io/ioutil" 18 | "net" 19 | "net/http" 20 | "strconv" 21 | "strings" 22 | "sync" 23 | "time" 24 | 25 | "github.com/tailscale/tscert/internal/paths" 26 | "github.com/tailscale/tscert/internal/safesocket" 27 | ) 28 | 29 | var ( 30 | // TailscaledSocket is the tailscaled Unix socket. It's used by the TailscaledDialer. 31 | TailscaledSocket = paths.DefaultTailscaledSocket() 32 | 33 | // TailscaledSocketSetExplicitly reports whether the user explicitly set TailscaledSocket. 34 | TailscaledSocketSetExplicitly bool 35 | 36 | // TailscaledDialer is the DialContext func that connects to the local machine's 37 | // tailscaled or equivalent. 38 | TailscaledDialer = DialLocalAPI 39 | 40 | // TailscaledTransport is the RoundTripper that sends LocalAPI requests 41 | // to the local machine's tailscaled or equivalent. 42 | // If nil, a default RoundTripper is used that uses TailscaledDialer. 43 | TailscaledTransport http.RoundTripper 44 | ) 45 | 46 | // DialLocalAPI connects to the LocalAPI server of the tailscaled instance on the machine. 47 | func DialLocalAPI(ctx context.Context, network, addr string) (net.Conn, error) { 48 | if addr != "local-tailscaled.sock:80" { 49 | return nil, fmt.Errorf("unexpected URL address %q", addr) 50 | } 51 | // TODO: make this part of a safesocket.ConnectionStrategy 52 | if !TailscaledSocketSetExplicitly { 53 | // On macOS, when dialing from non-sandboxed program to sandboxed GUI running 54 | // a TCP server on a random port, find the random port. For HTTP connections, 55 | // we don't send the token. It gets added in an HTTP Basic-Auth header. 56 | if port, _, err := safesocket.LocalTCPPortAndToken(); err == nil { 57 | var d net.Dialer 58 | return d.DialContext(ctx, "tcp", "localhost:"+strconv.Itoa(port)) 59 | } 60 | } 61 | s := safesocket.DefaultConnectionStrategy(TailscaledSocket) 62 | // The user provided a non-default tailscaled socket address. 63 | // Connect only to exactly what they provided. 64 | s.UseFallback(false) 65 | return safesocket.Connect(s) 66 | } 67 | 68 | var ( 69 | // tsClient does HTTP requests to the local Tailscale daemon. 70 | // We lazily initialize the client in case the caller wants to 71 | // override TailscaledDialer. 72 | tsClient *http.Client 73 | tsClientOnce sync.Once 74 | ) 75 | 76 | // DoLocalRequest makes an HTTP request to the local machine's Tailscale daemon. 77 | // 78 | // URLs are of the form http://local-tailscaled.sock/localapi/v0/whois?ip=1.2.3.4. 79 | // 80 | // The hostname must be "local-tailscaled.sock", even though it 81 | // doesn't actually do any DNS lookup. The actual means of connecting to and 82 | // authenticating to the local Tailscale daemon vary by platform. 83 | // 84 | // DoLocalRequest may mutate the request to add Authorization headers. 85 | func DoLocalRequest(req *http.Request) (*http.Response, error) { 86 | tsClientOnce.Do(func() { 87 | tr := TailscaledTransport 88 | if tr == nil { 89 | tr = &http.Transport{ 90 | DialContext: TailscaledDialer, 91 | } 92 | } 93 | tsClient = &http.Client{Transport: tr} 94 | }) 95 | if _, token, err := safesocket.LocalTCPPortAndToken(); err == nil { 96 | req.SetBasicAuth("", token) 97 | } 98 | return tsClient.Do(req) 99 | } 100 | 101 | func doLocalRequestNiceError(req *http.Request) (*http.Response, error) { 102 | res, err := DoLocalRequest(req) 103 | if err == nil { 104 | if res.StatusCode == 403 { 105 | all, _ := ioutil.ReadAll(res.Body) 106 | return nil, &AccessDeniedError{errors.New(errorMessageFromBody(all))} 107 | } 108 | return res, nil 109 | } 110 | return nil, err 111 | } 112 | 113 | type errorJSON struct { 114 | Error string 115 | } 116 | 117 | // AccessDeniedError is an error due to permissions. 118 | type AccessDeniedError struct { 119 | err error 120 | } 121 | 122 | func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) } 123 | func (e *AccessDeniedError) Unwrap() error { return e.err } 124 | 125 | // IsAccessDeniedError reports whether err is or wraps an AccessDeniedError. 126 | func IsAccessDeniedError(err error) bool { 127 | var ae *AccessDeniedError 128 | return errors.As(err, &ae) 129 | } 130 | 131 | // bestError returns either err, or if body contains a valid JSON 132 | // object of type errorJSON, its non-empty error body. 133 | func bestError(err error, body []byte) error { 134 | var j errorJSON 135 | if err := json.Unmarshal(body, &j); err == nil && j.Error != "" { 136 | return errors.New(j.Error) 137 | } 138 | return err 139 | } 140 | 141 | func errorMessageFromBody(body []byte) string { 142 | var j errorJSON 143 | if err := json.Unmarshal(body, &j); err == nil && j.Error != "" { 144 | return j.Error 145 | } 146 | return strings.TrimSpace(string(body)) 147 | } 148 | 149 | func send(ctx context.Context, method, path string, wantStatus int, body io.Reader) ([]byte, error) { 150 | req, err := http.NewRequestWithContext(ctx, method, "http://local-tailscaled.sock"+path, body) 151 | if err != nil { 152 | return nil, err 153 | } 154 | res, err := doLocalRequestNiceError(req) 155 | if err != nil { 156 | return nil, err 157 | } 158 | defer res.Body.Close() 159 | slurp, err := ioutil.ReadAll(res.Body) 160 | if err != nil { 161 | return nil, err 162 | } 163 | if res.StatusCode != wantStatus { 164 | return nil, bestError(err, slurp) 165 | } 166 | return slurp, nil 167 | } 168 | 169 | func get200(ctx context.Context, path string) ([]byte, error) { 170 | return send(ctx, "GET", path, 200, nil) 171 | } 172 | 173 | // Status is a stripped down version of tailscale.com/ipn/ipnstate.Status 174 | // for the tscert package. 175 | type Status struct { 176 | // Version is the daemon's long version (see version.Long). 177 | Version string 178 | 179 | // BackendState is an ipn.State string value: 180 | // "NoState", "NeedsLogin", "NeedsMachineAuth", "Stopped", 181 | // "Starting", "Running". 182 | BackendState string 183 | 184 | // Health contains health check problems. 185 | // Empty means everything is good. (or at least that no known 186 | // problems are detected) 187 | Health []string 188 | 189 | // TailscaleIPs are the Tailscale IP(s) assigned to this node 190 | TailscaleIPs []string 191 | 192 | // MagicDNSSuffix is the network's MagicDNS suffix for nodes 193 | // in the network such as "userfoo.tailscale.net". 194 | // There are no surrounding dots. 195 | // MagicDNSSuffix should be populated regardless of whether a domain 196 | // has MagicDNS enabled. 197 | MagicDNSSuffix string 198 | 199 | // CertDomains are the set of DNS names for which the control 200 | // plane server will assist with provisioning TLS 201 | // certificates. See SetDNSRequest for dns-01 ACME challenges 202 | // for e.g. LetsEncrypt. These names are FQDNs without 203 | // trailing periods, and without any "_acme-challenge." prefix. 204 | CertDomains []string 205 | } 206 | 207 | // GetStatus returns a stripped down status from tailscaled. For a full 208 | // version, use tailscale.com/client/tailscale.Status. 209 | func GetStatus(ctx context.Context) (*Status, error) { 210 | body, err := get200(ctx, "/localapi/v0/status") 211 | if err != nil { 212 | return nil, err 213 | } 214 | st := new(Status) 215 | if err := json.Unmarshal(body, st); err != nil { 216 | return nil, err 217 | } 218 | return st, nil 219 | } 220 | 221 | // CertPair returns a cert and private key for the provided DNS domain. 222 | // 223 | // It returns a cached certificate from disk if it's still valid. 224 | func CertPair(ctx context.Context, domain string) (certPEM, keyPEM []byte, err error) { 225 | res, err := send(ctx, "GET", "/localapi/v0/cert/"+domain+"?type=pair", 200, nil) 226 | if err != nil { 227 | return nil, nil, err 228 | } 229 | // with ?type=pair, the response PEM is first the one private 230 | // key PEM block, then the cert PEM blocks. 231 | i := bytes.Index(res, []byte("--\n--")) 232 | if i == -1 { 233 | return nil, nil, fmt.Errorf("unexpected output: no delimiter") 234 | } 235 | i += len("--\n") 236 | keyPEM, certPEM = res[:i], res[i:] 237 | if bytes.Contains(certPEM, []byte(" PRIVATE KEY-----")) { 238 | return nil, nil, fmt.Errorf("unexpected output: key in cert") 239 | } 240 | return certPEM, keyPEM, nil 241 | } 242 | 243 | // GetCertificate fetches a TLS certificate for the TLS ClientHello in hi. 244 | // 245 | // It returns a cached certificate from disk if it's still valid. 246 | // 247 | // It's the right signature to use as the value of tls.Config.GetCertificate. 248 | func GetCertificate(hi *tls.ClientHelloInfo) (*tls.Certificate, error) { 249 | return GetCertificateWithContext(context.Background(), hi) 250 | } 251 | 252 | // GetCertificateWithContext fetches a TLS certificate for the TLS ClientHello in hi. 253 | // 254 | // It returns a cached certificate from disk if it's still valid. 255 | // 256 | // Use GetCertificate instead if a value for tls.Config.GetCertificate is needed. 257 | func GetCertificateWithContext(ctx context.Context, hi *tls.ClientHelloInfo) (*tls.Certificate, error) { 258 | if hi == nil || hi.ServerName == "" { 259 | return nil, errors.New("no SNI ServerName") 260 | } 261 | ctx, cancel := context.WithTimeout(ctx, time.Minute) 262 | defer cancel() 263 | 264 | name := hi.ServerName 265 | if !strings.Contains(name, ".") { 266 | if v, ok := ExpandSNIName(ctx, name); ok { 267 | name = v 268 | } 269 | } 270 | certPEM, keyPEM, err := CertPair(ctx, name) 271 | if err != nil { 272 | return nil, err 273 | } 274 | cert, err := tls.X509KeyPair(certPEM, keyPEM) 275 | if err != nil { 276 | return nil, err 277 | } 278 | return &cert, nil 279 | } 280 | 281 | // ExpandSNIName expands bare label name into the the most likely actual TLS cert name. 282 | func ExpandSNIName(ctx context.Context, name string) (fqdn string, ok bool) { 283 | st, err := GetStatus(ctx) 284 | if err != nil { 285 | return "", false 286 | } 287 | for _, d := range st.CertDomains { 288 | if len(d) > len(name)+1 && strings.HasPrefix(d, name) && d[len(name)] == '.' { 289 | return d, true 290 | } 291 | } 292 | return "", false 293 | } 294 | --------------------------------------------------------------------------------