mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-29 23:33:45 +00:00
cmd/tailscale/cli: make configure kubeconfig accept Tailscale Services (#16601)
The Kubernetes API server proxy is getting the ability to serve on a Tailscale Service instead of individual node names. Update the configure kubeconfig sub-command to accept arguments that look like a Tailscale Service. Note, we can't know for sure whether a peer is advertising a Tailscale Service, we can only guess based on the ExtraRecords in the netmap and that IP showing up in a peer's AllowedIPs. Also adds an --http flag to allow targeting individual proxies that can be adverting on http for their node name, and makes the command a bit more forgiving on the range of inputs it accepts and how eager it is to print the help text when the input is obviously wrong. Updates #13358 Change-Id: Ica0509c6b2c707252a43d7c18b530ec1acf7508f Signed-off-by: Tom Proctor <tomhjp@users.noreply.github.com>
This commit is contained in:
parent
8453170aa1
commit
6f7e78b10f
@ -9,17 +9,29 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"k8s.io/client-go/util/homedir"
|
"k8s.io/client-go/util/homedir"
|
||||||
"sigs.k8s.io/yaml"
|
"sigs.k8s.io/yaml"
|
||||||
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/types/netmap"
|
||||||
|
"tailscale.com/util/dnsname"
|
||||||
"tailscale.com/version"
|
"tailscale.com/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var configureKubeconfigArgs struct {
|
||||||
|
http bool // Use HTTP instead of HTTPS (default) for the auth proxy.
|
||||||
|
}
|
||||||
|
|
||||||
func configureKubeconfigCmd() *ffcli.Command {
|
func configureKubeconfigCmd() *ffcli.Command {
|
||||||
return &ffcli.Command{
|
return &ffcli.Command{
|
||||||
Name: "kubeconfig",
|
Name: "kubeconfig",
|
||||||
@ -34,6 +46,7 @@ See: https://tailscale.com/s/k8s-auth-proxy
|
|||||||
`),
|
`),
|
||||||
FlagSet: (func() *flag.FlagSet {
|
FlagSet: (func() *flag.FlagSet {
|
||||||
fs := newFlagSet("kubeconfig")
|
fs := newFlagSet("kubeconfig")
|
||||||
|
fs.BoolVar(&configureKubeconfigArgs.http, "http", false, "Use HTTP instead of HTTPS to connect to the auth proxy. Ignored if you include a scheme in the hostname argument.")
|
||||||
return fs
|
return fs
|
||||||
})(),
|
})(),
|
||||||
Exec: runConfigureKubeconfig,
|
Exec: runConfigureKubeconfig,
|
||||||
@ -70,10 +83,13 @@ func kubeconfigPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runConfigureKubeconfig(ctx context.Context, args []string) error {
|
func runConfigureKubeconfig(ctx context.Context, args []string) error {
|
||||||
if len(args) != 1 {
|
if len(args) != 1 || args[0] == "" {
|
||||||
return errors.New("unknown arguments")
|
return flag.ErrHelp
|
||||||
|
}
|
||||||
|
hostOrFQDNOrIP, http, err := getInputs(args[0], configureKubeconfigArgs.http)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing inputs: %w", err)
|
||||||
}
|
}
|
||||||
hostOrFQDN := args[0]
|
|
||||||
|
|
||||||
st, err := localClient.Status(ctx)
|
st, err := localClient.Status(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -82,22 +98,45 @@ func runConfigureKubeconfig(ctx context.Context, args []string) error {
|
|||||||
if st.BackendState != "Running" {
|
if st.BackendState != "Running" {
|
||||||
return errors.New("Tailscale is not running")
|
return errors.New("Tailscale is not running")
|
||||||
}
|
}
|
||||||
targetFQDN, ok := nodeDNSNameFromArg(st, hostOrFQDN)
|
nm, err := getNetMap(ctx)
|
||||||
if !ok {
|
if err != nil {
|
||||||
return fmt.Errorf("no peer found with hostname %q", hostOrFQDN)
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetFQDN, err := nodeOrServiceDNSNameFromArg(st, nm, hostOrFQDNOrIP)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
targetFQDN = strings.TrimSuffix(targetFQDN, ".")
|
targetFQDN = strings.TrimSuffix(targetFQDN, ".")
|
||||||
var kubeconfig string
|
var kubeconfig string
|
||||||
if kubeconfig, err = kubeconfigPath(); err != nil {
|
if kubeconfig, err = kubeconfigPath(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = setKubeconfigForPeer(targetFQDN, kubeconfig); err != nil {
|
scheme := "https://"
|
||||||
|
if http {
|
||||||
|
scheme = "http://"
|
||||||
|
}
|
||||||
|
if err = setKubeconfigForPeer(scheme, targetFQDN, kubeconfig); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
printf("kubeconfig configured for %q\n", hostOrFQDN)
|
printf("kubeconfig configured for %q at URL %q\n", targetFQDN, scheme+targetFQDN)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getInputs(arg string, httpArg bool) (string, bool, error) {
|
||||||
|
u, err := url.Parse(arg)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch u.Scheme {
|
||||||
|
case "http", "https":
|
||||||
|
return u.Host, u.Scheme == "http", nil
|
||||||
|
default:
|
||||||
|
return arg, httpArg, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// appendOrSetNamed finds a map with a "name" key matching name in dst, and
|
// 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.
|
// 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 {
|
func appendOrSetNamed(dst []any, name string, val map[string]any) []any {
|
||||||
@ -116,7 +155,7 @@ func appendOrSetNamed(dst []any, name string, val map[string]any) []any {
|
|||||||
|
|
||||||
var errInvalidKubeconfig = errors.New("invalid kubeconfig")
|
var errInvalidKubeconfig = errors.New("invalid kubeconfig")
|
||||||
|
|
||||||
func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) {
|
func updateKubeconfig(cfgYaml []byte, scheme, fqdn string) ([]byte, error) {
|
||||||
var cfg map[string]any
|
var cfg map[string]any
|
||||||
if len(cfgYaml) > 0 {
|
if len(cfgYaml) > 0 {
|
||||||
if err := yaml.Unmarshal(cfgYaml, &cfg); err != nil {
|
if err := yaml.Unmarshal(cfgYaml, &cfg); err != nil {
|
||||||
@ -139,7 +178,7 @@ func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) {
|
|||||||
cfg["clusters"] = appendOrSetNamed(clusters, fqdn, map[string]any{
|
cfg["clusters"] = appendOrSetNamed(clusters, fqdn, map[string]any{
|
||||||
"name": fqdn,
|
"name": fqdn,
|
||||||
"cluster": map[string]string{
|
"cluster": map[string]string{
|
||||||
"server": "https://" + fqdn,
|
"server": scheme + fqdn,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -172,7 +211,7 @@ func updateKubeconfig(cfgYaml []byte, fqdn string) ([]byte, error) {
|
|||||||
return yaml.Marshal(cfg)
|
return yaml.Marshal(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setKubeconfigForPeer(fqdn, filePath string) error {
|
func setKubeconfigForPeer(scheme, fqdn, filePath string) error {
|
||||||
dir := filepath.Dir(filePath)
|
dir := filepath.Dir(filePath)
|
||||||
if _, err := os.Stat(dir); err != nil {
|
if _, err := os.Stat(dir); err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if !os.IsNotExist(err) {
|
||||||
@ -191,9 +230,97 @@ func setKubeconfigForPeer(fqdn, filePath string) error {
|
|||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("reading kubeconfig: %w", err)
|
return fmt.Errorf("reading kubeconfig: %w", err)
|
||||||
}
|
}
|
||||||
b, err = updateKubeconfig(b, fqdn)
|
b, err = updateKubeconfig(b, scheme, fqdn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(filePath, b, 0600)
|
return os.WriteFile(filePath, b, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nodeOrServiceDNSNameFromArg returns the PeerStatus.DNSName value from a peer
|
||||||
|
// in st that matches the input arg which can be a base name, full DNS name, or
|
||||||
|
// an IP. If none is found, it looks for a Tailscale Service
|
||||||
|
func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, nm *netmap.NetworkMap, arg string) (string, error) {
|
||||||
|
// First check for a node DNS name.
|
||||||
|
if dnsName, ok := nodeDNSNameFromArg(st, arg); ok {
|
||||||
|
return dnsName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found, check for a Tailscale Service DNS name.
|
||||||
|
rec, ok := serviceDNSRecordFromNetMap(nm, st.CurrentTailnet.MagicDNSSuffix, arg)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("no peer found for %q", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate we can see a peer advertising the Tailscale Service.
|
||||||
|
ip, err := netip.ParseAddr(rec.Value)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error parsing ExtraRecord IP address %q: %w", rec.Value, err)
|
||||||
|
}
|
||||||
|
ipPrefix := netip.PrefixFrom(ip, ip.BitLen())
|
||||||
|
for _, ps := range st.Peer {
|
||||||
|
for _, allowedIP := range ps.AllowedIPs.All() {
|
||||||
|
if allowedIP == ipPrefix {
|
||||||
|
return rec.Name, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("%q is in MagicDNS, but is not currently reachable on any known peer", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
watcher, err := localClient.WatchIPNBus(ctx, ipn.NotifyInitialNetMap)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer watcher.Close()
|
||||||
|
|
||||||
|
n, err := watcher.Next()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return n.NetMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, tcd, arg string) (rec tailcfg.DNSRecord, ok bool) {
|
||||||
|
argIP, _ := netip.ParseAddr(arg)
|
||||||
|
argFQDN, err := dnsname.ToFQDN(arg)
|
||||||
|
argFQDNValid := err == nil
|
||||||
|
if !argIP.IsValid() && !argFQDNValid {
|
||||||
|
return rec, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rec := range nm.DNS.ExtraRecords {
|
||||||
|
if argIP.IsValid() {
|
||||||
|
recIP, _ := netip.ParseAddr(rec.Value)
|
||||||
|
if recIP == argIP {
|
||||||
|
return rec, true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !argFQDNValid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
recFirstLabel := dnsname.FirstLabel(rec.Name)
|
||||||
|
if strings.EqualFold(arg, recFirstLabel) {
|
||||||
|
return rec, true
|
||||||
|
}
|
||||||
|
|
||||||
|
recFQDN, err := dnsname.ToFQDN(rec.Name)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(argFQDN.WithTrailingDot(), recFQDN.WithTrailingDot()) {
|
||||||
|
return rec, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tailcfg.DNSRecord{}, false
|
||||||
|
}
|
||||||
|
@ -6,6 +6,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -16,6 +17,7 @@ func TestKubeconfig(t *testing.T) {
|
|||||||
const fqdn = "foo.tail-scale.ts.net"
|
const fqdn = "foo.tail-scale.ts.net"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
http bool
|
||||||
in string
|
in string
|
||||||
want string
|
want string
|
||||||
wantErr error
|
wantErr error
|
||||||
@ -48,6 +50,27 @@ contexts:
|
|||||||
current-context: foo.tail-scale.ts.net
|
current-context: foo.tail-scale.ts.net
|
||||||
kind: Config
|
kind: Config
|
||||||
users:
|
users:
|
||||||
|
- name: tailscale-auth
|
||||||
|
user:
|
||||||
|
token: unused`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty_http",
|
||||||
|
http: true,
|
||||||
|
in: "",
|
||||||
|
want: `apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: http://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
|
- name: tailscale-auth
|
||||||
user:
|
user:
|
||||||
token: unused`,
|
token: unused`,
|
||||||
@ -202,7 +225,11 @@ users:
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := updateKubeconfig([]byte(tt.in), fqdn)
|
scheme := "https://"
|
||||||
|
if tt.http {
|
||||||
|
scheme = "http://"
|
||||||
|
}
|
||||||
|
got, err := updateKubeconfig([]byte(tt.in), scheme, fqdn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != tt.wantErr {
|
if err != tt.wantErr {
|
||||||
t.Fatalf("updateKubeconfig() error = %v, wantErr %v", err, tt.wantErr)
|
t.Fatalf("updateKubeconfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
@ -219,3 +246,30 @@ users:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetInputs(t *testing.T) {
|
||||||
|
for _, arg := range []string{
|
||||||
|
"foo.tail-scale.ts.net",
|
||||||
|
"foo",
|
||||||
|
"127.0.0.1",
|
||||||
|
} {
|
||||||
|
for _, prefix := range []string{"", "https://", "http://"} {
|
||||||
|
for _, httpFlag := range []bool{false, true} {
|
||||||
|
expectedHost := arg
|
||||||
|
expectedHTTP := (httpFlag && !strings.HasPrefix(prefix, "https://")) || strings.HasPrefix(prefix, "http://")
|
||||||
|
t.Run(fmt.Sprintf("%s%s_http=%v", prefix, arg, httpFlag), func(t *testing.T) {
|
||||||
|
host, http, err := getInputs(prefix+arg, httpFlag)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if host != expectedHost {
|
||||||
|
t.Errorf("host = %v, want %v", host, expectedHost)
|
||||||
|
}
|
||||||
|
if http != expectedHTTP {
|
||||||
|
t.Errorf("http = %v, want %v", http, expectedHTTP)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user