diff --git a/build/diagnostics/Dockerfile b/build/diagnostics/Dockerfile new file mode 100644 index 0000000000..ee7352bbd3 --- /dev/null +++ b/build/diagnostics/Dockerfile @@ -0,0 +1,6 @@ +# syntax=docker/dockerfile:1 +ARG BUSYBOX_IMAGE="busybox:1.36.1" +FROM ${BUSYBOX_IMAGE} +COPY testkube-diagnostics / +USER 1001 +ENTRYPOINT ["/testkube-diagnostics"] diff --git a/cmd/diagnostics/commands/dns.go b/cmd/diagnostics/commands/dns.go new file mode 100644 index 0000000000..c7ac450a85 --- /dev/null +++ b/cmd/diagnostics/commands/dns.go @@ -0,0 +1,137 @@ +package commands + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/kubeshop/testkube/pkg/ui" + "github.com/spf13/cobra" +) + +func NewDnsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dns", + Short: "Check DNS entry", + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + ui.Failf("Please pass domain name") + } + domain := args[0] + analyzer := NewDNSAnalyzer(domain) + results := analyzer.LookupAll(context.Background()) + analyzer.PrintResults(results) + }, + } + + return cmd +} + +type DNSResult struct { + RecordType string + Records []string + Duration time.Duration + Error error +} + +type DNSAnalyzer struct { + domain string + resolver *net.Resolver +} + +func NewDNSAnalyzer(domain string) *DNSAnalyzer { + return &DNSAnalyzer{ + domain: domain, + resolver: net.DefaultResolver, + } +} + +func (da *DNSAnalyzer) LookupAll(ctx context.Context) map[string]DNSResult { + results := make(map[string]DNSResult) + + // Perform A record lookup + results["A"] = da.lookupA(ctx) + + // Perform NS record lookup + results["NS"] = da.lookupNS(ctx) + + // Perform CNAME record lookup + results["CNAME"] = da.lookupCNAME(ctx) + + return results +} + +func (da *DNSAnalyzer) lookupA(ctx context.Context) DNSResult { + start := time.Now() + ips, err := da.resolver.LookupIP(ctx, da.domain, "ip4") + duration := time.Since(start) + + var records []string + for _, ip := range ips { + records = append(records, ip.String()) + } + + return DNSResult{ + RecordType: "A", + Records: records, + Duration: duration, + Error: err, + } +} + +func (da *DNSAnalyzer) lookupNS(ctx context.Context) DNSResult { + start := time.Now() + nss, err := da.resolver.LookupNS(ctx, da.domain) + duration := time.Since(start) + + var records []string + for _, ns := range nss { + records = append(records, ns.Host) + } + + return DNSResult{ + RecordType: "NS", + Records: records, + Duration: duration, + Error: err, + } +} + +func (da *DNSAnalyzer) lookupCNAME(ctx context.Context) DNSResult { + start := time.Now() + cname, err := da.resolver.LookupCNAME(ctx, da.domain) + duration := time.Since(start) + + var records []string + if cname != "" { + records = append(records, cname) + } + + return DNSResult{ + RecordType: "CNAME", + Records: records, + Duration: duration, + Error: err, + } +} + +func (da *DNSAnalyzer) PrintResults(results map[string]DNSResult) { + fmt.Printf("DNS Analysis Results for %s\n", da.domain) + fmt.Println(strings.Repeat("-", 50)) + + for recordType, result := range results { + fmt.Printf("\n%s Records:\n", recordType) + if result.Error != nil { + fmt.Printf(" Error: %v\n", result.Error) + } else if len(result.Records) == 0 { + fmt.Println(" No records found") + } else { + for _, record := range result.Records { + fmt.Printf(" %s\n", record) + } + } + fmt.Printf(" Lookup Duration: %v\n", result.Duration) + } +} diff --git a/cmd/diagnostics/commands/root.go b/cmd/diagnostics/commands/root.go new file mode 100644 index 0000000000..8298e0483d --- /dev/null +++ b/cmd/diagnostics/commands/root.go @@ -0,0 +1,61 @@ +package commands + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "golang.org/x/sync/errgroup" + + "github.com/kubeshop/testkube/pkg/ui" +) + +func init() { + // New commands + RootCmd.AddCommand(NewDnsCmd()) + RootCmd.AddCommand(NewTLSCmd()) +} + +var RootCmd = &cobra.Command{ + Use: "diagnostics", + Short: "Testkube entrypoint for kubectl plugin", + + Run: func(cmd *cobra.Command, args []string) { + ui.Logo() + err := cmd.Usage() + ui.PrintOnError("Displaying usage", err) + cmd.DisableAutoGenTag = true + }, +} + +func Execute() { + // Run services within an errgroup to propagate errors between services. + g, ctx := errgroup.WithContext(context.Background()) + + // Cancel the errgroup context on SIGINT and SIGTERM, + // which shuts everything down gracefully. + stopSignal := make(chan os.Signal, 1) + signal.Notify(stopSignal, syscall.SIGINT, syscall.SIGTERM) + g.Go(func() error { + select { + case <-ctx.Done(): + return nil + case sig := <-stopSignal: + go func() { + <-stopSignal + os.Exit(137) + }() + return errors.Errorf("received signal: %v", sig) + } + }) + + if err := RootCmd.ExecuteContext(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/diagnostics/commands/tls.go b/cmd/diagnostics/commands/tls.go new file mode 100644 index 0000000000..c87da06260 --- /dev/null +++ b/cmd/diagnostics/commands/tls.go @@ -0,0 +1,125 @@ +package commands + +import ( + "crypto/tls" + "fmt" + "time" + + "github.com/kubeshop/testkube/pkg/ui" + "github.com/spf13/cobra" +) + +func NewTLSCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dns", + Short: "Check DNS entry", + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + ui.Failf("Please pass domain name") + } + host := args[0] + checkTLS(host) + }, + } + + return cmd +} + +func checkTLS(host string) { + // Test connection with modern configuration + conf := &tls.Config{ + InsecureSkipVerify: false, + MinVersion: tls.VersionTLS12, + } + + fmt.Printf("TLS Diagnostic Results for %s\n", host) + fmt.Println("=====================================") + + // Connect and get certificate info + conn, err := tls.Dial("tcp", host, conf) + if err != nil { + fmt.Printf("Error connecting: %v\n", err) + return + } + defer conn.Close() + + // Get certificate details + certs := conn.ConnectionState().PeerCertificates + if len(certs) > 0 { + cert := certs[0] + fmt.Println("\nCertificate Details:") + fmt.Printf("Subject: %s\n", cert.Subject) + fmt.Printf("Issuer: %s\n", cert.Issuer) + fmt.Printf("Valid from: %s\n", cert.NotBefore) + fmt.Printf("Valid until: %s\n", cert.NotAfter) + fmt.Printf("Expiring in: %d days\n", int(cert.NotAfter.Sub(time.Now()).Hours()/24)) + fmt.Printf("Serial number: %x\n", cert.SerialNumber) + fmt.Printf("Version: %d\n", cert.Version) + + // Check for domain names + fmt.Println("\nSAN (Subject Alternative Names):") + for _, dns := range cert.DNSNames { + fmt.Printf("- %s\n", dns) + } + } + + // Test supported TLS versions + fmt.Println("\nTLS Version Support:") + versions := map[uint16]string{ + tls.VersionTLS10: "TLS 1.0", + tls.VersionTLS11: "TLS 1.1", + tls.VersionTLS12: "TLS 1.2", + tls.VersionTLS13: "TLS 1.3", + } + + for version, name := range versions { + testConfig := &tls.Config{ + InsecureSkipVerify: true, + MinVersion: version, + MaxVersion: version, + } + + testConn, err := tls.Dial("tcp", host, testConfig) + if err != nil { + fmt.Printf("✗ %s: Not supported\n", name) + continue + } + testConn.Close() + fmt.Printf("✓ %s: Supported\n", name) + } + + // Test cipher suites + fmt.Println("\nSupported Cipher Suites:") + state := conn.ConnectionState() + fmt.Printf("Negotiated Cipher Suite: %s\n", tls.CipherSuiteName(state.CipherSuite)) + + // Connection info + fmt.Println("\nConnection Information:") + fmt.Printf("Protocol: %s\n", versionToString(state.Version)) + fmt.Printf("Server Name: %s\n", state.ServerName) + fmt.Printf("Handshake Complete: %v\n", state.HandshakeComplete) + fmt.Printf("Mutual TLS: %v\n", state.NegotiatedProtocolIsMutual) + + // Basic security checks + fmt.Println("\nSecurity Assessment:") + if state.Version < tls.VersionTLS12 { + fmt.Println("⚠️ Warning: Using TLS version below 1.2") + } else { + fmt.Println("✓ TLS version >= 1.2") + } +} + +func versionToString(version uint16) string { + switch version { + case tls.VersionTLS10: + return "TLS 1.0" + case tls.VersionTLS11: + return "TLS 1.1" + case tls.VersionTLS12: + return "TLS 1.2" + case tls.VersionTLS13: + return "TLS 1.3" + default: + return "Unknown" + } +} diff --git a/cmd/diagnostics/main.go b/cmd/diagnostics/main.go new file mode 100644 index 0000000000..b27f6b57df --- /dev/null +++ b/cmd/diagnostics/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/kubeshop/testkube/cmd/diagnostics/commands" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" +) + +var ( + commit string + version string = "999.0.0-dev" + builtBy string + date string +) + +func init() { + // pass data from goreleaser to commands package + common.Version = version + common.BuiltBy = builtBy + common.Commit = commit + common.Date = date +} + +func main() { + commands.Execute() +} diff --git a/cmd/kubectl-testkube/commands/common/helper.go b/cmd/kubectl-testkube/commands/common/helper.go index 22b6f1ad0b..8dc57d049a 100644 --- a/cmd/kubectl-testkube/commands/common/helper.go +++ b/cmd/kubectl-testkube/commands/common/helper.go @@ -813,6 +813,58 @@ func KubectlDescribeIngresses(namespace string) error { return process.ExecuteAndStreamOutput(kubectl, args...) } +func KubectlGetNamespacesHavingSecrets(secretName string) ([]string, error) { + kubectl, clierr := lookupKubectlPath() + if clierr != nil { + return nil, clierr.ActualError + } + + args := []string{ + "get", + "secret", + "-A", + } + + if ui.IsVerbose() { + ui.ShellCommand(kubectl, args...) + ui.NL() + } + + out, err := process.Execute(kubectl, args...) + if err != nil { + return nil, err + } + + nss := extractUniqueNamespaces(string(out), secretName) + return nss, nil +} + +func extractUniqueNamespaces(data string, secretName string) []string { + // Split the data into lines + lines := strings.Split(data, "\n") + + // Map to store unique namespaces + uniq := make(map[string]bool) + + for _, line := range lines { + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + if parts[1] == secretName { + uniq[parts[0]] = true + } + } + + // Convert map keys (namespaces) to a slice of strings + list := make([]string, 0, len(uniq)) + for namespace := range uniq { + list = append(list, namespace) + } + + return list +} + func KubectlGetPodEnvs(selector, namespace string) (map[string]string, error) { kubectl, clierr := lookupKubectlPath() if clierr != nil { @@ -821,7 +873,7 @@ func KubectlGetPodEnvs(selector, namespace string) (map[string]string, error) { args := []string{ "get", - "pod", + "secret", selector, "-n", namespace, "-o", `jsonpath='{range .items[*].spec.containers[*]}{"\nContainer: "}{.name}{"\n"}{range .env[*]}{.name}={.value}{"\n"}{end}{end}'`, diff --git a/cmd/kubectl-testkube/commands/diagnostics.go b/cmd/kubectl-testkube/commands/diagnostics.go index fb29375451..5c231accec 100644 --- a/cmd/kubectl-testkube/commands/diagnostics.go +++ b/cmd/kubectl-testkube/commands/diagnostics.go @@ -17,6 +17,7 @@ func NewDiagnosticsCmd() *cobra.Command { Run: NewRunDiagnosticsCmdFunc(), } + cmd.Flags().Bool("offline-override", false, "Pass License key manually (we will not try to locate it automatically)") cmd.Flags().StringP("key-override", "k", "", "Pass License key manually (we will not try to locate it automatically)") cmd.Flags().StringP("file-override", "f", "", "Pass License file manually (we will not try to locate it automatically)") diff --git a/cmd/kubectl-testkube/commands/diagnostics/install.go b/cmd/kubectl-testkube/commands/diagnostics/install.go index a1ffd6b551..14910d0d0e 100644 --- a/cmd/kubectl-testkube/commands/diagnostics/install.go +++ b/cmd/kubectl-testkube/commands/diagnostics/install.go @@ -22,9 +22,6 @@ func NewInstallCheckCmd() *cobra.Command { Run: RunInstallCheckFunc(), } - cmd.Flags().StringP("key-override", "k", "", "Pass License key manually (we will not try to locate it automatically)") - cmd.Flags().StringP("file-override", "f", "", "Pass License file manually (we will not try to locate it automatically)") - return cmd } diff --git a/cmd/kubectl-testkube/commands/diagnostics/license.go b/cmd/kubectl-testkube/commands/diagnostics/license.go index 165614c2a2..3195572084 100644 --- a/cmd/kubectl-testkube/commands/diagnostics/license.go +++ b/cmd/kubectl-testkube/commands/diagnostics/license.go @@ -3,6 +3,7 @@ package diagnostics import ( "github.com/spf13/cobra" + "github.com/kubeshop/testkube/cmd/kubectl-testkube/commands/common" "github.com/kubeshop/testkube/pkg/diagnostics" "github.com/kubeshop/testkube/pkg/diagnostics/loader" "github.com/kubeshop/testkube/pkg/diagnostics/validators/license" @@ -14,6 +15,26 @@ func RegisterLicenseValidators(cmd *cobra.Command, d diagnostics.Diagnostics) { namespace := cmd.Flag("namespace").Value.String() keyOverride := cmd.Flag("key-override").Value.String() fileOverride := cmd.Flag("file-override").Value.String() + isOfflineOverride := cmd.Flag("offline-override").Changed && cmd.Flag("offline-override").Value.String() == "true" + + // if not namespace provided load all namespaces having license file secret + if !cmd.Flag("namespace").Changed { + namespaces, err := common.KubectlGetNamespacesHavingSecrets("testkube-enterprise-license") + if err != nil { + ui.Errf("Can't check for namespaces, make sure you have valid access rights to list resources in Kubernetes") + ui.ExitOnError("error:", err) + return + } + + switch true { + case len(namespaces) == 0: + ui.Failf("Can't locate any Testkube installations please pass `--namespace` parameter") + case len(namespaces) == 1: + namespace = namespaces[0] + case len(namespaces) > 1: + namespace = ui.Select("Choose namespace to check license", namespaces) + } + } var err error l := loader.License{} @@ -28,19 +49,23 @@ func RegisterLicenseValidators(cmd *cobra.Command, d diagnostics.Diagnostics) { if fileOverride != "" && keyOverride != "" { l.EnterpriseOfflineActivation = true } + if isOfflineOverride { + l.EnterpriseOfflineActivation = isOfflineOverride + } - if fileOverride == "" || keyOverride == "" { + if keyOverride == "" || (l.EnterpriseOfflineActivation && fileOverride == "") { l, err = loader.GetLicenseConfig(namespace, "") ui.ExitOnError("loading license data", err) } // License validator - licenseGroup := d.AddValidatorGroup("license.validation", l.EnterpriseLicenseKey) if l.EnterpriseOfflineActivation { + licenseGroup := d.AddValidatorGroup("offline.license.validation", l.EnterpriseLicenseKey) licenseGroup.AddValidator(license.NewFileValidator()) licenseGroup.AddValidator(license.NewOfflineLicenseKeyValidator()) licenseGroup.AddValidator(license.NewOfflineLicenseValidator(l.EnterpriseLicenseKey, l.EnterpriseLicenseFile)) } else { + licenseGroup := d.AddValidatorGroup("online.license.validation", l.EnterpriseLicenseKey) licenseGroup.AddValidator(license.NewOnlineLicenseKeyValidator()) licenseGroup.AddValidator(license.NewKeygenShValidator()) } @@ -54,6 +79,7 @@ func NewLicenseCheckCmd() *cobra.Command { Run: RunLicenseCheckFunc(), } + cmd.Flags().Bool("offline-override", false, "Pass License key manually (we will not try to locate it automatically)") cmd.Flags().StringP("key-override", "k", "", "Pass License key manually (we will not try to locate it automatically)") cmd.Flags().StringP("file-override", "f", "", "Pass License file manually (we will not try to locate it automatically)") @@ -62,6 +88,8 @@ func NewLicenseCheckCmd() *cobra.Command { func RunLicenseCheckFunc() func(cmd *cobra.Command, args []string) { return func(cmd *cobra.Command, args []string) { + ui.H1("Check licensing issues") + d := diagnostics.New() RegisterLicenseValidators(cmd, d) diff --git a/pkg/diagnostics/validators/license/errors.go b/pkg/diagnostics/validators/license/errors.go index 41b2c25a89..82281b4577 100644 --- a/pkg/diagnostics/validators/license/errors.go +++ b/pkg/diagnostics/validators/license/errors.go @@ -14,7 +14,7 @@ var ( ErrLicenseKeyInvalidFormat = v.Err("license key invalid format", v.ErrorKindInvalidKeyContent) ErrOnlineLicenseKeyInvalidLength = v.Err("license key invalid length", v.ErrorKindInvalidKeyContent). - WithDetails("License key should be in form XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX - 37 chars in length"). + WithSuggestion("License key should be in form XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX-XX - 37 chars in length"). WithSuggestion("Make sure license key is in valid format"). WithSuggestion("Make sure there is no whitespaces on the begining and the end of the key") @@ -34,7 +34,7 @@ var ( ErrOfflineLicenseKeyInvalidPrefix = v.Err("license key has invalid prefix", v.ErrorKindInvalidKeyContent). WithDetails("License key should start with 'key/' string"). WithSuggestion("Make sure license key is in valid format"). - WithSuggestion("Make sure you're NOT using offline keys for air-gapped (offline) installations"). + WithSuggestion("Make sure you're NOT using 'online' keys for air-gapped ('offline') installations"). WithSuggestion("Make sure there is no whitespaces on the begining and the end of the key") ErrOfflineLicensePublicKeyMissing = v.Err("public key is missing", v.ErrorKindLicenseInvalid) diff --git a/pkg/ui/select.go b/pkg/ui/select.go index ad77d6cef9..6dcb10b97a 100644 --- a/pkg/ui/select.go +++ b/pkg/ui/select.go @@ -5,6 +5,7 @@ import "github.com/pterm/pterm" func (ui *UI) Select(label string, options []string) string { val, _ := pterm.DefaultInteractiveSelect. WithOptions(options). + WithDefaultText(label). Show() ui.NL()