diff --git a/internal/cmd/skupper/common/flags.go b/internal/cmd/skupper/common/flags.go index 464855880..892a5c95f 100644 --- a/internal/cmd/skupper/common/flags.go +++ b/internal/cmd/skupper/common/flags.go @@ -266,6 +266,11 @@ type CommandVersionFlags struct { type CommandDebugFlags struct { } +type CommandDebugCertFlags struct { + Output string + File string +} + type CommandConnSweeperFlags struct { IdleThreshold int Execute bool diff --git a/internal/cmd/skupper/debug/cert/display.go b/internal/cmd/skupper/debug/cert/display.go new file mode 100644 index 000000000..47687fa1d --- /dev/null +++ b/internal/cmd/skupper/debug/cert/display.go @@ -0,0 +1,173 @@ +package cert + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/skupperproject/skupper/internal/certs" + "github.com/skupperproject/skupper/internal/cmd/skupper/common/utils" +) + +// Info holds the decoded fields of an X.509 certificate for display. +type Info struct { + Name string `json:"name"` + Subject string `json:"subject"` + Issuer string `json:"issuer"` + SerialNumber string `json:"serialNumber"` + NotBefore string `json:"notBefore"` + NotAfter string `json:"notAfter"` + DNSNames []string `json:"dnsNames,omitempty"` + IPAddresses []string `json:"ipAddresses,omitempty"` + EmailAddresses []string `json:"emailAddresses,omitempty"` + URIs []string `json:"uris,omitempty"` + IsCA bool `json:"isCA"` + IsSigningCert *bool `json:"isSigningCert,omitempty"` + PublicKeyAlgorithm string `json:"publicKeyAlgorithm"` + PublicKeySize int `json:"publicKeySize"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + Status string `json:"status,omitempty"` + CrExpiration string `json:"crExpiration,omitempty"` +} + +func ParseCertificate(name string, certData []byte) (*Info, error) { + cert, err := certs.DecodeCertificate(certData) + if err != nil { + return nil, err + } + return certToInfo(name, cert), nil +} + +func ParseCertificateFile(name, path string) (*Info, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseCertificate(name, data) +} + +func certToInfo(name string, cert *x509.Certificate) *Info { + algo, size := publicKeyInfo(cert) + ips := make([]string, len(cert.IPAddresses)) + for i, ip := range cert.IPAddresses { + ips[i] = ip.String() + } + uris := make([]string, len(cert.URIs)) + for i, uri := range cert.URIs { + uris[i] = uri.String() + } + return &Info{ + Name: name, + Subject: formatName(cert.Subject), + Issuer: formatName(cert.Issuer), + SerialNumber: cert.SerialNumber.String(), + NotBefore: cert.NotBefore.Format(time.RFC3339), + NotAfter: cert.NotAfter.Format(time.RFC3339), + DNSNames: cert.DNSNames, + IPAddresses: ips, + EmailAddresses: cert.EmailAddresses, + URIs: uris, + IsCA: cert.IsCA, + PublicKeyAlgorithm: algo, + PublicKeySize: size, + SignatureAlgorithm: cert.SignatureAlgorithm.String(), + } +} + +func formatName(name pkix.Name) string { + if name.CommonName != "" { + return name.CommonName + } + return name.String() +} + +func publicKeyInfo(cert *x509.Certificate) (algorithm string, size int) { + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + return "RSA", pub.N.BitLen() + case *ecdsa.PublicKey: + return "ECDSA", pub.Curve.Params().BitSize + default: + return fmt.Sprintf("%T", cert.PublicKey), 0 + } +} + +func formatSANs(info *Info) string { + var parts []string + parts = append(parts, info.DNSNames...) + parts = append(parts, info.IPAddresses...) + parts = append(parts, info.EmailAddresses...) + parts = append(parts, info.URIs...) + if len(parts) == 0 { + return "" + } + return strings.Join(parts, ", ") +} + +// Display renders one or more certificates to stdout. +func Display(infos []Info, output string, detail bool) error { + if output != "" { + for _, info := range infos { + encoded, err := utils.Encode(output, info) + if err != nil { + return err + } + fmt.Println(encoded) + } + return nil + } + if detail && len(infos) == 1 { + displayDetail(infos[0]) + return nil + } + return displayList(infos) +} + +func displayDetail(info Info) { + fmt.Printf("Name\t\t: %s\n", info.Name) + fmt.Printf("Subject\t\t: %s\n", info.Subject) + fmt.Printf("Issuer\t\t: %s\n", info.Issuer) + fmt.Printf("Serial Number\t: %s\n", info.SerialNumber) + fmt.Printf("Not Before\t: %s\n", info.NotBefore) + fmt.Printf("Not After\t: %s\n", info.NotAfter) + fmt.Printf("Is CA\t\t: %t\n", info.IsCA) + if info.IsSigningCert != nil { + fmt.Printf("Is Signing Cert\t: %t\n", *info.IsSigningCert) + } + fmt.Printf("SANs\t\t: %s\n", formatSANs(&info)) + if info.PublicKeySize > 0 { + fmt.Printf("Public Key\t: %s (%d bits)\n", info.PublicKeyAlgorithm, info.PublicKeySize) + } else { + fmt.Printf("Public Key\t: %s\n", info.PublicKeyAlgorithm) + } + fmt.Printf("Signature\t: %s\n", info.SignatureAlgorithm) + if info.Status != "" { + fmt.Printf("Status\t\t: %s\n", info.Status) + } + if info.CrExpiration != "" { + fmt.Printf("CR Expiration\t: %s\n", info.CrExpiration) + } +} + +func displayList(infos []Info) error { + if len(infos) == 0 { + fmt.Println("No certificates found") + return nil + } + writer := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) + fmt.Fprintln(writer, "NAME\tSUBJECT\tISSUER\tNOT AFTER\tSANs") + for _, info := range infos { + sans := formatSANs(&info) + if len(sans) > 60 { + sans = sans[:57] + "..." + } + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", info.Name, info.Subject, info.Issuer, info.NotAfter, sans) + } + return writer.Flush() +} diff --git a/internal/cmd/skupper/debug/cert/display_test.go b/internal/cmd/skupper/debug/cert/display_test.go new file mode 100644 index 000000000..1c5f28741 --- /dev/null +++ b/internal/cmd/skupper/debug/cert/display_test.go @@ -0,0 +1,33 @@ +package cert + +import ( + "testing" + + "github.com/skupperproject/skupper/internal/certs" + "gotest.tools/v3/assert" +) + +func TestParseCertificate(t *testing.T) { + caSecret, err := certs.GenerateSecret("test-ca", "test-ca.example.com", nil, 0, nil) + assert.NilError(t, err) + + leafSecret, err := certs.GenerateSecret("test-cert", "test.example.com", []string{"test.example.com"}, 86400000000000, caSecret) + assert.NilError(t, err) + + info, err := ParseCertificate("test-cert", leafSecret.Data["tls.crt"]) + assert.NilError(t, err) + + assert.Equal(t, "test-cert", info.Name) + assert.Equal(t, "test.example.com", info.Subject) + assert.Equal(t, "test-ca.example.com", info.Issuer) + assert.Equal(t, "RSA", info.PublicKeyAlgorithm) + assert.Equal(t, 2048, info.PublicKeySize) + assert.Assert(t, len(info.NotBefore) > 0) + assert.Assert(t, len(info.NotAfter) > 0) + assert.DeepEqual(t, []string{"test.example.com"}, info.DNSNames) +} + +func TestParseCertificateInvalidPEM(t *testing.T) { + _, err := ParseCertificate("bad", []byte("not a pem")) + assert.ErrorContains(t, err, "Could not decode PEM block") +} diff --git a/internal/cmd/skupper/debug/debug.go b/internal/cmd/skupper/debug/debug.go index 6ebad64a6..29988a653 100644 --- a/internal/cmd/skupper/debug/debug.go +++ b/internal/cmd/skupper/debug/debug.go @@ -19,6 +19,7 @@ func NewCmdDebug() *cobra.Command { } platform := common.Platform(config.GetPlatform()) cmd.AddCommand(CmdDebugDumpFactory(platform)) + cmd.AddCommand(CmdDebugCertFactory(platform)) cmd.AddCommand(CmdDebugSweepFactory(platform)) return cmd @@ -64,6 +65,38 @@ skupper debug sweep --port 8080 --port 9090 --idle-threshold 14400 --execute`, return cmd } +func CmdDebugCertFactory(configuredPlatform common.Platform) *cobra.Command { + kubeCommand := kube.NewCmdDebugCert() + nonKubeCommand := nonkube.NewCmdDebugCert() + + cmdDesc := common.SkupperCmdDescription{ + Use: "cert-inspect [name]", + Short: "Inspect X.509 certificates in use by Skupper", + Long: `Decode and display key fields of X.509 certificates managed by Skupper, +including subject, issuer, validity period, SANs, and public key information. + +Without a name, all certificates in the namespace are listed. With a name, +detailed information for that certificate is shown.`, + Example: `skupper debug cert-inspect +skupper debug cert-inspect skupper-local-server +skupper debug cert-inspect --file /path/to/tls.crt +skupper debug cert-inspect -o yaml`, + } + + cmd := common.ConfigureCobraCommand(configuredPlatform, cmdDesc, kubeCommand, nonKubeCommand) + + cmdFlags := common.CommandDebugCertFlags{} + cmd.Flags().StringVarP(&cmdFlags.Output, common.FlagNameOutput, "o", "", common.FlagDescOutput) + cmd.Flags().StringVar(&cmdFlags.File, "file", "", "Inspect a local PEM-encoded certificate file") + + kubeCommand.CobraCmd = cmd + kubeCommand.Flags = &cmdFlags + nonKubeCommand.CobraCmd = cmd + nonKubeCommand.Flags = &cmdFlags + + return cmd +} + func CmdDebugDumpFactory(configuredPlatform common.Platform) *cobra.Command { kubeCommand := kube.NewCmdDebug() nonKubeCommand := nonkube.NewCmdDebug() diff --git a/internal/cmd/skupper/debug/debug_test.go b/internal/cmd/skupper/debug/debug_test.go index 198cc40a5..997875317 100644 --- a/internal/cmd/skupper/debug/debug_test.go +++ b/internal/cmd/skupper/debug/debug_test.go @@ -24,6 +24,14 @@ func TestCmdLinkFactory(t *testing.T) { expectedFlagsWithDefaultValue: map[string]interface{}{}, command: CmdDebugDumpFactory(common.PlatformKubernetes), }, + { + name: "CmdDebugCertFactory", + expectedFlagsWithDefaultValue: map[string]interface{}{ + "output": "", + "file": "", + }, + command: CmdDebugCertFactory(common.PlatformKubernetes), + }, } for _, test := range testTable { diff --git a/internal/cmd/skupper/debug/kube/cert.go b/internal/cmd/skupper/debug/kube/cert.go new file mode 100644 index 000000000..b64e4ea29 --- /dev/null +++ b/internal/cmd/skupper/debug/kube/cert.go @@ -0,0 +1,209 @@ +package kube + +import ( + "context" + "errors" + "fmt" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + "github.com/skupperproject/skupper/internal/cmd/skupper/common/utils" + certdisplay "github.com/skupperproject/skupper/internal/cmd/skupper/debug/cert" + "github.com/skupperproject/skupper/internal/kube/client" + "github.com/skupperproject/skupper/internal/kube/secrets" + "github.com/skupperproject/skupper/internal/utils/validator" + "github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1" + skupperv2alpha1 "github.com/skupperproject/skupper/pkg/generated/client/clientset/versioned/typed/skupper/v2alpha1" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type CmdDebugCert struct { + Client skupperv2alpha1.SkupperV2alpha1Interface + KubeClient kubernetes.Interface + CobraCmd *cobra.Command + Flags *common.CommandDebugCertFlags + Namespace string + certName string + output string +} + +func NewCmdDebugCert() *CmdDebugCert { + return &CmdDebugCert{} +} + +func (cmd *CmdDebugCert) NewClient(cobraCommand *cobra.Command, args []string) { + if cmd.Flags != nil && cmd.Flags.File != "" { + return + } + cli, err := client.NewClient( + cobraCommand.Flag("namespace").Value.String(), + cobraCommand.Flag("context").Value.String(), + cobraCommand.Flag("kubeconfig").Value.String(), + ) + if err != nil { + return + } + cmd.Client = cli.GetSkupperClient().SkupperV2alpha1() + cmd.KubeClient = cli.GetKubeClient() + cmd.Namespace = cli.Namespace +} + +func (cmd *CmdDebugCert) ValidateInput(args []string) error { + var validationErrors []error + outputTypeValidator := validator.NewOptionValidator(common.OutputTypes) + filePathValidator := validator.NewFilePathStringValidator() + + if cmd.Flags != nil && cmd.Flags.File != "" { + ok, err := filePathValidator.Evaluate(cmd.Flags.File) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("file path is not valid: %s", err)) + } + if len(args) > 0 { + validationErrors = append(validationErrors, fmt.Errorf("file flag cannot be used with a certificate name argument")) + } + } else { + if cmd.Client == nil || cmd.KubeClient == nil { + validationErrors = append(validationErrors, fmt.Errorf("failed setting up command")) + } + if len(args) > 1 { + validationErrors = append(validationErrors, fmt.Errorf("only one certificate name is allowed")) + } + if len(args) == 1 { + cmd.certName = args[0] + } + } + + if cmd.Flags != nil && cmd.Flags.Output != "" { + ok, err := outputTypeValidator.Evaluate(cmd.Flags.Output) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("output type is not valid: %s", err)) + } + } + + return errors.Join(validationErrors...) +} + +func (cmd *CmdDebugCert) InputToOptions() { + if cmd.Flags != nil { + cmd.output = cmd.Flags.Output + } +} + +func (cmd *CmdDebugCert) Run() error { + if cmd.Flags != nil && cmd.Flags.File != "" { + info, err := certdisplay.ParseCertificateFile(cmd.Flags.File, cmd.Flags.File) + if err != nil { + return fmt.Errorf("failed to parse certificate from %s: %w", cmd.Flags.File, err) + } + return certdisplay.Display([]certdisplay.Info{*info}, cmd.output, true) + } + + if cmd.certName != "" { + info, err := cmd.certInfoByName(cmd.certName) + if err != nil { + return err + } + return certdisplay.Display([]certdisplay.Info{*info}, cmd.output, true) + } + + infos, err := cmd.collectCertInfos() + if err != nil { + return err + } + if len(infos) == 0 { + fmt.Println("No certificates found in the namespace") + return nil + } + return certdisplay.Display(infos, cmd.output, false) +} + +func (cmd *CmdDebugCert) certInfoByName(name string) (*certdisplay.Info, error) { + certificate, err := cmd.Client.Certificates(cmd.Namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err == nil { + return cmd.certInfoFromCR(certificate) + } + if !apierrors.IsNotFound(err) { + return nil, err + } + + secret, err := cmd.KubeClient.CoreV1().Secrets(cmd.Namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + if !secrets.IsTlsCredentialSecret(secret) { + return nil, fmt.Errorf("secret %s is not a TLS credential", name) + } + return cmd.certInfoFromSecret(name, secret, "", "", nil) +} + +func (cmd *CmdDebugCert) collectCertInfos() ([]certdisplay.Info, error) { + seen := map[string]bool{} + var infos []certdisplay.Info + + certificateList, err := cmd.Client.Certificates(cmd.Namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, utils.HandleMissingCrds(err) + } + + for _, certificate := range certificateList.Items { + info, err := cmd.certInfoFromCR(&certificate) + if err != nil { + return nil, err + } + seen[certificate.Name] = true + infos = append(infos, *info) + } + + secretList, err := cmd.KubeClient.CoreV1().Secrets(cmd.Namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, secret := range secretList.Items { + if seen[secret.Name] { + continue + } + if !secrets.IsTlsCredentialSecret(&secret) { + continue + } + if _, ok := secret.Data["tls.crt"]; !ok { + continue + } + info, err := cmd.certInfoFromSecret(secret.Name, &secret, "", "", nil) + if err != nil { + return nil, err + } + infos = append(infos, *info) + } + + return infos, nil +} + +func (cmd *CmdDebugCert) certInfoFromCR(certificate *v2alpha1.Certificate) (*certdisplay.Info, error) { + secret, err := cmd.KubeClient.CoreV1().Secrets(cmd.Namespace).Get(context.TODO(), certificate.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get secret for certificate %s: %w", certificate.Name, err) + } + isSigning := certificate.Spec.Signing + return cmd.certInfoFromSecret(certificate.Name, secret, string(certificate.Status.StatusType), certificate.Status.Expiration, &isSigning) +} + +func (cmd *CmdDebugCert) certInfoFromSecret(name string, secret *corev1.Secret, status, crExpiration string, isSigning *bool) (*certdisplay.Info, error) { + certData, ok := secret.Data["tls.crt"] + if !ok || len(certData) == 0 { + return nil, fmt.Errorf("secret %s does not contain tls.crt", name) + } + info, err := certdisplay.ParseCertificate(name, certData) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate %s: %w", name, err) + } + info.Status = status + info.CrExpiration = crExpiration + info.IsSigningCert = isSigning + return info, nil +} + +func (cmd *CmdDebugCert) WaitUntil() error { return nil } diff --git a/internal/cmd/skupper/debug/kube/cert_test.go b/internal/cmd/skupper/debug/kube/cert_test.go new file mode 100644 index 000000000..58c3e017a --- /dev/null +++ b/internal/cmd/skupper/debug/kube/cert_test.go @@ -0,0 +1,154 @@ +package kube + +import ( + "testing" + + "github.com/skupperproject/skupper/internal/certs" + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + "github.com/skupperproject/skupper/internal/cmd/skupper/common/testutils" + fakeclient "github.com/skupperproject/skupper/internal/kube/client/fake" + "github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1" + "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestCmdDebugCert_ValidateInput(t *testing.T) { + type test struct { + name string + args []string + flags common.CommandDebugCertFlags + k8sObjects []runtime.Object + skupperObjects []runtime.Object + expectedError string + } + + testTable := []test{ + { + name: "more than one argument", + args: []string{"cert-a", "cert-b"}, + expectedError: "only one certificate name is allowed", + }, + { + name: "file and name argument", + flags: common.CommandDebugCertFlags{File: "tls.crt"}, + args: []string{"my-cert"}, + expectedError: "file flag cannot be used with a certificate name argument", + }, + { + name: "invalid output format", + flags: common.CommandDebugCertFlags{Output: "xml"}, + expectedError: "output type is not valid: value xml not allowed. It should be one of this options: [json yaml]", + }, + } + + for _, tt := range testTable { + t.Run(tt.name, func(t *testing.T) { + cmd, err := newCmdDebugCertWithMocks("test", tt.k8sObjects, tt.skupperObjects, "") + assert.Assert(t, err) + cmd.Flags = &tt.flags + testutils.CheckValidateInput(t, cmd, tt.expectedError, tt.args) + }) + } +} + +func TestCmdDebugCert_certInfoFromSecret(t *testing.T) { + caSecret, err := certs.GenerateSecret("test-ca", "ca.example.com", nil, 0, nil) + assert.NilError(t, err) + + leafSecret, err := certs.GenerateSecret("my-cert", "my.example.com", []string{"my.example.com"}, 86400000000000, caSecret) + assert.NilError(t, err) + + cmd := NewCmdDebugCert() + info, err := cmd.certInfoFromSecret("my-cert", leafSecret, "Ready", "2027-01-01T00:00:00Z", nil) + assert.NilError(t, err) + assert.Equal(t, "my-cert", info.Name) + assert.Equal(t, "my.example.com", info.Subject) + assert.Equal(t, "Ready", info.Status) + assert.Equal(t, "2027-01-01T00:00:00Z", info.CrExpiration) + assert.Assert(t, info.IsSigningCert == nil) +} + +func TestCmdDebugCert_RunWithSecretOnly(t *testing.T) { + linkSecret, err := certs.GenerateSecret("link-profile", "link.example.com", []string{"link.example.com"}, 86400000000000, nil) + assert.NilError(t, err) + linkSecret.Namespace = "test" + linkSecret.Type = corev1.SecretTypeTLS + + cmd, err := newCmdDebugCertWithMocks("test", []runtime.Object{linkSecret}, nil, "") + assert.Assert(t, err) + cmd.certName = "link-profile" + + err = cmd.Run() + assert.NilError(t, err) +} + +func TestCmdDebugCert_RunWithCertificateCR(t *testing.T) { + leafSecret, err := certs.GenerateSecret("skupper-local-server", "local.example.com", []string{"local.example.com"}, 0, nil) + assert.NilError(t, err) + leafSecret.Namespace = "test" + + certCR := &v2alpha1.Certificate{ + ObjectMeta: v1.ObjectMeta{Name: "skupper-local-server", Namespace: "test"}, + Spec: v2alpha1.CertificateSpec{ + Signing: false, + }, + Status: v2alpha1.CertificateStatus{ + Status: v2alpha1.Status{ + StatusType: v2alpha1.StatusReady, + }, + Expiration: "2027-01-01T00:00:00Z", + }, + } + + cmd, err := newCmdDebugCertWithMocks("test", []runtime.Object{leafSecret}, []runtime.Object{certCR}, "") + assert.Assert(t, err) + cmd.certName = "skupper-local-server" + + err = cmd.Run() + assert.NilError(t, err) + + info, err := cmd.certInfoFromCR(certCR) + assert.NilError(t, err) + assert.Assert(t, info.IsSigningCert != nil) + assert.Equal(t, false, *info.IsSigningCert) +} + +func TestCmdDebugCert_SigningFromCR(t *testing.T) { + caSecret, err := certs.GenerateSecret("skupper-site-ca", "site-ca.example.com", nil, 0, nil) + assert.NilError(t, err) + caSecret.Namespace = "test" + + certCR := &v2alpha1.Certificate{ + ObjectMeta: v1.ObjectMeta{Name: "skupper-site-ca", Namespace: "test"}, + Spec: v2alpha1.CertificateSpec{ + Signing: true, + }, + Status: v2alpha1.CertificateStatus{ + Status: v2alpha1.Status{ + StatusType: v2alpha1.StatusReady, + }, + }, + } + + cmd, err := newCmdDebugCertWithMocks("test", []runtime.Object{caSecret}, []runtime.Object{certCR}, "") + assert.Assert(t, err) + + info, err := cmd.certInfoFromCR(certCR) + assert.NilError(t, err) + assert.Assert(t, info.IsSigningCert != nil) + assert.Equal(t, true, *info.IsSigningCert) +} + +func newCmdDebugCertWithMocks(namespace string, k8sObjects []runtime.Object, skupperObjects []runtime.Object, fakeSkupperError string) (*CmdDebugCert, error) { + client, err := fakeclient.NewFakeClient(namespace, k8sObjects, skupperObjects, fakeSkupperError) + if err != nil { + return nil, err + } + return &CmdDebugCert{ + Client: client.GetSkupperClient().SkupperV2alpha1(), + KubeClient: client.GetKubeClient(), + Namespace: namespace, + }, nil +} diff --git a/internal/cmd/skupper/debug/nonkube/cert.go b/internal/cmd/skupper/debug/nonkube/cert.go new file mode 100644 index 000000000..e3783b3e4 --- /dev/null +++ b/internal/cmd/skupper/debug/nonkube/cert.go @@ -0,0 +1,233 @@ +package nonkube + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + certdisplay "github.com/skupperproject/skupper/internal/cmd/skupper/debug/cert" + "github.com/skupperproject/skupper/internal/utils/validator" + "github.com/skupperproject/skupper/pkg/nonkube/api" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/yaml" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" +) + +type CmdDebugCert struct { + CobraCmd *cobra.Command + Flags *common.CommandDebugCertFlags + namespace string + certName string + output string +} + +func NewCmdDebugCert() *CmdDebugCert { + return &CmdDebugCert{} +} + +func (cmd *CmdDebugCert) NewClient(cobraCommand *cobra.Command, args []string) { + if cmd.CobraCmd != nil && cmd.CobraCmd.Flag(common.FlagNameNamespace) != nil { + cmd.namespace = cmd.CobraCmd.Flag(common.FlagNameNamespace).Value.String() + } +} + +func (cmd *CmdDebugCert) ValidateInput(args []string) error { + var validationErrors []error + outputTypeValidator := validator.NewOptionValidator(common.OutputTypes) + filePathValidator := validator.NewFilePathStringValidator() + + if cmd.Flags != nil && cmd.Flags.File != "" { + ok, err := filePathValidator.Evaluate(cmd.Flags.File) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("file path is not valid: %s", err)) + } + if len(args) > 0 { + validationErrors = append(validationErrors, fmt.Errorf("file flag cannot be used with a certificate name argument")) + } + } else { + if len(args) > 1 { + validationErrors = append(validationErrors, fmt.Errorf("only one certificate name is allowed")) + } + if len(args) == 1 { + cmd.certName = args[0] + } + } + + if cmd.Flags != nil && cmd.Flags.Output != "" { + ok, err := outputTypeValidator.Evaluate(cmd.Flags.Output) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("output type is not valid: %s", err)) + } + } + + return errors.Join(validationErrors...) +} + +func (cmd *CmdDebugCert) InputToOptions() { + if cmd.Flags != nil { + cmd.output = cmd.Flags.Output + } +} + +func (cmd *CmdDebugCert) Run() error { + if cmd.Flags != nil && cmd.Flags.File != "" { + info, err := certdisplay.ParseCertificateFile(cmd.Flags.File, cmd.Flags.File) + if err != nil { + return fmt.Errorf("failed to parse certificate from %s: %w", cmd.Flags.File, err) + } + return certdisplay.Display([]certdisplay.Info{*info}, cmd.output, true) + } + + infos, err := cmd.collectCerts() + if err != nil { + return err + } + + if cmd.certName != "" { + for _, info := range infos { + if info.Name == cmd.certName { + return certdisplay.Display([]certdisplay.Info{info}, cmd.output, true) + } + } + return fmt.Errorf("certificate %s not found", cmd.certName) + } + + return certdisplay.Display(infos, cmd.output, false) +} + +func (cmd *CmdDebugCert) collectCerts() ([]certdisplay.Info, error) { + var infos []certdisplay.Info + var err error + seen := map[string]bool{} + + certPaths := []struct { + basePath api.InternalPath + prefix string + isSigning bool + }{ + {api.CertificatesPath, "", false}, + {api.InputCertificatesPath, "input/", false}, + {api.IssuersPath, "issuers/", true}, + {api.InputIssuersPath, "input/issuers/", true}, + } + + for _, cp := range certPaths { + infos, err = cmd.collectCertsFromDir(cp.basePath, cp.prefix, cp.isSigning, seen, infos) + if err != nil { + return nil, err + } + } + + return cmd.collectInputResourceSecrets(seen, infos) +} + +func (cmd *CmdDebugCert) collectCertsFromDir(basePath api.InternalPath, prefix string, isSigning bool, seen map[string]bool, infos []certdisplay.Info) ([]certdisplay.Info, error) { + dir := api.GetInternalOutputPath(cmd.namespace, basePath) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return infos, nil + } + return nil, fmt.Errorf("failed to read certificate directory %s: %w", dir, err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + displayName := prefix + name + if seen[displayName] { + continue + } + certFile := filepath.Join(dir, name, "tls.crt") + data, err := os.ReadFile(certFile) + if err != nil { + continue + } + info, err := certdisplay.ParseCertificate(displayName, data) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate %s: %w", displayName, err) + } + signing := isSigning + info.IsSigningCert = &signing + seen[displayName] = true + infos = append(infos, *info) + } + return infos, nil +} + +func (cmd *CmdDebugCert) collectInputResourceSecrets(seen map[string]bool, infos []certdisplay.Info) ([]certdisplay.Info, error) { + dir := api.GetInternalOutputPath(cmd.namespace, api.InputSiteStatePath) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return infos, nil + } + return nil, fmt.Errorf("failed to read certificate directory %s: %w", dir, err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + filename := entry.Name() + if !strings.HasPrefix(filename, "Secret-") { + continue + } + if !strings.HasSuffix(filename, ".yaml") && !strings.HasSuffix(filename, ".yml") { + continue + } + secret, err := decodeSecretFile(filepath.Join(dir, filename)) + if err != nil { + return nil, fmt.Errorf("failed to decode secret from %s: %w", filename, err) + } + if secret.Data == nil || len(secret.Data["tls.crt"]) == 0 { + continue + } + displayName := "input/secret/" + secret.Name + if seen[displayName] { + continue + } + info, err := certdisplay.ParseCertificate(displayName, secret.Data["tls.crt"]) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate %s: %w", displayName, err) + } + seen[displayName] = true + infos = append(infos, *info) + } + return infos, nil +} + +func decodeSecretFile(path string) (*corev1.Secret, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + yamlDecoder := yamlutil.NewYAMLOrJSONDecoder(bufio.NewReader(file), 1024) + var rawObj runtime.RawExtension + if err := yamlDecoder.Decode(&rawObj); err != nil { + return nil, err + } + obj, gvk, err := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme).Decode(rawObj.Raw, nil, nil) + if err != nil { + return nil, err + } + if gvk.Kind != "Secret" { + return nil, fmt.Errorf("expected Secret, got %s", gvk.Kind) + } + var secret corev1.Secret + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.(runtime.Unstructured).UnstructuredContent(), &secret); err != nil { + return nil, err + } + return &secret, nil +} + +func (cmd *CmdDebugCert) WaitUntil() error { return nil }