diff --git a/cli/cmd/root.go b/cli/cmd/root.go index b234a080..004e7352 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -9,11 +9,22 @@ package cmd import ( "context" "os" + "path/filepath" "github.com/edgelesssys/marblerun/cli/internal/cmd" + "github.com/edgelesssys/marblerun/cli/internal/helm" "github.com/spf13/cobra" ) +// defaultCoordinatorCertCache is the default path to the Coordinator's certificate cache. +var defaultCoordinatorCertCache = func() string { + configDir, err := os.UserConfigDir() + if err != nil { + panic(err) + } + return filepath.Join(configDir, "marblerun", "coordinator-cert.pem") +}() + // Execute starts the CLI. func Execute() error { cobra.EnableCommandSorting = false @@ -51,10 +62,13 @@ To install and configure MarbleRun, run: rootCmd.AddCommand(cmd.NewPackageInfoCmd()) rootCmd.AddCommand(cmd.NewVersionCmd()) + rootCmd.PersistentFlags().String("coordinator-cert", defaultCoordinatorCertCache, "Path to MarbleRun Coordinator's root certificate to use for TLS connections") rootCmd.PersistentFlags().String("era-config", "", "Path to remote attestation config file in json format, if none provided the newest configuration will be loaded from github") rootCmd.PersistentFlags().BoolP("insecure", "i", false, "Set to skip quote verification, needed when running in simulation mode") rootCmd.PersistentFlags().StringSlice("accepted-tcb-statuses", []string{"UpToDate"}, "Comma-separated list of user accepted TCB statuses (e.g. ConfigurationNeeded,ConfigurationAndSWHardeningNeeded)") + rootCmd.PersistentFlags().StringP("namespace", "n", helm.Namespace, "Kubernetes namespace of the MarbleRun installation") + must(rootCmd.MarkPersistentFlagFilename("coordinator-cert", "pem", "crt")) must(rootCmd.MarkPersistentFlagFilename("era-config", "json")) return rootCmd diff --git a/cli/internal/cmd/certificate.go b/cli/internal/cmd/certificate.go index aeb6a7a9..9e24804e 100644 --- a/cli/internal/cmd/certificate.go +++ b/cli/internal/cmd/certificate.go @@ -7,8 +7,15 @@ package cmd import ( + "crypto/x509" + "encoding/pem" "errors" + "fmt" + "io" + "github.com/edgelesssys/marblerun/cli/internal/file" + "github.com/edgelesssys/marblerun/cli/internal/rest" + "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -27,6 +34,50 @@ func NewCertificateCmd() *cobra.Command { return cmd } +func runCertificate(saveCert func(io.Writer, *file.Handler, []*pem.Block) error, +) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + hostname := args[0] + fs := afero.NewOsFs() + flags, err := parseRestFlags(cmd.Flags()) + if err != nil { + return err + } + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + localCerts, err := rest.LoadCoordinatorCachedCert(cmd.Flags(), fs) + if err != nil { + return err + } + rootCert, err := getRootCertFromPEMChain(localCerts) + if err != nil { + return fmt.Errorf("parsing root certificate from local cache: %w", err) + } + + certs, err := rest.VerifyCoordinator( + cmd.Context(), cmd.OutOrStdout(), hostname, + flags.eraConfig, flags.k8sNamespace, flags.insecure, flags.acceptedTCBStatuses, + ) + if err != nil { + return fmt.Errorf("retrieving certificate from Coordinator: %w", err) + } + + remoteRootCert, err := getRootCertFromPEMChain(certs) + if err != nil { + return fmt.Errorf("parsing root certificate from Coordinator: %w", err) + } + + if !remoteRootCert.Equal(rootCert) { + return errors.New("root certificate of Coordinator changed. Run 'marblerun manifest verify' to verify the instance and update the local cache") + } + + return saveCert(cmd.OutOrStdout(), file.New(output, fs), certs) + } +} + func outputFlagNotEmpty(cmd *cobra.Command, _ []string) error { output, err := cmd.Flags().GetString("output") if err != nil { @@ -37,3 +88,10 @@ func outputFlagNotEmpty(cmd *cobra.Command, _ []string) error { } return nil } + +func getRootCertFromPEMChain(certs []*pem.Block) (*x509.Certificate, error) { + if len(certs) == 0 { + return nil, errors.New("no certificates received from Coordinator") + } + return x509.ParseCertificate(certs[len(certs)-1].Bytes) +} diff --git a/cli/internal/cmd/certificateChain.go b/cli/internal/cmd/certificateChain.go index b803b97c..1e6597ad 100644 --- a/cli/internal/cmd/certificateChain.go +++ b/cli/internal/cmd/certificateChain.go @@ -13,8 +13,6 @@ import ( "io" "github.com/edgelesssys/marblerun/cli/internal/file" - "github.com/edgelesssys/marblerun/cli/internal/rest" - "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -24,7 +22,7 @@ func newCertificateChain() *cobra.Command { Short: "Returns the certificate chain of the MarbleRun Coordinator", Long: `Returns the certificate chain of the MarbleRun Coordinator`, Args: cobra.ExactArgs(1), - RunE: runCertificateChain, + RunE: runCertificate(saveCertChain), PreRunE: outputFlagNotEmpty, } @@ -33,29 +31,8 @@ func newCertificateChain() *cobra.Command { return cmd } -func runCertificateChain(cmd *cobra.Command, args []string) error { - hostname := args[0] - flags, err := rest.ParseFlags(cmd) - if err != nil { - return err - } - output, err := cmd.Flags().GetString("output") - if err != nil { - return err - } - - certs, err := rest.VerifyCoordinator( - cmd.Context(), cmd.OutOrStdout(), hostname, - flags.EraConfig, flags.Insecure, flags.AcceptedTCBStatuses, - ) - if err != nil { - return fmt.Errorf("retrieving certificate chain from Coordinator: %w", err) - } - return cliCertificateChain(cmd.OutOrStdout(), file.New(output, afero.NewOsFs()), certs) -} - -// cliCertificateChain gets the certificate chain of the MarbleRun Coordinator. -func cliCertificateChain(out io.Writer, file *file.Handler, certs []*pem.Block) error { +// saveCertChain saves the certificate chain of the MarbleRun Coordinator. +func saveCertChain(out io.Writer, certFile *file.Handler, certs []*pem.Block) error { if len(certs) == 0 { return errors.New("no certificates received from Coordinator") } @@ -68,10 +45,10 @@ func cliCertificateChain(out io.Writer, file *file.Handler, certs []*pem.Block) chain = append(chain, pem.EncodeToMemory(cert)...) } - if err := file.Write(chain); err != nil { + if err := certFile.Write(chain, file.OptOverwrite); err != nil { return err } - fmt.Fprintf(out, "Certificate chain written to %s\n", file.Name()) + fmt.Fprintf(out, "Certificate chain written to %s\n", certFile.Name()) return nil } diff --git a/cli/internal/cmd/certificateIntermediate.go b/cli/internal/cmd/certificateIntermediate.go index fdb3d3ce..f9159cfd 100644 --- a/cli/internal/cmd/certificateIntermediate.go +++ b/cli/internal/cmd/certificateIntermediate.go @@ -13,8 +13,6 @@ import ( "io" "github.com/edgelesssys/marblerun/cli/internal/file" - "github.com/edgelesssys/marblerun/cli/internal/rest" - "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -24,7 +22,7 @@ func newCertificateIntermediate() *cobra.Command { Short: "Returns the intermediate certificate of the MarbleRun Coordinator", Long: `Returns the intermediate certificate of the MarbleRun Coordinator`, Args: cobra.ExactArgs(1), - RunE: runCertificateIntermediate, + RunE: runCertificate(saveIntermediateCert), PreRunE: outputFlagNotEmpty, } @@ -33,36 +31,15 @@ func newCertificateIntermediate() *cobra.Command { return cmd } -func runCertificateIntermediate(cmd *cobra.Command, args []string) error { - hostname := args[0] - flags, err := rest.ParseFlags(cmd) - if err != nil { - return err - } - output, err := cmd.Flags().GetString("output") - if err != nil { - return err - } - - certs, err := rest.VerifyCoordinator( - cmd.Context(), cmd.OutOrStdout(), hostname, - flags.EraConfig, flags.Insecure, flags.AcceptedTCBStatuses, - ) - if err != nil { - return fmt.Errorf("retrieving intermediate certificate from Coordinator: %w", err) - } - return cliCertificateIntermediate(cmd.OutOrStdout(), file.New(output, afero.NewOsFs()), certs) -} - -// cliCertificateIntermediate gets the intermediate certificate of the MarbleRun Coordinator. -func cliCertificateIntermediate(out io.Writer, file *file.Handler, certs []*pem.Block) error { +// cliCertificateIntermediate saves the intermediate certificate of the MarbleRun Coordinator. +func saveIntermediateCert(out io.Writer, certFile *file.Handler, certs []*pem.Block) error { if len(certs) < 2 { return errors.New("no intermediate certificate received from Coordinator") } - if err := file.Write(pem.EncodeToMemory(certs[0])); err != nil { + if err := certFile.Write(pem.EncodeToMemory(certs[0]), file.OptOverwrite); err != nil { return err } - fmt.Fprintf(out, "Intermediate certificate written to %s\n", file.Name()) + fmt.Fprintf(out, "Intermediate certificate written to %s\n", certFile.Name()) return nil } diff --git a/cli/internal/cmd/certificateRoot.go b/cli/internal/cmd/certificateRoot.go index 49701fb6..bfd36d3a 100644 --- a/cli/internal/cmd/certificateRoot.go +++ b/cli/internal/cmd/certificateRoot.go @@ -13,8 +13,6 @@ import ( "io" "github.com/edgelesssys/marblerun/cli/internal/file" - "github.com/edgelesssys/marblerun/cli/internal/rest" - "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -24,7 +22,7 @@ func newCertificateRoot() *cobra.Command { Short: "Returns the root certificate of the MarbleRun Coordinator", Long: `Returns the root certificate of the MarbleRun Coordinator`, Args: cobra.ExactArgs(1), - RunE: runCertificateRoot, + RunE: runCertificate(saveRootCert), PreRunE: outputFlagNotEmpty, } @@ -33,36 +31,15 @@ func newCertificateRoot() *cobra.Command { return cmd } -func runCertificateRoot(cmd *cobra.Command, args []string) error { - hostname := args[0] - flags, err := rest.ParseFlags(cmd) - if err != nil { - return err - } - output, err := cmd.Flags().GetString("output") - if err != nil { - return err - } - - certs, err := rest.VerifyCoordinator( - cmd.Context(), cmd.OutOrStdout(), hostname, - flags.EraConfig, flags.Insecure, flags.AcceptedTCBStatuses, - ) - if err != nil { - return fmt.Errorf("retrieving root certificate from Coordinator: %w", err) - } - return cliCertificateRoot(cmd.OutOrStdout(), file.New(output, afero.NewOsFs()), certs) -} - -// cliCertificateRoot gets the root certificate of the MarbleRun Coordinator and saves it to a file. -func cliCertificateRoot(out io.Writer, file *file.Handler, certs []*pem.Block) error { +// saveRootCert saves the root certificate of the MarbleRun Coordinator to a file. +func saveRootCert(out io.Writer, certFile *file.Handler, certs []*pem.Block) error { if len(certs) == 0 { return errors.New("no certificates received from Coordinator") } - if err := file.Write(pem.EncodeToMemory(certs[len(certs)-1])); err != nil { + if err := certFile.Write(pem.EncodeToMemory(certs[len(certs)-1]), file.OptOverwrite); err != nil { return err } - fmt.Fprintf(out, "Root certificate written to %s\n", file.Name()) + fmt.Fprintf(out, "Root certificate written to %s\n", certFile.Name()) return nil } diff --git a/cli/internal/cmd/certificate_test.go b/cli/internal/cmd/certificate_test.go index b6c464d4..b8be4edc 100644 --- a/cli/internal/cmd/certificate_test.go +++ b/cli/internal/cmd/certificate_test.go @@ -99,7 +99,7 @@ func TestCertificateRoot(t *testing.T) { var out bytes.Buffer - err := cliCertificateRoot(&out, tc.file, tc.certs) + err := saveRootCert(&out, tc.file, tc.certs) if tc.wantErr { assert.Error(err) return @@ -154,7 +154,7 @@ func TestCertificateIntermediate(t *testing.T) { var out bytes.Buffer - err := cliCertificateIntermediate(&out, tc.file, tc.certs) + err := saveIntermediateCert(&out, tc.file, tc.certs) if tc.wantErr { assert.Error(err) return @@ -208,7 +208,7 @@ func TestCertificateChain(t *testing.T) { var out bytes.Buffer - err := cliCertificateChain(&out, tc.file, tc.certs) + err := saveCertChain(&out, tc.file, tc.certs) if tc.wantErr { assert.Error(err) return diff --git a/cli/internal/cmd/check.go b/cli/internal/cmd/check.go index 43de4405..2896061c 100644 --- a/cli/internal/cmd/check.go +++ b/cli/internal/cmd/check.go @@ -31,7 +31,6 @@ func NewCheckCmd() *cobra.Command { } cmd.Flags().Uint("timeout", 60, "Time to wait before aborting in seconds") - cmd.Flags().String("namespace", helm.Namespace, "Namespace MarbleRun is deployed to") return cmd } diff --git a/cli/internal/cmd/cmd.go b/cli/internal/cmd/cmd.go index 61023509..1b9386ca 100644 --- a/cli/internal/cmd/cmd.go +++ b/cli/internal/cmd/cmd.go @@ -11,6 +11,9 @@ import ( "context" "io" + "github.com/edgelesssys/marblerun/cli/internal/rest" + "github.com/spf13/afero" + "github.com/spf13/pflag" "k8s.io/apimachinery/pkg/util/version" "k8s.io/client-go/kubernetes" ) @@ -25,6 +28,47 @@ type poster interface { Post(ctx context.Context, path, contentType string, body io.Reader) ([]byte, error) } +// restFlags are command line flags used to configure the REST client to talk to the Coordinator. +type restFlags struct { + // k8sNamespace is the namespace of the MarbleRun installation. + // We use this to try to find the Coordinator when retrieving the era config. + k8sNamespace string + // eraConfig is the path to the era config file. + eraConfig string + // insecure is a flag to disable TLS verification. + insecure bool + // acceptedTCBStatuses is a list of TCB statuses that are accepted by the CLI. + // This can be used to allow connections to Coordinator instances running on outdated hardware or firmware. + acceptedTCBStatuses []string +} + +// parseRestFlags parses the command line flags used to configure the REST client. +func parseRestFlags(flags *pflag.FlagSet) (restFlags, error) { + eraConfig, err := flags.GetString("era-config") + if err != nil { + return restFlags{}, err + } + insecure, err := flags.GetBool("insecure") + if err != nil { + return restFlags{}, err + } + acceptedTCBStatuses, err := flags.GetStringSlice("accepted-tcb-statuses") + if err != nil { + return restFlags{}, err + } + k8snamespace, err := flags.GetString("namespace") + if err != nil { + return restFlags{}, err + } + + return restFlags{ + k8sNamespace: k8snamespace, + eraConfig: eraConfig, + insecure: insecure, + acceptedTCBStatuses: acceptedTCBStatuses, + }, nil +} + func checkLegacyKubernetesVersion(kubeClient kubernetes.Interface) (bool, error) { serverVersion, err := kubeClient.Discovery().ServerVersion() if err != nil { @@ -43,6 +87,24 @@ func checkLegacyKubernetesVersion(kubeClient kubernetes.Interface) (bool, error) return false, nil } +func newMutualAuthClient(hostname string, flags *pflag.FlagSet, fs afero.Fs) (*rest.Client, error) { + insecureTLS, err := flags.GetBool("insecure") + if err != nil { + return nil, err + } + + caCert, err := rest.LoadCoordinatorCachedCert(flags, fs) + if err != nil { + return nil, err + } + clientCert, err := rest.LoadClientCert(flags) + if err != nil { + return nil, err + } + + return rest.NewClient(hostname, caCert, clientCert, insecureTLS) +} + func must(err error) { if err != nil { panic(err) diff --git a/cli/internal/cmd/install.go b/cli/internal/cmd/install.go index 11eb7b17..ad185a9e 100644 --- a/cli/internal/cmd/install.go +++ b/cli/internal/cmd/install.go @@ -59,20 +59,25 @@ marblerun install --dcap-pccs-url https://pccs.example.com/sgx/certification/v4/ } func runInstall(cmd *cobra.Command, _ []string) error { + namespace, err := cmd.Flags().GetString("namespace") + if err != nil { + return err + } + kubeClient, err := kube.NewClient() if err != nil { return err } - helmClient, err := helm.New() + helmClient, err := helm.New(namespace) if err != nil { return err } - return cliInstall(cmd, helmClient, kubeClient) + return cliInstall(cmd, helmClient, kubeClient, namespace) } // cliInstall installs MarbleRun on the cluster. -func cliInstall(cmd *cobra.Command, helmClient *helm.Client, kubeClient kubernetes.Interface) error { +func cliInstall(cmd *cobra.Command, helmClient *helm.Client, kubeClient kubernetes.Interface, namespace string) error { flags, err := parseInstallFlags(cmd) if err != nil { return fmt.Errorf("parsing install flags: %w", err) @@ -92,9 +97,9 @@ func cliInstall(cmd *cobra.Command, helmClient *helm.Client, kubeClient kubernet var webhookSettings []string if !flags.disableInjection { - webhookSettings, err = installWebhook(cmd, kubeClient) + webhookSettings, err = installWebhook(cmd, kubeClient, namespace) if err != nil { - return errorAndCleanup(cmd.Context(), fmt.Errorf("installing webhook certs: %w", err), kubeClient) + return errorAndCleanup(cmd.Context(), fmt.Errorf("installing webhook certs: %w", err), kubeClient, namespace) } } @@ -113,11 +118,11 @@ func cliInstall(cmd *cobra.Command, helmClient *helm.Client, kubeClient kubernet chart.Values, ) if err != nil { - return errorAndCleanup(cmd.Context(), fmt.Errorf("generating helm values: %w", err), kubeClient) + return errorAndCleanup(cmd.Context(), fmt.Errorf("generating helm values: %w", err), kubeClient, namespace) } if err := helmClient.Install(cmd.Context(), flags.wait, chart, values); err != nil { - return errorAndCleanup(cmd.Context(), fmt.Errorf("installing MarbleRun: %w", err), kubeClient) + return errorAndCleanup(cmd.Context(), fmt.Errorf("installing MarbleRun: %w", err), kubeClient, namespace) } cmd.Println("MarbleRun installed successfully") @@ -125,9 +130,9 @@ func cliInstall(cmd *cobra.Command, helmClient *helm.Client, kubeClient kubernet } // installWebhook enables a mutating admission webhook to allow automatic injection of values into pods. -func installWebhook(cmd *cobra.Command, kubeClient kubernetes.Interface) ([]string, error) { +func installWebhook(cmd *cobra.Command, kubeClient kubernetes.Interface, namespace string) ([]string, error) { // verify 'marblerun' namespace exists, if not create it - if err := verifyNamespace(cmd.Context(), helm.Namespace, kubeClient); err != nil { + if err := verifyNamespace(cmd.Context(), namespace, kubeClient); err != nil { return nil, err } @@ -154,7 +159,7 @@ func installWebhook(cmd *cobra.Command, kubeClient kubernetes.Interface) ([]stri } cmd.Print(".") - if err := createSecret(cmd.Context(), certificateHandler.getKey(), cert, kubeClient); err != nil { + if err := createSecret(cmd.Context(), namespace, certificateHandler.getKey(), cert, kubeClient); err != nil { return nil, err } cmd.Printf(" Done\n") @@ -162,7 +167,7 @@ func installWebhook(cmd *cobra.Command, kubeClient kubernetes.Interface) ([]stri } // createSecret creates a secret containing the signed certificate and private key for the webhook server. -func createSecret(ctx context.Context, privKey *rsa.PrivateKey, crt []byte, kubeClient kubernetes.Interface) error { +func createSecret(ctx context.Context, namespace string, privKey *rsa.PrivateKey, crt []byte, kubeClient kubernetes.Interface) error { rsaPEM := pem.EncodeToMemory( &pem.Block{ Type: "RSA PRIVATE KEY", @@ -173,7 +178,7 @@ func createSecret(ctx context.Context, privKey *rsa.PrivateKey, crt []byte, kube newSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: "marble-injector-webhook-certs", - Namespace: helm.Namespace, + Namespace: namespace, }, Data: map[string][]byte{ "tls.crt": crt, @@ -181,7 +186,7 @@ func createSecret(ctx context.Context, privKey *rsa.PrivateKey, crt []byte, kube }, } - _, err := kubeClient.CoreV1().Secrets(helm.Namespace).Create(ctx, newSecret, metav1.CreateOptions{}) + _, err := kubeClient.CoreV1().Secrets(namespace).Create(ctx, newSecret, metav1.CreateOptions{}) return err } @@ -243,10 +248,10 @@ func getSGXResourceKey(ctx context.Context, kubeClient kubernetes.Interface) (st // errorAndCleanup returns the given error and deletes resources which might have been created previously. // This prevents secrets and CSRs to stay on the cluster after a failed installation attempt. -func errorAndCleanup(ctx context.Context, err error, kubeClient kubernetes.Interface) error { +func errorAndCleanup(ctx context.Context, err error, kubeClient kubernetes.Interface, namespace string) error { // We dont care about any additional errors here _ = cleanupCSR(ctx, kubeClient) - _ = cleanupSecrets(ctx, kubeClient) + _ = cleanupSecrets(ctx, kubeClient, namespace) return err } diff --git a/cli/internal/cmd/install_test.go b/cli/internal/cmd/install_test.go index a59a9623..216fed63 100644 --- a/cli/internal/cmd/install_test.go +++ b/cli/internal/cmd/install_test.go @@ -47,13 +47,13 @@ func TestCreateSecret(t *testing.T) { _, err = testClient.CoreV1().Namespaces().Create(ctx, newNamespace1, metav1.CreateOptions{}) require.NoError(err) - err = createSecret(ctx, testKey, crt, testClient) + err = createSecret(ctx, helm.Namespace, testKey, crt, testClient) require.NoError(err) _, err = testClient.CoreV1().Secrets(helm.Namespace).Get(context.TODO(), "marble-injector-webhook-certs", metav1.GetOptions{}) require.NoError(err) // we should get an error since the secret was already created in the previous step - err = createSecret(ctx, testKey, crt, testClient) + err = createSecret(ctx, helm.Namespace, testKey, crt, testClient) require.Error(err) } @@ -131,7 +131,7 @@ func TestInstallWebhook(t *testing.T) { var out bytes.Buffer cmd.SetOut(&out) - testValues, err := installWebhook(cmd, testClient) + testValues, err := installWebhook(cmd, testClient, helm.Namespace) assert.NoError(err) assert.Equal("marbleInjector.start=true", testValues[0], "failed to set start to true") assert.Contains(testValues[1], "LS0t", "failed to set CABundle") @@ -178,7 +178,7 @@ func TestErrorAndCleanup(t *testing.T) { ctx := context.Background() testError := errors.New("test") - err := errorAndCleanup(ctx, testError, testClient) + err := errorAndCleanup(ctx, testError, testClient, helm.Namespace) assert.Equal(testError, err) // Create and test for CSR @@ -201,7 +201,7 @@ func TestErrorAndCleanup(t *testing.T) { _, err = testClient.CertificatesV1().CertificateSigningRequests().Get(context.TODO(), webhookName, metav1.GetOptions{}) require.NoError(err) - err = errorAndCleanup(ctx, testError, testClient) + err = errorAndCleanup(ctx, testError, testClient, helm.Namespace) assert.Equal(testError, err) _, err = testClient.CertificatesV1().CertificateSigningRequests().Get(context.TODO(), webhookName, metav1.GetOptions{}) diff --git a/cli/internal/cmd/manifestGet.go b/cli/internal/cmd/manifestGet.go index b5ec7a99..c3dc9f0f 100644 --- a/cli/internal/cmd/manifestGet.go +++ b/cli/internal/cmd/manifestGet.go @@ -32,6 +32,7 @@ Optionally get the manifests signature or merge updates into the displayed manif RunE: runManifestGet, } + cmd.Flags().Bool("keep-cert", false, "Set to keep the certificate of the Coordinator and save it to the location specified by --coordinator-cert") cmd.Flags().BoolP("signature", "s", false, "Set to additionally display the manifests signature") cmd.Flags().BoolP("display-update", "u", false, "Set to merge updates into the displayed manifest") cmd.Flags().StringP("output", "o", "", "Save output to file instead of printing to stdout") @@ -40,7 +41,21 @@ Optionally get the manifests signature or merge updates into the displayed manif func runManifestGet(cmd *cobra.Command, args []string) error { hostname := args[0] - client, err := rest.NewClient(cmd, hostname) + fs := afero.NewOsFs() + + restFlags, err := parseRestFlags(cmd.Flags()) + if err != nil { + return err + } + caCert, err := rest.VerifyCoordinator( + cmd.Context(), cmd.OutOrStdout(), hostname, + restFlags.eraConfig, restFlags.k8sNamespace, restFlags.insecure, restFlags.acceptedTCBStatuses, + ) + if err != nil { + return err + } + + client, err := rest.NewClient(hostname, caCert, nil, restFlags.insecure) if err != nil { return err } @@ -50,12 +65,20 @@ func runManifestGet(cmd *cobra.Command, args []string) error { if err != nil { return err } - file := file.New(flags.output, afero.NewOsFs()) + file := file.New(flags.output, fs) + + if err := cliManifestGet(cmd, flags, file, client); err != nil { + return err + } - return cliManifestGet(cmd, flags, file, client) + keep, err := cmd.Flags().GetBool("keep-cert") + if err == nil && keep { + return rest.SaveCoordinatorCachedCert(cmd.Flags(), fs, caCert) + } + return err } -func cliManifestGet(cmd *cobra.Command, flags manifestGetFlags, file *file.Handler, client getter) error { +func cliManifestGet(cmd *cobra.Command, flags manifestGetFlags, mnfFile *file.Handler, client getter) error { resp, err := client.Get(cmd.Context(), rest.ManifestEndpoint, http.NoBody) if err != nil { return fmt.Errorf("getting manifest: %w", err) @@ -70,8 +93,8 @@ func cliManifestGet(cmd *cobra.Command, flags manifestGetFlags, file *file.Handl manifest = fmt.Sprintf("{\n\"ManifestSignature\": \"%s\",\n\"Manifest\": %s}", gjson.GetBytes(resp, "ManifestSignature"), manifest) } - if file != nil { - return file.Write([]byte(manifest)) + if mnfFile != nil { + return mnfFile.Write([]byte(manifest), file.OptOverwrite) } cmd.Println(manifest) return nil diff --git a/cli/internal/cmd/manifestLog.go b/cli/internal/cmd/manifestLog.go index 06364ee4..998968e9 100644 --- a/cli/internal/cmd/manifestLog.go +++ b/cli/internal/cmd/manifestLog.go @@ -33,28 +33,39 @@ func newManifestLog() *cobra.Command { func runManifestLog(cmd *cobra.Command, args []string) error { hostname := args[0] + fs := afero.NewOsFs() output, err := cmd.Flags().GetString("output") if err != nil { return err } - client, err := rest.NewClient(cmd, hostname) + insecureTLS, err := cmd.Flags().GetBool("insecure") + if err != nil { + return err + } + + caCert, err := rest.LoadCoordinatorCachedCert(cmd.Flags(), fs) + if err != nil { + return err + } + + client, err := rest.NewClient(hostname, caCert, nil, insecureTLS) if err != nil { return err } cmd.Println("Successfully verified Coordinator, now requesting update log") - return cliManifestLog(cmd, file.New(output, afero.NewOsFs()), client) + return cliManifestLog(cmd, file.New(output, fs), client) } -func cliManifestLog(cmd *cobra.Command, file *file.Handler, client getter) error { +func cliManifestLog(cmd *cobra.Command, logFile *file.Handler, client getter) error { resp, err := client.Get(cmd.Context(), rest.UpdateEndpoint, http.NoBody) if err != nil { return fmt.Errorf("retrieving update log: %w", err) } - if file != nil { - return file.Write(resp) + if logFile != nil { + return logFile.Write(resp, file.OptOverwrite) } cmd.Printf("Update log:\n%s", resp) return nil diff --git a/cli/internal/cmd/manifestSet.go b/cli/internal/cmd/manifestSet.go index 3c1c0180..d5e86055 100644 --- a/cli/internal/cmd/manifestSet.go +++ b/cli/internal/cmd/manifestSet.go @@ -33,34 +33,53 @@ func newManifestSet() *cobra.Command { return cmd } -func runManifestSet(cmd *cobra.Command, args []string) error { +func runManifestSet(cmd *cobra.Command, args []string) (retErr error) { manifestFile := args[0] hostname := args[1] + fs := afero.NewOsFs() recoveryFilename, err := cmd.Flags().GetString("recoverydata") if err != nil { return err } - client, err := rest.NewClient(cmd, hostname) + restFlags, err := parseRestFlags(cmd.Flags()) + if err != nil { + return err + } + + caCert, err := rest.VerifyCoordinator( + cmd.Context(), cmd.OutOrStdout(), hostname, + restFlags.eraConfig, restFlags.k8sNamespace, restFlags.insecure, restFlags.acceptedTCBStatuses, + ) + if err != nil { + return err + } + + client, err := rest.NewClient(hostname, caCert, nil, restFlags.insecure) if err != nil { return err } cmd.Println("Successfully verified Coordinator, now uploading manifest") - manifest, err := loadManifestFile(file.New(manifestFile, afero.NewOsFs())) + manifest, err := loadManifestFile(file.New(manifestFile, fs)) if err != nil { return err } signature := cliManifestSignature(manifest) cmd.Printf("Manifest signature: %s\n", signature) - return cliManifestSet(cmd, manifest, file.New(recoveryFilename, afero.NewOsFs()), client) + if err := cliManifestSet(cmd, manifest, file.New(recoveryFilename, afero.NewOsFs()), client); err != nil { + return err + } + + // Save the certificate of this Coordinator instance to disk + return rest.SaveCoordinatorCachedCert(cmd.Flags(), fs, caCert) } // cliManifestSet sets the Coordinators manifest using its rest api. -func cliManifestSet(cmd *cobra.Command, manifest []byte, file *file.Handler, client poster) error { +func cliManifestSet(cmd *cobra.Command, manifest []byte, recFile *file.Handler, client poster) error { resp, err := client.Post(cmd.Context(), rest.ManifestEndpoint, rest.ContentJSON, bytes.NewReader(manifest)) if err != nil { return fmt.Errorf("setting manifest: %w", err) @@ -72,11 +91,11 @@ func cliManifestSet(cmd *cobra.Command, manifest []byte, file *file.Handler, cli return nil } // recovery secret was sent, print or save to file - if file != nil { - if err := file.Write(resp); err != nil { + if recFile != nil { + if err := recFile.Write(resp, file.OptOverwrite); err != nil { return err } - cmd.Printf("Recovery data saved to: %s\n", file.Name()) + cmd.Printf("Recovery data saved to: %s\n", recFile.Name()) } else { cmd.Println(string(resp)) } diff --git a/cli/internal/cmd/manifestUpdate.go b/cli/internal/cmd/manifestUpdate.go index 28efac1f..f0459198 100644 --- a/cli/internal/cmd/manifestUpdate.go +++ b/cli/internal/cmd/manifestUpdate.go @@ -13,7 +13,6 @@ import ( "fmt" "io" "net/http" - "os" "github.com/edgelesssys/marblerun/cli/internal/file" "github.com/edgelesssys/marblerun/cli/internal/rest" @@ -114,13 +113,14 @@ func newUpdateGet() *cobra.Command { func runUpdateApply(cmd *cobra.Command, args []string) error { manifestFile := args[0] hostname := args[1] + fs := afero.NewOsFs() - client, err := rest.NewAuthenticatedClient(cmd, hostname) + client, err := newMutualAuthClient(hostname, cmd.Flags(), fs) if err != nil { return err } - manifest, err := loadManifestFile(file.New(manifestFile, afero.NewOsFs())) + manifest, err := loadManifestFile(file.New(manifestFile, fs)) if err != nil { return err } @@ -143,13 +143,14 @@ func cliManifestUpdateApply(cmd *cobra.Command, manifest []byte, client poster) func runUpdateAcknowledge(cmd *cobra.Command, args []string) error { manifestFile := args[0] hostname := args[1] + fs := afero.NewOsFs() - client, err := rest.NewAuthenticatedClient(cmd, hostname) + client, err := newMutualAuthClient(hostname, cmd.Flags(), fs) if err != nil { return err } - manifest, err := loadManifestFile(file.New(manifestFile, afero.NewOsFs())) + manifest, err := loadManifestFile(file.New(manifestFile, fs)) if err != nil { return err } @@ -171,7 +172,7 @@ func cliManifestUpdateAcknowledge(cmd *cobra.Command, manifest []byte, client po func runUpdateCancel(cmd *cobra.Command, args []string) error { hostname := args[0] - client, err := rest.NewAuthenticatedClient(cmd, hostname) + client, err := newMutualAuthClient(hostname, cmd.Flags(), afero.NewOsFs()) if err != nil { return err } @@ -191,6 +192,7 @@ func cliManifestUpdateCancel(cmd *cobra.Command, client poster) error { func runUpdateGet(cmd *cobra.Command, args []string) (retErr error) { hostname := args[0] + fs := afero.NewOsFs() outputFile, err := cmd.Flags().GetString("output") if err != nil { @@ -200,21 +202,31 @@ func runUpdateGet(cmd *cobra.Command, args []string) (retErr error) { if err != nil { return err } - client, err := rest.NewClient(cmd, hostname) + insecureTLS, err := cmd.Flags().GetBool("insecure") + if err != nil { + return err + } + + caCert, err := rest.LoadCoordinatorCachedCert(cmd.Flags(), fs) + if err != nil { + return err + } + + client, err := rest.NewClient(hostname, caCert, nil, insecureTLS) if err != nil { return err } var out io.Writer if outputFile != "" { - file, err := os.Create(outputFile) + file, err := fs.Create(outputFile) if err != nil { return err } defer func() { _ = file.Close() if retErr != nil { - _ = os.Remove(outputFile) + _ = fs.Remove(outputFile) } }() out = file diff --git a/cli/internal/cmd/manifestVerify.go b/cli/internal/cmd/manifestVerify.go index 1659e857..2bb8c7e1 100644 --- a/cli/internal/cmd/manifestVerify.go +++ b/cli/internal/cmd/manifestVerify.go @@ -36,21 +36,38 @@ func newManifestVerify() *cobra.Command { func runManifestVerify(cmd *cobra.Command, args []string) error { manifest := args[0] hostname := args[1] + fs := afero.NewOsFs() - localSignature, err := getSignatureFromString(manifest, afero.Afero{Fs: afero.NewOsFs()}) + localSignature, err := getSignatureFromString(manifest, fs) if err != nil { return err } - client, err := rest.NewClient(cmd, hostname) + restFlags, err := parseRestFlags(cmd.Flags()) if err != nil { return err } - return cliManifestVerify(cmd, localSignature, client) + caCert, err := rest.VerifyCoordinator( + cmd.Context(), cmd.OutOrStdout(), hostname, + restFlags.eraConfig, restFlags.k8sNamespace, restFlags.insecure, restFlags.acceptedTCBStatuses, + ) + if err != nil { + return err + } + + client, err := rest.NewClient(hostname, caCert, nil, restFlags.insecure) + if err != nil { + return err + } + if err := cliManifestVerify(cmd, localSignature, client); err != nil { + return err + } + + return rest.SaveCoordinatorCachedCert(cmd.Flags(), fs, caCert) } // getSignatureFromString checks if a string is a file or a valid signature. -func getSignatureFromString(manifest string, fs afero.Afero) (string, error) { +func getSignatureFromString(manifest string, fs afero.Fs) (string, error) { if _, err := fs.Stat(manifest); err != nil { if !errors.Is(err, afero.ErrFileNotFound) { return "", err diff --git a/cli/internal/cmd/recover.go b/cli/internal/cmd/recover.go index 85916e8e..fd6bbbd2 100644 --- a/cli/internal/cmd/recover.go +++ b/cli/internal/cmd/recover.go @@ -34,13 +34,30 @@ func NewRecoverCmd() *cobra.Command { func runRecover(cmd *cobra.Command, args []string) error { keyFile := args[0] hostname := args[1] + fs := afero.NewOsFs() - recoveryKey, err := file.New(keyFile, afero.NewOsFs()).Read() + recoveryKey, err := file.New(keyFile, fs).Read() if err != nil { return err } - client, err := rest.NewClient(cmd, hostname) + flags, err := parseRestFlags(cmd.Flags()) + if err != nil { + return err + } + + // A Coordinator in recovery mode will have a different certificate than what is cached + // Only unsealing the Coordinator will allow it to use the original certificate again + // Therefore we need to verify the Coordinator is running in the expected enclave instead + caCert, err := rest.VerifyCoordinator( + cmd.Context(), cmd.OutOrStdout(), hostname, + flags.eraConfig, flags.k8sNamespace, flags.insecure, flags.acceptedTCBStatuses, + ) + if err != nil { + return err + } + + client, err := rest.NewClient(hostname, caCert, nil, flags.insecure) if err != nil { return err } diff --git a/cli/internal/cmd/secretGet.go b/cli/internal/cmd/secretGet.go index 50299853..6071f71f 100644 --- a/cli/internal/cmd/secretGet.go +++ b/cli/internal/cmd/secretGet.go @@ -41,22 +41,23 @@ and need permissions in the manifest to read the requested secrets. func runSecretGet(cmd *cobra.Command, args []string) error { hostname := args[len(args)-1] secretIDs := args[0 : len(args)-1] + fs := afero.NewOsFs() output, err := cmd.Flags().GetString("output") if err != nil { return err } - client, err := rest.NewAuthenticatedClient(cmd, hostname) + client, err := newMutualAuthClient(hostname, cmd.Flags(), fs) if err != nil { return err } - return cliSecretGet(cmd, secretIDs, file.New(output, afero.NewOsFs()), client) + return cliSecretGet(cmd, secretIDs, file.New(output, fs), client) } // cliSecretGet requests one or more secrets from the MarbleRun Coordinator. -func cliSecretGet(cmd *cobra.Command, secretIDs []string, file *file.Handler, client getter) error { +func cliSecretGet(cmd *cobra.Command, secretIDs []string, secFile *file.Handler, client getter) error { var query []string for _, secretID := range secretIDs { query = append(query, "s", secretID) @@ -72,14 +73,14 @@ func cliSecretGet(cmd *cobra.Command, secretIDs []string, file *file.Handler, cl return fmt.Errorf("did not receive the same number of secrets as requested") } - if file == nil { + if secFile == nil { return printSecrets(cmd.OutOrStdout(), response) } - if err := file.Write([]byte(response.String())); err != nil { + if err := secFile.Write([]byte(response.String()), file.OptOverwrite); err != nil { return err } - cmd.Printf("Saved secrets to: %s\n", file.Name()) + cmd.Printf("Saved secrets to: %s\n", secFile.Name()) return nil } diff --git a/cli/internal/cmd/secretSet.go b/cli/internal/cmd/secretSet.go index ecc52aca..a568044e 100644 --- a/cli/internal/cmd/secretSet.go +++ b/cli/internal/cmd/secretSet.go @@ -50,13 +50,14 @@ marblerun secret set certificate.pem $MARBLERUN -c admin.crt -k admin.key --from func runSecretSet(cmd *cobra.Command, args []string) error { secretFile := args[0] hostname := args[1] + fs := afero.NewOsFs() fromPem, err := cmd.Flags().GetString("from-pem") if err != nil { return err } - newSecrets, err := file.New(secretFile, afero.NewOsFs()).Read() + newSecrets, err := file.New(secretFile, fs).Read() if err != nil { return err } @@ -68,7 +69,7 @@ func runSecretSet(cmd *cobra.Command, args []string) error { } } - client, err := rest.NewAuthenticatedClient(cmd, hostname) + client, err := newMutualAuthClient(hostname, cmd.Flags(), fs) if err != nil { return err } diff --git a/cli/internal/cmd/status.go b/cli/internal/cmd/status.go index 697a92a3..527596ed 100644 --- a/cli/internal/cmd/status.go +++ b/cli/internal/cmd/status.go @@ -12,6 +12,7 @@ import ( "net/http" "github.com/edgelesssys/marblerun/cli/internal/rest" + "github.com/spf13/afero" "github.com/spf13/cobra" ) @@ -53,7 +54,16 @@ func NewStatusCmd() *cobra.Command { func runStatus(cmd *cobra.Command, args []string) error { hostname := args[0] - client, err := rest.NewClient(cmd, hostname) + caCert, err := rest.LoadCoordinatorCachedCert(cmd.Flags(), afero.NewOsFs()) + if err != nil { + return err + } + insecureTLS, err := cmd.Flags().GetBool("insecure") + if err != nil { + return err + } + + client, err := rest.NewClient(hostname, caCert, nil, insecureTLS) if err != nil { return err } diff --git a/cli/internal/cmd/uninstall.go b/cli/internal/cmd/uninstall.go index 8b1db3ca..a7a02f8f 100644 --- a/cli/internal/cmd/uninstall.go +++ b/cli/internal/cmd/uninstall.go @@ -33,19 +33,26 @@ func NewUninstallCmd() *cobra.Command { } func runUninstall(cmd *cobra.Command, _ []string) error { + namespace, err := cmd.Flags().GetString("namespace") + if err != nil { + return err + } + kubeClient, err := kube.NewClient() if err != nil { return err } - helmClient, err := helm.New() + helmClient, err := helm.New(namespace) if err != nil { return err } - return cliUninstall(cmd, helmClient, kubeClient) + return cliUninstall(cmd, helmClient, kubeClient, namespace) } // cliUninstall uninstalls MarbleRun. -func cliUninstall(cmd *cobra.Command, helmClient *helm.Client, kubeClient kubernetes.Interface) error { +func cliUninstall( + cmd *cobra.Command, helmClient *helm.Client, kubeClient kubernetes.Interface, namespace string, +) error { wait, err := cmd.Flags().GetBool("wait") if err != nil { return err @@ -56,7 +63,7 @@ func cliUninstall(cmd *cobra.Command, helmClient *helm.Client, kubeClient kubern // If we get a "not found" error the resource was already removed / never created // and we can continue on without a problem - if err := cleanupSecrets(cmd.Context(), kubeClient); err != nil && !errors.IsNotFound(err) { + if err := cleanupSecrets(cmd.Context(), kubeClient, namespace); err != nil && !errors.IsNotFound(err) { return err } @@ -70,8 +77,8 @@ func cliUninstall(cmd *cobra.Command, helmClient *helm.Client, kubeClient kubern } // cleanupSecrets removes secretes set for the Admission Controller. -func cleanupSecrets(ctx context.Context, kubeClient kubernetes.Interface) error { - return kubeClient.CoreV1().Secrets(helm.Namespace).Delete(ctx, "marble-injector-webhook-certs", metav1.DeleteOptions{}) +func cleanupSecrets(ctx context.Context, kubeClient kubernetes.Interface, namespace string) error { + return kubeClient.CoreV1().Secrets(namespace).Delete(ctx, "marble-injector-webhook-certs", metav1.DeleteOptions{}) } // cleanupCSR removes a potentially leftover CSR from the Admission Controller. diff --git a/cli/internal/cmd/uninstall_test.go b/cli/internal/cmd/uninstall_test.go index 5785a141..8ade921b 100644 --- a/cli/internal/cmd/uninstall_test.go +++ b/cli/internal/cmd/uninstall_test.go @@ -97,7 +97,7 @@ func TestCleanupWebhook(t *testing.T) { _, err = testClient.CoreV1().Secrets(helm.Namespace).Get(ctx, "marble-injector-webhook-certs", metav1.GetOptions{}) require.Error(err) - err = cleanupSecrets(ctx, testClient) + err = cleanupSecrets(ctx, testClient, helm.Namespace) require.Error(err) assert.True(errors.IsNotFound(err), "function returned an error other than not found") @@ -119,6 +119,6 @@ func TestCleanupWebhook(t *testing.T) { _, err = testClient.CoreV1().Secrets(helm.Namespace).Get(ctx, "marble-injector-webhook-certs", metav1.GetOptions{}) require.NoError(err) - err = cleanupSecrets(ctx, testClient) + err = cleanupSecrets(ctx, testClient, helm.Namespace) require.NoError(err) } diff --git a/cli/internal/cmd/version.go b/cli/internal/cmd/version.go index ef177cc8..4b13c2e3 100644 --- a/cli/internal/cmd/version.go +++ b/cli/internal/cmd/version.go @@ -32,8 +32,13 @@ func NewVersionCmd() *cobra.Command { func runVersion(cmd *cobra.Command, _ []string) { cmd.Printf("CLI Version: v%s \nCommit: %s\n", Version, GitCommit) + namespace, err := cmd.Flags().GetString("namespace") + if err != nil { + cmd.Println(err) + return + } - cVersion, err := kube.CoordinatorVersion(cmd.Context()) + cVersion, err := kube.CoordinatorVersion(cmd.Context(), namespace) if err != nil { cmd.Println("Unable to find MarbleRun Coordinator") return diff --git a/cli/internal/file/file.go b/cli/internal/file/file.go index d3c4d8e2..2c4b5b21 100644 --- a/cli/internal/file/file.go +++ b/cli/internal/file/file.go @@ -6,7 +6,25 @@ package file -import "github.com/spf13/afero" +import ( + "errors" + "os" + "path/filepath" + + "github.com/spf13/afero" +) + +// Option are extra options for the file writer. +type Option uint + +const ( + // OptNone is the default option. + OptNone Option = 1 << iota + // OptOverwrite overwrites the file if it already exist. + OptOverwrite + // OptMkdirAll creates the parent directory if it does not exist. + OptMkdirAll +) // Handler is a wrapper around afero.Afero, // providing a simple interface for reading and writing files. @@ -30,8 +48,31 @@ func New(filename string, fs afero.Fs) *Handler { } // Write writes the given data to the file. -func (f *Handler) Write(data []byte) error { - return f.fs.WriteFile(f.filename, data, 0o644) +func (f *Handler) Write(data []byte, opt ...Option) error { + opts := OptNone + for _, o := range opt { + opts |= o + } + + if opts&OptMkdirAll != 0 { + if err := f.fs.MkdirAll(filepath.Dir(f.filename), 0o755); err != nil { + return err + } + } + + flags := os.O_WRONLY | os.O_CREATE | os.O_EXCL + if opts&OptOverwrite != 0 { + flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + } + + file, err := f.fs.OpenFile(f.filename, flags, 0o600) + if err != nil { + return err + } + + _, err = file.Write(data) + errTmp := file.Close() + return errors.Join(err, errTmp) } // Name returns the filename. diff --git a/cli/internal/helm/client.go b/cli/internal/helm/client.go index 35a05a83..c078b77f 100644 --- a/cli/internal/helm/client.go +++ b/cli/internal/helm/client.go @@ -44,23 +44,25 @@ type Options struct { // Client provides functionality to install and uninstall Helm charts. type Client struct { - config *action.Configuration - settings *cli.EnvSettings + namespace string + config *action.Configuration + settings *cli.EnvSettings } // New initializes a new helm client. -func New() (*Client, error) { +func New(namespace string) (*Client, error) { settings := cli.New() // settings.KubeConfig = kubeConfigPath actionConfig := &action.Configuration{} - if err := actionConfig.Init(settings.RESTClientGetter(), Namespace, os.Getenv("HELM_DRIVER"), nopLog); err != nil { + if err := actionConfig.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER"), nopLog); err != nil { return nil, err } return &Client{ - config: actionConfig, - settings: settings, + namespace: namespace, + config: actionConfig, + settings: settings, }, nil } @@ -189,7 +191,7 @@ func UpdateValues(options Options, chartValues map[string]interface{}) (map[stri // Install installs MarbleRun using the provided chart and values. func (c *Client) Install(ctx context.Context, wait bool, chart *chart.Chart, values map[string]interface{}) error { installer := action.NewInstall(c.config) - installer.Namespace = Namespace + installer.Namespace = c.namespace installer.ReleaseName = release installer.CreateNamespace = true installer.Wait = wait diff --git a/cli/internal/kube/client.go b/cli/internal/kube/client.go index f5d3a78b..59b113a4 100644 --- a/cli/internal/kube/client.go +++ b/cli/internal/kube/client.go @@ -45,13 +45,13 @@ func NewClient() (*kubernetes.Clientset, error) { } // CoordinatorVersion returns the version of the Coordinator deployment. -func CoordinatorVersion(ctx context.Context) (string, error) { +func CoordinatorVersion(ctx context.Context, namespace string) (string, error) { kubeClient, err := NewClient() if err != nil { return "", err } - coordinatorDeployment, err := kubeClient.AppsV1().Deployments(helm.Namespace).Get(ctx, helm.CoordinatorDeployment, metav1.GetOptions{}) + coordinatorDeployment, err := kubeClient.AppsV1().Deployments(namespace).Get(ctx, helm.CoordinatorDeployment, metav1.GetOptions{}) if err != nil { return "", fmt.Errorf("retrieving deployment information: %w", err) } diff --git a/cli/internal/rest/cert.go b/cli/internal/rest/cert.go new file mode 100644 index 00000000..1a0d2d35 --- /dev/null +++ b/cli/internal/rest/cert.go @@ -0,0 +1,85 @@ +// Copyright (c) Edgeless Systems GmbH. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package rest + +import ( + "crypto/tls" + "encoding/pem" + + "github.com/edgelesssys/marblerun/cli/internal/file" + "github.com/spf13/afero" + "github.com/spf13/pflag" +) + +// SaveCoordinatorCachedCert saves the Coordinator's certificate to a cert cache. +func SaveCoordinatorCachedCert(flags *pflag.FlagSet, fs afero.Fs, caCert []*pem.Block) error { + certName, err := flags.GetString("coordinator-cert") + if err != nil { + return err + } + return saveCert(file.New(certName, fs), caCert) +} + +// LoadCoordinatorCachedCert loads a cached Coordinator certificate. +func LoadCoordinatorCachedCert(flags *pflag.FlagSet, fs afero.Fs) (caCert []*pem.Block, err error) { + // Skip loading the certificate if we're accepting insecure connections. + if insecure, err := flags.GetBool("insecure"); err != nil { + return nil, err + } else if insecure { + return nil, nil + } + certName, err := flags.GetString("coordinator-cert") + if err != nil { + return nil, err + } + return loadCert(file.New(certName, fs)) +} + +// LoadClientCert parses the command line flags to load a TLS client certificate. +func LoadClientCert(flags *pflag.FlagSet) (*tls.Certificate, error) { + certFile, err := flags.GetString("cert") + if err != nil { + return nil, err + } + keyFile, err := flags.GetString("key") + if err != nil { + return nil, err + } + clientCert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + + return &clientCert, nil +} + +func saveCert(fh *file.Handler, caCert []*pem.Block) error { + var pemCert []byte + for _, block := range caCert { + pemCert = append(pemCert, pem.EncodeToMemory(block)...) + } + + return fh.Write(pemCert, file.OptMkdirAll|file.OptOverwrite) +} + +func loadCert(file *file.Handler) ([]*pem.Block, error) { + pemCert, err := file.Read() + if err != nil { + return nil, err + } + + var caCert []*pem.Block + for { + var block *pem.Block + block, pemCert = pem.Decode(pemCert) + if block == nil { + break + } + caCert = append(caCert, block) + } + return caCert, nil +} diff --git a/cli/internal/rest/cert_test.go b/cli/internal/rest/cert_test.go new file mode 100644 index 00000000..feff60ee --- /dev/null +++ b/cli/internal/rest/cert_test.go @@ -0,0 +1,218 @@ +// Copyright (c) Edgeless Systems GmbH. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package rest + +import ( + "encoding/pem" + "strings" + "testing" + + "github.com/spf13/afero" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSaveCoordinatorCachedCert(t *testing.T) { + defaultFlags := func() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.String("coordinator-cert", "cert", "") + return flagSet + } + + testCases := map[string]struct { + flags *pflag.FlagSet + certs []*pem.Block + fs afero.Fs + wantErr bool + }{ + "write error": { + flags: defaultFlags(), + certs: []*pem.Block{ + { + Type: "CERTIFICATE", + Bytes: []byte("cert"), + }, + }, + fs: afero.NewReadOnlyFs(afero.NewMemMapFs()), + wantErr: true, + }, + "success": { + flags: defaultFlags(), + certs: []*pem.Block{ + { + Type: "CERTIFICATE", + Bytes: []byte("cert"), + }, + }, + fs: afero.NewMemMapFs(), + }, + "cert chain": { + flags: defaultFlags(), + certs: []*pem.Block{ + { + Type: "CERTIFICATE", + Bytes: []byte("intermediate"), + }, + { + Type: "CERTIFICATE", + Bytes: []byte("root"), + }, + }, + fs: afero.NewMemMapFs(), + }, + "cert flag not defined": { + flags: pflag.NewFlagSet("test", pflag.ContinueOnError), + certs: []*pem.Block{ + { + Type: "CERTIFICATE", + Bytes: []byte("cert"), + }, + }, + fs: afero.NewMemMapFs(), + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + err := SaveCoordinatorCachedCert(tc.flags, tc.fs, tc.certs) + if tc.wantErr { + assert.Error(err) + return + } + assert.NoError(err) + certLocation, err := tc.flags.GetString("coordinator-cert") + require.NoError(err) + data, err := afero.ReadFile(tc.fs, certLocation) + require.NoError(err) + assert.Len(tc.certs, strings.Count(string(data), "-----BEGIN CERTIFICATE-----")) + }) + } +} + +func TestLoadCoordinatorCachedCert(t *testing.T) { + defaultCertName := "cert" + defaultFlags := func() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.String("coordinator-cert", defaultCertName, "") + flagSet.Bool("insecure", false, "") + return flagSet + } + + testCases := map[string]struct { + flags *pflag.FlagSet + fs afero.Fs + wantErr bool + }{ + "success": { + flags: defaultFlags(), + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, defaultCertName, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: []byte("cert"), + }), 0o644) + require.NoError(t, err) + return fs + }(), + }, + "success cert chain": { + flags: defaultFlags(), + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, defaultCertName, + append( + pem.EncodeToMemory( + &pem.Block{ + Type: "CERTIFICATE", + Bytes: []byte("intermediate"), + }, + ), + pem.EncodeToMemory( + &pem.Block{ + Type: "CERTIFICATE", + Bytes: []byte("root"), + }, + )..., + ), 0o644) + require.NoError(t, err) + return fs + }(), + }, + "cert flag not defined": { + flags: func() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.Bool("insecure", false, "") + return flagSet + }(), + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, defaultCertName, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: []byte("cert"), + }), 0o644) + require.NoError(t, err) + return fs + }(), + wantErr: true, + }, + "insecure flag not defined": { + flags: func() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.String("coordinator-cert", defaultCertName, "") + return flagSet + }(), + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, defaultCertName, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: []byte("cert"), + }), 0o644) + require.NoError(t, err) + return fs + }(), + wantErr: true, + }, + "no cert": { + flags: defaultFlags(), + fs: afero.NewMemMapFs(), + wantErr: true, + }, + "insecure flag disables cert loading": { + flags: func() *pflag.FlagSet { + flagSet := pflag.NewFlagSet("test", pflag.ContinueOnError) + flagSet.String("coordinator-cert", defaultCertName, "") + flagSet.Bool("insecure", true, "") + return flagSet + }(), + fs: afero.NewMemMapFs(), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + cert, err := LoadCoordinatorCachedCert(tc.flags, tc.fs) + if tc.wantErr { + assert.Error(err) + return + } + assert.NoError(err) + insecure, err := tc.flags.GetBool("insecure") + require.NoError(err) + if !insecure { + assert.NotNil(cert) + } + }) + } +} diff --git a/cli/internal/rest/rest.go b/cli/internal/rest/rest.go index f5dd65c4..38ea911c 100644 --- a/cli/internal/rest/rest.go +++ b/cli/internal/rest/rest.go @@ -25,7 +25,6 @@ import ( "github.com/edgelesssys/era/era" "github.com/edgelesssys/era/util" "github.com/edgelesssys/marblerun/cli/internal/kube" - "github.com/spf13/cobra" "github.com/tidwall/gjson" "k8s.io/client-go/tools/clientcmd" ) @@ -49,124 +48,37 @@ const ( dataField = "data" ) -// Flags are command line flags used to configure the REST client. -type Flags struct { - EraConfig string - Insecure bool - AcceptedTCBStatuses []string -} - -// ParseFlags parses the command line flags used to configure the REST client. -func ParseFlags(cmd *cobra.Command) (Flags, error) { - eraConfig, err := cmd.Flags().GetString("era-config") - if err != nil { - return Flags{}, err - } - insecure, err := cmd.Flags().GetBool("insecure") - if err != nil { - return Flags{}, err - } - acceptedTCBStatuses, err := cmd.Flags().GetStringSlice("accepted-tcb-statuses") - if err != nil { - return Flags{}, err - } - - return Flags{ - EraConfig: eraConfig, - Insecure: insecure, - AcceptedTCBStatuses: acceptedTCBStatuses, - }, nil -} - -type authenticatedFlags struct { - Flags - ClientCert tls.Certificate -} - -func parseAuthenticatedFlags(cmd *cobra.Command) (authenticatedFlags, error) { - flags, err := ParseFlags(cmd) - if err != nil { - return authenticatedFlags{}, err - } - certFile, err := cmd.Flags().GetString("cert") - if err != nil { - return authenticatedFlags{}, err - } - keyFile, err := cmd.Flags().GetString("key") - if err != nil { - return authenticatedFlags{}, err - } - clientCert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return authenticatedFlags{}, err - } - - return authenticatedFlags{ - Flags: flags, - ClientCert: clientCert, - }, nil -} - // Client is a REST client for the MarbleRun Coordinator. type Client struct { client *http.Client host string } -// NewClient creates and returns an http client using the flags of cmd. -func NewClient(cmd *cobra.Command, host string) (*Client, error) { - flags, err := ParseFlags(cmd) - if err != nil { - return nil, fmt.Errorf("parsing flags: %w", err) - } - caCert, err := VerifyCoordinator( - cmd.Context(), cmd.OutOrStdout(), host, - flags.EraConfig, flags.Insecure, flags.AcceptedTCBStatuses, - ) - if err != nil { - return nil, fmt.Errorf("failed to verify Coordinator enclave: %w", err) - } - return newClient(host, caCert, nil) -} - -// NewAuthenticatedClient creates and returns an http client with client authentication using the flags of cmd. -func NewAuthenticatedClient(cmd *cobra.Command, host string) (*Client, error) { - flags, err := parseAuthenticatedFlags(cmd) - if err != nil { - return nil, fmt.Errorf("parsing flags: %w", err) - } - caCert, err := VerifyCoordinator( - cmd.Context(), cmd.OutOrStdout(), host, - flags.EraConfig, flags.Insecure, flags.AcceptedTCBStatuses, - ) - if err != nil { - return nil, fmt.Errorf("failed to verify Coordinator enclave: %w", err) - } - return newClient(host, caCert, &flags.ClientCert) -} - -func newClient(host string, caCert []*pem.Block, clCert *tls.Certificate) (*Client, error) { +// NewClient creates and returns an http client using the given certificate of the server. +// An optional clientCert can be used to enable client authentication. +func NewClient(host string, caCert []*pem.Block, clientCert *tls.Certificate, insecureTLS bool) (*Client, error) { // Set rootCA for connection to Coordinator certPool := x509.NewCertPool() - if ok := certPool.AppendCertsFromPEM(pem.EncodeToMemory(caCert[len(caCert)-1])); !ok { - return nil, errors.New("failed to parse certificate") - } - // Add intermediate cert if applicable - if len(caCert) > 1 { - if ok := certPool.AppendCertsFromPEM(pem.EncodeToMemory(caCert[0])); !ok { + + if len(caCert) == 0 && !insecureTLS { + return nil, errors.New("no certificates provided") + } else if !insecureTLS { + if ok := certPool.AppendCertsFromPEM(pem.EncodeToMemory(caCert[len(caCert)-1])); !ok { return nil, errors.New("failed to parse certificate") } } var tlsConfig *tls.Config - if clCert != nil { + if clientCert != nil { tlsConfig = &tls.Config{ - RootCAs: certPool, - Certificates: []tls.Certificate{*clCert}, + RootCAs: certPool, + Certificates: []tls.Certificate{*clientCert}, + InsecureSkipVerify: insecureTLS, } } else { tlsConfig = &tls.Config{ - RootCAs: certPool, + RootCAs: certPool, + InsecureSkipVerify: insecureTLS, } } client := &http.Client{ @@ -244,7 +156,7 @@ func (c *Client) do(req *http.Request) ([]byte, error) { // VerifyCoordinator verifies the connection to the MarbleRun Coordinator. func VerifyCoordinator( - ctx context.Context, out io.Writer, host, configFilename string, + ctx context.Context, out io.Writer, host, configFilename, k8sNamespace string, insecure bool, acceptedTCBStatuses []string, ) ([]*pem.Block, error) { // skip verification if specified @@ -260,7 +172,7 @@ func VerifyCoordinator( // or try to get latest config from github if it does not exist if _, err := os.Stat(configFilename); err == nil { fmt.Fprintln(out, "Reusing existing config file") - } else if err := fetchLatestCoordinatorConfiguration(ctx, out); err != nil { + } else if err := fetchLatestCoordinatorConfiguration(ctx, out, k8sNamespace); err != nil { return nil, err } } @@ -276,8 +188,8 @@ func VerifyCoordinator( return pemBlock, nil } -func fetchLatestCoordinatorConfiguration(ctx context.Context, out io.Writer) error { - coordinatorVersion, err := kube.CoordinatorVersion(ctx) +func fetchLatestCoordinatorConfiguration(ctx context.Context, out io.Writer, k8sNamespace string) error { + coordinatorVersion, err := kube.CoordinatorVersion(ctx, k8sNamespace) eraURL := fmt.Sprintf("https://github.com/edgelesssys/marblerun/releases/download/%s/coordinator-era.json", coordinatorVersion) if err != nil { // if errors were caused by an empty kube config file or by being unable to connect to a cluster we assume the Coordinator is running as a standalone diff --git a/docs/docs/getting-started/quickstart.md b/docs/docs/getting-started/quickstart.md index d0a63b9a..f5866e3b 100644 --- a/docs/docs/getting-started/quickstart.md +++ b/docs/docs/getting-started/quickstart.md @@ -5,6 +5,17 @@ The following steps guide you through the process of deploying MarbleRun in your A working SGX DCAP environment is required for MarbleRun. For ease of exploring and testing, we provide a simulation mode with `--simulation` that runs without SGX hardware. Depending on your setup, you may follow the quickstart for SGX-enabled clusters. Alternatively, if your setup doesn't support SGX, you can follow the quickstart in simulation mode by selecting the respective tabs. +## Step 0: Clone the demo repository + +To get a feel for how MarbleRun would work for one of your services, you can install a demo application. +The emojivoto application is a standalone Kubernetes application that uses a mix of gRPC and HTTP calls to allow users to vote on their favorite emojis. +Created as a demo application for the popular [Linkerd](https://linkerd.io) service mesh, we've made a confidential variant that uses a confidential service mesh for all gRPC and HTTP connections. +Clone the [demo application's repository](https://github.com/edgelesssys/emojivoto.git) from GitHub by running the following: + +```bash +git clone https://github.com/edgelesssys/emojivoto.git && cd emojivoto +``` + ## Step 1: Install the control plane onto your cluster Install MarbleRun's *Coordinator* control plane by running: @@ -48,61 +59,33 @@ kubectl -n marblerun port-forward svc/coordinator-client-api 4433:4433 --address export MARBLERUN=localhost:4433 ``` -## Step 2: Verify the Coordinator +### Step 2: Configure MarbleRun -After installing the Coordinator, we need to verify its integrity. -For this, we utilize SGX remote attestation and obtain the Coordinator's root certificate. - -Verify the quote and get the Coordinator's root certificate +MarbleRun guarantees that the topology of your distributed app adheres to a manifest specified in simple JSON. +MarbleRun verifies the integrity of services, bootstraps them, and sets up encrypted connections between them. +The emojivoto demo already comes with a [manifest](https://github.com/edgelesssys/emojivoto/blob/main/tools/manifest.json), which you can deploy onto MarbleRun by running the following: ```bash -marblerun certificate root $MARBLERUN -o marblerun.crt -``` - - - - -```bash -marblerun certificate root $MARBLERUN -o marblerun.crt --insecure +marblerun manifest set tools/manifest.json $MARBLERUN ``` -The insecure flag tells MarbleRun that real SGX hardware might not be present and the quote verification should be omitted. - - - - The CLI will obtain the Coordinator's remote attestation quote and verify it against the configuration on our [release page](https://github.com/edgelesssys/marblerun/releases/latest/download/coordinator-era.json). The SGX quote proves the integrity of the Coordinator pod. -The CLI returns a certificate and stores it as `marblerun.crt` in your current directory. -The certificate is bound to the quote and can be used for future verification. +The CLI saves the TLS certificate of the Coordinator as `coordinator-cert.pem` in your config directory. +The certificate is bound to the quote and is used by the CLI for future verification. It can also be used as a root of trust for [authenticating your confidential applications](../features/attestation.md). -## Step 3: Deploy the demo application - -To get a feel for how MarbleRun would work for one of your services, you can install a demo application. -The emojivoto application is a standalone Kubernetes application that uses a mix of gRPC and HTTP calls to allow users to vote on their favorite emojis. -Created as a demo application for the popular [Linkerd](https://linkerd.io) service mesh, we've made a confidential variant that uses a confidential service mesh for all gRPC and HTTP connections. -Clone the [demo application's repository](https://github.com/edgelesssys/emojivoto.git) from GitHub by running the following: - -```bash -git clone https://github.com/edgelesssys/emojivoto.git && cd emojivoto -``` - -### Step 3.1: Configure MarbleRun +:::info -MarbleRun guarantees that the topology of your distributed app adheres to a manifest specified in simple JSON. -MarbleRun verifies the integrity of services, bootstraps them, and sets up encrypted connections between them. -The emojivoto demo already comes with a [manifest](https://github.com/edgelesssys/emojivoto/blob/main/tools/manifest.json), which you can deploy onto MarbleRun by running the following: - - - +By default the certificate is saved to `$XDG_CONFIG_HOME/marblerun/coordinator-cert.pem`, +or `$HOME/.config/marblerun/coordinator-cert.pem` if `$XDG_CONFIG_HOME` is not set. +Subsequent CLI commands will try loading the certificate from that location. +Use the `--coordinator-cert` flag to choose your own location to save or load the certificate. -```bash -marblerun manifest set tools/manifest.json $MARBLERUN -``` +::: @@ -111,6 +94,9 @@ marblerun manifest set tools/manifest.json $MARBLERUN marblerun manifest set tools/manifest.json $MARBLERUN --insecure ``` +The insecure flag tells MarbleRun that real SGX hardware might not be present and the quote verification should be omitted. +Since this bypasses all security checks on the TLS connection to the Coordinator, you MUST NOT use it in production. + @@ -133,7 +119,7 @@ marblerun status $MARBLERUN --insecure -### Step 3.2: Deploy emojivoto +## Step 3: Deploy the demo application Finally, install the demo application onto your cluster. Please make sure you have [Helm](https://helm.sh/docs/intro/install/) ("the package manager for Kubernetes") installed at least at Version v3.2.0. diff --git a/docs/docs/workflows/set-manifest.md b/docs/docs/workflows/set-manifest.md index ef71338a..924dbe5b 100644 --- a/docs/docs/workflows/set-manifest.md +++ b/docs/docs/workflows/set-manifest.md @@ -9,6 +9,17 @@ marblerun manifest set manifest.json $MARBLERUN ``` The command first performs [remote attestation on the Coordinator](../features/attestation.md#coordinator-deployment) before uploading the manifest. +If successful, the TLS root certificate of the Coordinator is saved for future connections with the MarbleRun instance. +This ensures you are always talking to the same instance the manifest was uploaded to. + +:::info + +By default the certificate is saved to `$XDG_CONFIG_HOME/marblerun/coordinator-cert.pem`, +or `$HOME/.config/marblerun/coordinator-cert.pem` if `$XDG_CONFIG_HOME` is not set. +Subsequent CLI commands will try loading the certificate from that location. +Use the `--coordinator-cert` flag to choose your own location to save or load the certificate. + +::: If the manifest contains a `RecoveryKeys` entry, you will receive a JSON reply including the recovery secrets, encrypted with the public keys supplied in `RecoveryKeys`. diff --git a/docs/docs/workflows/verification.md b/docs/docs/workflows/verification.md index f916b777..81e837eb 100644 --- a/docs/docs/workflows/verification.md +++ b/docs/docs/workflows/verification.md @@ -41,3 +41,15 @@ You can verify your local `manifest.json` against the Coordinator's version with ```bash marblerun manifest verify manifest.json $MARBLERUN ``` + +If successful, the TLS root certificate of the Coordinator is saved for future connections with the MarbleRun instance. +This ensures you are always talking to the same instance that you verified the manifest against. + +:::info + +By default `marblerun manifest verify` will save the Coordinators certificate chain to `$XDG_CONFIG_HOME/marblerun/coordinator-cert.pem`, +or `$HOME/.config/marblerun/coordinator-cert.pem` if `$XDG_CONFIG_HOME` is not set. +Subsequent CLI commands will try loading the certificate from that location. +Use the `--coordinator-cert` flag to choose your own location to save or load the certificate. + +:::