diff --git a/README.md b/README.md index 71d193a22..fe35ddaf0 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,31 @@ This step creates the following defaults: 2. component files in the components folder called `pubsub.yaml` and `statestore.yaml`. 3. default config file `$HOME/.dapr/config.yaml` for Linux/MacOS or for Windows at `%USERPROFILE%\.dapr\config.yaml` to enable tracing on `dapr init` call. Can be overridden with the `--config` flag on `dapr run`. +#### Initialize Dapr with mTLS (self-hosted) + +To enable mutual TLS and start the local Sentry certificate authority in one step: + +```bash +dapr init --enable-mtls +``` + +This command: + +- Generates root and issuer certificates under `$HOME/.dapr/certs/` (`ca.crt`, `issuer.crt`, `issuer.key`) +- Starts the `dapr_sentry` container alongside placement, scheduler, redis, and zipkin +- Writes `spec.mtls.enabled: true` to the default `$HOME/.dapr/config.yaml` + +After that, `dapr run` uses the default config and obtains a SPIFFE workload identity from Sentry without setting `DAPR_TRUST_ANCHORS`, `DAPR_CERT_CHAIN`, or `DAPR_CERT_KEY` manually. + +Output should look like: + +``` +ℹ️ dapr_sentry container is running. +ℹ️ Sentry running, mTLS enabled, trust domain: cluster.local +``` + +`dapr uninstall` removes the Sentry container along with the other self-hosted services. + #### Slim Init Alternatively to the above, to have the CLI not install any default configuration files or run Docker containers, use the `--slim` flag with the init command. Only Dapr binaries will be installed. diff --git a/cmd/init.go b/cmd/init.go index 80d05443f..7d1e721c4 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -99,6 +99,9 @@ dapr init --runtime-path # Initialize Dapr in self-hosted mode with Redis Stack for RediSearch/vector store support dapr init --redis-stack +# Initialize Dapr in self-hosted mode with mTLS enabled (starts Sentry service) +dapr init --enable-mtls + # See more at: https://docs.dapr.io/getting-started/ `, Run: func(cmd *cobra.Command, args []string) { @@ -188,6 +191,7 @@ dapr init --redis-stack RuntimeVersion: runtimeVersion, DockerNetwork: dockerNetwork, SlimMode: slimMode, + EnableMTLS: cmd.Flags().Changed("enable-mtls") && enableMTLS, ImageRegistryURL: imageRegistryURI, FromDir: fromDir, ContainerRuntime: containerRuntime, diff --git a/pkg/standalone/common.go b/pkg/standalone/common.go index 5e2ee888e..1141da2a1 100644 --- a/pkg/standalone/common.go +++ b/pkg/standalone/common.go @@ -27,6 +27,7 @@ const ( defaultDaprBinDirName = "bin" defaultComponentsDirName = "components" + defaultCertsDirName = "certs" defaultSchedulerDirName = "scheduler" defaultSchedulerDataDirName = "data" ) @@ -84,3 +85,7 @@ func GetDaprComponentsPath(daprDir string) string { func GetDaprConfigPath(daprDir string) string { return path_filepath.Join(daprDir, DefaultConfigFileName) } + +func GetDaprCertsPath(daprDir string) string { + return path_filepath.Join(daprDir, defaultCertsDirName) +} diff --git a/pkg/standalone/mtls.go b/pkg/standalone/mtls.go new file mode 100644 index 000000000..8d53c926c --- /dev/null +++ b/pkg/standalone/mtls.go @@ -0,0 +1,151 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package standalone + +import ( + "fmt" + "os" + "runtime" + "sync" + + "github.com/dapr/cli/utils" + "gopkg.in/yaml.v2" +) + +const ( + credentialsContainerPath = "/var/run/dapr/credentials" + trustAnchorsContainerPath = credentialsContainerPath + "/" + trustAnchorsFile + sentryAddressHostGateway = "host.docker.internal:50001" +) + +func sentryAddressForContainer(dockerNetwork string) string { + if dockerNetwork != "" { + return DaprSentryContainerName + ":50001" + } + return sentryAddressHostGateway +} + +func appendDockerHostGateway(dockerRunArgs []string, dockerNetwork string) []string { + if dockerNetwork == "" && runtime.GOOS != daprWindowsOS { + return append(dockerRunArgs, "--add-host=host.docker.internal:host-gateway") + } + return dockerRunArgs +} + +func appendMTLSCredentialsMount(dockerRunArgs []string, installDir string) []string { + certsDir := GetDaprCertsPath(installDir) + return append(dockerRunArgs, "-v", certsDir+":"+credentialsContainerPath) +} + +func mtlsControlPlaneServiceArgs(dockerNetwork string) []string { + return []string{ + "--mode", sentryStandaloneMode, + "--tls-enabled", + "--trust-domain", defaultTrustDomain, + "--trust-anchors-file", trustAnchorsContainerPath, + "--sentry-address", sentryAddressForContainer(dockerNetwork), + } +} + +func appendMTLSContainerRunArgs(dockerRunArgs []string, info initInfo) []string { + if !info.enableMTLS { + return dockerRunArgs + } + dockerRunArgs = appendMTLSCredentialsMount(dockerRunArgs, info.installDir) + return appendDockerHostGateway(dockerRunArgs, info.dockerNetwork) +} + +func buildSentryContainerRunArgs(info initInfo, image string) []string { + certsDir := GetDaprCertsPath(info.installDir) + configPath := GetDaprConfigPath(info.installDir) + sentryContainerName := utils.CreateContainerName(DaprSentryContainerName, info.dockerNetwork) + + args := []string{ + "run", + "--name", sentryContainerName, + "--restart", "always", + "-d", + "--entrypoint", "./sentry", + "-v", certsDir + ":/var/run/dapr/credentials", + "-v", configPath + ":" + sentryConfigContainerPath + ":ro", + } + + if info.dockerNetwork != "" { + args = append(args, + "--network", info.dockerNetwork, + "--network-alias", DaprSentryContainerName) + } else { + args = append(args, + "-p", fmt.Sprintf("%v:50001", sentryGRPCPort), + "-p", fmt.Sprintf("%v:8080", sentryHealthPort), + "-p", fmt.Sprintf("%v:9090", sentryMetricPort), + ) + } + + args = append(args, image, + "--mode", sentryStandaloneMode, + "--config", sentryConfigContainerPath, + "--issuer-credentials", credentialsContainerPath, + "--trust-domain", defaultTrustDomain, + ) + + return args +} + +func mergeMTLSIntoConfiguration(filePath string) error { + b, err := os.ReadFile(filePath) + if err != nil { + return err + } + + var config configuration + if err := yaml.Unmarshal(b, &config); err != nil { + return err + } + if config.APIVersion == "" { + config.APIVersion = "dapr.io/v1alpha1" + } + if config.Kind == "" { + config.Kind = "Configuration" + } + if config.Metadata.Name == "" { + config.Metadata.Name = "daprConfig" + } + config.Spec.MTLS.Enabled = true + + out, err := yaml.Marshal(&config) + if err != nil { + return err + } + return os.WriteFile(filePath, out, 0o644) +} + +func runParallelInitSteps(steps []func(*sync.WaitGroup, chan<- error, initInfo), info initInfo) error { + var wg sync.WaitGroup + errorChan := make(chan error, len(steps)) + wg.Add(len(steps)) + for _, step := range steps { + go step(&wg, errorChan, info) + } + go func() { + wg.Wait() + close(errorChan) + }() + for err := range errorChan { + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/standalone/mtls_test.go b/pkg/standalone/mtls_test.go new file mode 100644 index 000000000..b734b9396 --- /dev/null +++ b/pkg/standalone/mtls_test.go @@ -0,0 +1,119 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package standalone + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSentryAddressForContainer(t *testing.T) { + assert.Equal(t, "dapr_sentry:50001", sentryAddressForContainer("dapr-network")) + assert.Equal(t, sentryAddressHostGateway, sentryAddressForContainer("")) +} + +func TestMTLSControlPlaneServiceArgs(t *testing.T) { + args := mtlsControlPlaneServiceArgs("") + assert.Contains(t, args, "--tls-enabled") + assert.Contains(t, args, "--trust-domain") + assert.Contains(t, args, defaultTrustDomain) + assert.Contains(t, args, "--trust-anchors-file") + assert.Contains(t, args, trustAnchorsContainerPath) + assert.Contains(t, args, "--sentry-address") + assert.Contains(t, args, sentryAddressHostGateway) +} + +func TestBuildSentryContainerRunArgs(t *testing.T) { + installDir := t.TempDir() + certsDir := GetDaprCertsPath(installDir) + require.NoError(t, os.MkdirAll(certsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(certsDir, trustAnchorsFile), []byte("test"), 0o600)) + require.NoError(t, os.WriteFile(GetDaprConfigPath(installDir), []byte("apiVersion: dapr.io/v1alpha1"), 0o644)) + + info := initInfo{ + installDir: installDir, + enableMTLS: true, + } + args := buildSentryContainerRunArgs(info, "daprio/dapr:1.18.1") + + assert.Contains(t, args, "--mode") + assert.Contains(t, args, sentryStandaloneMode) + assert.Contains(t, args, "--config") + assert.Contains(t, args, sentryConfigContainerPath) + assert.Contains(t, args, "--issuer-credentials") + assert.Contains(t, args, credentialsContainerPath) + assert.Contains(t, args, "--trust-domain") + assert.Contains(t, args, defaultTrustDomain) + assert.Contains(t, args, "dapr_sentry") +} + +func TestMergeMTLSIntoConfiguration(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + existing := `apiVersion: dapr.io/v1alpha1 +kind: Configuration +metadata: + name: daprConfig +spec: + tracing: + samplingRate: "1" + zipkin: + endpointAddress: http://localhost:9411/api/v2/spans +` + require.NoError(t, os.WriteFile(configPath, []byte(existing), 0o644)) + + require.NoError(t, mergeMTLSIntoConfiguration(configPath)) + + content, err := os.ReadFile(configPath) + require.NoError(t, err) + text := string(content) + assert.Contains(t, text, "mtls:") + assert.Contains(t, text, "enabled: true") + assert.Contains(t, text, "zipkin:") +} + +func TestGenerateCertsForMTLSInternal(t *testing.T) { + installDir := t.TempDir() + info := initInfo{ + installDir: installDir, + enableMTLS: true, + } + + require.NoError(t, generateCertsForMTLSInternal(info)) + + certsDir := GetDaprCertsPath(installDir) + for _, file := range []string{trustAnchorsFile, issuerCertFile, issuerKeyFile} { + path := filepath.Join(certsDir, file) + assert.FileExists(t, path) + stat, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), stat.Mode().Perm()) + } + + require.NoError(t, generateCertsForMTLSInternal(info)) +} + +func TestContainersToRemoveIncludesSentry(t *testing.T) { + containers := containersToRemove(true, false, false) + for _, c := range containers { + if c.name == DaprSentryContainerName { + assert.False(t, c.warnIfMissing) + return + } + } + t.Fatal("expected sentry container in removal list") +} diff --git a/pkg/standalone/run.go b/pkg/standalone/run.go index 649ff7f0e..d81d9ba97 100644 --- a/pkg/standalone/run.go +++ b/pkg/standalone/run.go @@ -19,6 +19,7 @@ import ( "net" "os" "os/exec" + "path/filepath" "reflect" "runtime" "strconv" @@ -32,6 +33,7 @@ import ( "github.com/dapr/cli/pkg/print" localloader "github.com/dapr/dapr/pkg/components/loader" + "github.com/dapr/dapr/pkg/security/consts" ) type LogDestType string @@ -384,7 +386,11 @@ func (config *RunConfig) getArgs() []string { sentryAddress := mtlsEndpoint(config.ConfigFile) if sentryAddress != "" { // mTLS is enabled locally, set it up. - args = append(args, "--enable-mtls", "--sentry-address", sentryAddress) + args = append(args, + "--enable-mtls", + "--sentry-address", sentryAddress, + "--control-plane-trust-domain", defaultTrustDomain, + ) } } @@ -581,9 +587,30 @@ func GetDaprCommand(config *RunConfig) (*exec.Cmd, error) { args := config.getArgs() cmd := exec.Command(daprCMD, args...) + if trustAnchors, ok := mtlsTrustAnchors(config); ok { + cmd.Env = append(os.Environ(), fmt.Sprintf("%s=%s", consts.TrustAnchorsEnvVar, trustAnchors)) + } return cmd, nil } +func mtlsTrustAnchors(config *RunConfig) (string, bool) { + if config == nil || mtlsEndpoint(config.ConfigFile) == "" { + return "", false + } + + daprDir, err := GetDaprRuntimePath(config.DaprdInstallPath) + if err != nil { + return "", false + } + + trustAnchors, err := os.ReadFile(filepath.Join(GetDaprCertsPath(daprDir), trustAnchorsFile)) + if err != nil { + return "", false + } + + return string(trustAnchors), true +} + func mtlsEndpoint(configFile string) string { if configFile == "" { return "" diff --git a/pkg/standalone/run_test.go b/pkg/standalone/run_test.go index a861344a7..c4227c2f0 100644 --- a/pkg/standalone/run_test.go +++ b/pkg/standalone/run_test.go @@ -14,12 +14,16 @@ limitations under the License. package standalone import ( + "os" + "path/filepath" "runtime" "sort" "strings" "testing" "github.com/stretchr/testify/assert" + + "github.com/dapr/dapr/pkg/security/consts" ) func strPtr(s string) *string { return &s } @@ -144,6 +148,54 @@ func TestGetEnv(t *testing.T) { }) } +func TestGetArgsWithMTLSConfig(t *testing.T) { + configFile := filepath.Join(t.TempDir(), "config.yaml") + err := createDefaultConfiguration("", configFile, true) + assert.NoError(t, err) + + config := &RunConfig{ + SharedRunConfig: SharedRunConfig{ + ConfigFile: configFile, + }, + } + + args := config.getArgs() + + assert.Contains(t, args, "--enable-mtls") + assert.Contains(t, args, "--sentry-address") + assert.Contains(t, args, sentryDefaultAddress) + assert.Contains(t, args, "--control-plane-trust-domain") + assert.Contains(t, args, defaultTrustDomain) +} + +func TestGetDaprCommandSetsMTLSTrustAnchors(t *testing.T) { + runtimePath := t.TempDir() + daprDir := filepath.Join(runtimePath, DefaultDaprDirName) + certsDir := GetDaprCertsPath(daprDir) + err := os.MkdirAll(certsDir, 0o755) + assert.NoError(t, err) + + trustAnchors := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n" + err = os.WriteFile(filepath.Join(certsDir, trustAnchorsFile), []byte(trustAnchors), 0o600) + assert.NoError(t, err) + + configFile := filepath.Join(t.TempDir(), "config.yaml") + err = createDefaultConfiguration("", configFile, true) + assert.NoError(t, err) + + cmd, err := GetDaprCommand(&RunConfig{ + SharedRunConfig: SharedRunConfig{ + ConfigFile: configFile, + DaprdInstallPath: runtimePath, + }, + }) + assert.NoError(t, err) + + assert.Contains(t, cmd.Env, consts.TrustAnchorsEnvVar+"="+trustAnchors) + assert.Contains(t, cmd.Args, "--control-plane-trust-domain") + assert.Contains(t, cmd.Args, defaultTrustDomain) +} + func TestValidatePlacementHostAddr(t *testing.T) { t.Run("empty disables placement", func(t *testing.T) { cfg := &RunConfig{SharedRunConfig: SharedRunConfig{PlacementHostAddr: strPtr("")}} diff --git a/pkg/standalone/standalone.go b/pkg/standalone/standalone.go index 5f505ca62..ddff7a0e8 100644 --- a/pkg/standalone/standalone.go +++ b/pkg/standalone/standalone.go @@ -18,6 +18,9 @@ import ( "archive/zip" "compress/gzip" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "errors" "fmt" "io" @@ -38,6 +41,7 @@ import ( "github.com/dapr/cli/pkg/print" cli_ver "github.com/dapr/cli/pkg/version" "github.com/dapr/cli/utils" + "github.com/dapr/dapr/pkg/sentry/server/ca/bundle" ) const ( @@ -77,6 +81,8 @@ const ( DaprPlacementContainerName = "dapr_placement" // DaprSchedulerContainerName is the container name of scheduler service. DaprSchedulerContainerName = "dapr_scheduler" + // DaprSentryContainerName is the container name of sentry service. + DaprSentryContainerName = "dapr_sentry" // DaprRedisContainerName is the container name of redis. DaprRedisContainerName = "dapr_redis" // DaprZipkinContainerName is the container name of zipkin. @@ -91,6 +97,17 @@ const ( schedulerMetricPort = 59091 schedulerEtcdPort = 2379 + sentryGRPCPort = 50001 + sentryHealthPort = 58082 + sentryMetricPort = 59092 + sentryConfigContainerPath = "/etc/dapr/config.yaml" + sentryStandaloneMode = "standalone" + + defaultTrustDomain = "cluster.local" + trustAnchorsFile = "ca.crt" + issuerCertFile = "issuer.crt" + issuerKeyFile = "issuer.key" + daprVersionsWithScheduler = ">= 1.14.x" ) @@ -112,6 +129,9 @@ type configuration struct { EndpointAddress string `yaml:"endpointAddress,omitempty"` } `yaml:"zipkin,omitempty"` } `yaml:"tracing,omitempty"` + MTLS struct { + Enabled bool `yaml:"enabled,omitempty"` + } `yaml:"mtls,omitempty"` } `yaml:"spec"` } @@ -138,6 +158,7 @@ type initInfo struct { installDir string bundleDet *bundleDetails slimMode bool + enableMTLS bool runtimeVersion string dockerNetwork string imageRegistryURL string @@ -153,6 +174,7 @@ type InitOptions struct { RuntimeVersion string DockerNetwork string SlimMode bool + EnableMTLS bool ImageRegistryURL string FromDir string ContainerRuntime string @@ -208,6 +230,7 @@ func Init(opts InitOptions) error { runtimeVersion := opts.RuntimeVersion dockerNetwork := opts.DockerNetwork slimMode := opts.SlimMode + enableMTLS := opts.EnableMTLS imageRegistryURL := opts.ImageRegistryURL fromDir := opts.FromDir containerRuntime := opts.ContainerRuntime @@ -217,6 +240,10 @@ func Init(opts InitOptions) error { schedulerOverrideBroadcastHostPort := opts.SchedulerOverrideBroadcastHostPort redisStack := opts.RedisStack + if enableMTLS && slimMode { + return fmt.Errorf("--enable-mtls is not supported with --slim mode") + } + containerRuntime = strings.TrimSpace(containerRuntime) daprInstallPath = strings.TrimSpace(daprInstallPath) // AirGap init flow is true when fromDir var is set i.e. --from-dir flag has value. @@ -291,23 +318,21 @@ func Init(opts InitOptions) error { return er } - var wg sync.WaitGroup - errorChan := make(chan error) - initSteps := []func(*sync.WaitGroup, chan<- error, initInfo){ + prepSteps := []func(*sync.WaitGroup, chan<- error, initInfo){ createSlimConfiguration, createComponentsAndConfiguration, + generateCertsForMTLS, installDaprRuntime, installPlacement, installScheduler, + } + containerSteps := []func(*sync.WaitGroup, chan<- error, initInfo){ runPlacementService, runSchedulerService, runRedis, runZipkin, } - // Init other configurations, containers. - wg.Add(len(initSteps)) - msg := "Downloading binaries and setting up components..." if isAirGapInit { msg = "Extracting binaries and setting up components..." @@ -327,6 +352,7 @@ func Init(opts InitOptions) error { fromDir: fromDir, installDir: installDir, slimMode: slimMode, + enableMTLS: enableMTLS, runtimeVersion: runtimeVersion, dockerNetwork: dockerNetwork, imageRegistryURL: imageRegistryURL, @@ -336,20 +362,33 @@ func Init(opts InitOptions) error { schedulerOverrideBroadcastHostPort: schedulerOverrideBroadcastHostPort, redisStack: redisStack, } - for _, step := range initSteps { - // Run init on the configurations and containers. - go step(&wg, errorChan, info) - } - - go func() { - wg.Wait() - close(errorChan) - }() - - for err := range errorChan { - if err != nil { + if enableMTLS { + if err := runParallelInitSteps(prepSteps, info); err != nil { + return err + } + if err := runSentryService(info); err != nil { return err } + if err := runParallelInitSteps(containerSteps, info); err != nil { + return err + } + } else { + initSteps := append(prepSteps, containerSteps...) + var wg sync.WaitGroup + errorChan := make(chan error) + wg.Add(len(initSteps)) + for _, step := range initSteps { + go step(&wg, errorChan, info) + } + go func() { + wg.Wait() + close(errorChan) + }() + for err := range errorChan { + if err != nil { + return err + } + } } stopSpinning(print.Success) @@ -375,6 +414,9 @@ func Init(opts InitOptions) error { if err == nil && hasScheduler { dockerContainerNames = append(dockerContainerNames, DaprSchedulerContainerName) } + if info.enableMTLS { + dockerContainerNames = append(dockerContainerNames, DaprSentryContainerName) + } for _, container := range dockerContainerNames { containerName := utils.CreateContainerName(container, dockerNetwork) ok, err := confirmContainerIsRunningOrExists(containerName, true, runtimeCmd) @@ -386,6 +428,16 @@ func Init(opts InitOptions) error { } } print.InfoStatusEvent(os.Stdout, "Use `%s ps` to check running containers.", runtimeCmd) + if info.enableMTLS { + sentryContainerName := utils.CreateContainerName(DaprSentryContainerName, dockerNetwork) + ok, err := confirmContainerIsRunningOrExists(sentryContainerName, true, runtimeCmd) + if err != nil { + return err + } + if ok { + print.InfoStatusEvent(os.Stdout, "Sentry running, mTLS enabled, trust domain: %s", defaultTrustDomain) + } + } } return nil } @@ -606,7 +658,11 @@ func runPlacementService(wg *sync.WaitGroup, errorChan chan<- error, info initIn ) } + args = appendMTLSContainerRunArgs(args, info) args = append(args, image) + if info.enableMTLS { + args = append(args, mtlsControlPlaneServiceArgs(info.dockerNetwork)...) + } _, err = utils.RunCmdAndWait(runtimeCmd, args...) if err != nil { @@ -718,6 +774,8 @@ func runSchedulerService(wg *sync.WaitGroup, errorChan chan<- error, info initIn ) } + args = appendMTLSContainerRunArgs(args, info) + if strings.EqualFold(info.imageVariant, "mariner") { args = append(args, image, "--etcd-data-dir=/var/tmp/dapr/scheduler") } else { @@ -736,6 +794,10 @@ func runSchedulerService(wg *sync.WaitGroup, errorChan chan<- error, info initIn args = append(args, "--etcd-client-listen-address=0.0.0.0") } + if info.enableMTLS { + args = append(args, mtlsControlPlaneServiceArgs(info.dockerNetwork)...) + } + // On non-elevated Windows with WSL2 installed, verify the scheduler ports // are free before attempting the container start, but only when the // scheduler is publishing host ports. WSL2 commonly holds :2379 (etcd) @@ -802,6 +864,116 @@ func runSchedulerService(wg *sync.WaitGroup, errorChan chan<- error, info initIn errorChan <- nil } +func generateCertsForMTLS(wg *sync.WaitGroup, errorChan chan<- error, info initInfo) { + defer wg.Done() + + if err := generateCertsForMTLSInternal(info); err != nil { + errorChan <- err + } +} + +func generateCertsForMTLSInternal(info initInfo) error { + if !info.enableMTLS { + return nil + } + + certsDir := GetDaprCertsPath(info.installDir) + + if err := os.MkdirAll(certsDir, 0o755); err != nil { + return fmt.Errorf("error creating certs directory: %w", err) + } + + caPath := path_filepath.Join(certsDir, trustAnchorsFile) + issuerCertPath := path_filepath.Join(certsDir, issuerCertFile) + issuerKeyPath := path_filepath.Join(certsDir, issuerKeyFile) + + if _, err := os.Stat(caPath); err == nil { + if _, err := os.Stat(issuerCertPath); err == nil { + if _, err := os.Stat(issuerKeyPath); err == nil { + return nil + } + } + } + + rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("error generating root key for mTLS: %w", err) + } + + certValidity := 365 * 24 * time.Hour + certBundle, err := bundle.GenerateX509(bundle.OptionsX509{ + X509RootKey: rootKey, + TrustDomain: defaultTrustDomain, + OverrideCATTL: &certValidity, + }) + if err != nil { + return fmt.Errorf("error generating mTLS certificates: %w", err) + } + + if err := os.WriteFile(caPath, certBundle.TrustAnchors, 0o600); err != nil { + return fmt.Errorf("error writing CA certificate: %w", err) + } + if err := os.WriteFile(issuerCertPath, certBundle.IssChainPEM, 0o600); err != nil { + return fmt.Errorf("error writing issuer certificate: %w", err) + } + if err := os.WriteFile(issuerKeyPath, certBundle.IssKeyPEM, 0o600); err != nil { + return fmt.Errorf("error writing issuer key: %w", err) + } + return nil +} + +func runSentryService(info initInfo) error { + if !info.enableMTLS || info.slimMode { + return nil + } + + runtimeCmd := utils.GetContainerRuntimeCmd(info.containerRuntime) + sentryContainerName := utils.CreateContainerName(DaprSentryContainerName, info.dockerNetwork) + + exists, err := confirmContainerIsRunningOrExists(sentryContainerName, false, runtimeCmd) + if err != nil { + return err + } else if exists { + return fmt.Errorf("%s container exists or is running. %s", sentryContainerName, errInstallTemplate) + } + + var image string + + imgInfo := daprImageInfo{ + ghcrImageName: daprGhcrImageName, + dockerHubImageName: daprDockerImageName, + imageRegistryURL: info.imageRegistryURL, + imageRegistryName: defaultImageRegistryName, + } + + if isAirGapInit { + dir := path_filepath.Join(info.fromDir, *info.bundleDet.ImageSubDir) + image = info.bundleDet.getDaprImageName() + err = loadContainer(dir, info.bundleDet.getDaprImageFileName(), info.containerRuntime) + if err != nil { + return err + } + } else { + image, err = getDaprImageName(imgInfo, info) + if err != nil { + return err + } + } + + args := buildSentryContainerRunArgs(info, image) + + _, err = utils.RunCmdAndWait(runtimeCmd, args...) + if err != nil { + runError := isContainerRunError(err) + if !runError { + return parseContainerRuntimeError("sentry service", err) + } else { + return fmt.Errorf("%s %s failed with: %w", runtimeCmd, args, err) + } + } + return nil +} + // checkSchedulerPorts verifies that all ports required by the scheduler // service are available. grpcPort is the platform-specific gRPC port // (50006 on Linux/Mac, 6060 on Windows). @@ -967,7 +1139,7 @@ func createComponentsAndConfiguration(wg *sync.WaitGroup, errorChan chan<- error errorChan <- fmt.Errorf("error creating redis statestore component file: %w", err) return } - err = createDefaultConfiguration(zipkinHost, configPath) + err = createDefaultConfiguration(zipkinHost, configPath, info.enableMTLS) if err != nil { errorChan <- fmt.Errorf("error creating default configuration file: %w", err) return @@ -983,7 +1155,7 @@ func createSlimConfiguration(wg *sync.WaitGroup, errorChan chan<- error, info in configPath := GetDaprConfigPath(info.installDir) // For --slim we pass empty string so that we do not configure zipkin. - err := createDefaultConfiguration("", configPath) + err := createDefaultConfiguration("", configPath, info.enableMTLS) if err != nil { errorChan <- fmt.Errorf("error creating default configuration file: %w", err) return @@ -1275,7 +1447,7 @@ func createRedisPubSub(redisHost string, componentsPath string) error { return err } -func createDefaultConfiguration(zipkinHost, filePath string) error { +func createDefaultConfiguration(zipkinHost, filePath string, enableMTLS bool) error { defaultConfig := configuration{ APIVersion: "dapr.io/v1alpha1", Kind: "Configuration", @@ -1285,11 +1457,20 @@ func createDefaultConfiguration(zipkinHost, filePath string) error { defaultConfig.Spec.Tracing.SamplingRate = "1" defaultConfig.Spec.Tracing.Zipkin.EndpointAddress = fmt.Sprintf("http://%s:9411/api/v2/spans", zipkinHost) //nolint:nosprintfhostport } + if enableMTLS { + defaultConfig.Spec.MTLS.Enabled = true + } b, err := yaml.Marshal(&defaultConfig) if err != nil { return err } + if enableMTLS { + if _, err := os.Stat(filePath); err == nil { + return mergeMTLSIntoConfiguration(filePath) + } + } + err = checkAndOverWriteFile(filePath, b) return err diff --git a/pkg/standalone/standalone_test.go b/pkg/standalone/standalone_test.go index 7600547c4..a9132b2a8 100644 --- a/pkg/standalone/standalone_test.go +++ b/pkg/standalone/standalone_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/dapr/cli/utils" "github.com/dapr/kit/ptr" @@ -38,7 +39,7 @@ spec: endpointAddress: http://test_zipkin_host:9411/api/v2/spans ` os.Remove(testFile) - createDefaultConfiguration("test_zipkin_host", testFile) + createDefaultConfiguration("test_zipkin_host", testFile, false) assert.FileExists(t, testFile) content, err := os.ReadFile(testFile) assert.NoError(t, err) @@ -53,13 +54,52 @@ metadata: spec: {} ` os.Remove(testFile) - createDefaultConfiguration("", testFile) + createDefaultConfiguration("", testFile, false) assert.FileExists(t, testFile) content, err := os.ReadFile(testFile) assert.NoError(t, err) assert.Equal(t, expectConfigSlim, string(content)) }) + t.Run("Standalone config with mTLS", func(t *testing.T) { + expectConfigMTLS := `apiVersion: dapr.io/v1alpha1 +kind: Configuration +metadata: + name: daprConfig +spec: + mtls: + enabled: true +` + os.Remove(testFile) + createDefaultConfiguration("", testFile, true) + assert.FileExists(t, testFile) + content, err := os.ReadFile(testFile) + assert.NoError(t, err) + assert.Equal(t, expectConfigMTLS, string(content)) + }) + + t.Run("Standalone config with mTLS merges existing tracing", func(t *testing.T) { + existing := `apiVersion: dapr.io/v1alpha1 +kind: Configuration +metadata: + name: daprConfig +spec: + tracing: + samplingRate: "1" + zipkin: + endpointAddress: http://test_zipkin_host:9411/api/v2/spans +` + os.Remove(testFile) + require.NoError(t, os.WriteFile(testFile, []byte(existing), 0o644)) + require.NoError(t, createDefaultConfiguration("ignored_zipkin_host", testFile, true)) + content, err := os.ReadFile(testFile) + require.NoError(t, err) + text := string(content) + assert.Contains(t, text, "mtls:") + assert.Contains(t, text, "enabled: true") + assert.Contains(t, text, "zipkin:") + }) + os.Remove(testFile) } diff --git a/pkg/standalone/uninstall.go b/pkg/standalone/uninstall.go index 7eb9fecbc..9e42e87cf 100644 --- a/pkg/standalone/uninstall.go +++ b/pkg/standalone/uninstall.go @@ -27,27 +27,45 @@ import ( func removeContainers(uninstallPlacementContainer, uninstallSchedulerContainer, uninstallAll bool, dockerNetwork, runtimeCmd string) []error { var containerErrs []error - if uninstallPlacementContainer { - containerErrs = removeDockerContainer(containerErrs, DaprPlacementContainerName, dockerNetwork, runtimeCmd) + for _, container := range containersToRemove(uninstallPlacementContainer, uninstallSchedulerContainer, uninstallAll) { + containerErrs = removeDockerContainer(containerErrs, container.name, dockerNetwork, runtimeCmd, container.warnIfMissing) } + return containerErrs +} + +type removableContainer struct { + name string + warnIfMissing bool +} + +func containersToRemove(uninstallPlacementContainer, uninstallSchedulerContainer, uninstallAll bool) []removableContainer { + containers := []removableContainer{} + if uninstallPlacementContainer { + containers = append(containers, removableContainer{name: DaprPlacementContainerName, warnIfMissing: true}) + } if uninstallSchedulerContainer { - containerErrs = removeDockerContainer(containerErrs, DaprSchedulerContainerName, dockerNetwork, runtimeCmd) + containers = append(containers, removableContainer{name: DaprSchedulerContainerName, warnIfMissing: true}) + } + if uninstallPlacementContainer || uninstallAll { + containers = append(containers, removableContainer{name: DaprSentryContainerName}) } - if uninstallAll { - containerErrs = removeDockerContainer(containerErrs, DaprRedisContainerName, dockerNetwork, runtimeCmd) - containerErrs = removeDockerContainer(containerErrs, DaprZipkinContainerName, dockerNetwork, runtimeCmd) + containers = append(containers, + removableContainer{name: DaprRedisContainerName, warnIfMissing: true}, + removableContainer{name: DaprZipkinContainerName, warnIfMissing: true}, + ) } - - return containerErrs + return containers } -func removeDockerContainer(containerErrs []error, containerName, network, runtimeCmd string) []error { +func removeDockerContainer(containerErrs []error, containerName, network, runtimeCmd string, warnIfMissing bool) []error { container := utils.CreateContainerName(containerName, network) exists, _ := confirmContainerIsRunningOrExists(container, false, runtimeCmd) if !exists { - print.WarningStatusEvent(os.Stdout, "WARNING: %s container does not exist", container) + if warnIfMissing { + print.WarningStatusEvent(os.Stdout, "WARNING: %s container does not exist", container) + } return containerErrs } print.InfoStatusEvent(os.Stdout, "Removing container: %s", container) diff --git a/pkg/standalone/uninstall_test.go b/pkg/standalone/uninstall_test.go new file mode 100644 index 000000000..d62954eb9 --- /dev/null +++ b/pkg/standalone/uninstall_test.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package standalone + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContainersToRemove(t *testing.T) { + t.Run("container mode uninstall removes sentry without warning", func(t *testing.T) { + containers := containersToRemove(true, true, false) + var sentry *removableContainer + for i := range containers { + if containers[i].name == DaprSentryContainerName { + sentry = &containers[i] + break + } + } + require.NotNil(t, sentry) + assert.False(t, sentry.warnIfMissing) + }) + + t.Run("uninstall all includes redis and zipkin", func(t *testing.T) { + containers := containersToRemove(true, true, true) + names := map[string]bool{} + for _, c := range containers { + names[c.name] = true + } + assert.True(t, names[DaprRedisContainerName]) + assert.True(t, names[DaprZipkinContainerName]) + assert.True(t, names[DaprSentryContainerName]) + }) + + t.Run("sentry not removed if placement not removed and not uninstallAll", func(t *testing.T) { + containers := containersToRemove(false, true, false) + for _, c := range containers { + assert.NotEqual(t, DaprSentryContainerName, c.name) + } + }) +} + +func TestRemoveDir(t *testing.T) { + t.Run("remove existing directory", func(t *testing.T) { + dir := t.TempDir() + err := removeDir(dir) + assert.NoError(t, err) + _, err = os.Stat(dir) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("remove non-existent directory", func(t *testing.T) { + err := removeDir(filepath.Join(t.TempDir(), "non-existent")) + assert.NoError(t, err) + }) +} diff --git a/tests/e2e/standalone/init_test.go b/tests/e2e/standalone/init_test.go index 6280dfdf2..f82bf2a05 100644 --- a/tests/e2e/standalone/init_test.go +++ b/tests/e2e/standalone/init_test.go @@ -266,6 +266,33 @@ func TestStandaloneInit(t *testing.T) { verifyBinaries(t, daprPath, latestDaprRuntimeVersion) verifyConfigs(t, daprPath) }) + + t.Run("init with mTLS", func(t *testing.T) { + if isSlimMode() { + t.Skip("Skipping mTLS init test because of slim installation") + } + + must(t, cmdUninstall, "failed to uninstall Dapr") + + args := []string{ + "--runtime-version", daprRuntimeVersion, + "--enable-mtls", + } + output, err := cmdInit(args...) + t.Log(output) + require.NoError(t, err, "init with mTLS failed") + assert.Contains(t, output, "Success! Dapr is up and running.") + assert.Contains(t, output, "Sentry running, mTLS enabled, trust domain: cluster.local") + + homeDir, err := os.UserHomeDir() + require.NoError(t, err, "failed to get user home directory") + + daprPath := filepath.Join(homeDir, ".dapr") + verifyMTLSCerts(t, daprPath) + verifyMTLSConfig(t, daprPath) + verifySentryContainer(t, daprRuntimeVersion) + verifyTCPLocalhost(t, 50001) + }) } func verifyRedisContainerImage(t *testing.T, expectedImageSubstring string) { @@ -289,6 +316,56 @@ func verifyRedisContainerImage(t *testing.T, expectedImageSubstring string) { require.Fail(t, "dapr_redis container was not found") } +func verifyMTLSCerts(t *testing.T, daprPath string) { + t.Helper() + + certsPath := filepath.Join(daprPath, "certs") + for _, file := range []string{"ca.crt", "issuer.crt", "issuer.key"} { + require.FileExists(t, filepath.Join(certsPath, file), "expected cert file %s", file) + } +} + +func verifyMTLSConfig(t *testing.T, daprPath string) { + t.Helper() + + configPath := filepath.Join(daprPath, "config.yaml") + bytes, err := os.ReadFile(configPath) + require.NoError(t, err, "failed to read config file") + + var actual map[string]interface{} + err = yaml.Unmarshal(bytes, &actual) + require.NoError(t, err, "failed to unmarshal config file") + + spec, ok := actual["spec"].(map[interface{}]interface{}) + require.True(t, ok, "expected spec in config") + mtls, ok := spec["mtls"].(map[interface{}]interface{}) + require.True(t, ok, "expected mtls in config spec") + assert.Equal(t, true, mtls["enabled"]) +} + +func verifySentryContainer(t *testing.T, daprRuntimeVersion string) { + t.Helper() + + cli, err := dockerClient.NewClientWithOpts(dockerClient.FromEnv, dockerClient.WithVersion("1.48")) + require.NoError(t, err) + + containers, err := cli.ContainerList(context.Background(), container.ListOptions{}) + require.NoError(t, err) + + for _, c := range containers { + for _, name := range c.Names { + if strings.TrimPrefix(name, "/") != "dapr_sentry" { + continue + } + assert.Equal(t, "running", c.State) + assert.Contains(t, c.Image, daprRuntimeVersion) + return + } + } + + require.Fail(t, "dapr_sentry container was not found") +} + // verifyContainers ensures that the correct containers are up and running. // Note, in case of slim installation, the containers are not installed and // this test is automatically skipped.