diff --git a/build_dist.sh b/build_dist.sh index 831834b76..a46f18546 100755 --- a/build_dist.sh +++ b/build_dist.sh @@ -54,7 +54,7 @@ while [ "$#" -gt 1 ]; do --extra-small) shift ldflags="$ldflags -w -s" - tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap" + tags="${tags:+$tags,}ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube" ;; --box) shift diff --git a/cmd/tailscale/cli/configure-kube.go b/cmd/tailscale/cli/configure-kube.go new file mode 100644 index 000000000..65ff85e14 --- /dev/null +++ b/cmd/tailscale/cli/configure-kube.go @@ -0,0 +1,152 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +//go:build !ts_omit_kube + +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "golang.org/x/exp/slices" + "k8s.io/client-go/util/homedir" + "sigs.k8s.io/yaml" +) + +func init() { + configureCmd.Subcommands = append(configureCmd.Subcommands, configureKubeconfigCmd) +} + +var configureKubeconfigCmd = &ffcli.Command{ + Name: "kubeconfig", + ShortHelp: "Configure kubeconfig to use Tailscale", + ShortUsage: "kubeconfig ", + LongHelp: strings.TrimSpace(` +Run this command to configure your kubeconfig to use Tailscale for authentication to a Kubernetes cluster. + +The hostname argument should be set to the Tailscale hostname of the peer running as an auth proxy in the cluster. +`), + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("kubeconfig") + return fs + })(), + Exec: runConfigureKubeconfig, +} + +func runConfigureKubeconfig(ctx context.Context, args []string) error { + if len(args) != 1 { + return errors.New("unknown arguments") + } + hostOrFQDN := args[0] + + st, err := localClient.Status(ctx) + if err != nil { + return err + } + if st.BackendState != "Running" { + return errors.New("Tailscale is not running") + } + targetFQDN, ok := nodeDNSNameFromArg(st, hostOrFQDN) + if !ok { + return fmt.Errorf("no peer found with hostname %q", hostOrFQDN) + } + targetFQDN = strings.TrimSuffix(targetFQDN, ".") + confPath := filepath.Join(homedir.HomeDir(), ".kube", "config") + if err := setKubeconfigForPeer(targetFQDN, confPath); err != nil { + return err + } + printf("kubeconfig configured for %q\n", hostOrFQDN) + return nil +} + +// appendOrSetNamed finds a map with a "name" key matching name in dst, and +// replaces it with val. If no such map is found, val is appended to dst. +func appendOrSetNamed(dst []any, name string, val map[string]any) []any { + if got := slices.IndexFunc(dst, func(m any) bool { + if m, ok := m.(map[string]any); ok { + return m["name"] == name + } + return false + }); got != -1 { + dst[got] = val + } else { + dst = append(dst, val) + } + return dst +} + +var errInvalidKubeconfig = errors.New("invalid kubeconfig") + +func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) { + var cfg map[string]any + if len(cfgYaml) > 0 { + if err := yaml.Unmarshal(cfgYaml, &cfg); err != nil { + return nil, errInvalidKubeconfig + } + } + if cfg == nil { + cfg = map[string]any{ + "apiVersion": "v1", + "kind": "Config", + } + } else if cfg["apiVersion"] != "v1" || cfg["kind"] != "Config" { + return nil, errInvalidKubeconfig + } + + var clusters []any + if cm, ok := cfg["clusters"]; ok { + clusters = cm.([]any) + } + cfg["clusters"] = appendOrSetNamed(clusters, fqdn, map[string]any{ + "name": fqdn, + "cluster": map[string]string{ + "server": "https://" + fqdn, + }, + }) + + var users []any + if um, ok := cfg["users"]; ok { + users = um.([]any) + } + cfg["users"] = appendOrSetNamed(users, "tailscale-auth", map[string]any{ + // We just need one of these, and can reuse it for all clusters. + "name": "tailscale-auth", + "user": map[string]string{ + // We do not use the token, but if we do not set anything here + // kubectl will prompt for a username and password. + "token": "unused", + }, + }) + + var contexts []any + if cm, ok := cfg["contexts"]; ok { + contexts = cm.([]any) + } + cfg["contexts"] = appendOrSetNamed(contexts, fqdn, map[string]any{ + "name": fqdn, + "context": map[string]string{ + "cluster": fqdn, + "user": "tailscale-auth", + }, + }) + cfg["current-context"] = fqdn + return yaml.Marshal(cfg) +} + +func setKubeconfigForPeer(fqdn, filePath string) error { + b, err := os.ReadFile(filePath) + if err != nil && !os.IsNotExist(err) { + return err + } + b, err = updateKubeconfig(b, fqdn) + if err != nil { + return err + } + return os.WriteFile(filePath, b, 0600) +} diff --git a/cmd/tailscale/cli/configure-kube_test.go b/cmd/tailscale/cli/configure-kube_test.go new file mode 100644 index 000000000..0f326cd64 --- /dev/null +++ b/cmd/tailscale/cli/configure-kube_test.go @@ -0,0 +1,196 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +//go:build !ts_omit_kube + +package cli + +import ( + "bytes" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestKubeconfig(t *testing.T) { + const fqdn = "foo.tail-scale.ts.net" + tests := []struct { + name string + in string + want string + wantErr error + }{ + { + name: "invalid-yaml", + in: `apiVersion: v1 +kind: ,asdf`, + wantErr: errInvalidKubeconfig, + }, + { + name: "invalid-cfg", + in: `apiVersion: v1 +kind: Pod`, + wantErr: errInvalidKubeconfig, + }, + { + name: "empty", + in: "", + want: `apiVersion: v1 +clusters: +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +current-context: foo.tail-scale.ts.net +kind: Config +users: +- name: tailscale-auth + user: + token: unused`, + }, + { + name: "already-configured", + in: `apiVersion: v1 +clusters: +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +kind: Config +current-context: foo.tail-scale.ts.net +users: +- name: tailscale-auth + user: + token: unused`, + want: `apiVersion: v1 +clusters: +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +current-context: foo.tail-scale.ts.net +kind: Config +users: +- name: tailscale-auth + user: + token: unused`, + }, + { + name: "other-cluster", + in: `apiVersion: v1 +clusters: +- cluster: + server: https://192.168.1.1:8443 + name: some-cluster +contexts: +- context: + cluster: some-cluster + user: some-auth + name: some-cluster +kind: Config +current-context: some-cluster +users: +- name: some-auth + user: + token: asdfasdf`, + want: `apiVersion: v1 +clusters: +- cluster: + server: https://192.168.1.1:8443 + name: some-cluster +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: some-cluster + user: some-auth + name: some-cluster +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +current-context: foo.tail-scale.ts.net +kind: Config +users: +- name: some-auth + user: + token: asdfasdf +- name: tailscale-auth + user: + token: unused`, + }, + { + name: "already-using-tailscale", + in: `apiVersion: v1 +clusters: +- cluster: + server: https://bar.tail-scale.ts.net + name: bar.tail-scale.ts.net +contexts: +- context: + cluster: bar.tail-scale.ts.net + user: tailscale-auth + name: bar.tail-scale.ts.net +kind: Config +current-context: bar.tail-scale.ts.net +users: +- name: tailscale-auth + user: + token: unused`, + want: `apiVersion: v1 +clusters: +- cluster: + server: https://bar.tail-scale.ts.net + name: bar.tail-scale.ts.net +- cluster: + server: https://foo.tail-scale.ts.net + name: foo.tail-scale.ts.net +contexts: +- context: + cluster: bar.tail-scale.ts.net + user: tailscale-auth + name: bar.tail-scale.ts.net +- context: + cluster: foo.tail-scale.ts.net + user: tailscale-auth + name: foo.tail-scale.ts.net +current-context: foo.tail-scale.ts.net +kind: Config +users: +- name: tailscale-auth + user: + token: unused`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := updateKubeconfig([]byte(tt.in), fqdn) + if err != nil { + if err != tt.wantErr { + t.Fatalf("updateKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + } + return + } else if tt.wantErr != nil { + t.Fatalf("updateKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + } + got = bytes.TrimSpace(got) + want := []byte(strings.TrimSpace(tt.want)) + if d := cmp.Diff(want, got); d != "" { + t.Errorf("Kubeconfig() mismatch (-want +got):\n%s", d) + } + }) + } +} diff --git a/cmd/tailscale/cli/configure-synology.go b/cmd/tailscale/cli/configure-synology.go index 3c3ed8e14..86c739f2e 100644 --- a/cmd/tailscale/cli/configure-synology.go +++ b/cmd/tailscale/cli/configure-synology.go @@ -18,9 +18,9 @@ import ( "tailscale.com/version/distro" ) -// configureHostCmd is the "tailscale configure-host" command which -// was once used to configure Synology devices, but is now a redirect -// to "tailscale configure synology". +// configureHostCmd is the "tailscale configure-host" command which was once +// used to configure Synology devices, but is now a compatibility alias to +// "tailscale configure synology". var configureHostCmd = &ffcli.Command{ Name: "configure-host", Exec: runConfigureSynology, diff --git a/cmd/tailscale/cli/configure.go b/cmd/tailscale/cli/configure.go index f8aaad449..a40039081 100644 --- a/cmd/tailscale/cli/configure.go +++ b/cmd/tailscale/cli/configure.go @@ -7,13 +7,19 @@ import ( "context" "flag" "runtime" + "strings" "github.com/peterbourgon/ff/v3/ffcli" "tailscale.com/version/distro" ) var configureCmd = &ffcli.Command{ - Name: "configure", + Name: "configure", + ShortHelp: "Configure the host to enable more Tailscale features", + LongHelp: strings.TrimSpace(` +The 'configure' command is intended to provide a way to configure different +services on the host to enable more Tailscale features. +`), FlagSet: (func() *flag.FlagSet { fs := newFlagSet("configure") return fs diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 98c5c4b00..a926d9eae 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -40,9 +40,12 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep 💣 go4.org/mem from tailscale.com/derp+ go4.org/netipx from tailscale.com/wgengine/filter W 💣 golang.zx2c4.com/wireguard/windows/tunnel/winipcfg from tailscale.com/net/interfaces+ + gopkg.in/yaml.v2 from sigs.k8s.io/yaml + k8s.io/client-go/util/homedir from tailscale.com/cmd/tailscale/cli nhooyr.io/websocket from tailscale.com/derp/derphttp+ nhooyr.io/websocket/internal/errd from nhooyr.io/websocket nhooyr.io/websocket/internal/xsync from nhooyr.io/websocket + sigs.k8s.io/yaml from tailscale.com/cmd/tailscale/cli software.sslmate.com/src/go-pkcs12 from tailscale.com/cmd/tailscale/cli software.sslmate.com/src/go-pkcs12/internal/rc2 from software.sslmate.com/src/go-pkcs12 tailscale.com from tailscale.com/version