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:
Tom Proctor 2025-07-22 10:07:09 +01:00 committed by GitHub
parent 8453170aa1
commit 6f7e78b10f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 194 additions and 13 deletions

View File

@ -9,17 +9,29 @@ import (
"errors"
"flag"
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/peterbourgon/ff/v3/ffcli"
"k8s.io/client-go/util/homedir"
"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"
)
var configureKubeconfigArgs struct {
http bool // Use HTTP instead of HTTPS (default) for the auth proxy.
}
func configureKubeconfigCmd() *ffcli.Command {
return &ffcli.Command{
Name: "kubeconfig",
@ -34,6 +46,7 @@ See: https://tailscale.com/s/k8s-auth-proxy
`),
FlagSet: (func() *flag.FlagSet {
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
})(),
Exec: runConfigureKubeconfig,
@ -70,10 +83,13 @@ func kubeconfigPath() (string, error) {
}
func runConfigureKubeconfig(ctx context.Context, args []string) error {
if len(args) != 1 {
return errors.New("unknown arguments")
if len(args) != 1 || args[0] == "" {
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)
if err != nil {
@ -82,22 +98,45 @@ func runConfigureKubeconfig(ctx context.Context, args []string) error {
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)
nm, err := getNetMap(ctx)
if err != nil {
return err
}
targetFQDN, err := nodeOrServiceDNSNameFromArg(st, nm, hostOrFQDNOrIP)
if err != nil {
return err
}
targetFQDN = strings.TrimSuffix(targetFQDN, ".")
var kubeconfig string
if kubeconfig, err = kubeconfigPath(); err != nil {
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
}
printf("kubeconfig configured for %q\n", hostOrFQDN)
printf("kubeconfig configured for %q at URL %q\n", targetFQDN, scheme+targetFQDN)
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
// 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 {
@ -116,7 +155,7 @@ func appendOrSetNamed(dst []any, name string, val map[string]any) []any {
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
if len(cfgYaml) > 0 {
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{
"name": fqdn,
"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)
}
func setKubeconfigForPeer(fqdn, filePath string) error {
func setKubeconfigForPeer(scheme, fqdn, filePath string) error {
dir := filepath.Dir(filePath)
if _, err := os.Stat(dir); err != nil {
if !os.IsNotExist(err) {
@ -191,9 +230,97 @@ func setKubeconfigForPeer(fqdn, filePath string) error {
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading kubeconfig: %w", err)
}
b, err = updateKubeconfig(b, fqdn)
b, err = updateKubeconfig(b, scheme, fqdn)
if err != nil {
return err
}
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
}

View File

@ -6,6 +6,7 @@ package cli
import (
"bytes"
"fmt"
"strings"
"testing"
@ -16,6 +17,7 @@ func TestKubeconfig(t *testing.T) {
const fqdn = "foo.tail-scale.ts.net"
tests := []struct {
name string
http bool
in string
want string
wantErr error
@ -48,6 +50,27 @@ contexts:
current-context: foo.tail-scale.ts.net
kind: Config
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
user:
token: unused`,
@ -202,7 +225,11 @@ users:
}
for _, tt := range tests {
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 != 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)
}
})
}
}
}
}