mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-29 15:23: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"
|
||||
"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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user