cmd/tailscale/cli: add "configure kubeconfig" subcommand

It takes in a node hostname and configures the local kubeconfig file to
point to that.

Updates #7220

Signed-off-by: Maisem Ali <maisem@tailscale.com>
This commit is contained in:
Maisem Ali 2023-02-08 15:25:00 -08:00
parent 181a3da513
commit c01c84ea8e
6 changed files with 362 additions and 5 deletions

View File

@ -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

View File

@ -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 <hostname-or-fqdn>",
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)
}

View File

@ -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)
}
})
}
}

View File

@ -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,

View File

@ -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

View File

@ -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