From aeb548649f4bcd9d706f8007cf4c28dcce1405bc Mon Sep 17 00:00:00 2001 From: Lee Briggs Date: Thu, 24 Jul 2025 09:43:35 -0700 Subject: [PATCH] add support for multiple tailnets Signed-off-by: Lee Briggs --- cmd/tscli/config/cli.go | 2 + cmd/tscli/config/tailnet/add/cli.go | 34 +++++ cmd/tscli/config/tailnet/cli.go | 24 +++ cmd/tscli/config/tailnet/list/cli.go | 86 +++++++++++ cmd/tscli/config/tailnet/remove/cli.go | 31 ++++ cmd/tscli/config/tailnet/switch_/cli.go | 31 ++++ cmd/tscli/config/tailnet/update/cli.go | 67 +++++++++ cmd/tscli/main.go | 19 ++- pkg/config/config.go | 188 +++++++++++++++++++++++- pkg/tscli/client.go | 42 +++++- 10 files changed, 516 insertions(+), 8 deletions(-) create mode 100644 cmd/tscli/config/tailnet/add/cli.go create mode 100644 cmd/tscli/config/tailnet/cli.go create mode 100644 cmd/tscli/config/tailnet/list/cli.go create mode 100644 cmd/tscli/config/tailnet/remove/cli.go create mode 100644 cmd/tscli/config/tailnet/switch_/cli.go create mode 100644 cmd/tscli/config/tailnet/update/cli.go diff --git a/cmd/tscli/config/cli.go b/cmd/tscli/config/cli.go index 069bcf5..445ad4c 100644 --- a/cmd/tscli/config/cli.go +++ b/cmd/tscli/config/cli.go @@ -4,6 +4,7 @@ import ( "github.com/jaxxstorm/tscli/cmd/tscli/config/get" "github.com/jaxxstorm/tscli/cmd/tscli/config/set" "github.com/jaxxstorm/tscli/cmd/tscli/config/show" + "github.com/jaxxstorm/tscli/cmd/tscli/config/tailnet" "github.com/spf13/cobra" ) @@ -17,5 +18,6 @@ func Command() *cobra.Command { command.AddCommand(show.Command()) command.AddCommand(set.Command()) command.AddCommand(get.Command()) + command.AddCommand(tailnet.Command()) return command } diff --git a/cmd/tscli/config/tailnet/add/cli.go b/cmd/tscli/config/tailnet/add/cli.go new file mode 100644 index 0000000..3f4ca5c --- /dev/null +++ b/cmd/tscli/config/tailnet/add/cli.go @@ -0,0 +1,34 @@ +package add + +import ( + "fmt" + + "github.com/jaxxstorm/tscli/pkg/config" + "github.com/spf13/cobra" +) + +func Command() *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "Add a new tailnet configuration", + Args: cobra.ExactArgs(2), + RunE: func(_ *cobra.Command, args []string) error { + // Check if using legacy config and warn + if config.IsLegacyConfig() { + fmt.Println("Warning: You are currently using legacy configuration (single api-key).") + fmt.Println("Adding a tailnet will switch you to the new multi-tailnet configuration.") + fmt.Println("Your existing api-key will remain accessible via the legacy config until you migrate.") + fmt.Println() + } + + name, apiKey := args[0], args[1] + + if err := config.AddTailnet(name, apiKey); err != nil { + return fmt.Errorf("failed to add tailnet: %w", err) + } + + fmt.Printf("Tailnet %q added successfully\n", name) + return nil + }, + } +} diff --git a/cmd/tscli/config/tailnet/cli.go b/cmd/tscli/config/tailnet/cli.go new file mode 100644 index 0000000..6a59324 --- /dev/null +++ b/cmd/tscli/config/tailnet/cli.go @@ -0,0 +1,24 @@ +package tailnet + +import ( + "github.com/jaxxstorm/tscli/cmd/tscli/config/tailnet/add" + "github.com/jaxxstorm/tscli/cmd/tscli/config/tailnet/list" + "github.com/jaxxstorm/tscli/cmd/tscli/config/tailnet/remove" + "github.com/jaxxstorm/tscli/cmd/tscli/config/tailnet/switch_" + "github.com/spf13/cobra" +) + +func Command() *cobra.Command { + command := &cobra.Command{ + Use: "tailnet", + Short: "Manage tailnet configurations", + Long: "Commands to add, remove, list, and switch between tailnet configurations", + } + + command.AddCommand(add.Command()) + command.AddCommand(list.Command()) + command.AddCommand(remove.Command()) + command.AddCommand(switch_.Command()) + + return command +} diff --git a/cmd/tscli/config/tailnet/list/cli.go b/cmd/tscli/config/tailnet/list/cli.go new file mode 100644 index 0000000..4ae1f4c --- /dev/null +++ b/cmd/tscli/config/tailnet/list/cli.go @@ -0,0 +1,86 @@ +package list + +import ( + "encoding/json" + "fmt" + + "github.com/jaxxstorm/tscli/pkg/config" + "github.com/jaxxstorm/tscli/pkg/output" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func Command() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all tailnet configurations", + RunE: func(_ *cobra.Command, _ []string) error { + // Check if using legacy config + if config.IsLegacyConfig() { + fmt.Println("Warning: You are using legacy configuration (single api-key).") + fmt.Println("No tailnet configurations found. Use 'tscli config tailnet add' to start using multi-tailnet configuration.") + return nil + } + + tailnets, active, err := config.ListTailnets() + if err != nil { + return fmt.Errorf("failed to list tailnets: %w", err) + } + + if len(tailnets) == 0 { + fmt.Println("No tailnets configured") + return nil + } + + outputType := viper.GetString("output") + if outputType == "json" || outputType == "yaml" { + type tailnetDisplay struct { + Name string `json:"name" yaml:"name"` + APIKey string `json:"api-key" yaml:"api-key"` + IsActive bool `json:"is-active" yaml:"is-active"` + } + + var displayTailnets []tailnetDisplay + for _, tailnet := range tailnets { + displayTailnets = append(displayTailnets, tailnetDisplay{ + Name: tailnet.Name, + APIKey: tailnet.APIKey, + IsActive: tailnet.Name == active, + }) + } + + out, err := json.MarshalIndent(displayTailnets, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal output: %w", err) + } + + return output.Print(outputType, out) + } + + // Human-readable output + fmt.Println("Configured tailnets:") + for _, tailnet := range tailnets { + marker := " " + if tailnet.Name == active { + marker = "*" + } + + // Safely truncate API key + apiKeyDisplay := tailnet.APIKey + if len(apiKeyDisplay) > 8 { + apiKeyDisplay = apiKeyDisplay[:8] + "..." + } else if len(apiKeyDisplay) > 0 { + apiKeyDisplay = apiKeyDisplay + "..." + } + + fmt.Printf("%s %s (API Key: %s)\n", marker, tailnet.Name, apiKeyDisplay) + } + + if active != "" { + fmt.Printf("\nActive tailnet: %s\n", active) + } + + return nil + }, + } +} diff --git a/cmd/tscli/config/tailnet/remove/cli.go b/cmd/tscli/config/tailnet/remove/cli.go new file mode 100644 index 0000000..5b7ca3c --- /dev/null +++ b/cmd/tscli/config/tailnet/remove/cli.go @@ -0,0 +1,31 @@ +package remove + +import ( + "fmt" + + "github.com/jaxxstorm/tscli/pkg/config" + "github.com/spf13/cobra" +) + +func Command() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove a tailnet configuration", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + // Check if using legacy config + if config.IsLegacyConfig() { + return fmt.Errorf("cannot remove tailnets: you are using legacy configuration (single api-key). Use 'tscli config tailnet add' to start using multi-tailnet configuration") + } + + name := args[0] + + if err := config.RemoveTailnet(name); err != nil { + return fmt.Errorf("failed to remove tailnet: %w", err) + } + + fmt.Printf("Tailnet %q removed successfully\n", name) + return nil + }, + } +} diff --git a/cmd/tscli/config/tailnet/switch_/cli.go b/cmd/tscli/config/tailnet/switch_/cli.go new file mode 100644 index 0000000..6a5c738 --- /dev/null +++ b/cmd/tscli/config/tailnet/switch_/cli.go @@ -0,0 +1,31 @@ +package switch_ + +import ( + "fmt" + + "github.com/jaxxstorm/tscli/pkg/config" + "github.com/spf13/cobra" +) + +func Command() *cobra.Command { + return &cobra.Command{ + Use: "switch ", + Short: "Switch to a different tailnet configuration", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + // Check if using legacy config + if config.IsLegacyConfig() { + return fmt.Errorf("cannot switch tailnets: you are using legacy configuration (single api-key). Use 'tscli config tailnet add' to start using multi-tailnet configuration") + } + + name := args[0] + + if err := config.SetActiveTailnet(name); err != nil { + return fmt.Errorf("failed to switch tailnet: %w", err) + } + + fmt.Printf("Switched to tailnet %q\n", name) + return nil + }, + } +} diff --git a/cmd/tscli/config/tailnet/update/cli.go b/cmd/tscli/config/tailnet/update/cli.go new file mode 100644 index 0000000..60f0d97 --- /dev/null +++ b/cmd/tscli/config/tailnet/update/cli.go @@ -0,0 +1,67 @@ +package update + +import ( + "fmt" + + "github.com/jaxxstorm/tscli/pkg/config" + "github.com/spf13/cobra" +) + +func Command() *cobra.Command { + return &cobra.Command{ + Use: "update [new-api-key]", + Short: "Update an existing tailnet configuration", + Args: cobra.RangeArgs(2, 3), + RunE: func(_ *cobra.Command, args []string) error { + oldName := args[0] + newName := args[1] + var newAPIKey string + + // Get current tailnets + tailnets, active, err := config.ListTailnets() + if err != nil { + return fmt.Errorf("failed to list tailnets: %w", err) + } + + // Find the tailnet to update + var currentTailnet *config.TailnetConfig + for _, tailnet := range tailnets { + if tailnet.Name == oldName { + currentTailnet = &tailnet + break + } + } + + if currentTailnet == nil { + return fmt.Errorf("tailnet %q not found", oldName) + } + + // Use existing API key if not provided + if len(args) == 3 { + newAPIKey = args[2] + } else { + newAPIKey = currentTailnet.APIKey + } + + // Remove old tailnet + if err := config.RemoveTailnet(oldName); err != nil { + return fmt.Errorf("failed to remove old tailnet: %w", err) + } + + // Add new tailnet + if err := config.AddTailnet(newName, newAPIKey); err != nil { + return fmt.Errorf("failed to add updated tailnet: %w", err) + } + + // If this was the active tailnet, make the new one active + if active == oldName { + if err := config.SetActiveTailnet(newName); err != nil { + return fmt.Errorf("failed to set active tailnet: %w", err) + } + } + + fmt.Printf("Tailnet updated from %q to %q\n", oldName, newName) + return nil + }, + } +} diff --git a/cmd/tscli/main.go b/cmd/tscli/main.go index 56e9b2a..96f9ec5 100644 --- a/cmd/tscli/main.go +++ b/cmd/tscli/main.go @@ -38,15 +38,28 @@ func configureCLI() *cobra.Command { Use: "tscli", Long: "A CLI tool for interacting with the Tailscale API.", PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - // skip validation for "help" and "version" commands - if cmd.Name() == "help" || cmd.Name() == "version" { + // skip validation for "help", "version", and "config" commands + if cmd.Name() == "help" || cmd.Name() == "version" || cmd.Name() == "config" || + (cmd.Parent() != nil && cmd.Parent().Name() == "config") || + (cmd.Parent() != nil && cmd.Parent().Parent() != nil && cmd.Parent().Parent().Name() == "config") { return nil } _ = v.BindPFlags(cmd.Flags()) - if v.GetString("api-key") == "" { + + // Check for API key based on configuration mode + var hasAPIKey bool + if config.IsNewConfig() { + tailnetConfig, err := config.GetActiveTailnetConfig() + hasAPIKey = err == nil && tailnetConfig != nil && tailnetConfig.APIKey != "" + } else { + hasAPIKey = v.GetString("api-key") != "" + } + + if !hasAPIKey { return fmt.Errorf("a Tailscale API key is required") } + if v.GetString("tailnet") == "" { v.Set("tailnet", "-") } diff --git a/pkg/config/config.go b/pkg/config/config.go index 33b984c..407a78d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" @@ -12,6 +13,25 @@ const ( fileType = "yaml" ) +// TailnetConfig represents a single tailnet configuration +type TailnetConfig struct { + Name string `yaml:"name" json:"name"` + APIKey string `yaml:"api-key" json:"api-key"` +} + +// Config represents the overall configuration structure +type Config struct { + // Legacy fields for backward compatibility + APIKey string `yaml:"api-key,omitempty" json:"api-key,omitempty"` + Debug bool `yaml:"debug" json:"debug"` + Output string `yaml:"output" json:"output"` + Help bool `yaml:"help" json:"help"` + + // New multi-tailnet support + Tailnets []TailnetConfig `yaml:"tailnets,omitempty" json:"tailnets,omitempty"` + ActiveTailnet string `yaml:"active-tailnet,omitempty" json:"active-tailnet,omitempty"` +} + func Init() { v := viper.GetViper() @@ -23,8 +43,10 @@ func Init() { v.AddConfigPath(home) } - _ = v.ReadInConfig() // ignore “not found” + _ = v.ReadInConfig() // ignore "not found" v.SetDefault("output", "json") + + // NO automatic migration - keep both old and new logic working } func Save() error { @@ -36,3 +58,167 @@ func Save() error { } return v.WriteConfigAs(path) } + +// GetActiveTailnetConfig returns the configuration for the currently active tailnet +// Returns nil if using legacy configuration +func GetActiveTailnetConfig() (*TailnetConfig, error) { + v := viper.GetViper() + + // Check if we're using the new multi-tailnet configuration + if v.IsSet("tailnets") && len(getTailnets()) > 0 { + activeName := v.GetString("active-tailnet") + if activeName == "" { + return nil, fmt.Errorf("no active tailnet configured") + } + + tailnets := getTailnets() + for _, tailnet := range tailnets { + if tailnet.Name == activeName { + return &tailnet, nil + } + } + + return nil, fmt.Errorf("active tailnet %q not found", activeName) + } + + // Using legacy configuration - return nil to indicate legacy mode + return nil, nil +} + +// IsLegacyConfig returns true if using the old single api-key configuration +func IsLegacyConfig() bool { + v := viper.GetViper() + // Legacy if we have api-key set but no tailnets configured + return v.IsSet("api-key") && v.GetString("api-key") != "" && !v.IsSet("tailnets") +} + +// IsNewConfig returns true if using the new multi-tailnet configuration +func IsNewConfig() bool { + v := viper.GetViper() + return v.IsSet("tailnets") && len(getTailnets()) > 0 +} + +// getTailnets returns all configured tailnets +func getTailnets() []TailnetConfig { + v := viper.GetViper() + + // Get the raw tailnets data the same way config show does + allSettings := v.AllSettings() + tailnetsData, exists := allSettings["tailnets"] + if !exists { + return []TailnetConfig{} + } + + var tailnets []TailnetConfig + + // Convert the interface{} data to our struct + if slice, ok := tailnetsData.([]interface{}); ok { + for _, item := range slice { + if m, ok := item.(map[string]interface{}); ok { + name, _ := m["name"].(string) + apiKey, _ := m["api-key"].(string) + if name != "" { + tailnets = append(tailnets, TailnetConfig{ + Name: name, + APIKey: apiKey, + }) + } + } + } + } + + return tailnets +} + +// AddTailnet adds a new tailnet configuration +func AddTailnet(name, apiKey string) error { + v := viper.GetViper() + + tailnets := getTailnets() + + // Check if tailnet already exists + for _, tailnet := range tailnets { + if tailnet.Name == name { + return fmt.Errorf("tailnet %q already exists", name) + } + } + + // Add new tailnet + newTailnet := TailnetConfig{ + Name: name, + APIKey: apiKey, + } + + tailnets = append(tailnets, newTailnet) + v.Set("tailnets", tailnets) + + // If this is the first tailnet, make it active + if len(tailnets) == 1 { + v.Set("active-tailnet", name) + } + + return Save() +} + +// RemoveTailnet removes a tailnet configuration +func RemoveTailnet(name string) error { + v := viper.GetViper() + + tailnets := getTailnets() + var updatedTailnets []TailnetConfig + found := false + + for _, tailnet := range tailnets { + if tailnet.Name != name { + updatedTailnets = append(updatedTailnets, tailnet) + } else { + found = true + } + } + + if !found { + return fmt.Errorf("tailnet %q not found", name) + } + + v.Set("tailnets", updatedTailnets) + + // If we removed the active tailnet, clear the active setting + if v.GetString("active-tailnet") == name { + if len(updatedTailnets) > 0 { + v.Set("active-tailnet", updatedTailnets[0].Name) + } else { + v.Set("active-tailnet", "") + } + } + + return Save() +} + +// SetActiveTailnet switches to a different tailnet +func SetActiveTailnet(name string) error { + v := viper.GetViper() + + tailnets := getTailnets() + found := false + + for _, tailnet := range tailnets { + if tailnet.Name == name { + found = true + break + } + } + + if !found { + return fmt.Errorf("tailnet %q not found", name) + } + + v.Set("active-tailnet", name) + return Save() +} + +// ListTailnets returns all configured tailnets with indication of which is active +func ListTailnets() ([]TailnetConfig, string, error) { + tailnets := getTailnets() + active := viper.GetString("active-tailnet") + return tailnets, active, nil +} diff --git a/pkg/tscli/client.go b/pkg/tscli/client.go index 27f5d6c..56b2e6f 100644 --- a/pkg/tscli/client.go +++ b/pkg/tscli/client.go @@ -18,6 +18,7 @@ import ( "os" "strings" + "github.com/jaxxstorm/tscli/pkg/config" "github.com/jaxxstorm/tscli/pkg/version" "github.com/spf13/viper" tsapi "tailscale.com/client/tailscale/v2" @@ -33,12 +34,45 @@ func getUserAgent() string { return fmt.Sprintf("tscli/%s (Go client)", version.GetVersion()) } +// getConfigFromActiveContext returns the active tailnet configuration +func getConfigFromActiveContext() *config.TailnetConfig { + tailnetConfig, err := config.GetActiveTailnetConfig() + if err != nil { + return nil + } + return tailnetConfig +} + func New() (*tsapi.Client, error) { - tailnet := viper.GetString("tailnet") - apiKey := viper.GetString("api-key") - if tailnet == "" { - return nil, fmt.Errorf("tailnet is required") + var tailnet, apiKey string + + // Check if using new multi-tailnet configuration + if config.IsNewConfig() { + tailnetConfig, err := config.GetActiveTailnetConfig() + if err != nil { + return nil, fmt.Errorf("failed to get active tailnet config: %w", err) + } + if tailnetConfig == nil { + return nil, fmt.Errorf("no active tailnet configured") + } + + // Use tailnet from flag/env or from active config + tailnet = viper.GetString("tailnet") + if tailnet == "" || tailnet == "-" { + tailnet = tailnetConfig.Name + } + apiKey = tailnetConfig.APIKey + } else { + // Using legacy configuration + tailnet = viper.GetString("tailnet") + apiKey = viper.GetString("api-key") + + // Apply legacy defaults + if tailnet == "" { + tailnet = "-" + } } + if apiKey == "" { return nil, fmt.Errorf("api-key is required") }