diff --git a/cmd/tailscale/cli/configure-kube.go b/cmd/tailscale/cli/configure-kube.go index 8160025c6..e3835dea1 100644 --- a/cmd/tailscale/cli/configure-kube.go +++ b/cmd/tailscale/cli/configure-kube.go @@ -52,18 +52,15 @@ func configureKubeconfigCmd() *ffcli.Command { } // kubeconfigPath returns the path to the kubeconfig file for the current user. -func kubeconfigPath() (string, error) { +func kubeconfigPath() string { if kubeconfig := os.Getenv("KUBECONFIG"); kubeconfig != "" { - if version.IsSandboxedMacOS() { - return "", errors.New("cannot read $KUBECONFIG on GUI builds of the macOS client: this requires the open-source tailscaled distribution") - } var out string for _, out = range filepath.SplitList(kubeconfig) { if info, err := os.Stat(out); !os.IsNotExist(err) && !info.IsDir() { break } } - return out, nil + return out } var dir string @@ -77,7 +74,64 @@ func kubeconfigPath() (string, error) { } else { dir = homedir.HomeDir() } - return filepath.Join(dir, ".kube", "config"), nil + return filepath.Join(dir, ".kube", "config") +} + +// checkKubeconfigWritable returns nil if the kubeconfig at path can be written, +// or an error explaining why it can't. A not-yet-created file or .kube +// directory is fine as long as the nearest existing ancestor is writable. +// +// On sandboxed macOS builds, kubeconfigPath resolves path to the user's real +// ~/.kube/config, which we can only write via the home-relative-path +// entitlement. If that write would fail (e.g. because $KUBECONFIG points +// somewhere the sandbox can't reach), we want to surface a clear error pointing +// at the open-source tailscaled distribution rather than silently writing a +// config the user's kubectl will never read into the sandbox container. +func checkKubeconfigWritable(path string) error { + for try := path; ; try = filepath.Dir(try) { + if _, err := os.Stat(try); err == nil { + if err := isWritable(try); err != nil { + return kubeconfigAccessErr(path, err) + } + return nil + } else if !os.IsNotExist(err) { + return kubeconfigAccessErr(path, err) + } + if parent := filepath.Dir(try); parent == try { + return nil // reached the filesystem root + } + } +} + +// isWritable reports whether path can be opened or created for writing. For a +// directory it probes by creating and removing a temporary file. +func isWritable(path string) error { + fi, err := os.Stat(path) + if err != nil { + return err + } + if fi.IsDir() { + f, err := os.CreateTemp(path, ".tailscale-kubeconfig-*") + if err != nil { + return err + } + f.Close() + return os.Remove(f.Name()) + } + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + return err + } + return f.Close() +} + +// kubeconfigAccessErr wraps err with context about path, adding macOS sandbox +// guidance when the process is sandboxed. +func kubeconfigAccessErr(path string, err error) error { + if version.IsSandboxedMacOS() { + return fmt.Errorf("cannot write kubeconfig at %q: %w; GUI builds of the macOS client run in a sandbox and can only access files under your home directory, use the open-source tailscaled distribution for other locations", path, err) + } + return fmt.Errorf("cannot write kubeconfig at %q: %w", path, err) } func runConfigureKubeconfig(ctx context.Context, args []string) error { @@ -106,8 +160,8 @@ func runConfigureKubeconfig(ctx context.Context, args []string) error { return err } targetFQDN = strings.TrimSuffix(targetFQDN, ".") - var kubeconfig string - if kubeconfig, err = kubeconfigPath(); err != nil { + kubeconfig := kubeconfigPath() + if err := checkKubeconfigWritable(kubeconfig); err != nil { return err } scheme := "https://" @@ -215,13 +269,8 @@ func setKubeconfigForPeer(scheme, fqdn, filePath string) error { if !os.IsNotExist(err) { return err } - if err := os.Mkdir(dir, 0755); err != nil { - if version.IsSandboxedMacOS() && errors.Is(err, os.ErrPermission) { - // macOS sandboxing prevents us from creating the .kube directory - // in the home directory. - return errors.New("unable to create .kube directory in home directory, please create it manually (e.g. mkdir ~/.kube") - } - return err + if err := os.MkdirAll(dir, 0755); err != nil { + return kubeconfigAccessErr(filePath, err) } } b, err := os.ReadFile(filePath) diff --git a/cmd/tailscale/cli/configure-kube_test.go b/cmd/tailscale/cli/configure-kube_test.go index 2c2a05ac0..d5425531b 100644 --- a/cmd/tailscale/cli/configure-kube_test.go +++ b/cmd/tailscale/cli/configure-kube_test.go @@ -7,6 +7,9 @@ import ( "bytes" "fmt" + "os" + "path/filepath" + "runtime" "strings" "testing" @@ -247,6 +250,69 @@ func TestKubeconfig(t *testing.T) { } } +func TestCheckKubeconfigWritable(t *testing.T) { + t.Run("nonexistent-file-in-writable-dir", func(t *testing.T) { + dir := t.TempDir() + if err := checkKubeconfigWritable(filepath.Join(dir, "config")); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("nonexistent-file-and-dir-in-writable-parent", func(t *testing.T) { + dir := t.TempDir() + // The .kube directory does not exist yet, but its parent does and is + // writable, so this should be fine. + if err := checkKubeconfigWritable(filepath.Join(dir, ".kube", "config")); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("existing-writable-file", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte("apiVersion: v1\nkind: Config\n"), 0600); err != nil { + t.Fatal(err) + } + if err := checkKubeconfigWritable(path); err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + + t.Run("unwritable-existing-file", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file mode permissions are not enforced the same way on Windows") + } + if os.Getuid() == 0 { + t.Skip("root bypasses file permission checks") + } + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte("x"), 0400); err != nil { + t.Fatal(err) + } + if err := checkKubeconfigWritable(path); err == nil { + t.Error("expected error for read-only file, got nil") + } + }) + + t.Run("unwritable-dir", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory mode permissions are not enforced the same way on Windows") + } + if os.Getuid() == 0 { + t.Skip("root bypasses directory permission checks") + } + dir := t.TempDir() + sub := filepath.Join(dir, "ro") + if err := os.Mkdir(sub, 0500); err != nil { + t.Fatal(err) + } + if err := checkKubeconfigWritable(filepath.Join(sub, "config")); err == nil { + t.Error("expected error for unwritable dir, got nil") + } + }) +} + func TestGetInputs(t *testing.T) { for _, arg := range []string{ "foo.tail-scale.ts.net",