mirror of
https://github.com/tailscale/tailscale.git
synced 2026-06-24 07:52:47 -04:00
cmd/tailscale/cli: check kubeconfig writability instead of refusing $KUBECONFIG (#20009)
When running under the macOS sandbox, "tailscale configure kubeconfig" refused outright whenever $KUBECONFIG was set, assuming the path would not be writable. Yet when $KUBECONFIG was unset it happily relied on the home-relative-path entitlement to write to ~/.kube/config, so the two paths made inconsistent assumptions about what the sandbox can reach. Resolve the kubeconfig path first, then check whether the target file (or the nearest existing parent directory) is actually writable. Only report an error if it is not, and include macOS sandbox guidance in that error since a path outside the home directory is the likely cause. This lets a $KUBECONFIG that does point under the home directory work, rather than being rejected unconditionally. Fixes #20007 Change-Id: I9880363c38b981efaed7e97367851ddacf647be1 Signed-off-by: James Tucker <james@tailscale.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user