diff --git a/cmd/context/delete.go b/cmd/context/delete.go index c6b039394..d25aeb5e7 100644 --- a/cmd/context/delete.go +++ b/cmd/context/delete.go @@ -6,6 +6,8 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/spf13/cobra" ) @@ -59,6 +61,8 @@ func (cmd *DeleteCmd) Run(ctx context.Context, context string) error { return fmt.Errorf("cannot delete 'default' context") } + deleteContextSecrets(devsyConfig, context) + delete(devsyConfig.Contexts, context) if devsyConfig.DefaultContext == context { devsyConfig.DefaultContext = "default" @@ -74,3 +78,21 @@ func (cmd *DeleteCmd) Run(ctx context.Context, context string) error { return nil } + +func deleteContextSecrets(devsyConfig *config.Config, contextName string) { + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil || len(ctxConfig.Secrets) == 0 { + return + } + + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + log.Warnf("skip deleting secrets for context %q: %v", contextName, err) + return + } + for _, name := range ctxConfig.Secrets { + if err := store.Delete(contextName, name); err != nil { + log.Warnf("delete secret %q for context %q: %v", name, contextName, err) + } + } +} diff --git a/cmd/env/delete.go b/cmd/env/delete.go new file mode 100644 index 000000000..14bb9d348 --- /dev/null +++ b/cmd/env/delete.go @@ -0,0 +1,47 @@ +package env + +import ( + "context" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +type DeleteCmd struct { + *flags.GlobalFlags +} + +func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &DeleteCmd{GlobalFlags: flags} + deleteCmd := &cobra.Command{ + Use: "delete NAME", + Aliases: []string{"rm"}, + Short: "Delete an environment variable from the active context", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + return deleteCmd +} + +func (cmd *DeleteCmd) Run(_ context.Context, name string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + meta, err := store.Meta(contextName, name) + if err != nil { + return err + } + if meta.Sensitive() { + return fmt.Errorf("%q is a secret; use \"devsy secret delete\"", name) + } + if err := store.Delete(contextName, name); err != nil { + return err + } + log.Infof("env var %q deleted from context %q", name, contextName) + return nil +} diff --git a/cmd/env/env.go b/cmd/env/env.go new file mode 100644 index 000000000..51d686c3f --- /dev/null +++ b/cmd/env/env.go @@ -0,0 +1,38 @@ +package env + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +// NewEnvCmd returns the `devsy env` command group: managed, non-sensitive +// environment variables stored per context and injected into workspaces. +func NewEnvCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + envCmd := &cobra.Command{ + Use: "env", + Short: "Devsy managed environment variables", + Long: `Manage named environment variables stored per context and injected +into workspaces. Values are stored in plaintext in the Devsy config directory; +use "devsy secret" for sensitive values.`, + } + envCmd.AddCommand(NewSetCmd(globalFlags)) + envCmd.AddCommand(NewListCmd(globalFlags)) + envCmd.AddCommand(NewGetCmd(globalFlags)) + envCmd.AddCommand(NewDeleteCmd(globalFlags)) + return envCmd +} + +// resolveContext returns the active context name and a Store for it. +func resolveContext(globalFlags *flags.GlobalFlags) (string, secrets.Store, error) { + devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) + if err != nil { + return "", nil, err + } + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return "", nil, err + } + return devsyConfig.DefaultContext, store, nil +} diff --git a/cmd/env/get.go b/cmd/env/get.go new file mode 100644 index 000000000..a0ef8871e --- /dev/null +++ b/cmd/env/get.go @@ -0,0 +1,47 @@ +package env + +import ( + "context" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" +) + +type GetCmd struct { + *flags.GlobalFlags +} + +func NewGetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &GetCmd{GlobalFlags: flags} + getCmd := &cobra.Command{ + Use: "get NAME", + Short: "Print an environment variable's value", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + return getCmd +} + +func (cmd *GetCmd) Run(_ context.Context, name string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + meta, err := store.Meta(contextName, name) + if err != nil { + return err + } + if meta.Sensitive() { + return fmt.Errorf("%q is a secret; use \"devsy secret get\"", name) + } + value, err := store.Get(contextName, name) + if err != nil { + return err + } + //nolint:forbidigo // get prints the raw value to stdout for scripting. + fmt.Println(value) + return nil +} diff --git a/cmd/env/list.go b/cmd/env/list.go new file mode 100644 index 000000000..5ff41d23e --- /dev/null +++ b/cmd/env/list.go @@ -0,0 +1,82 @@ +package env + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/table" + "github.com/spf13/cobra" +) + +type ListCmd struct { + *flags.GlobalFlags +} + +func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &ListCmd{GlobalFlags: flags} + listCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List environment variables in the active context", + Args: cobra.NoArgs, + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context()) + }, + } + return listCmd +} + +type envEntry struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func (cmd *ListCmd) Run(_ context.Context) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + metas, err := store.List(contextName) + if err != nil { + return err + } + + entries := make([]envEntry, 0, len(metas)) + for _, m := range metas { + if m.Sensitive() { + continue + } + entries = append(entries, envEntry{Name: m.Name, Value: m.Value}) + } + + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + if mode == output.ModeJSON { + return renderJSON(entries) + } + renderPlain(entries) + return nil +} + +func renderJSON(entries []envEntry) error { + out, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + //nolint:forbidigo // list --result-format json prints structured data to stdout. + fmt.Print(string(out)) + return nil +} + +func renderPlain(entries []envEntry) { + rows := make([][]string, 0, len(entries)) + for _, e := range entries { + rows = append(rows, []string{e.Name, e.Value}) + } + table.Print([]string{"Name", "Value"}, rows) +} diff --git a/cmd/env/set.go b/cmd/env/set.go new file mode 100644 index 000000000..c77c6d91a --- /dev/null +++ b/cmd/env/set.go @@ -0,0 +1,57 @@ +package env + +import ( + "context" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +type SetCmd struct { + *flags.GlobalFlags + + Value string +} + +func NewSetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &SetCmd{GlobalFlags: flags} + setCmd := &cobra.Command{ + Use: "set NAME=VALUE | set NAME --value VALUE", + Short: "Create or update an environment variable", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + cliflags.Add( + setCmd, + cliflags.String(&cmd.Value, names.Value, "", "The value (alternative to NAME=VALUE)"), + ) + return setCmd +} + +func (cmd *SetCmd) Run(_ context.Context, arg string) error { + name, value := arg, cmd.Value + if k, v, ok := strings.Cut(arg, "="); ok { + name, value = k, v + } + if err := secrets.ValidateName(name); err != nil { + return err + } + + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + if err := store.Set(contextName, name, value, secrets.KindEnv); err != nil { + return err + } + + log.Infof("env var %q set in context %q", name, contextName) + return nil +} diff --git a/cmd/internal/agentcontainer/deferred_hooks.go b/cmd/internal/agentcontainer/deferred_hooks.go index 5a32c0544..ee56cd846 100644 --- a/cmd/internal/agentcontainer/deferred_hooks.go +++ b/cmd/internal/agentcontainer/deferred_hooks.go @@ -4,11 +4,15 @@ package agentcontainer import ( "context" + "encoding/base64" "encoding/json" "fmt" + "os" + "strings" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/compress" + config2 "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/setup" cliflags "github.com/devsy-org/devsy/pkg/flags" @@ -17,6 +21,36 @@ import ( "github.com/spf13/cobra" ) +// encodeSecretsEnv serializes KEY=VALUE secrets for the DEVSY_SECRETS_ENV var +// used to pass them to re-exec'd hook processes. Each entry is base64-encoded so +// values may contain newlines or any bytes (e.g. PEM keys) without corruption. +func encodeSecretsEnv(secretsEnv []string) string { + encoded := make([]string, len(secretsEnv)) + for i, entry := range secretsEnv { + encoded[i] = base64.StdEncoding.EncodeToString([]byte(entry)) + } + return strings.Join(encoded, "\n") +} + +func secretsEnvFromEnvironment() []string { + raw := os.Getenv(config2.EnvSecretsEnv) + if raw == "" { + return nil + } + + parts := strings.Split(raw, "\n") + entries := make([]string, 0, len(parts)) + for _, p := range parts { + decoded, err := base64.StdEncoding.DecodeString(p) + if err != nil { + continue + } + entries = append(entries, string(decoded)) + } + + return entries +} + // DeferredHooksCmd runs deferred lifecycle hooks as a detached background process. type DeferredHooksCmd struct { *flags.GlobalFlags @@ -24,7 +58,6 @@ type DeferredHooksCmd struct { Prebuild bool DotfilesRepo string DotfilesScript string - SecretsEnv []string } // NewDeferredHooksCmd creates a new command. @@ -51,12 +84,6 @@ func NewDeferredHooksCmd(flags *flags.GlobalFlags) *cobra.Command { "", "Dotfiles install script path", ), - cliflags.StringSlice( - &cmd.SecretsEnv, - names.SecretsEnv, - []string{}, - "Secrets to inject into lifecycle commands (KEY=VALUE)", - ), ) _ = deferredCmd.MarkFlagRequired(names.SetupInfo) return deferredCmd @@ -79,7 +106,8 @@ func (cmd *DeferredHooksCmd) Run(ctx context.Context) error { Repository: cmd.DotfilesRepo, InstallScript: cmd.DotfilesScript, RemoteUser: config.GetRemoteUser(setupInfo), - }, cmd.SecretsEnv, setup.SkipPhases{}) + // Mount values aren't in this process's env; read them back for redaction. + }, secretsEnvFromEnvironment(), setup.MountSecretsForRedaction(), setup.SkipPhases{}) if err != nil { return fmt.Errorf("deferred hooks setup: %w", err) } diff --git a/cmd/internal/agentcontainer/post_attach.go b/cmd/internal/agentcontainer/post_attach.go index 012493280..cb35e55fe 100644 --- a/cmd/internal/agentcontainer/post_attach.go +++ b/cmd/internal/agentcontainer/post_attach.go @@ -19,8 +19,7 @@ import ( // PostAttachCmd runs postAttachCommand hooks as a detached background process. type PostAttachCmd struct { *flags.GlobalFlags - SetupInfo string - SecretsEnv []string + SetupInfo string } // NewPostAttachCmd creates a new command. @@ -39,12 +38,6 @@ func NewPostAttachCmd(flags *flags.GlobalFlags) *cobra.Command { cliflags.Add( postAttachCmd, cliflags.String(&cmd.SetupInfo, names.SetupInfo, "", "The container setup info"), - cliflags.StringSlice( - &cmd.SecretsEnv, - names.SecretsEnv, - []string{}, - "Secrets to inject into lifecycle commands (KEY=VALUE)", - ), ) _ = postAttachCmd.MarkFlagRequired(names.SetupInfo) return postAttachCmd @@ -63,7 +56,13 @@ func (cmd *PostAttachCmd) Run(ctx context.Context) error { } log.Debugf("running postAttachCommand hooks") - if err := setup.RunPostAttachHooks(ctx, setupInfo, cmd.SecretsEnv); err != nil { + // Mount values aren't in this process's env; read them back for redaction. + if err := setup.RunPostAttachHooks( + ctx, + setupInfo, + secretsEnvFromEnvironment(), + setup.MountSecretsForRedaction(), + ); err != nil { log.Errorf("postAttachCommand failed: %v", err) } diff --git a/cmd/internal/agentcontainer/setup.go b/cmd/internal/agentcontainer/setup.go index eeb0336b9..832a94417 100644 --- a/cmd/internal/agentcontainer/setup.go +++ b/cmd/internal/agentcontainer/setup.go @@ -132,6 +132,7 @@ type setupContext struct { workspaceInfo *provider2.ContainerWorkspaceInfo setupInfo *config.Result tunnelClient tunnel.TunnelClient + secretsEnv []string } // Run runs the command logic. @@ -212,11 +213,40 @@ func (cmd *SetupContainerCmd) prepareWorkspace(sctx *setupContext) error { return cloneErr } +// fetchSecrets pulls secrets over the tunnel. +func fetchSecrets( + ctx context.Context, + client tunnel.TunnelClient, +) (env, mount []string, err error) { + resp, err := client.Secrets(ctx, &tunnel.Empty{}) + if err != nil { + return nil, nil, fmt.Errorf("fetch secrets: %w", err) + } + + for _, secret := range resp.GetSecrets() { + entry := secret.GetName() + "=" + secret.GetValue() + if secret.GetMount() { + mount = append(mount, entry) + } else { + env = append(env, entry) + } + } + + return env, mount, nil +} + func (cmd *SetupContainerCmd) finalizeSetup(sctx *setupContext) error { + secretsEnv, secretsMount, err := fetchSecrets(sctx.ctx, sctx.tunnelClient) + if err != nil { + return err + } + sctx.secretsEnv = secretsEnv + cfg := &setup.ContainerSetupConfig{ SetupInfo: sctx.setupInfo, ExtraWorkspaceEnv: sctx.workspaceInfo.CLIOptions.WorkspaceEnv, - SecretsEnv: sctx.workspaceInfo.CLIOptions.SecretsEnv, + SecretsEnv: secretsEnv, + SecretsMount: secretsMount, ChownProjects: cmd.ChownWorkspace, PlatformOptions: &sctx.workspaceInfo.CLIOptions.Platform, TunnelClient: sctx.tunnelClient, @@ -285,7 +315,7 @@ func (cmd *SetupContainerCmd) setupPostAttach( if !deferred.Empty() { err = cmd.startDeferredHooks( resolvedSetupInfo, cmd.DotfilesRepo, cmd.DotfilesScript, - sctx.workspaceInfo.CLIOptions.SecretsEnv, + sctx.secretsEnv, ) if err != nil { log.Errorf("failed to start deferred lifecycle hooks: %v", err) @@ -345,14 +375,15 @@ func buildDeferredHooksCmd( if dotfilesScript != "" { args = append(args, names.Flag(names.DotfilesScript), dotfilesScript) } + cmd := &exec.Cmd{ + Path: binaryPath, + Args: append([]string{binaryPath}, args...), + } if len(secretsEnv) > 0 { - args = append(args, names.Flag(names.SecretsEnv), strings.Join(secretsEnv, ",")) + cmd.Env = append(os.Environ(), config2.EnvSecretsEnv+"="+encodeSecretsEnv(secretsEnv)) } - return &exec.Cmd{ - Path: binaryPath, - Args: append([]string{binaryPath}, args...), - }, nil + return cmd, nil } func (cmd *SetupContainerCmd) initializeTunnelClient( @@ -532,18 +563,18 @@ func (cmd *SetupContainerCmd) startPostAttachHooks(sctx *setupContext) error { cmdInternal, cmdAgent, cmdContainer, "post-attach", names.Flag(names.SetupInfo), cmd.SetupInfo, } - if len(sctx.workspaceInfo.CLIOptions.SecretsEnv) > 0 { - args = append( - args, - names.Flag(names.SecretsEnv), - strings.Join(sctx.workspaceInfo.CLIOptions.SecretsEnv, ","), + execCmd := &exec.Cmd{ + Path: binaryPath, + Args: append([]string{binaryPath}, args...), + } + if secretsEnv := sctx.secretsEnv; len(secretsEnv) > 0 { + execCmd.Env = append( + os.Environ(), + config2.EnvSecretsEnv+"="+encodeSecretsEnv(secretsEnv), ) } - return &exec.Cmd{ - Path: binaryPath, - Args: append([]string{binaryPath}, args...), - }, nil + return execCmd, nil }) } diff --git a/cmd/internal/agentcontainer/setup_internal_test.go b/cmd/internal/agentcontainer/setup_internal_test.go index 1b5387e78..97a5bacda 100644 --- a/cmd/internal/agentcontainer/setup_internal_test.go +++ b/cmd/internal/agentcontainer/setup_internal_test.go @@ -54,3 +54,19 @@ func TestCompressSetupInfoPreservesSubstitutedValues(t *testing.T) { require.NotNil(t, gotHome) assert.Equal(t, "/home/testuser", *gotHome) } + +func TestSecretsEnvRoundTripPreservesMultilineValues(t *testing.T) { + // PEM keys and certs carry newlines; the DEVSY_SECRETS_ENV round-trip must + // preserve them exactly rather than truncating at the first newline. + entries := []string{ + "TLS_KEY=-----BEGIN KEY-----\nline1\nline2\n-----END KEY-----", + "SIMPLE=value", + "WITH_EQUALS=a=b=c", + } + + encoded := encodeSecretsEnv(entries) + t.Setenv("DEVSY_SECRETS_ENV", encoded) + + got := secretsEnvFromEnvironment() + assert.Equal(t, entries, got) +} diff --git a/cmd/root.go b/cmd/root.go index 29ac60e98..39de946bd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/devsy-org/devsy/cmd/completion" cliconfig "github.com/devsy-org/devsy/cmd/config" "github.com/devsy-org/devsy/cmd/context" + envcmd "github.com/devsy-org/devsy/cmd/env" "github.com/devsy-org/devsy/cmd/feature" "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/cmd/ide" @@ -20,6 +21,7 @@ import ( "github.com/devsy-org/devsy/cmd/mcp" "github.com/devsy-org/devsy/cmd/pro" "github.com/devsy-org/devsy/cmd/provider" + secretscmd "github.com/devsy-org/devsy/cmd/secrets" "github.com/devsy-org/devsy/cmd/self" "github.com/devsy-org/devsy/cmd/template" wsCmdPkg "github.com/devsy-org/devsy/cmd/workspace" @@ -316,6 +318,12 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) contextCmd := context.NewContextCmd(globalFlags) contextCmd.GroupID = groupConfig rootCmd.AddCommand(contextCmd) + secretsCmd := secretscmd.NewSecretsCmd(globalFlags) + secretsCmd.GroupID = groupConfig + rootCmd.AddCommand(secretsCmd) + envCmd := envcmd.NewEnvCmd(globalFlags) + envCmd.GroupID = groupConfig + rootCmd.AddCommand(envCmd) if proEnabled() { proCmd := pro.NewProCmd(globalFlags) proCmd.GroupID = groupPlatform diff --git a/cmd/secrets/bind.go b/cmd/secrets/bind.go new file mode 100644 index 000000000..367de9a82 --- /dev/null +++ b/cmd/secrets/bind.go @@ -0,0 +1,115 @@ +package secrets + +import ( + "context" + "fmt" + "slices" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +// AttachCmd binds a secret to the active context. +type AttachCmd struct { + *flags.GlobalFlags +} + +func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &AttachCmd{GlobalFlags: flags} + attachCmd := &cobra.Command{ + Use: "attach NAME", + Short: "Bind a secret to the active context so it is injected automatically on up", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + return attachCmd +} + +func (cmd *AttachCmd) Run(_ context.Context, name string) error { + if err := secrets.ValidateName(name); err != nil { + return err + } + + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + if _, err := store.Get(contextName, name); err != nil { + return fmt.Errorf("cannot attach secret %q: %w", name, err) + } + + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil { + return fmt.Errorf("context %q doesn't exist", contextName) + } + if slices.Contains(ctxConfig.Secrets, name) { + log.Infof("secret %q already attached to context %q", name, contextName) + return nil + } + ctxConfig.Secrets = append(ctxConfig.Secrets, name) + + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + + log.Infof("secret %q attached to context %q", name, contextName) + return nil +} + +// DetachCmd unbinds a secret from the active context. +type DetachCmd struct { + *flags.GlobalFlags +} + +func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &DetachCmd{GlobalFlags: flags} + detachCmd := &cobra.Command{ + Use: "detach NAME", + Short: "Unbind a secret from the active context", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + return detachCmd +} + +func (cmd *DetachCmd) Run(_ context.Context, name string) error { + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil { + return fmt.Errorf("context %q doesn't exist", contextName) + } + + idx := slices.Index(ctxConfig.Secrets, name) + if idx < 0 { + log.Infof("secret %q is not attached to context %q", name, contextName) + return nil + } + ctxConfig.Secrets = slices.Delete(ctxConfig.Secrets, idx, idx+1) + + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + + log.Infof("secret %q detached from context %q", name, contextName) + return nil +} diff --git a/cmd/secrets/delete.go b/cmd/secrets/delete.go new file mode 100644 index 000000000..f3f5cff80 --- /dev/null +++ b/cmd/secrets/delete.go @@ -0,0 +1,53 @@ +package secrets + +import ( + "context" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/log" + "github.com/spf13/cobra" +) + +type DeleteCmd struct { + *flags.GlobalFlags +} + +func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &DeleteCmd{ + GlobalFlags: flags, + } + deleteCmd := &cobra.Command{ + Use: "delete NAME", + Aliases: []string{"rm"}, + Short: "Delete a secret from the active context", + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + return deleteCmd +} + +func (cmd *DeleteCmd) Run(_ context.Context, name string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + + meta, err := store.Meta(contextName, name) + if err != nil { + return err + } + if !meta.Sensitive() { + return fmt.Errorf("%q is an environment variable; use \"devsy env delete\"", name) + } + + if err := store.Delete(contextName, name); err != nil { + return err + } + + log.Infof("secret %q deleted from context %q", name, contextName) + return nil +} diff --git a/cmd/secrets/get.go b/cmd/secrets/get.go new file mode 100644 index 000000000..60f93d720 --- /dev/null +++ b/cmd/secrets/get.go @@ -0,0 +1,57 @@ +package secrets + +import ( + "context" + "fmt" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/spf13/cobra" +) + +type GetCmd struct { + *flags.GlobalFlags +} + +func NewGetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &GetCmd{ + GlobalFlags: flags, + } + getCmd := &cobra.Command{ + Use: "get NAME", + Short: "Print a secret's value", + Long: `Print a secret's value to standard output. + +Intended for scripting. The value is written without a trailing newline +alteration beyond a single terminating newline.`, + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + return getCmd +} + +func (cmd *GetCmd) Run(_ context.Context, name string) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + + meta, err := store.Meta(contextName, name) + if err != nil { + return err + } + if !meta.Sensitive() { + return fmt.Errorf("%q is an environment variable; use \"devsy env get\"", name) + } + + value, err := store.Get(contextName, name) + if err != nil { + return err + } + + //nolint:forbidigo // get prints the raw value to stdout for scripting. + fmt.Println(value) + return nil +} diff --git a/cmd/secrets/list.go b/cmd/secrets/list.go new file mode 100644 index 000000000..90ba14014 --- /dev/null +++ b/cmd/secrets/list.go @@ -0,0 +1,123 @@ +package secrets + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/table" + "github.com/spf13/cobra" +) + +type ListCmd struct { + *flags.GlobalFlags +} + +func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &ListCmd{ + GlobalFlags: flags, + } + listCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List secrets in the active context", + Args: cobra.NoArgs, + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context()) + }, + } + + return listCmd +} + +type secretEntry struct { + Name string `json:"name"` + Context string `json:"context"` + Created string `json:"created,omitempty"` + LastUsed string `json:"lastUsed,omitempty"` + Orphaned bool `json:"orphaned,omitempty"` +} + +func (cmd *ListCmd) Run(_ context.Context) error { + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + + all, err := store.List(contextName) + if err != nil { + return err + } + metas := make([]secrets.SecretMeta, 0, len(all)) + for _, m := range all { + if m.Sensitive() { + metas = append(metas, m) + } + } + + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + + switch mode { + case output.ModePlain: + renderPlain(metas) + case output.ModeJSON: + return renderJSON(metas) + } + + return nil +} + +func renderPlain(metas []secrets.SecretMeta) { + tableEntries := [][]string{} + for _, m := range metas { + tableEntries = append(tableEntries, []string{ + m.Name, + formatTime(m.Created), + formatTime(m.LastUsed), + orphanLabel(m.Orphaned), + }) + } + table.Print([]string{"Name", "Created", "Last Used", "Status"}, tableEntries) +} + +func renderJSON(metas []secrets.SecretMeta) error { + entries := []secretEntry{} + for _, m := range metas { + entries = append(entries, secretEntry{ + Name: m.Name, + Context: m.Context, + Created: formatTime(m.Created), + LastUsed: formatTime(m.LastUsed), + Orphaned: m.Orphaned, + }) + } + out, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + + //nolint:forbidigo // list --result-format json prints structured data to stdout. + fmt.Print(string(out)) + return nil +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func orphanLabel(orphaned bool) string { + if orphaned { + return "orphaned" + } + return "ok" +} diff --git a/cmd/secrets/secrets.go b/cmd/secrets/secrets.go new file mode 100644 index 000000000..371c689bc --- /dev/null +++ b/cmd/secrets/secrets.go @@ -0,0 +1,44 @@ +package secrets + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +// NewSecretsCmd returns the `devsy secrets` command group. +func NewSecretsCmd(flags *flags.GlobalFlags) *cobra.Command { + secretsCmd := &cobra.Command{ + Use: "secret", + Short: "Devsy Secrets commands", + Long: `Manage named secrets stored locally and injected into workspaces. + +Secret values are kept in the OS keyring (macOS Keychain, Windows Credential +Manager, or libsecret) when available, and in an age-encrypted file otherwise. +Only non-sensitive metadata is stored in the Devsy config directory.`, + } + + secretsCmd.AddCommand(NewSetCmd(flags)) + secretsCmd.AddCommand(NewListCmd(flags)) + secretsCmd.AddCommand(NewGetCmd(flags)) + secretsCmd.AddCommand(NewDeleteCmd(flags)) + secretsCmd.AddCommand(NewAttachCmd(flags)) + secretsCmd.AddCommand(NewDetachCmd(flags)) + return secretsCmd +} + +// resolveContext returns the active context name and a Store for it. +func resolveContext(globalFlags *flags.GlobalFlags) (string, secrets.Store, error) { + devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) + if err != nil { + return "", nil, err + } + + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return "", nil, err + } + + return devsyConfig.DefaultContext, store, nil +} diff --git a/cmd/secrets/set.go b/cmd/secrets/set.go new file mode 100644 index 000000000..3ebd12766 --- /dev/null +++ b/cmd/secrets/set.go @@ -0,0 +1,122 @@ +package secrets + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/devsy-org/devsy/cmd/flags" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/survey" + "github.com/spf13/cobra" +) + +type SetCmd struct { + *flags.GlobalFlags + + Value string + FromFile string + Stdin bool +} + +func NewSetCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &SetCmd{ + GlobalFlags: flags, + } + setCmd := &cobra.Command{ + Use: "set NAME", + Short: "Create or update a secret", + Long: `Create or update a secret in the active context. + +The value can be supplied with --value, piped via --stdin, read from a file +with --from-file, or entered at a hidden interactive prompt when none is given.`, + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.Run(cobraCmd.Context(), args[0]) + }, + } + + cliflags.Add( + setCmd, + cliflags.String( + &cmd.Value, names.Value, "", + "The secret value (prefer --stdin for sensitive values)", + ), + cliflags.String(&cmd.FromFile, names.FromFile, "", "Read the secret value from a file"), + cliflags.Bool(&cmd.Stdin, names.Stdin, false, "Read the secret value from standard input"), + ) + return setCmd +} + +func (cmd *SetCmd) Run(_ context.Context, name string) error { + if err := secrets.ValidateName(name); err != nil { + return err + } + + value, err := cmd.resolveValue(name) + if err != nil { + return err + } + + contextName, store, err := resolveContext(cmd.GlobalFlags) + if err != nil { + return err + } + + if err := store.Set(contextName, name, value, secrets.KindSecret); err != nil { + return err + } + + log.Infof("secret %q set in context %q", name, contextName) + return nil +} + +func (cmd *SetCmd) resolveValue(name string) (string, error) { + sources := 0 + for _, on := range []bool{cmd.Value != "", cmd.FromFile != "", cmd.Stdin} { + if on { + sources++ + } + } + if sources > 1 { + return "", fmt.Errorf("specify only one of --value, --from-file, or --stdin") + } + + switch { + case cmd.Value != "": + return cmd.Value, nil + case cmd.FromFile != "": + return readTrimmedFile(cmd.FromFile) + case cmd.Stdin: + return readTrimmed(os.Stdin, "stdin") + default: + return survey.NewSurvey().Question(&survey.QuestionOptions{ + Question: fmt.Sprintf("Enter value for secret %q", name), + IsPassword: true, + }) + } +} + +func readTrimmedFile(path string) (string, error) { + f, err := os.Open(path) // #nosec G304 -- user-specified secret source file. + if err != nil { + return "", fmt.Errorf("read secret file: %w", err) + } + defer func() { _ = f.Close() }() + + return readTrimmed(f, "secret file") +} + +func readTrimmed(r io.Reader, source string) (string, error) { + data, err := io.ReadAll(r) + if err != nil { + return "", fmt.Errorf("read %s: %w", source, err) + } + + return strings.TrimRight(string(data), "\n"), nil +} diff --git a/cmd/secrets/set_internal_test.go b/cmd/secrets/set_internal_test.go new file mode 100644 index 000000000..8b813a798 --- /dev/null +++ b/cmd/secrets/set_internal_test.go @@ -0,0 +1,74 @@ +package secrets + +import ( + "os" + "strings" + "testing" +) + +// withStdin replaces os.Stdin with a pipe carrying input for the duration of fn. +func withStdin(t *testing.T, input string, fn func()) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + orig := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = orig }) + + go func() { + _, _ = w.WriteString(input) + _ = w.Close() + }() + + fn() + _ = r.Close() +} + +func TestResolveValue_Stdin(t *testing.T) { + cmd := &SetCmd{Stdin: true} + var got string + var gotErr error + withStdin(t, "s3cr3t\n", func() { + got, gotErr = cmd.resolveValue("TOKEN") + }) + if gotErr != nil { + t.Fatal(gotErr) + } + // A single trailing newline is trimmed (piping `printf` vs `echo`). + if got != "s3cr3t" { + t.Fatalf("resolveValue(--stdin) = %q, want %q", got, "s3cr3t") + } +} + +func TestResolveValue_StdinPreservesInnerNewlines(t *testing.T) { + cmd := &SetCmd{Stdin: true} + pem := "-----BEGIN KEY-----\nline1\nline2\n-----END KEY-----" + var got string + withStdin(t, pem, func() { + got, _ = cmd.resolveValue("TLS_KEY") + }) + if got != pem { + t.Fatalf("resolveValue(--stdin) = %q, want multiline value preserved", got) + } +} + +func TestResolveValue_Value(t *testing.T) { + cmd := &SetCmd{Value: "plain"} + got, err := cmd.resolveValue("K") + if err != nil { + t.Fatal(err) + } + if got != "plain" { + t.Fatalf("resolveValue(--value) = %q, want %q", got, "plain") + } +} + +func TestResolveValue_RejectsMultipleSources(t *testing.T) { + cmd := &SetCmd{Value: "a", Stdin: true} + _, err := cmd.resolveValue("K") + if err == nil || !strings.Contains(err.Error(), "only one of") { + t.Fatalf("expected mutual-exclusion error, got %v", err) + } +} diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index 8a5525974..2e7ca9923 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -98,6 +98,7 @@ func (cmd *UpCmd) devsyUpProxy( true, true, client.WorkspaceConfig(), + tunnelserver.WithGitToken(cmd.GitToken), ) if err != nil { return nil, fmt.Errorf("run tunnel machine: %w", err) @@ -227,6 +228,7 @@ func (cmd *UpCmd) devsyUpMachineSSH( client.AgentInjectGitCredentials(cmd.CLIOptions), client.AgentInjectDockerCredentials(cmd.CLIOptions), client.WorkspaceConfig(), + tunnelserver.WithGitToken(cmd.GitToken), ) }, }) diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go new file mode 100644 index 000000000..1a7bb37a0 --- /dev/null +++ b/cmd/workspace/up/secrets_test.go @@ -0,0 +1,126 @@ +package up + +import ( + "sort" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + secretAPIKey = "API_KEY" + secretToken = "TOKEN" + secretTLSKey = "TLS_KEY" +) + +func testConfig(bound ...string) *config.Config { + return &config.Config{ + DefaultContext: "default", + Contexts: map[string]*config.ContextConfig{ + "default": {Secrets: bound}, + }, + } +} + +func sortedRequests(reqs []secretRequest) []secretRequest { + sort.Slice(reqs, func(i, j int) bool { return reqs[i].name < reqs[j].name }) + return reqs +} + +func TestCollectSecretRequests_FlagsDefaultToEnv(t *testing.T) { + got, err := collectSecretRequests( + []string{secretAPIKey, "DB_PW,target=DATABASE_PASSWORD"}, + testConfig(), + ) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: secretAPIKey, target: secretAPIKey, mount: false}, + {name: "DB_PW", target: "DATABASE_PASSWORD", mount: false}, + }, sortedRequests(got)) +} + +func TestCollectSecretRequests_MountType(t *testing.T) { + got, err := collectSecretRequests([]string{secretTLSKey + ",type=mount"}, testConfig()) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: secretTLSKey, target: secretTLSKey, mount: true}, + }, got) +} + +func TestCollectSecretRequests_MountWithTarget(t *testing.T) { + got, err := collectSecretRequests( + []string{secretTLSKey + ",type=mount,target=tls.key"}, + testConfig(), + ) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: secretTLSKey, target: "tls.key", mount: true}, + }, got) +} + +func TestCollectSecretRequests_ContextBindings(t *testing.T) { + got, err := collectSecretRequests(nil, testConfig(secretToken, "SECRET_TWO")) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: "SECRET_TWO", target: "SECRET_TWO", mount: false}, + {name: secretToken, target: secretToken, mount: false}, + }, sortedRequests(got)) +} + +func TestCollectSecretRequests_FlagOverridesBinding(t *testing.T) { + got, err := collectSecretRequests([]string{"TOKEN,target=CI_TOKEN"}, testConfig(secretToken)) + require.NoError(t, err) + assert.Equal(t, []secretRequest{ + {name: secretToken, target: "CI_TOKEN", mount: false}, + }, got) +} + +func TestCollectSecretRequests_Empty(t *testing.T) { + got, err := collectSecretRequests(nil, testConfig()) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestCollectSecretRequests_InvalidType(t *testing.T) { + _, err := collectSecretRequests([]string{"A,type=bogus"}, testConfig()) + require.Error(t, err) +} + +func TestCollectSecretRequests_InvalidOption(t *testing.T) { + _, err := collectSecretRequests([]string{"A,bogus"}, testConfig()) + require.Error(t, err) +} + +func TestCollectSecretRequests_DuplicateMountTargetRejected(t *testing.T) { + _, err := collectSecretRequests( + []string{"A,type=mount,target=tok", "B,type=mount,target=tok"}, + testConfig(), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "both mount to target") +} + +func TestApplyEnvVars_RejectsSensitiveSecret(t *testing.T) { + cmd := &UpCmd{} + cmd.EnvVars = []string{secretAPIKey} + get := func(string) (string, error) { return "plaintext", nil } + isSensitive := func(string) (bool, error) { return true, nil } + + err := cmd.applyEnvVars(get, isSensitive) + require.Error(t, err) + assert.Contains(t, err.Error(), "is a secret and cannot be passed with --env") + assert.Empty(t, cmd.WorkspaceEnv, "no value must reach the ps-visible WorkspaceEnv") +} + +func TestApplyEnvVars_AllowsNonSensitive(t *testing.T) { + cmd := &UpCmd{} + cmd.EnvVars = []string{"LOG_LEVEL=DEBUG_TARGET"} + get := func(string) (string, error) { return "debug", nil } + isSensitive := func(string) (bool, error) { return false, nil } + + err := cmd.applyEnvVars(get, isSensitive) + require.NoError(t, err) + assert.Equal(t, []string{"DEBUG_TARGET=debug"}, cmd.WorkspaceEnv) +} diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 3f8b7d44e..b2a4969b4 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -43,6 +43,11 @@ type UpCmd struct { SSHConfigPath string SecretsFile string FeatureSecretsFile string + Secrets []string + EnvVars []string + BuildSecretNames []string + GitTokenSecret string + GitTokenUsername string WorkspaceFolder string DotfilesSource string diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 9f0fcddc1..95a1a0ebb 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -3,7 +3,10 @@ package up import ( "context" "fmt" + "net/url" "os" + "sort" + "strings" client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/client/clientimplementation" @@ -68,7 +71,7 @@ func (cmd *UpCmd) prepareClient( log.Debug("Using error output stream") config.MergeContextOptions(devsyConfig.Current(), os.Environ()) } - if err := cmd.prepareSecrets(); err != nil { + if err := cmd.prepareSecrets(devsyConfig); err != nil { return nil, err } source, err := cmd.parseWorkspaceSource() @@ -119,7 +122,7 @@ func (cmd *UpCmd) resolveParams( } } -func (cmd *UpCmd) prepareSecrets() error { +func (cmd *UpCmd) prepareSecrets(devsyConfig *config.Config) error { if err := mergeEnvFromFiles(&cmd.CLIOptions); err != nil { return err } @@ -134,6 +137,10 @@ func (cmd *UpCmd) prepareSecrets() error { } } + if err := cmd.resolveStoredSecrets(devsyConfig); err != nil { + return err + } + if cmd.FeatureSecretsFile == "" { cmd.FeatureSecretsFile = os.Getenv("DEVCONTAINER_SECRETS_FILE") } @@ -150,6 +157,274 @@ func (cmd *UpCmd) prepareSecrets() error { return nil } +func (cmd *UpCmd) resolveStoredSecrets(devsyConfig *config.Config) error { + requests, err := collectSecretRequests(cmd.Secrets, devsyConfig) + if err != nil { + return err + } + if !cmd.hasStoredValues(requests) { + return nil + } + + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + r := secretResolver{store: store, context: devsyConfig.DefaultContext} + + if err := cmd.applyLifecycleSecrets(requests, r.get); err != nil { + return err + } + if err := cmd.applyEnvVars(r.get, r.sensitive); err != nil { + return err + } + if err := cmd.applyBuildSecrets(r.get); err != nil { + return err + } + return cmd.applyGitToken(r.get) +} + +// secretResolver reads values and kinds for a single context, wrapping store +// errors with the secret name. +type secretResolver struct { + store secrets.Store + context string +} + +func (r secretResolver) get(name string) (string, error) { + value, err := r.store.Get(r.context, name) + if err != nil { + return "", fmt.Errorf("resolve secret %q in context %q: %w", name, r.context, err) + } + return value, nil +} + +func (r secretResolver) sensitive(name string) (bool, error) { + meta, err := r.store.Meta(r.context, name) + if err != nil { + return false, fmt.Errorf("resolve secret %q in context %q: %w", name, r.context, err) + } + return meta.Sensitive(), nil +} + +func (cmd *UpCmd) hasStoredValues(requests []secretRequest) bool { + return len(requests) > 0 || len(cmd.EnvVars) > 0 || + len(cmd.BuildSecretNames) > 0 || cmd.GitTokenSecret != "" +} + +func (cmd *UpCmd) applyLifecycleSecrets( + requests []secretRequest, + get func(string) (string, error), +) error { + for _, req := range requests { + value, err := get(req.name) + if err != nil { + return err + } + if req.mount { + cmd.SecretsMount = append(cmd.SecretsMount, req.target+"="+value) + } else { + cmd.SecretsEnv = append(cmd.SecretsEnv, req.target+"="+value) + } + } + return nil +} + +// applyEnvVars resolves --env entries into WorkspaceEnv. A sensitive secret must +// never be routed here: WorkspaceEnv rides in the setup argv (ps-visible). +func (cmd *UpCmd) applyEnvVars( + get func(string) (string, error), + isSensitive func(string) (bool, error), +) error { + for _, entry := range cmd.EnvVars { + name, target, ok := strings.Cut(entry, "=") + if !ok { + target = name + } + sensitive, err := isSensitive(name) + if err != nil { + return err + } + if sensitive { + return fmt.Errorf( + "%q is a secret and cannot be passed with --env (it would be visible in the process list); use --secret %s instead", + name, + name, + ) + } + value, err := get(name) + if err != nil { + return err + } + cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, target+"="+value) + } + return nil +} + +func (cmd *UpCmd) applyBuildSecrets(get func(string) (string, error)) error { + for _, name := range cmd.BuildSecretNames { + value, err := get(name) + if err != nil { + return err + } + cmd.BuildSecrets = append(cmd.BuildSecrets, name+"="+value) + } + return nil +} + +func (cmd *UpCmd) applyGitToken(get func(string) (string, error)) error { + if cmd.GitTokenSecret == "" { + return nil + } + token, err := get(cmd.GitTokenSecret) + if err != nil { + return err + } + gitToken, err := cmd.buildGitToken(token) + if err != nil { + return err + } + cmd.GitToken = gitToken + return nil +} + +// buildGitToken host-scopes the token; it fails on an unresolvable host so the +// token is never left unscoped. +func (cmd *UpCmd) buildGitToken(token string) (*provider2.GitToken, error) { + host := gitHostFromSource(cmd.Source) + if host == "" { + return nil, fmt.Errorf( + "cannot use --git-token: workspace source %q has no HTTP(S) host to scope the token to", + cmd.Source, + ) + } + username := cmd.GitTokenUsername + if username == "" { + username = gitTokenUsernameForHost(host) + } + + return &provider2.GitToken{Host: host, Username: username, Token: token}, nil +} + +// gitHostFromSource returns the host of an HTTP(S) git source, or "". +func gitHostFromSource(source string) string { + s := strings.TrimPrefix(source, "git:") + u, err := url.Parse(s) + if err != nil { + return "" + } + return u.Host +} + +func gitTokenUsernameForHost(host string) string { + if strings.Contains(host, "gitlab") { + return "oauth2" + } + return "x-access-token" +} + +type secretRequest struct { + name string + target string + mount bool +} + +// collectSecretRequests merges context bindings with secret flags; flags override bindings. +func collectSecretRequests(flags []string, devsyConfig *config.Config) ([]secretRequest, error) { + byName := map[string]secretRequest{} + + if ctxConfig := devsyConfig.Contexts[devsyConfig.DefaultContext]; ctxConfig != nil { + for _, name := range ctxConfig.Secrets { + byName[name] = secretRequest{name: name, target: name} + } + } + + for _, entry := range flags { + req, err := parseSecretFlag(entry) + if err != nil { + return nil, err + } + byName[req.name] = req + } + + requests := make([]secretRequest, 0, len(byName)) + for _, req := range byName { + requests = append(requests, req) + } + sort.Slice(requests, func(i, j int) bool { return requests[i].name < requests[j].name }) + + if err := checkDuplicateMountTargets(requests); err != nil { + return nil, err + } + + return requests, nil +} + +func checkDuplicateMountTargets(requests []secretRequest) error { + targets := map[string]string{} + for _, req := range requests { + if !req.mount { + continue + } + if other, dup := targets[req.target]; dup { + return fmt.Errorf( + "secrets %q and %q both mount to target %q; give one a distinct target=", + other, req.name, req.target, + ) + } + targets[req.target] = req.name + } + return nil +} + +// parseSecretFlag parses NAME[,type=env|mount][,target=X]. +func parseSecretFlag(entry string) (secretRequest, error) { + parts := strings.Split(entry, ",") + req := secretRequest{name: parts[0]} + if req.name == "" { + return secretRequest{}, fmt.Errorf("invalid secret %q: missing name", entry) + } + + for _, opt := range parts[1:] { + key, value, ok := strings.Cut(opt, "=") + if !ok { + return secretRequest{}, fmt.Errorf( + "invalid secret option %q, expected key=value", + opt, + ) + } + if err := req.applyOption(key, value); err != nil { + return secretRequest{}, err + } + } + + if req.target == "" { + req.target = req.name + } + + return req, nil +} + +func (req *secretRequest) applyOption(key, value string) error { + switch key { + case "type": + switch value { + case "env": + req.mount = false + case "mount": + req.mount = true + default: + return fmt.Errorf("invalid secret type %q, expected env or mount", value) + } + case "target": + req.target = value + default: + return fmt.Errorf("invalid secret option %q, expected type or target", key) + } + + return nil +} + func (cmd *UpCmd) parseWorkspaceSource() (*provider2.WorkspaceSource, error) { if cmd.Source == "" { return nil, nil diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index a8dbd2751..87fc019a6 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -198,7 +198,19 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { flags.StringSlice(&cmd.WorkspaceEnvFile, names.WorkspaceEnvFile, nil, "File of env vars for the workspace"), flags.String(&cmd.SecretsFile, names.SecretsFile, "", - "Dotenv file of KEY=VALUE secrets for lifecycle commands"), + `JSON file ({"KEY":"value"}) of secrets for lifecycle commands`), + flags.StringArray(&cmd.Secrets, names.Secret, nil, + "Stored Devsy secret to inject, as NAME[,type=env|mount][,target=X]; "+ + "type=env (default) sets an env var, type=mount writes /run/secrets/. Repeatable"), + flags.StringArray(&cmd.EnvVars, names.Env, nil, + "Stored Devsy env var to inject into the workspace as NAME[=TARGET]. Repeatable"), + flags.StringArray(&cmd.BuildSecretNames, names.BuildSecret, nil, + "Stored Devsy secret exposed to the build via BuildKit "+ + "(RUN --mount=type=secret,id=NAME). Repeatable"), + flags.String(&cmd.GitTokenSecret, names.GitToken, "", + "Stored Devsy secret holding an access token for cloning a private HTTP repository"), + flags.String(&cmd.GitTokenUsername, names.GitTokenUsername, "", + "Username for --git-token (default inferred from the repo host)"), flags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "", `JSON file of feature secret values, format: {"featureId": {"optionName": "value"}}`), flags.StringArray(&cmd.InitEnv, names.InitEnv, nil, diff --git a/desktop/src/main/__tests__/cli.test.ts b/desktop/src/main/__tests__/cli.test.ts index 4f611cc3e..7693a6a5e 100644 --- a/desktop/src/main/__tests__/cli.test.ts +++ b/desktop/src/main/__tests__/cli.test.ts @@ -1,8 +1,31 @@ // @vitest-environment node -import { execFile } from "node:child_process" +import { execFile, spawn } from "node:child_process" +import { EventEmitter } from "node:events" import { beforeEach, describe, expect, it, vi } from "vitest" import { CliRunner } from "../cli.js" +// fakeChild models the slice of ChildProcess that runRawStdin drives: readable +// stdout/stderr, a stdin sink capturing what is written, and close/error events. +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter + stderr: EventEmitter + stdin: EventEmitter & { end: (input: string) => void; written: string } + } + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + const stdin = new EventEmitter() as EventEmitter & { + end: (input: string) => void + written: string + } + stdin.written = "" + stdin.end = (input: string) => { + stdin.written = input + } + child.stdin = stdin + return child +} + vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal() return { @@ -125,6 +148,47 @@ describe("CliRunner", () => { }) }) + describe("runRawStdin", () => { + it("writes the value to the child's stdin and resolves with stdout", async () => { + const child = fakeChild() + const mockSpawn = vi.mocked(spawn) as unknown as ReturnType + mockSpawn.mockReturnValue(child) + + const promise = cli.runRawStdin(["secret", "set", "FOO", "--stdin"], "bar") + // runRawStdin awaits acquire() before spawning, so let microtasks flush + // until the child exists and stdin has been driven. + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()) + + // Regression guard: the value must actually reach stdin. The bug was that + // execFile's { input } option is a no-op on the async variant, so the CLI + // blocked forever reading stdin and the desktop hung on "Saving...". + expect(child.stdin.written).toBe("bar") + + child.stdout.emit("data", "ok\n") + child.emit("close", 0) + + await expect(promise).resolves.toBe("ok\n") + expect(mockSpawn).toHaveBeenCalledWith( + "/usr/local/bin/devsy", + ["secret", "set", "FOO", "--stdin", "--log-output", "json"], + expect.objectContaining({ env: expect.any(Object) }), + ) + }) + + it("rejects with stderr on non-zero exit", async () => { + const child = fakeChild() + const mockSpawn = vi.mocked(spawn) as unknown as ReturnType + mockSpawn.mockReturnValue(child) + + const promise = cli.runRawStdin(["secret", "set", "BAD", "--stdin"], "x") + await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled()) + child.stderr.emit("data", "boom") + child.emit("close", 1) + + await expect(promise).rejects.toThrow(/boom|exit code 1/) + }) + }) + describe("constructor with .cjs binary", () => { it("runs .cjs files through node from PATH", async () => { const jsCli = new CliRunner("/tmp/mock.cjs") diff --git a/desktop/src/main/cli.ts b/desktop/src/main/cli.ts index 76fd4fb3c..bf7625ec9 100644 --- a/desktop/src/main/cli.ts +++ b/desktop/src/main/cli.ts @@ -188,6 +188,55 @@ export class CliRunner { } } + /** + * Like runRaw, but writes `input` to the child's stdin. Used for secret + * values so they never appear in the process argument list (e.g. `ps`). + */ + async runRawStdin(args: string[], input: string): Promise { + await this.acquire() + try { + return await this.spawnWithStdin( + [...this.prefixArgs, ...args, "--log-output", "json"], + input, + ) + } catch (error: unknown) { + throw this.wrapError(error) + } finally { + this.release() + } + } + + /** + * Spawn the CLI, write `input` to its stdin and close it, then resolve with + * stdout. Node's async execFile ignores the `input` option (it only works on + * the *Sync variants), which would leave the child blocked reading stdin + * forever — so this uses spawn and drives stdin explicitly. + */ + private spawnWithStdin(args: string[], input: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(this.execPath, args, { env: this.env }) + let stdout = "" + let stderr = "" + child.stdout.on("data", (d) => (stdout += d)) + child.stderr.on("data", (d) => (stderr += d)) + child.on("error", reject) + child.on("close", (code) => { + if (code === 0) { + resolve(stdout) + return + } + const err = new Error( + `command failed with exit code ${code}`, + ) as Error & { stderr: string; code: number | null } + err.stderr = stderr + err.code = code + reject(err) + }) + child.stdin.on("error", reject) + child.stdin.end(input) + }) + } + private activeChildren = new Set() /** * Tracks streaming children by workspace so callers can cancel everything diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 0ce2637b0..ef3643983 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -29,6 +29,21 @@ import { type ProviderEntry, parseProviderEntries } from "./watcher.js" const execFileAsync = promisify(execFile) +// Matches the JSON emitted by `devsy secret list`. +interface SecretEntry { + name: string + context: string + created?: string + lastUsed?: string + orphaned?: boolean +} + +// Matches the JSON emitted by `devsy env list`. +interface EnvEntry { + name: string + value: string +} + function dockerArch(nodeArch: string): string { if (nodeArch === "x64") return "amd64" if (nodeArch === "arm") return "arm" @@ -538,6 +553,41 @@ export function registerIpcHandlers(deps: IpcDependencies): { await cli.runRaw(["context", "delete", args.name]) }) + ipcMain.handle("secret_list", async () => + cli.run(["secret", "list"]), + ) + + ipcMain.handle( + "secret_set", + async (_event, args: { name: string; value: string }) => { + trackEvent("secret_set") + await cli.runRawStdin( + ["secret", "set", args.name, "--stdin"], + args.value, + ) + }, + ) + + ipcMain.handle("secret_delete", async (_event, args: { name: string }) => { + trackEvent("secret_delete") + await cli.runRaw(["secret", "delete", args.name]) + }) + + ipcMain.handle("env_list", async () => cli.run(["env", "list"])) + + ipcMain.handle( + "env_set", + async (_event, args: { name: string; value: string }) => { + trackEvent("env_set") + await cli.runRaw(["env", "set", args.name, "--value", args.value]) + }, + ) + + ipcMain.handle("env_delete", async (_event, args: { name: string }) => { + trackEvent("env_delete") + await cli.runRaw(["env", "delete", args.name]) + }) + // ── System ── ipcMain.handle("devsy_version", async () => { return cli.runRaw(["--version"]) diff --git a/desktop/src/renderer/src/App.svelte b/desktop/src/renderer/src/App.svelte index 3e3c05cbe..950384e5f 100644 --- a/desktop/src/renderer/src/App.svelte +++ b/desktop/src/renderer/src/App.svelte @@ -13,6 +13,8 @@ import { initWorkspaces, destroyWorkspaces } from "$lib/stores/workspaces.js" import { initProviders, destroyProviders } from "$lib/stores/providers.js" import { initMachines, destroyMachines } from "$lib/stores/machines.js" import { initContexts, destroyContexts } from "$lib/stores/contexts.js" +import { initSecrets } from "$lib/stores/secrets.js" +import { initEnv } from "$lib/stores/env.js" import { initSettings, syncAutoUpdateFromMain, @@ -42,6 +44,8 @@ import ProviderAddPage from "./pages/ProviderAddPage.svelte" import MachinesPage from "./pages/MachinesPage.svelte" import MachineDetailPage from "./pages/MachineDetailPage.svelte" import ContextsPage from "./pages/ContextsPage.svelte" +import SecretsPage from "./pages/SecretsPage.svelte" +import EnvPage from "./pages/EnvPage.svelte" import SettingsPage from "./pages/SettingsPage.svelte" import SshKeysPage from "./pages/SshKeysPage.svelte" import TerminalsPage from "./pages/TerminalsPage.svelte" @@ -58,6 +62,8 @@ const routes = { "/machines": MachinesPage, "/machines/:id": MachineDetailPage, "/contexts": ContextsPage, + "/secrets": SecretsPage, + "/env": EnvPage, "/settings": SettingsPage, "/ssh-keys": SshKeysPage, "/terminals": TerminalsPage, @@ -122,6 +128,8 @@ function screenName(path: string): string { "/machines": "machines", "/machines/:id": "machine_detail", "/contexts": "contexts", + "/secrets": "secrets", + "/env": "env", "/settings": "settings", "/ssh-keys": "ssh_keys", "/terminals": "terminals", @@ -134,6 +142,8 @@ onMount(async () => { initProviders() initMachines() initContexts() + initSecrets() + initEnv() destroySettings = initSettings() stopSessionTracking = initSessionTracking() diff --git a/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte b/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte index c0277495f..92a38296c 100644 --- a/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte +++ b/desktop/src/renderer/src/lib/components/layout/Sidebar.svelte @@ -1,9 +1,11 @@ + +
+
+

Environment Variables

+ + + {#snippet child({ props })} + + {/snippet} + + + + Add Environment Variable + + Stored in plaintext in your Devsy config and injected into workspaces. + + +
{ e.preventDefault(); handleCreate() }} class="space-y-4"> +
+ + (newName = e.currentTarget.value)} + disabled={saving} + /> + {#if newName && !nameValid} +

+ Only letters, digits, and underscores are allowed. +

+ {/if} +
+
+ + (newValue = e.currentTarget.value)} + disabled={saving} + /> +
+ + + + +
+
+
+
+ + {#if $envLoading} +
+ {#each Array(2) as _} + + {/each} +
+ {:else if $envVars.length === 0} +
+ +

No environment variables stored.

+
+ {:else} +
+ + (searchTerm = e.currentTarget.value)} + /> +
+ {#if filteredEnvVars.length === 0} +
+ +

No environment variables matching "{searchTerm}"

+
+ {:else} +
+ {#each filteredEnvVars as envVar (envVar.name)} +
+
+
+ +

{envVar.name}

+
+
+ + +
+
+

+ {revealed[envVar.name] ? envVar.value : "••••••••"} +

+
+ {/each} +
+ {/if} + {/if} +
diff --git a/desktop/src/renderer/src/pages/SecretsPage.svelte b/desktop/src/renderer/src/pages/SecretsPage.svelte new file mode 100644 index 000000000..ab1b004df --- /dev/null +++ b/desktop/src/renderer/src/pages/SecretsPage.svelte @@ -0,0 +1,170 @@ + + +
+
+

Secrets

+ + + {#snippet child({ props })} + + {/snippet} + + + + Add Secret + + Stored in your OS keyring and injected into workspaces on demand. + + +
{ e.preventDefault(); handleCreate() }} class="space-y-4"> +
+ + (newName = e.currentTarget.value)} + disabled={saving} + /> + {#if newName && !nameValid} +

+ Only letters, digits, and underscores are allowed. +

+ {/if} +
+
+ + (newValue = e.currentTarget.value)} + disabled={saving} + /> +
+ + + + +
+
+
+
+ + {#if $secretsLoading} +
+ {#each Array(2) as _} + + {/each} +
+ {:else if $secrets.length === 0} +
+ +

No secrets stored.

+
+ {:else} +
+ + (searchTerm = e.currentTarget.value)} + /> +
+ {#if filteredSecrets.length === 0} +
+ +

No secrets matching "{searchTerm}"

+
+ {:else} +
+ {#each filteredSecrets as secret (secret.name)} +
+
+
+ +

{secret.name}

+
+
+ {#if secret.orphaned} + orphaned + {/if} + +
+
+

+ Context: {secret.context} +

+
+ {/each} +
+ {/if} + {/if} +
diff --git a/e2e/tests/up/helper.go b/e2e/tests/up/helper.go index 2fbb84a38..e25bf48b6 100644 --- a/e2e/tests/up/helper.go +++ b/e2e/tests/up/helper.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "os" "strings" "github.com/devsy-org/devsy/e2e/framework" @@ -15,6 +16,43 @@ import ( "github.com/onsi/ginkgo/v2" ) +const secretCmd = "secret" + +// useFileSecretsBackend forces the file backend so tests do not depend on an OS +// keyring (unavailable in CI); env is restored on cleanup. +func useFileSecretsBackend() { + for k, v := range map[string]string{ + "DEVSY_SECRETS_BACKEND": "file", + "DEVSY_SECRETS_PASSPHRASE": "e2e-passphrase", + } { + prev, had := os.LookupEnv(k) + framework.ExpectNoError(os.Setenv(k, v)) + ginkgo.DeferCleanup(func() { + if had { + _ = os.Setenv(k, prev) + } else { + _ = os.Unsetenv(k) + } + }) + } +} + +func (dtc *dockerTestContext) storeSecret(ctx context.Context, name, value string) { + _, err := dtc.f.ExecCommandOutput(ctx, []string{secretCmd, "set", name, "--value", value}) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { + _, _ = dtc.f.ExecCommandOutput(ctx, []string{secretCmd, "delete", name}) + }) +} + +func (dtc *dockerTestContext) storeEnv(ctx context.Context, name, value string) { + _, err := dtc.f.ExecCommandOutput(ctx, []string{"env", "set", name, "--value", value}) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { + _, _ = dtc.f.ExecCommandOutput(ctx, []string{"env", "delete", name}) + }) +} + // logLine represents a single JSON log line from devsy output. // Only the fields we need for test assertions are included. type logLine struct { diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index 654e02dde..fddffd00c 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -678,10 +678,10 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) - secretsFile := filepath.Join(secretsDir, "secrets.env") + secretsFile := filepath.Join(secretsDir, "secrets.json") err = os.WriteFile( secretsFile, - []byte("MY_SECRET=test-value-12345\nANOTHER_SECRET=second-secret-42\n"), + []byte(`{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`), 0o600, ) framework.ExpectNoError(err) @@ -698,6 +698,81 @@ var _ = ginkgo.Describe( gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("second-secret-42")) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + ginkgo.It( + "managed secret injects into lifecycle commands via --secret", + func(ctx context.Context) { + useFileSecretsBackend() + + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-managed-secret", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + dtc.storeSecret(ctx, "MANAGED_SECRET", "managed-value-99") + + err = dtc.f.DevsyUp(ctx, tempDir, "--secret", "MANAGED_SECRET") + framework.ExpectNoError(err) + + out, err := dtc.execSSH(ctx, tempDir, "cat /tmp/managed-secret-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("managed-value-99")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "managed secret mounts as a file under /run/secrets via --secret type=mount", + func(ctx context.Context) { + useFileSecretsBackend() + + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-managed-secret-mount", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + dtc.storeSecret(ctx, "MOUNTED_SECRET", "mounted-value-77") + + err = dtc.f.DevsyUp( + ctx, tempDir, + "--secret", "MOUNTED_SECRET,type=mount,target=mounted_secret", + ) + framework.ExpectNoError(err) + + out, err := dtc.execSSH(ctx, tempDir, "cat /tmp/mounted-secret-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("mounted-value-77")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + + ginkgo.It( + "managed env var injects into the workspace via --env", + func(ctx context.Context) { + useFileSecretsBackend() + + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-managed-secret", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + + dtc.storeEnv(ctx, "MANAGED_ENV", "env-value-55") + + err = dtc.f.DevsyUp(ctx, tempDir, "--env", "MANAGED_ENV") + framework.ExpectNoError(err) + + out, err := dtc.execSSH(ctx, tempDir, "bash -l -c 'echo -n $MANAGED_ENV'") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("env-value-55")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + ginkgo.It("multi devcontainer selection", func(ctx context.Context) { tempDir, err := setupWorkspace( "tests/up/testdata/docker-multi-devcontainer", diff --git a/e2e/tests/up/provider_podman.go b/e2e/tests/up/provider_podman.go index 27500a671..67a537b6f 100644 --- a/e2e/tests/up/provider_podman.go +++ b/e2e/tests/up/provider_podman.go @@ -348,10 +348,12 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) - secretsFile := filepath.Join(secretsDir, "secrets.env") + secretsFile := filepath.Join(secretsDir, "secrets.json") err = os.WriteFile( secretsFile, - []byte("MY_SECRET=test-value-12345\nANOTHER_SECRET=second-secret-42\n"), + []byte( + `{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`, + ), 0o600, ) framework.ExpectNoError(err) @@ -997,10 +999,12 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _ = os.RemoveAll(secretsDir) }) - secretsFile := filepath.Join(secretsDir, "secrets.env") + secretsFile := filepath.Join(secretsDir, "secrets.json") err = os.WriteFile( secretsFile, - []byte("MY_SECRET=test-value-12345\nANOTHER_SECRET=second-secret-42\n"), + []byte( + `{"MY_SECRET":"test-value-12345","ANOTHER_SECRET":"second-secret-42"}`, + ), 0o600, ) framework.ExpectNoError(err) diff --git a/e2e/tests/up/testdata/docker-managed-secret-mount/.devcontainer.json b/e2e/tests/up/testdata/docker-managed-secret-mount/.devcontainer.json new file mode 100644 index 000000000..644033a01 --- /dev/null +++ b/e2e/tests/up/testdata/docker-managed-secret-mount/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "postCreateCommand": "cat /run/secrets/mounted_secret > /tmp/mounted-secret-check.out" +} diff --git a/e2e/tests/up/testdata/docker-managed-secret/.devcontainer.json b/e2e/tests/up/testdata/docker-managed-secret/.devcontainer.json new file mode 100644 index 000000000..605db16f2 --- /dev/null +++ b/e2e/tests/up/testdata/docker-managed-secret/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "postCreateCommand": "echo -n $MANAGED_SECRET > /tmp/managed-secret-check.out" +} diff --git a/go.mod b/go.mod index a96c43012..a169c5914 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( al.essio.dev/pkg/shellescape v1.6.0 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.5 + filippo.io/age v1.3.1 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 @@ -56,6 +57,7 @@ require ( github.com/tidwall/gjson v1.19.0 github.com/tonistiigi/fsutil v0.0.0-20260717003753-6d9dc2ebad62 github.com/u-root/u-root v0.16.0 + github.com/zalando/go-keyring v0.2.8 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.54.0 @@ -87,6 +89,7 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect code.gitea.io/sdk/gitea v0.23.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/hpke v0.4.0 // indirect github.com/42wim/httpsig v1.2.4 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -145,6 +148,7 @@ require ( github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.7.0 // indirect github.com/creachadair/msync v0.7.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davidmz/go-pageant v1.0.2 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect diff --git a/go.sum b/go.sum index 4bafeded3..fae74c219 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ 9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= @@ -18,8 +20,12 @@ code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg= code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU= @@ -220,6 +226,8 @@ github.com/creativeprojects/go-selfupdate v1.6.0 h1:Bu3cIgdyfI1Pg8XsL8nbaT2uMjfZ github.com/creativeprojects/go-selfupdate v1.6.0/go.mod h1:Ids8O474XGQG0jZ5vpBIhWffcGYjUP6ccOI0mMcvQbI= github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -723,6 +731,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24= gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= diff --git a/pkg/agent/tunnel/tunnel.pb.go b/pkg/agent/tunnel/tunnel.pb.go index ebb402802..55197c060 100644 --- a/pkg/agent/tunnel/tunnel.pb.go +++ b/pkg/agent/tunnel/tunnel.pb.go @@ -326,6 +326,110 @@ func (x *Message) GetMessage() string { return "" } +type Secret struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + Mount bool `protobuf:"varint,3,opt,name=mount,proto3" json:"mount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Secret) Reset() { + *x = Secret{} + mi := &file_tunnel_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Secret) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Secret) ProtoMessage() {} + +func (x *Secret) ProtoReflect() protoreflect.Message { + mi := &file_tunnel_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Secret.ProtoReflect.Descriptor instead. +func (*Secret) Descriptor() ([]byte, []int) { + return file_tunnel_proto_rawDescGZIP(), []int{6} +} + +func (x *Secret) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Secret) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *Secret) GetMount() bool { + if x != nil { + return x.Mount + } + return false +} + +type SecretsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Secrets []*Secret `protobuf:"bytes,1,rep,name=secrets,proto3" json:"secrets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SecretsResponse) Reset() { + *x = SecretsResponse{} + mi := &file_tunnel_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SecretsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecretsResponse) ProtoMessage() {} + +func (x *SecretsResponse) ProtoReflect() protoreflect.Message { + mi := &file_tunnel_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecretsResponse.ProtoReflect.Descriptor instead. +func (*SecretsResponse) Descriptor() ([]byte, []int) { + return file_tunnel_proto_rawDescGZIP(), []int{7} +} + +func (x *SecretsResponse) GetSecrets() []*Secret { + if x != nil { + return x.Secrets + } + return nil +} + type Chunk struct { state protoimpl.MessageState `protogen:"open.v1"` Content []byte `protobuf:"bytes,1,opt,name=Content,proto3" json:"Content,omitempty"` @@ -335,7 +439,7 @@ type Chunk struct { func (x *Chunk) Reset() { *x = Chunk{} - mi := &file_tunnel_proto_msgTypes[6] + mi := &file_tunnel_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -347,7 +451,7 @@ func (x *Chunk) String() string { func (*Chunk) ProtoMessage() {} func (x *Chunk) ProtoReflect() protoreflect.Message { - mi := &file_tunnel_proto_msgTypes[6] + mi := &file_tunnel_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -360,7 +464,7 @@ func (x *Chunk) ProtoReflect() protoreflect.Message { // Deprecated: Use Chunk.ProtoReflect.Descriptor instead. func (*Chunk) Descriptor() ([]byte, []int) { - return file_tunnel_proto_rawDescGZIP(), []int{6} + return file_tunnel_proto_rawDescGZIP(), []int{8} } func (x *Chunk) GetContent() []byte { @@ -380,7 +484,7 @@ type LogMessage struct { func (x *LogMessage) Reset() { *x = LogMessage{} - mi := &file_tunnel_proto_msgTypes[7] + mi := &file_tunnel_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -392,7 +496,7 @@ func (x *LogMessage) String() string { func (*LogMessage) ProtoMessage() {} func (x *LogMessage) ProtoReflect() protoreflect.Message { - mi := &file_tunnel_proto_msgTypes[7] + mi := &file_tunnel_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -405,7 +509,7 @@ func (x *LogMessage) ProtoReflect() protoreflect.Message { // Deprecated: Use LogMessage.ProtoReflect.Descriptor instead. func (*LogMessage) Descriptor() ([]byte, []int) { - return file_tunnel_proto_rawDescGZIP(), []int{7} + return file_tunnel_proto_rawDescGZIP(), []int{9} } func (x *LogMessage) GetLogLevel() LogLevel { @@ -430,7 +534,7 @@ type Empty struct { func (x *Empty) Reset() { *x = Empty{} - mi := &file_tunnel_proto_msgTypes[8] + mi := &file_tunnel_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -442,7 +546,7 @@ func (x *Empty) String() string { func (*Empty) ProtoMessage() {} func (x *Empty) ProtoReflect() protoreflect.Message { - mi := &file_tunnel_proto_msgTypes[8] + mi := &file_tunnel_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -455,7 +559,7 @@ func (x *Empty) ProtoReflect() protoreflect.Message { // Deprecated: Use Empty.ProtoReflect.Descriptor instead. func (*Empty) Descriptor() ([]byte, []int) { - return file_tunnel_proto_rawDescGZIP(), []int{8} + return file_tunnel_proto_rawDescGZIP(), []int{10} } var File_tunnel_proto protoreflect.FileDescriptor @@ -476,71 +580,83 @@ var file_tunnel_proto_rawDesc = string([]byte{ 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x21, 0x0a, 0x05, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x43, 0x6f, - 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x07, 0x43, 0x6f, 0x6e, - 0x74, 0x65, 0x6e, 0x74, 0x22, 0x54, 0x0a, 0x0a, 0x4c, 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x2c, 0x0a, 0x08, 0x6c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, - 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x08, 0x6c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, - 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x2a, 0x41, 0x0a, 0x08, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, - 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, - 0x46, 0x4f, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x44, 0x4f, 0x4e, 0x45, 0x10, 0x02, 0x12, 0x0b, - 0x0a, 0x07, 0x57, 0x41, 0x52, 0x4e, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, - 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x32, 0x8b, 0x06, 0x0a, 0x06, 0x54, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x12, 0x26, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, - 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2a, 0x0a, 0x03, 0x4c, 0x6f, 0x67, - 0x12, 0x12, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x0a, 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, - 0x75, 0x6c, 0x74, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x11, 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x43, - 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x34, - 0x0a, 0x0e, 0x47, 0x69, 0x74, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, - 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x22, 0x00, 0x12, 0x35, 0x0a, 0x0f, 0x47, 0x69, 0x74, 0x53, 0x53, 0x48, 0x53, 0x69, - 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x65, 0x22, 0x48, 0x0a, 0x06, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, + 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3b, 0x0a, 0x0f, 0x53, + 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, + 0x0a, 0x07, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x0e, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, + 0x07, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x05, 0x43, 0x68, 0x75, 0x6e, + 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x0c, 0x52, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, 0x54, 0x0a, 0x0a, 0x4c, + 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2c, 0x0a, 0x08, 0x6c, 0x6f, 0x67, + 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x08, 0x6c, + 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x2a, 0x41, 0x0a, 0x08, 0x4c, 0x6f, + 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, + 0x00, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x44, + 0x4f, 0x4e, 0x45, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x57, 0x41, 0x52, 0x4e, 0x49, 0x4e, 0x47, + 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x32, 0xc1, 0x06, + 0x0a, 0x06, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x26, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, + 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, + 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, + 0x12, 0x2a, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x12, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x2e, 0x4c, 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x0a, + 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, + 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x11, + 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, + 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x34, 0x0a, 0x0e, 0x47, 0x69, 0x74, 0x43, 0x72, 0x65, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x2b, 0x0a, 0x07, 0x47, - 0x69, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x30, 0x0a, 0x0a, 0x4c, 0x6f, 0x66, 0x74, - 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x0d, 0x47, 0x50, - 0x47, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, + 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x35, 0x0a, 0x0f, 0x47, + 0x69, 0x74, 0x53, 0x53, 0x48, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x0f, + 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0x00, 0x12, 0x2b, 0x0a, 0x07, 0x47, 0x69, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0d, 0x2e, + 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, - 0x30, 0x0a, 0x0a, 0x4b, 0x75, 0x62, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, 0x2e, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, - 0x00, 0x12, 0x48, 0x0a, 0x0b, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, - 0x12, 0x1a, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, - 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x74, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x54, 0x0a, 0x0f, 0x53, - 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1e, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, - 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x33, 0x0a, 0x0f, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x57, 0x6f, 0x72, 0x6b, 0x73, - 0x70, 0x61, 0x63, 0x65, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, - 0x6e, 0x6b, 0x22, 0x00, 0x30, 0x01, 0x12, 0x3c, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1a, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, - 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, - 0x22, 0x00, 0x30, 0x01, 0x42, 0x2e, 0x5a, 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x73, 0x6b, 0x65, 0x76, 0x65, 0x74, 0x74, 0x65, 0x72, 0x2f, 0x64, 0x65, 0x76, - 0x70, 0x6f, 0x64, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x31, 0x0a, 0x0b, 0x44, 0x65, 0x76, 0x73, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, + 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, + 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x22, 0x00, 0x12, 0x33, 0x0a, 0x0d, 0x47, 0x50, 0x47, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, + 0x65, 0x79, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x30, 0x0a, 0x0a, 0x4b, 0x75, 0x62, 0x65, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, + 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x07, 0x53, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x73, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, + 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x65, 0x63, + 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, + 0x0a, 0x0b, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x2e, + 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, + 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x74, 0x75, 0x6e, 0x6e, + 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x54, 0x0a, 0x0f, 0x53, 0x74, 0x6f, 0x70, + 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1e, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x74, 0x75, + 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, + 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, + 0x0a, 0x0f, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, + 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x22, + 0x00, 0x30, 0x01, 0x12, 0x3c, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x6f, 0x75, + 0x6e, 0x74, 0x12, 0x1a, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, + 0x61, 0x6d, 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, + 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x22, 0x00, 0x30, + 0x01, 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x64, 0x65, 0x76, 0x73, 0x79, 0x2d, 0x6f, 0x72, 0x67, 0x2f, 0x64, 0x65, 0x76, 0x73, 0x79, 0x2f, + 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( @@ -556,7 +672,7 @@ func file_tunnel_proto_rawDescGZIP() []byte { } var file_tunnel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_tunnel_proto_goTypes = []any{ (LogLevel)(0), // 0: tunnel.LogLevel (*StreamMountRequest)(nil), // 1: tunnel.StreamMountRequest @@ -565,45 +681,50 @@ var file_tunnel_proto_goTypes = []any{ (*ForwardPortRequest)(nil), // 4: tunnel.ForwardPortRequest (*ForwardPortResponse)(nil), // 5: tunnel.ForwardPortResponse (*Message)(nil), // 6: tunnel.Message - (*Chunk)(nil), // 7: tunnel.Chunk - (*LogMessage)(nil), // 8: tunnel.LogMessage - (*Empty)(nil), // 9: tunnel.Empty + (*Secret)(nil), // 7: tunnel.Secret + (*SecretsResponse)(nil), // 8: tunnel.SecretsResponse + (*Chunk)(nil), // 9: tunnel.Chunk + (*LogMessage)(nil), // 10: tunnel.LogMessage + (*Empty)(nil), // 11: tunnel.Empty } var file_tunnel_proto_depIdxs = []int32{ - 0, // 0: tunnel.LogMessage.logLevel:type_name -> tunnel.LogLevel - 9, // 1: tunnel.Tunnel.Ping:input_type -> tunnel.Empty - 8, // 2: tunnel.Tunnel.Log:input_type -> tunnel.LogMessage - 6, // 3: tunnel.Tunnel.SendResult:input_type -> tunnel.Message - 6, // 4: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message - 6, // 5: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message - 6, // 6: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message - 9, // 7: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty - 6, // 8: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message - 6, // 9: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message - 6, // 10: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message - 4, // 11: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest - 2, // 12: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest - 9, // 13: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty - 1, // 14: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest - 9, // 15: tunnel.Tunnel.Ping:output_type -> tunnel.Empty - 9, // 16: tunnel.Tunnel.Log:output_type -> tunnel.Empty - 9, // 17: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty - 6, // 18: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message - 6, // 19: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message - 6, // 20: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message - 6, // 21: tunnel.Tunnel.GitUser:output_type -> tunnel.Message - 6, // 22: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message - 6, // 23: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message - 6, // 24: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message - 5, // 25: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse - 3, // 26: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse - 7, // 27: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk - 7, // 28: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk - 15, // [15:29] is the sub-list for method output_type - 1, // [1:15] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 7, // 0: tunnel.SecretsResponse.secrets:type_name -> tunnel.Secret + 0, // 1: tunnel.LogMessage.logLevel:type_name -> tunnel.LogLevel + 11, // 2: tunnel.Tunnel.Ping:input_type -> tunnel.Empty + 10, // 3: tunnel.Tunnel.Log:input_type -> tunnel.LogMessage + 6, // 4: tunnel.Tunnel.SendResult:input_type -> tunnel.Message + 6, // 5: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message + 6, // 6: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message + 6, // 7: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message + 11, // 8: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty + 6, // 9: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message + 6, // 10: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message + 6, // 11: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message + 11, // 12: tunnel.Tunnel.Secrets:input_type -> tunnel.Empty + 4, // 13: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest + 2, // 14: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest + 11, // 15: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty + 1, // 16: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest + 11, // 17: tunnel.Tunnel.Ping:output_type -> tunnel.Empty + 11, // 18: tunnel.Tunnel.Log:output_type -> tunnel.Empty + 11, // 19: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty + 6, // 20: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message + 6, // 21: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message + 6, // 22: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message + 6, // 23: tunnel.Tunnel.GitUser:output_type -> tunnel.Message + 6, // 24: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message + 6, // 25: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message + 6, // 26: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message + 8, // 27: tunnel.Tunnel.Secrets:output_type -> tunnel.SecretsResponse + 5, // 28: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse + 3, // 29: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse + 9, // 30: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk + 9, // 31: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk + 17, // [17:32] is the sub-list for method output_type + 2, // [2:17] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_tunnel_proto_init() } @@ -617,7 +738,7 @@ func file_tunnel_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tunnel_proto_rawDesc), len(file_tunnel_proto_rawDesc)), NumEnums: 1, - NumMessages: 9, + NumMessages: 11, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/agent/tunnel/tunnel.proto b/pkg/agent/tunnel/tunnel.proto index 775e82fa3..c8442294b 100644 --- a/pkg/agent/tunnel/tunnel.proto +++ b/pkg/agent/tunnel/tunnel.proto @@ -17,6 +17,7 @@ service Tunnel { rpc DevsyConfig(Message) returns (Message) {} rpc GPGPublicKeys(Message) returns (Message) {} rpc KubeConfig(Message) returns (Message) {} + rpc Secrets(Empty) returns (SecretsResponse) {} rpc ForwardPort(ForwardPortRequest) returns (ForwardPortResponse) {} rpc StopForwardPort(StopForwardPortRequest) returns (StopForwardPortResponse) {} @@ -49,6 +50,16 @@ message Message { string message = 1; } +message Secret { + string name = 1; + string value = 2; + bool mount = 3; +} + +message SecretsResponse { + repeated Secret secrets = 1; +} + message Chunk { bytes Content = 1; } diff --git a/pkg/agent/tunnel/tunnel_grpc.pb.go b/pkg/agent/tunnel/tunnel_grpc.pb.go index e6167ea99..ab5aac9e2 100644 --- a/pkg/agent/tunnel/tunnel_grpc.pb.go +++ b/pkg/agent/tunnel/tunnel_grpc.pb.go @@ -31,6 +31,7 @@ const ( Tunnel_DevsyConfig_FullMethodName = "/tunnel.Tunnel/DevsyConfig" Tunnel_GPGPublicKeys_FullMethodName = "/tunnel.Tunnel/GPGPublicKeys" Tunnel_KubeConfig_FullMethodName = "/tunnel.Tunnel/KubeConfig" + Tunnel_Secrets_FullMethodName = "/tunnel.Tunnel/Secrets" Tunnel_ForwardPort_FullMethodName = "/tunnel.Tunnel/ForwardPort" Tunnel_StopForwardPort_FullMethodName = "/tunnel.Tunnel/StopForwardPort" Tunnel_StreamWorkspace_FullMethodName = "/tunnel.Tunnel/StreamWorkspace" @@ -51,6 +52,7 @@ type TunnelClient interface { DevsyConfig(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) GPGPublicKeys(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) KubeConfig(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) + Secrets(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SecretsResponse, error) ForwardPort(ctx context.Context, in *ForwardPortRequest, opts ...grpc.CallOption) (*ForwardPortResponse, error) StopForwardPort(ctx context.Context, in *StopForwardPortRequest, opts ...grpc.CallOption) (*StopForwardPortResponse, error) StreamWorkspace(ctx context.Context, in *Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Chunk], error) @@ -165,6 +167,16 @@ func (c *tunnelClient) KubeConfig(ctx context.Context, in *Message, opts ...grpc return out, nil } +func (c *tunnelClient) Secrets(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SecretsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SecretsResponse) + err := c.cc.Invoke(ctx, Tunnel_Secrets_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *tunnelClient) ForwardPort(ctx context.Context, in *ForwardPortRequest, opts ...grpc.CallOption) (*ForwardPortResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForwardPortResponse) @@ -237,6 +249,7 @@ type TunnelServer interface { DevsyConfig(context.Context, *Message) (*Message, error) GPGPublicKeys(context.Context, *Message) (*Message, error) KubeConfig(context.Context, *Message) (*Message, error) + Secrets(context.Context, *Empty) (*SecretsResponse, error) ForwardPort(context.Context, *ForwardPortRequest) (*ForwardPortResponse, error) StopForwardPort(context.Context, *StopForwardPortRequest) (*StopForwardPortResponse, error) StreamWorkspace(*Empty, grpc.ServerStreamingServer[Chunk]) error @@ -281,6 +294,9 @@ func (UnimplementedTunnelServer) GPGPublicKeys(context.Context, *Message) (*Mess func (UnimplementedTunnelServer) KubeConfig(context.Context, *Message) (*Message, error) { return nil, status.Errorf(codes.Unimplemented, "method KubeConfig not implemented") } +func (UnimplementedTunnelServer) Secrets(context.Context, *Empty) (*SecretsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Secrets not implemented") +} func (UnimplementedTunnelServer) ForwardPort(context.Context, *ForwardPortRequest) (*ForwardPortResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ForwardPort not implemented") } @@ -494,6 +510,24 @@ func _Tunnel_KubeConfig_Handler(srv interface{}, ctx context.Context, dec func(i return interceptor(ctx, in, info, handler) } +func _Tunnel_Secrets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TunnelServer).Secrets(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Tunnel_Secrets_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TunnelServer).Secrets(ctx, req.(*Empty)) + } + return interceptor(ctx, in, info, handler) +} + func _Tunnel_ForwardPort_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ForwardPortRequest) if err := dec(in); err != nil { @@ -599,6 +633,10 @@ var Tunnel_ServiceDesc = grpc.ServiceDesc{ MethodName: "KubeConfig", Handler: _Tunnel_KubeConfig_Handler, }, + { + MethodName: "Secrets", + Handler: _Tunnel_Secrets_Handler, + }, { MethodName: "ForwardPort", Handler: _Tunnel_ForwardPort_Handler, diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index b238e2c2e..e3ce13527 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -1,7 +1,10 @@ package tunnelserver import ( + "strings" + "github.com/devsy-org/api/pkg/devsy" + "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/netstat" provider2 "github.com/devsy-org/devsy/pkg/provider" @@ -57,3 +60,30 @@ func WithPlatformOptions(options *devsy.PlatformOptions) Option { return s } } + +func WithSecrets(env, mount []string) Option { + return func(s *tunnelServer) *tunnelServer { + s.secrets = append(toSecrets(env, false), toSecrets(mount, true)...) + return s + } +} + +func WithGitToken(token *provider2.GitToken) Option { + return func(s *tunnelServer) *tunnelServer { + s.gitToken = token + return s + } +} + +func toSecrets(entries []string, mount bool) []*tunnel.Secret { + secrets := make([]*tunnel.Secret, 0, len(entries)) + for _, entry := range entries { + name, value, ok := strings.Cut(entry, "=") + if !ok || name == "" { + continue + } + secrets = append(secrets, &tunnel.Secret{Name: name, Value: value, Mount: mount}) + } + + return secrets +} diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index 23fde17fc..4fd0fce53 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -114,6 +114,8 @@ type tunnelServer struct { workspace *provider2.Workspace platformOptions *devsy.PlatformOptions + secrets []*tunnel.Secret + gitToken *provider2.GitToken } func (t *tunnelServer) RunWithResult( @@ -228,6 +230,13 @@ func (t *tunnelServer) DockerCredentials( return &tunnel.Message{Message: string(out)}, nil } +func (t *tunnelServer) Secrets( + _ context.Context, + _ *tunnel.Empty, +) (*tunnel.SecretsResponse, error) { + return &tunnel.SecretsResponse{Secrets: t.secrets}, nil +} + func (t *tunnelServer) GitUser(ctx context.Context, empty *tunnel.Empty) (*tunnel.Message, error) { workingDir := "" if t.workspace != nil { @@ -267,6 +276,19 @@ func (t *tunnelServer) GitCredentials( return nil, fmt.Errorf("decode git credentials request: %w", err) } + // A configured git token is returned only for its own host, so it is never + // offered to other hosts git contacts (submodules, redirects). + if t.gitToken != nil && t.gitToken.Token != "" && credentials.Host == t.gitToken.Host { + credentials.Username = t.gitToken.Username + credentials.Password = t.gitToken.Token + // #nosec G117 -- git credential response legitimately carries the token. + out, err := json.Marshal(credentials) + if err != nil { + return nil, err + } + return &tunnel.Message{Message: string(out)}, nil + } + if t.platformOptions != nil && t.platformOptions.Enabled { gitHttpCredentials := slices.Concat( t.platformOptions.UserCredentials.GitHttp, diff --git a/pkg/agent/tunnelserver/tunnelserver_gittoken_test.go b/pkg/agent/tunnelserver/tunnelserver_gittoken_test.go new file mode 100644 index 000000000..9241e5381 --- /dev/null +++ b/pkg/agent/tunnelserver/tunnelserver_gittoken_test.go @@ -0,0 +1,64 @@ +package tunnelserver + +import ( + "context" + "encoding/json" + "testing" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/devsy-org/devsy/pkg/gitcredentials" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func gitCredentialsRequest(t *testing.T, host string) *tunnel.Message { + t.Helper() + // #nosec G117 -- test request struct carries no secret. + raw, err := json.Marshal(gitcredentials.GitCredentials{Protocol: "https", Host: host}) + require.NoError(t, err) + return &tunnel.Message{Message: string(raw)} +} + +func TestGitCredentials_TokenReturnedForMatchingHost(t *testing.T) { + s := New( + WithAllowGitCredentials(true), + WithGitToken(&provider.GitToken{ + Host: "github.com", + Username: "x-access-token", + Token: "ghp_secret", + }), + ) + + resp, err := s.GitCredentials(context.Background(), gitCredentialsRequest(t, "github.com")) + require.NoError(t, err) + + var got gitcredentials.GitCredentials + require.NoError(t, json.Unmarshal([]byte(resp.Message), &got)) + assert.Equal(t, "x-access-token", got.Username) + assert.Equal(t, "ghp_secret", got.Password) +} + +func TestGitCredentials_TokenNotLeakedToOtherHost(t *testing.T) { + // A mismatched host must NOT receive the token; with no workspace and no + // platform options it falls through to normal resolution, which must not + // return our token. + s := New( + WithAllowGitCredentials(true), + WithGitToken(&provider.GitToken{ + Host: "github.com", + Username: "x-access-token", + Token: "ghp_secret", + }), + ) + + resp, err := s.GitCredentials( + context.Background(), + gitCredentialsRequest(t, "evil.example.com"), + ) + if err == nil { + var got gitcredentials.GitCredentials + require.NoError(t, json.Unmarshal([]byte(resp.Message), &got)) + assert.NotEqual(t, "ghp_secret", got.Password, "token must not be sent to a different host") + } +} diff --git a/pkg/agent/tunnelserver/tunnelserver_secrets_test.go b/pkg/agent/tunnelserver/tunnelserver_secrets_test.go new file mode 100644 index 000000000..272ec47fb --- /dev/null +++ b/pkg/agent/tunnelserver/tunnelserver_secrets_test.go @@ -0,0 +1,43 @@ +package tunnelserver + +import ( + "context" + "testing" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecrets_ReturnsEnvAndMountEntries(t *testing.T) { + s := New(WithSecrets( + []string{"API_KEY=abc", "NO_EQUALS"}, + []string{"tls.key=keydata"}, + )) + + resp, err := s.Secrets(context.Background(), &tunnel.Empty{}) + require.NoError(t, err) + + got := map[string]*tunnel.Secret{} + for _, sec := range resp.GetSecrets() { + got[sec.GetName()] = sec + } + + require.Contains(t, got, "API_KEY") + assert.Equal(t, "abc", got["API_KEY"].GetValue()) + assert.False(t, got["API_KEY"].GetMount()) + + require.Contains(t, got, "tls.key") + assert.Equal(t, "keydata", got["tls.key"].GetValue()) + assert.True(t, got["tls.key"].GetMount()) + + assert.NotContains(t, got, "NO_EQUALS") +} + +func TestSecrets_EmptyWhenUnset(t *testing.T) { + s := New() + + resp, err := s.Secrets(context.Background(), &tunnel.Empty{}) + require.NoError(t, err) + assert.Empty(t, resp.GetSecrets()) +} diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 4d8626de9..273a3d1c1 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -1099,7 +1099,8 @@ func runTunnelServer( opts.WorkspaceClient.AgentInjectGitCredentials(opts.CLIOptions), opts.WorkspaceClient.AgentInjectDockerCredentials(opts.CLIOptions), opts.WorkspaceClient.WorkspaceConfig(), - opts.TunnelOptions..., + append(opts.TunnelOptions, + tunnelserver.WithGitToken(opts.CLIOptions.GitToken))..., ) if err != nil { return nil, fmt.Errorf("run tunnel server: %w", err) diff --git a/pkg/config/config.go b/pkg/config/config.go index ffb0dfb88..37613219f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -43,6 +43,9 @@ type ContextConfig struct { // Providers holds the provider configuration Providers map[string]*ProviderConfig `json:"providers,omitempty"` + // Secrets are names of stored secrets bound to this context, injected on `up`. Names only, no values. + Secrets []string `json:"secrets,omitempty"` + // OriginalProvider is the original default provider OriginalProvider string `json:"-"` } diff --git a/pkg/config/context.go b/pkg/config/context.go index 1e0288de8..499b2ae93 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -24,6 +24,7 @@ const ( ContextOptionRegistryCache = "REGISTRY_CACHE" ContextOptionSSHStrictHostKeyChecking = "SSH_STRICT_HOST_KEY_CHECKING" ContextOptionSSHTunnelMode = "SSH_TUNNEL_MODE" + ContextOptionSecretsBackend = "SECRETS_BACKEND" ) var ContextOptions = []ContextOption{ @@ -117,6 +118,13 @@ var ContextOptions = []ContextOption{ Default: "false", Enum: []string{"true", "false"}, }, + { + Name: ContextOptionSecretsBackend, + Description: "Where secret values are stored: 'keyring' (OS keyring), " + + "'file' (age-encrypted file), or 'auto' (keyring when available, else file)", + Default: "auto", + Enum: []string{"auto", "keyring", "file"}, + }, } func MergeContextOptions(contextConfig *ContextConfig, environ []string) { diff --git a/pkg/config/env.go b/pkg/config/env.go index bce1752ac..9e930a08d 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -63,6 +63,10 @@ const ( // EnvWorkspaceDaemonConfig holds the workspace daemon configuration. EnvWorkspaceDaemonConfig = "DEVSY_WORKSPACE_DAEMON_CONFIG" + // EnvSecretsEnv passes base64-encoded KEY=VALUE secrets (newline-joined) to + // agent subprocesses via the environment (not argv, to keep them out of ps). + EnvSecretsEnv = "DEVSY_SECRETS_ENV" // #nosec G101 -- env var name, not a credential. + // EnvWorkspaceCredentialsPort is the workspace credentials server port. EnvWorkspaceCredentialsPort = "DEVSY_WORKSPACE_CREDENTIALS_PORT" // #nosec G101 diff --git a/pkg/credentials/integration_test.go b/pkg/credentials/integration_test.go index a18dad850..8974c425c 100644 --- a/pkg/credentials/integration_test.go +++ b/pkg/credentials/integration_test.go @@ -17,7 +17,7 @@ import ( ) func TestIntegration_SigningFailure_SurfacesServerError(t *testing.T) { - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { return nil, fmt.Errorf( "failed to sign commit: exit status 1, stderr: Permission denied (publickey)", @@ -55,7 +55,7 @@ func TestIntegration_SigningSuccess_WritesSigFile(t *testing.T) { "-----BEGIN SSH SIGNATURE-----\ntest-signature\n-----END SSH SIGNATURE-----\n", ) - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { response := gitsshsigning.GitSSHSignatureResponse{Signature: expectedSig} jsonBytes, err := json.Marshal(response) @@ -94,7 +94,7 @@ func TestIntegration_SigningSuccess_WritesSigFile(t *testing.T) { func TestIntegration_SignatureRequest_IncludesPublicKeyContent(t *testing.T) { var receivedMessage string - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { receivedMessage = msg.Message sig := gitsshsigning.GitSSHSignatureResponse{ diff --git a/pkg/credentials/mock_tunnel_client_test.go b/pkg/credentials/mock_tunnel_client_test.go index 2861f4a59..5efe4d5dd 100644 --- a/pkg/credentials/mock_tunnel_client_test.go +++ b/pkg/credentials/mock_tunnel_client_test.go @@ -8,51 +8,11 @@ import ( "google.golang.org/grpc" ) -type mockTunnelClient struct { +type mockCredentialsClient struct { gitSSHSignatureFunc func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) } -func (m *mockTunnelClient) Ping( - ctx context.Context, - in *tunnel.Empty, - opts ...grpc.CallOption, -) (*tunnel.Empty, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) Log( - ctx context.Context, - in *tunnel.LogMessage, - opts ...grpc.CallOption, -) (*tunnel.Empty, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) SendResult( - ctx context.Context, - in *tunnel.Message, - opts ...grpc.CallOption, -) (*tunnel.Empty, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) DockerCredentials( - ctx context.Context, - in *tunnel.Message, - opts ...grpc.CallOption, -) (*tunnel.Message, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) GitCredentials( - ctx context.Context, - in *tunnel.Message, - opts ...grpc.CallOption, -) (*tunnel.Message, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) GitSSHSignature( +func (m *mockCredentialsClient) GitSSHSignature( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, @@ -60,15 +20,15 @@ func (m *mockTunnelClient) GitSSHSignature( return m.gitSSHSignatureFunc(ctx, in) } -func (m *mockTunnelClient) GitUser( +func (m *mockCredentialsClient) GitCredentials( ctx context.Context, - in *tunnel.Empty, + in *tunnel.Message, opts ...grpc.CallOption, ) (*tunnel.Message, error) { return nil, fmt.Errorf("not implemented") } -func (m *mockTunnelClient) DevsyConfig( +func (m *mockCredentialsClient) DockerCredentials( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, @@ -76,7 +36,7 @@ func (m *mockTunnelClient) DevsyConfig( return nil, fmt.Errorf("not implemented") } -func (m *mockTunnelClient) GPGPublicKeys( +func (m *mockCredentialsClient) GPGPublicKeys( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, @@ -84,42 +44,10 @@ func (m *mockTunnelClient) GPGPublicKeys( return nil, fmt.Errorf("not implemented") } -func (m *mockTunnelClient) KubeConfig( +func (m *mockCredentialsClient) DevsyConfig( ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, ) (*tunnel.Message, error) { return nil, fmt.Errorf("not implemented") } - -func (m *mockTunnelClient) ForwardPort( - ctx context.Context, - in *tunnel.ForwardPortRequest, - opts ...grpc.CallOption, -) (*tunnel.ForwardPortResponse, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) StopForwardPort( - ctx context.Context, - in *tunnel.StopForwardPortRequest, - opts ...grpc.CallOption, -) (*tunnel.StopForwardPortResponse, error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) StreamWorkspace( - ctx context.Context, - in *tunnel.Empty, - opts ...grpc.CallOption, -) (grpc.ServerStreamingClient[tunnel.Chunk], error) { - return nil, fmt.Errorf("not implemented") -} - -func (m *mockTunnelClient) StreamMount( - ctx context.Context, - in *tunnel.StreamMountRequest, - opts ...grpc.CallOption, -) (grpc.ServerStreamingClient[tunnel.Chunk], error) { - return nil, fmt.Errorf("not implemented") -} diff --git a/pkg/credentials/server.go b/pkg/credentials/server.go index ca8694799..8fe1b8018 100644 --- a/pkg/credentials/server.go +++ b/pkg/credentials/server.go @@ -14,14 +14,35 @@ import ( "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "google.golang.org/grpc" ) const DefaultPort = "12049" +type CredentialsClient interface { + GitCredentials( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) + DockerCredentials( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) + GitSSHSignature( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) + GPGPublicKeys( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) + DevsyConfig( + ctx context.Context, in *tunnel.Message, opts ...grpc.CallOption, + ) (*tunnel.Message, error) +} + +var _ CredentialsClient = (tunnel.TunnelClient)(nil) + func RunCredentialsServer( ctx context.Context, port int, - client tunnel.TunnelClient, + client CredentialsClient, ) error { var handler http.Handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { log.Debugf("incoming client connection: path=%s", request.URL.Path) @@ -95,7 +116,7 @@ func handleDockerCredentialsRequest( ctx context.Context, writer http.ResponseWriter, request *http.Request, - client tunnel.TunnelClient, + client CredentialsClient, ) error { out, err := io.ReadAll(request.Body) if err != nil { @@ -119,7 +140,7 @@ func handleGitCredentialsRequest( ctx context.Context, writer http.ResponseWriter, request *http.Request, - client tunnel.TunnelClient, + client CredentialsClient, ) error { out, err := io.ReadAll(request.Body) if err != nil { @@ -144,7 +165,7 @@ func handleGitSSHSignatureRequest( ctx context.Context, writer http.ResponseWriter, request *http.Request, - client tunnel.TunnelClient, + client CredentialsClient, ) error { out, err := io.ReadAll(request.Body) if err != nil { @@ -180,7 +201,7 @@ func handleDevsyPlatformCredentialsRequest( ctx context.Context, writer http.ResponseWriter, request *http.Request, - client tunnel.TunnelClient, + client CredentialsClient, ) error { out, err := io.ReadAll(request.Body) if err != nil { @@ -204,7 +225,7 @@ func handleDevsyPlatformCredentialsRequest( func handleGPGPublicKeysRequest( ctx context.Context, writer http.ResponseWriter, - client tunnel.TunnelClient, + client CredentialsClient, ) error { response, err := client.GPGPublicKeys(ctx, &tunnel.Message{}) if err != nil { diff --git a/pkg/credentials/server_test.go b/pkg/credentials/server_test.go index 55859b55f..a5253430b 100644 --- a/pkg/credentials/server_test.go +++ b/pkg/credentials/server_test.go @@ -20,7 +20,7 @@ type errReader struct{ err error } func (e *errReader) Read([]byte) (int, error) { return 0, e.err } func TestHandleGitSSHSignature_GRPCError_ReturnsJSON500(t *testing.T) { - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { return nil, fmt.Errorf("Permission denied") }, @@ -47,7 +47,7 @@ func TestHandleGitSSHSignature_GRPCError_ReturnsJSON500(t *testing.T) { } func TestHandleGitSSHSignature_BodyReadError_ReturnsJSON500(t *testing.T) { - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { t.Fatal("gRPC should not be called when body read fails") return nil, nil @@ -76,7 +76,7 @@ func TestHandleGitSSHSignature_BodyReadError_ReturnsJSON500(t *testing.T) { func TestHandleGitSSHSignature_GRPCSuccess_ReturnsJSON200(t *testing.T) { expectedMessage := `{"signature":"abc123"}` - mock := &mockTunnelClient{ + mock := &mockCredentialsClient{ gitSSHSignatureFunc: func(ctx context.Context, msg *tunnel.Message) (*tunnel.Message, error) { return &tunnel.Message{Message: expectedMessage}, nil }, diff --git a/pkg/credentials/start.go b/pkg/credentials/start.go index f1b6a4d23..db53a9e26 100644 --- a/pkg/credentials/start.go +++ b/pkg/credentials/start.go @@ -7,7 +7,6 @@ import ( "strconv" "time" - "github.com/devsy-org/devsy/pkg/agent/tunnel" devsyhttp "github.com/devsy-org/devsy/pkg/http" "github.com/devsy-org/devsy/pkg/log" portpkg "github.com/devsy-org/devsy/pkg/port" @@ -16,7 +15,7 @@ import ( func StartCredentialsServer( ctx context.Context, - client tunnel.TunnelClient, + client CredentialsClient, ) (int, error) { port, err := portpkg.FindAvailablePort(random.InRange(13000, 17000)) if err != nil { diff --git a/pkg/devcontainer/build/options.go b/pkg/devcontainer/build/options.go index 2eb2c87f7..fa98f3a39 100644 --- a/pkg/devcontainer/build/options.go +++ b/pkg/devcontainer/build/options.go @@ -43,6 +43,11 @@ type BuildOptions struct { // Target specifies the target build stage in a multi-stage Dockerfile. Target string + // BuildSecrets are "NAME=VALUE" entries exposed to the build as BuildKit + // secrets (RUN --mount=type=secret,id=NAME). Values never become build args + // or land in an image layer. + BuildSecrets []string + // Load controls whether to load the built image into the local Docker daemon. // When true, uses BuildKit's "moby" exporter which creates a tar and imports it. Load bool @@ -107,6 +112,8 @@ func NewOptions(params NewOptionsParams) (*BuildOptions, error) { params.ExtendedBuildInfo, ) + buildOptions.BuildSecrets = params.Options.BuildSecrets + // get cli options buildOptions.CliOpts = params.ParsedConfig.Config.GetOptions() diff --git a/pkg/devcontainer/buildkit/buildkit.go b/pkg/devcontainer/buildkit/buildkit.go index a6300a874..1566f703e 100644 --- a/pkg/devcontainer/buildkit/buildkit.go +++ b/pkg/devcontainer/buildkit/buildkit.go @@ -49,6 +49,14 @@ func Build( ), ) + secretsAttachable, err := buildSecretsAttachable(options.BuildSecrets) + if err != nil { + return err + } + if secretsAttachable != nil { + attachable = append(attachable, secretsAttachable) + } + // create solve options solveOptions := buildkit.SolveOpt{ Frontend: "dockerfile.v0", diff --git a/pkg/devcontainer/buildkit/remote.go b/pkg/devcontainer/buildkit/remote.go index 77ad8bb15..d75ded84f 100644 --- a/pkg/devcontainer/buildkit/remote.go +++ b/pkg/devcontainer/buildkit/remote.go @@ -261,6 +261,14 @@ func prepareSolveOptions( return client.SolveOpt{}, err } + secretsAttachable, err := buildSecretsAttachable(opts.Options.BuildSecrets) + if err != nil { + return client.SolveOpt{}, err + } + if secretsAttachable != nil { + authSession = append(authSession, secretsAttachable) + } + buildOpts, err := build.NewOptions(build.NewOptionsParams{ DockerfilePath: opts.DockerfilePath, DockerfileContent: opts.DockerfileContent, diff --git a/pkg/devcontainer/buildkit/secrets.go b/pkg/devcontainer/buildkit/secrets.go new file mode 100644 index 000000000..8d4744be0 --- /dev/null +++ b/pkg/devcontainer/buildkit/secrets.go @@ -0,0 +1,34 @@ +package buildkit + +import ( + "fmt" + "strings" + + "github.com/moby/buildkit/session" + "github.com/moby/buildkit/session/secrets/secretsprovider" +) + +// ParseBuildSecrets converts "NAME=VALUE" entries into a map keyed by secret id. +func ParseBuildSecrets(entries []string) (map[string][]byte, error) { + secrets := make(map[string][]byte, len(entries)) + for _, entry := range entries { + name, value, ok := strings.Cut(entry, "=") + if !ok || name == "" { + return nil, fmt.Errorf("invalid build secret %q, expected NAME=VALUE", entry) + } + secrets[name] = []byte(value) + } + return secrets, nil +} + +// buildSecretsAttachable serves the given build secrets, or nil when none. +func buildSecretsAttachable(entries []string) (session.Attachable, error) { + if len(entries) == 0 { + return nil, nil + } + secrets, err := ParseBuildSecrets(entries) + if err != nil { + return nil, err + } + return secretsprovider.FromMap(secrets), nil +} diff --git a/pkg/devcontainer/buildkit/secrets_test.go b/pkg/devcontainer/buildkit/secrets_test.go new file mode 100644 index 000000000..04fc4a740 --- /dev/null +++ b/pkg/devcontainer/buildkit/secrets_test.go @@ -0,0 +1,90 @@ +package buildkit + +import ( + "testing" +) + +func TestParseBuildSecrets(t *testing.T) { + tests := []struct { + name string + entries []string + want map[string]string + wantErr bool + }{ + { + name: "empty", + entries: nil, + want: map[string]string{}, + }, + { + name: "single", + entries: []string{"TOKEN=abc123"}, + want: map[string]string{"TOKEN": "abc123"}, + }, + { + name: "value with equals", + entries: []string{"URL=key=value&x=y"}, + want: map[string]string{"URL": "key=value&x=y"}, + }, + { + name: "empty value", + entries: []string{"EMPTY="}, + want: map[string]string{"EMPTY": ""}, + }, + { + name: "missing equals", + entries: []string{"NOEQUALS"}, + wantErr: true, + }, + { + name: "empty name", + entries: []string{"=value"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseBuildSecrets(tt.entries) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("got %d secrets, want %d", len(got), len(tt.want)) + } + for k, v := range tt.want { + if string(got[k]) != v { + t.Errorf("secret %q = %q, want %q", k, got[k], v) + } + } + }) + } +} + +func TestBuildSecretsAttachable(t *testing.T) { + attachable, err := buildSecretsAttachable(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if attachable != nil { + t.Fatalf("expected nil attachable for empty secrets") + } + + attachable, err = buildSecretsAttachable([]string{"TOKEN=abc"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if attachable == nil { + t.Fatalf("expected non-nil attachable for non-empty secrets") + } + + if _, err := buildSecretsAttachable([]string{"invalid"}); err == nil { + t.Fatalf("expected error for invalid entry") + } +} diff --git a/pkg/devcontainer/compose_build.go b/pkg/devcontainer/compose_build.go index 575a1f1fd..778980248 100644 --- a/pkg/devcontainer/compose_build.go +++ b/pkg/devcontainer/compose_build.go @@ -115,6 +115,12 @@ func (r *runner) buildAndExtendDockerCompose( ctx context.Context, params *buildAndExtendParams, ) (composeExtendResult, error) { + if r.workspaceConfig != nil && len(r.workspaceConfig.CLIOptions.BuildSecrets) > 0 { + return composeExtendResult{}, fmt.Errorf( + "build secrets require the BuildKit builder and are not supported for docker compose builds", + ) + } + prepared, err := r.prepareExtendedComposeBuild(ctx, params) if err != nil { return composeExtendResult{}, err diff --git a/pkg/devcontainer/compose_up.go b/pkg/devcontainer/compose_up.go index 4cfa54a27..5b7513981 100644 --- a/pkg/devcontainer/compose_up.go +++ b/pkg/devcontainer/compose_up.go @@ -23,6 +23,14 @@ func escapeComposeLabelValue(value string) string { } func (r *runner) extendedDockerComposeUp(params *composeUpParams) (string, error) { + secretsMount, err := r.secretsMount() + if err != nil { + return "", err + } + if secretsMount != nil { + params.mergedConfig.Mounts = append(params.mergedConfig.Mounts, secretsMount) + } + dockerComposeUpProject := r.generateDockerComposeUpProject(params) dockerComposeData, err := yaml.Marshal(dockerComposeUpProject) if err != nil { diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index 017786d28..6ba630ed3 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -426,6 +426,10 @@ const ( ShutdownActionStopCompose = "stopCompose" ) +// SecretsMountDir is the in-container tmpfs directory where file-delivered +// secrets are written, matching the Docker/Podman convention. +const SecretsMountDir = "/run/secrets" + const ( AutoForwardIgnore = "ignore" AutoForwardNotify = "notify" diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index 38fd71243..e4e8228b5 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -287,9 +287,17 @@ func (r *runner) compressResult(result *config.Result) (string, error) { } func (r *runner) compressWorkspaceConfig() (string, error) { + // Strip secret values from the argv-embedded CLIOptions; the container pulls + // them over the tunnel (Secrets RPC) so they never appear in process listings. + cliOptions := r.workspaceConfig.CLIOptions + cliOptions.SecretsEnv = nil + cliOptions.SecretsMount = nil + cliOptions.BuildSecrets = nil + cliOptions.GitToken = nil + workspaceConfig := &provider2.ContainerWorkspaceInfo{ IDE: r.workspaceConfig.Workspace.IDE, - CLIOptions: r.workspaceConfig.CLIOptions, + CLIOptions: cliOptions, Dockerless: r.workspaceConfig.Agent.Dockerless, ContainerTimeout: r.workspaceConfig.Agent.ContainerTimeout, Source: r.workspaceConfig.Workspace.Source, @@ -445,6 +453,11 @@ func (r *runner) executeSetup( r.workspaceConfig.Agent.InjectDockerCredentials != stringFalse, config.GetMounts(result), tunnelserver.WithPlatformOptions(&r.workspaceConfig.CLIOptions.Platform), + tunnelserver.WithSecrets( + r.workspaceConfig.CLIOptions.SecretsEnv, + r.workspaceConfig.CLIOptions.SecretsMount, + ), + tunnelserver.WithGitToken(r.workspaceConfig.CLIOptions.GitToken), ) } diff --git a/pkg/devcontainer/setup/lifecyclehooks.go b/pkg/devcontainer/setup/lifecyclehooks.go index 0351ce3df..3d6a06421 100644 --- a/pkg/devcontainer/setup/lifecyclehooks.go +++ b/pkg/devcontainer/setup/lifecyclehooks.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "os/user" + "path/filepath" "regexp" "slices" "strings" @@ -18,9 +19,14 @@ import ( "al.essio.dev/pkg/shellescape" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/devsy-org/devsy/pkg/types" ) +// hookRedactor masks injected secret values in lifecycle-hook logs. It is set +// once per process by mergeSecretsEnv, the single point where secrets enter. +var hookRedactor = secrets.NewRedactor(nil) + // LifecyclePhase identifies a devcontainer lifecycle command. type LifecyclePhase string @@ -133,9 +139,11 @@ type lifecycleEnv struct { remoteEnv map[string]string } -// mergeSecretsEnv merges KEY=VALUE pairs from the --secrets-file flag into the -// lifecycle env map. Existing keys are NOT overridden (config takes precedence). -func mergeSecretsEnv(env map[string]string, secretsEnv []string) { +// mergeSecretsEnv injects secretsEnv into the hook env (config keys win) and +// builds the log redactor. secretsMount values are added to the redactor only: +// mount secrets are files, never injected into the env, but must still be masked. +func mergeSecretsEnv(env map[string]string, secretsEnv, secretsMount []string) { + hookRedactor = secrets.NewRedactor(slices.Concat(secretsEnv, secretsMount)) for _, entry := range secretsEnv { key, value, ok := strings.Cut(entry, "=") if !ok { @@ -147,6 +155,31 @@ func mergeSecretsEnv(env map[string]string, secretsEnv []string) { } } +// MountSecretsForRedaction reads SecretsMountDir into "name=value" entries so a +// re-exec'd hook process (which lacks the mount values in its env) can still +// mask them in logs. Best-effort: unreadable entries are skipped. +func MountSecretsForRedaction() []string { + dirEntries, err := os.ReadDir(config.SecretsMountDir) + if err != nil { + return nil + } + var entries []string + for _, e := range dirEntries { + if e.IsDir() { + continue + } + value, err := os.ReadFile( + filepath.Join(config.SecretsMountDir, e.Name()), + ) // #nosec G304 -- fixed secrets dir. + if err != nil { + continue + } + entries = append(entries, e.Name()+"="+string(value)) + } + + return entries +} + func resolveLifecycleEnv( ctx context.Context, setupInfo *config.Result, @@ -244,10 +277,11 @@ func RunPreAttachHooks( prebuild bool, dotfiles DotfilesConfig, secretsEnv []string, + secretsMount []string, skip SkipPhases, ) (DeferredHooks, error) { env := resolveLifecycleEnv(ctx, setupInfo) - mergeSecretsEnv(env.remoteEnv, secretsEnv) + mergeSecretsEnv(env.remoteEnv, secretsEnv, secretsMount) all := preAttachPhaseParams(setupInfo, env, prebuild) // Insert the dotfiles phase between postCreate and postStart. @@ -421,6 +455,7 @@ func RunPostAttachHooks( ctx context.Context, setupInfo *config.Result, secretsEnv []string, + secretsMount []string, skipPostAttach ...bool, ) error { if len(skipPostAttach) > 0 && skipPostAttach[0] { @@ -428,7 +463,7 @@ func RunPostAttachHooks( return nil } env := resolveLifecycleEnv(ctx, setupInfo) - mergeSecretsEnv(env.remoteEnv, secretsEnv) + mergeSecretsEnv(env.remoteEnv, secretsEnv, secretsMount) return runHook(hookRunParams{ commands: setupInfo.MergedConfig.PostAttachCommands, @@ -519,7 +554,10 @@ func runSingleHookCommand( remoteEnvArr []string, key string, c []string, ) error { - log.Infof("running %s lifecycle hook: %s %s", p.name, key, strings.Join(c, " ")) + log.Infof( + "running %s lifecycle hook: %s %s", + p.name, key, hookRedactor.Redact(strings.Join(c, " ")), + ) currentUser, err := user.Current() if err != nil { return err @@ -578,20 +616,26 @@ func executeAndLog(cmd *exec.Cmd, phaseName string, key string, c []string) erro log.Debugf( "failed running %s lifecycle script: command=%v, error=%v", key, - cmd.Args, + hookRedactor.Redact(strings.Join(cmd.Args, " ")), err, ) - return fmt.Errorf("%s: command %q failed: %w", phaseName, strings.Join(c, " "), err) + return fmt.Errorf( + "%s: command %q failed: %w", + phaseName, hookRedactor.Redact(strings.Join(c, " ")), err, + ) } - log.Infof("ran command: command=%s, args=%s", key, strings.Join(c, " ")) + log.Infof( + "ran command: command=%s, args=%s", + key, hookRedactor.Redact(strings.Join(c, " ")), + ) return nil } func logPipeOutput(pipe io.ReadCloser, isStdout bool) { scanner := bufio.NewScanner(pipe) for scanner.Scan() { - line := scanner.Text() + line := hookRedactor.Redact(scanner.Text()) if isStdout { log.Info(line) } else { diff --git a/pkg/devcontainer/setup/lifecyclehooks_test.go b/pkg/devcontainer/setup/lifecyclehooks_test.go index d6b91f8f8..14f18fede 100644 --- a/pkg/devcontainer/setup/lifecyclehooks_test.go +++ b/pkg/devcontainer/setup/lifecyclehooks_test.go @@ -100,11 +100,11 @@ func (s *LifecycleHookTestSuite) TestLifecycleHooksNoOpWithEmptyConfig() { } // Both functions should return nil with empty config (no commands to run) - deferred, err := RunPreAttachHooks(ctx, result, false, DotfilesConfig{}, nil, SkipPhases{}) + deferred, err := RunPreAttachHooks(ctx, result, false, DotfilesConfig{}, nil, nil, SkipPhases{}) assert.NoError(s.T(), err) assert.True(s.T(), deferred.Empty()) - err = RunPostAttachHooks(ctx, result, nil) + err = RunPostAttachHooks(ctx, result, nil, nil) assert.NoError(s.T(), err) } @@ -252,7 +252,7 @@ func (s *LifecycleHookTestSuite) TestPrebuildIgnoresWaitFor() { } // In prebuild mode, no deferred hooks are returned regardless of waitFor. - deferred, err := RunPreAttachHooks(ctx, result, true, DotfilesConfig{}, nil, SkipPhases{}) + deferred, err := RunPreAttachHooks(ctx, result, true, DotfilesConfig{}, nil, nil, SkipPhases{}) assert.NoError(s.T(), err) assert.True(s.T(), deferred.Empty()) } @@ -644,7 +644,7 @@ func (s *LifecycleHookTestSuite) TestWaitForEmptyPhaseLogsWarning() { func (s *LifecycleHookTestSuite) TestMergeSecretsEnv() { env := map[string]string{"EXISTING": "keep"} - mergeSecretsEnv(env, []string{"SECRET_KEY=secret_val", "OTHER=data"}) + mergeSecretsEnv(env, []string{"SECRET_KEY=secret_val", "OTHER=data"}, nil) assert.Equal(s.T(), "keep", env["EXISTING"]) assert.Equal(s.T(), "secret_val", env["SECRET_KEY"]) @@ -654,7 +654,7 @@ func (s *LifecycleHookTestSuite) TestMergeSecretsEnv() { func (s *LifecycleHookTestSuite) TestMergeSecretsEnvDoesNotOverride() { env := map[string]string{"MY_VAR": "original"} - mergeSecretsEnv(env, []string{"MY_VAR=overridden"}) + mergeSecretsEnv(env, []string{"MY_VAR=overridden"}, nil) assert.Equal(s.T(), "original", env["MY_VAR"]) } @@ -662,7 +662,7 @@ func (s *LifecycleHookTestSuite) TestMergeSecretsEnvDoesNotOverride() { func (s *LifecycleHookTestSuite) TestMergeSecretsEnvNil() { env := map[string]string{"KEY": "val"} - mergeSecretsEnv(env, nil) + mergeSecretsEnv(env, nil, nil) assert.Equal(s.T(), "val", env["KEY"]) assert.Len(s.T(), env, 1) @@ -671,11 +671,21 @@ func (s *LifecycleHookTestSuite) TestMergeSecretsEnvNil() { func (s *LifecycleHookTestSuite) TestMergeSecretsEnvValueWithEquals() { env := map[string]string{} - mergeSecretsEnv(env, []string{"CONN=host=db port=5432"}) + mergeSecretsEnv(env, []string{"CONN=host=db port=5432"}, nil) assert.Equal(s.T(), "host=db port=5432", env["CONN"]) } +// Mount-secret values must be masked in hook logs but never injected into env. +func (s *LifecycleHookTestSuite) TestMergeSecretsEnvRedactsMountWithoutInjecting() { + env := map[string]string{} + + mergeSecretsEnv(env, nil, []string{"tls.key=mount_secret_val"}) + + assert.NotContains(s.T(), env, "tls.key", "mount secrets must not enter hook env") + assert.Equal(s.T(), "***", hookRedactor.Redact("mount_secret_val")) +} + func (s *LifecycleHookTestSuite) TestPostAttachHooksRunEveryTime() { t := s.T() currentUser, err := user.Current() @@ -708,7 +718,7 @@ func (s *LifecycleHookTestSuite) TestPostAttachHooksRunEveryTime() { // Run postAttachCommand multiple times — it must execute every time. for i := 1; i <= 3; i++ { - err := RunPostAttachHooks(context.Background(), result, nil) + err := RunPostAttachHooks(context.Background(), result, nil, nil) assert.NoError(t, err) content, readErr := os.ReadFile(counterFile) //nolint:gosec // test file from TempDir diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 9c1e70d3f..ff9adafd2 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -39,6 +39,7 @@ type ContainerSetupConfig struct { SetupInfo *config.Result ExtraWorkspaceEnv []string SecretsEnv []string + SecretsMount []string ChownProjects bool Prebuild bool PlatformOptions *devsy.PlatformOptions @@ -70,6 +71,10 @@ func SetupContainerPreAttach( return DeferredHooks{}, err } + if err := writeSecretFiles(cfg); err != nil { + return DeferredHooks{}, err + } + setupOptionalFeatures(ctx, cfg) log.Debugf("running pre-attach lifecycle hooks") @@ -79,6 +84,7 @@ func SetupContainerPreAttach( cfg.Prebuild, cfg.Dotfiles, cfg.SecretsEnv, + cfg.SecretsMount, SkipPhases{ PostCreate: cfg.SkipPostCreate, PostStart: cfg.SkipPostStart, @@ -101,6 +107,7 @@ func SetupContainerPostAttach(ctx context.Context, cfg *ContainerSetupConfig) er ctx, cfg.SetupInfo, cfg.SecretsEnv, + cfg.SecretsMount, cfg.SkipPostAttach, ); err != nil { return fmt.Errorf("lifecycle hooks post-attach: %w", err) @@ -124,6 +131,79 @@ func validateContainerSetupConfig(cfg *ContainerSetupConfig) error { return nil } +// writeSecretFiles writes each TARGET=VALUE secret to SecretsMountDir (0600). +func writeSecretFiles(cfg *ContainerSetupConfig) error { + if len(cfg.SecretsMount) == 0 { + return nil + } + + // #nosec G301 -- dir must be traversable by the non-root remote user; per-file 0600 enforces secrecy. + if err := os.MkdirAll(config.SecretsMountDir, 0o755); err != nil { + return fmt.Errorf("create secrets mount dir: %w", err) + } + + user := config.GetRemoteUser(cfg.SetupInfo) + for _, entry := range cfg.SecretsMount { + name, value, ok := strings.Cut(entry, "=") + if !ok || name == "" { + continue + } + if err := writeSecretFile(name, value, user); err != nil { + return err + } + } + + return nil +} + +func writeSecretFile(name, value, user string) error { + path, err := secretMountPath(name) + if err != nil { + return err + } + + // Remove any pre-planted symlink (unlinks the link, not its target), then + // O_EXCL-create so the write cannot be redirected outside SecretsMountDir. + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("prepare secret file %s: %w", name, err) + } + f, err := os.OpenFile( + path, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0o600, + ) // #nosec G304 -- path validated to a plain filename under SecretsMountDir. + if err != nil { + return fmt.Errorf("write secret file %s: %w", name, err) + } + if _, err := f.WriteString(value); err != nil { + _ = f.Close() + return fmt.Errorf("write secret file %s: %w", name, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("write secret file %s: %w", name, err) + } + if err := copy2.Chown(path, user); err != nil { + log.Warnf("Error chowning secret file %s: %v", name, err) + } + + return nil +} + +// secretMountPath resolves target to a flat file under SecretsMountDir. Only a +// single path element is allowed, blocking both traversal and symlinked dirs. +func secretMountPath(target string) (string, error) { + if target != filepath.Base(target) || target == "." || target == ".." || + strings.ContainsRune(target, '/') || strings.ContainsRune(target, filepath.Separator) { + return "", fmt.Errorf( + "invalid secret mount target %q: must be a plain filename under %s", + target, + config.SecretsMountDir, + ) + } + + return filepath.Join(config.SecretsMountDir, target), nil +} + func writeResultFile(cfg *ContainerSetupConfig) { rawBytes, err := json.Marshal(cfg.SetupInfo) if err != nil { diff --git a/pkg/devcontainer/setup/setup_test.go b/pkg/devcontainer/setup/setup_test.go index 52e51443b..8b283a9d2 100644 --- a/pkg/devcontainer/setup/setup_test.go +++ b/pkg/devcontainer/setup/setup_test.go @@ -2,14 +2,44 @@ package setup import ( "context" + "path/filepath" "testing" "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" "go.uber.org/zap/zapcore" "google.golang.org/grpc" ) +// TestSecretMountPath_RejectsEscapes ensures only a plain filename is accepted: +// path separators, "." / ".." and traversal are rejected so a crafted target +// cannot escape SecretsMountDir or write through a symlinked intermediate dir. +func TestSecretMountPath_RejectsEscapes(t *testing.T) { + valid := []string{"tls.key", "a.b.c", "TOKEN"} + for _, target := range valid { + path, err := secretMountPath(target) + if err != nil { + t.Errorf("secretMountPath(%q) unexpected error: %v", target, err) + continue + } + if path != filepath.Join(config.SecretsMountDir, target) { + t.Errorf("secretMountPath(%q) = %q, want %q", target, path, + filepath.Join(config.SecretsMountDir, target)) + } + } + + rejected := []string{ + "../evil", "../../etc/cron.d/x", "sub/../../../evil", + "sub/tls.key", "/etc/passwd", ".", "..", "", + } + for _, target := range rejected { + if path, err := secretMountPath(target); err == nil { + t.Errorf("secretMountPath(%q) = %q, want rejection", target, path) + } + } +} + // fakeTunnelClient is a minimal stub of tunnel.TunnelClient. Only KubeConfig // is exercised here; the other methods return zero values and are unused by // setupKubeConfig. diff --git a/pkg/devcontainer/setup_secrets_test.go b/pkg/devcontainer/setup_secrets_test.go new file mode 100644 index 000000000..45edea1e0 --- /dev/null +++ b/pkg/devcontainer/setup_secrets_test.go @@ -0,0 +1,52 @@ +package devcontainer + +import ( + "encoding/json" + "testing" + + "github.com/devsy-org/devsy/pkg/compress" + "github.com/devsy-org/devsy/pkg/provider" +) + +// Secret values must never ride in the compressed CLIOptions embedded in the +// setup command's arguments; the container pulls them over the tunnel instead. +func TestCompressWorkspaceConfig_StripsSecretValues(t *testing.T) { + r := &runner{ + workspaceConfig: &provider.AgentWorkspaceInfo{ + Workspace: &provider.Workspace{}, + CLIOptions: provider.CLIOptions{ + SecretsEnv: []string{"API_KEY=super-secret"}, + SecretsMount: []string{"tls.key=key-material"}, + BuildSecrets: []string{"NPM_TOKEN=npm-secret"}, + GitToken: &provider.GitToken{Host: "github.com", Token: "ghp_secret"}, + }, + }, + } + + compressed, err := r.compressWorkspaceConfig() + if err != nil { + t.Fatal(err) + } + decompressed, err := compress.Decompress(compressed) + if err != nil { + t.Fatal(err) + } + + var info provider.ContainerWorkspaceInfo + if err := json.Unmarshal([]byte(decompressed), &info); err != nil { + t.Fatal(err) + } + + if info.CLIOptions.SecretsEnv != nil { + t.Error("SecretsEnv must be stripped from the setup-command CLIOptions") + } + if info.CLIOptions.SecretsMount != nil { + t.Error("SecretsMount must be stripped from the setup-command CLIOptions") + } + if info.CLIOptions.BuildSecrets != nil { + t.Error("BuildSecrets must be stripped from the setup-command CLIOptions") + } + if info.CLIOptions.GitToken != nil { + t.Error("GitToken must be stripped from the setup-command CLIOptions") + } +} diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 9b1fdced7..8d2a7220c 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -651,6 +651,10 @@ func (r *runner) getDockerlessRunOptions( Source: "dockerless-" + r.id, Target: "/workspaces/.dockerless", }) + mounts, err = r.withSecretsMount(mounts) + if err != nil { + return nil, err + } // build run options return &driver.RunOptions{ @@ -715,21 +719,14 @@ func (r *runner) getRunOptions( user = mergedConfig.ContainerUser } - // Resolve any ${containerEnv:VAR} references in containerEnv values using - // the image's inspected environment. ${containerWorkspaceFolder} and - // ${containerWorkspaceFolderBasename} are already substituted upstream in - // runner.substitute, since the host fully knows those values. - var imageEnv []string - if buildInfo.ImageDetails != nil { - imageEnv = buildInfo.ImageDetails.Config.Env + if err := resolveContainerEnv(mergedConfig, buildInfo); err != nil { + return nil, err } - resolvedContainerEnv, err := config.ResolveContainerEnvFromImage( - mergedConfig.ContainerEnv, imageEnv, - ) + + mounts, err := r.withSecretsMount(mergedConfig.Mounts) if err != nil { - return nil, fmt.Errorf("resolve containerEnv from image: %w", err) + return nil, err } - mergedConfig.ContainerEnv = resolvedContainerEnv return &driver.RunOptions{ UID: r.workspaceUID(), @@ -744,7 +741,7 @@ func (r *runner) getRunOptions( Init: mergedConfig.Init, WorkspaceMount: workspaceMountPtr, SecurityOpt: mergedConfig.SecurityOpt, - Mounts: mergedConfig.Mounts, + Mounts: mounts, Userns: substitutionContext.Userns, UidMap: substitutionContext.UidMap, GidMap: substitutionContext.GidMap, @@ -752,6 +749,63 @@ func (r *runner) getRunOptions( }, nil } +// resolveContainerEnv resolves ${containerEnv:VAR} references in containerEnv +// against the image's inspected environment. ${containerWorkspaceFolder} and +// ${containerWorkspaceFolderBasename} are already substituted upstream. +func resolveContainerEnv( + mergedConfig *config.MergedDevContainerConfig, + buildInfo *config.BuildInfo, +) error { + var imageEnv []string + if buildInfo.ImageDetails != nil { + imageEnv = buildInfo.ImageDetails.Config.Env + } + resolved, err := config.ResolveContainerEnvFromImage(mergedConfig.ContainerEnv, imageEnv) + if err != nil { + return fmt.Errorf("resolve containerEnv from image: %w", err) + } + mergedConfig.ContainerEnv = resolved + + return nil +} + +// withSecretsMount appends the secrets tmpfs mount when secrets are +// file-delivered. It fails when the driver cannot honor a tmpfs mount rather +// than silently writing secrets to a durable layer. +func (r *runner) withSecretsMount(mounts []*config.Mount) ([]*config.Mount, error) { + mount, err := r.secretsMount() + if err != nil { + return nil, err + } + if mount == nil { + return mounts, nil + } + + return append(mounts, mount), nil +} + +// secretsMount returns the /run/secrets tmpfs mount when a secret is +// file-delivered, or nil when none is. Values must never touch a persistent +// layer, so an unsupported driver is an error rather than a silent skip. +func (r *runner) secretsMount() (*config.Mount, error) { + if r.workspaceConfig == nil || len(r.workspaceConfig.CLIOptions.SecretsMount) == 0 { + return nil, nil + } + + if !driver.DriverSupportsMountType(r.driver, driver.MountTypeTmpfs) { + return nil, fmt.Errorf( + "the current provider does not support mounting secrets as files (--secret type=mount); " + + "use type=env instead", + ) + } + + return &config.Mount{ + Type: driver.MountTypeTmpfs, + Target: config.SecretsMountDir, + Other: []string{"tmpfs-mode=0755"}, + }, nil +} + // add environment variables that signals that we are in a remote container // (vscode compatibility) and specifically that we are using devsy. func (r *runner) addExtraEnvVars(env map[string]string) map[string]string { diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index e2c574242..8d7dc2365 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -219,6 +219,31 @@ func (r *DockerHelper) RunWithDir( return cmd.Run() } +// RunWithEnv runs a command with extra environment variables for this +// invocation only (used for env-backed buildx secrets). +// +//nolint:revive // IO stream params mirror Run/RunWithDir. +func (r *DockerHelper) RunWithEnv( + ctx context.Context, + extraEnv []string, + args []string, + stdin io.Reader, + stdout io.Writer, + stderr io.Writer, +) error { + cmd := r.buildCmd(ctx, args...) + if len(extraEnv) > 0 { + if cmd.Env == nil { + cmd.Env = os.Environ() + } + cmd.Env = append(cmd.Env, extraEnv...) + } + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + func (r *DockerHelper) StartContainer(ctx context.Context, containerId string) error { out, err := r.buildCmd(ctx, "start", containerId).CombinedOutput() if err != nil { diff --git a/pkg/driver/docker/build.go b/pkg/driver/docker/build.go index 184f5f559..af4e4a70e 100644 --- a/pkg/driver/docker/build.go +++ b/pkg/driver/docker/build.go @@ -75,10 +75,22 @@ func (s *dockerBuildxStrategy) build( options *build.BuildOptions, ) error { args := buildDockerBuildxArgs(options, platform) + secretEnv, secretArgs, err := buildxSecretArgs(options.BuildSecrets) + if err != nil { + return err + } + args = append(args, secretArgs...) log.Debugf("running docker buildx build with args: %s", strings.Join(args, " ")) stderrBuf := &bytes.Buffer{} multiWriter := io.MultiWriter(writer, stderrBuf) - if err := s.driver.Docker.Run(ctx, args, nil, writer, multiWriter); err != nil { + if err := s.driver.Docker.RunWithEnv( + ctx, + secretEnv, + args, + nil, + writer, + multiWriter, + ); err != nil { if stderrBuf.Len() > 0 { return fmt.Errorf( "failed to build image: %w: %s", @@ -114,6 +126,22 @@ func buildDockerBuildxArgs(options *build.BuildOptions, platform string) []strin return args } +// buildxSecretArgs maps NAME=VALUE build secrets to `--secret id=NAME,env=VAR` +// references, returning the env assignments so values stay out of the argv. +func buildxSecretArgs(buildSecrets []string) (env, args []string, err error) { + for _, entry := range buildSecrets { + name, value, ok := strings.Cut(entry, "=") + if !ok || name == "" { + return nil, nil, fmt.Errorf("invalid build secret %q, expected NAME=VALUE", entry) + } + envVar := "DEVSY_BUILD_SECRET_" + name + env = append(env, envVar+"="+value) + args = append(args, "--secret", "id="+name+",env="+envVar) + } + + return env, args, nil +} + func appendBuildFlags(args []string, load, push bool) []string { if load { args = append(args, "--load") @@ -318,10 +346,26 @@ func (d *dockerDriver) prepareBuildOptions( return nil, err } - log.Debugf("prepared build options: %+v", buildOptions) + log.Debugf("prepared build options: %+v", redactBuildSecrets(buildOptions)) return buildOptions, nil } +// redactBuildSecrets copies buildOptions with build-secret values masked to +// names, so the debug log never exposes them. +func redactBuildSecrets(buildOptions *build.BuildOptions) build.BuildOptions { + redacted := *buildOptions + if len(buildOptions.BuildSecrets) == 0 { + return redacted + } + names := make([]string, len(buildOptions.BuildSecrets)) + for i, entry := range buildOptions.BuildSecrets { + name, _, _ := strings.Cut(entry, "=") + names[i] = name + "=" + } + redacted.BuildSecrets = names + return redacted +} + func (d *dockerDriver) executeBuild( ctx context.Context, strategy buildStrategy, diff --git a/pkg/driver/docker/build_test.go b/pkg/driver/docker/build_test.go index a6442b937..a67805334 100644 --- a/pkg/driver/docker/build_test.go +++ b/pkg/driver/docker/build_test.go @@ -137,3 +137,28 @@ func TestBuildDockerBuildxArgs_PullAndNoCache(t *testing.T) { }) } } + +func TestBuildxSecretArgs(t *testing.T) { + env, args, err := buildxSecretArgs([]string{"NPM_TOKEN=abc123", "CONN=user=admin"}) + require.NoError(t, err) + assert.Equal(t, []string{ + "DEVSY_BUILD_SECRET_NPM_TOKEN=abc123", + "DEVSY_BUILD_SECRET_CONN=user=admin", + }, env) + assert.Equal(t, []string{ + "--secret", "id=NPM_TOKEN,env=DEVSY_BUILD_SECRET_NPM_TOKEN", + "--secret", "id=CONN,env=DEVSY_BUILD_SECRET_CONN", + }, args) +} + +func TestBuildxSecretArgs_Empty(t *testing.T) { + env, args, err := buildxSecretArgs(nil) + require.NoError(t, err) + assert.Empty(t, env) + assert.Empty(t, args) +} + +func TestBuildxSecretArgs_Invalid(t *testing.T) { + _, _, err := buildxSecretArgs([]string{"NOEQUALS"}) + require.Error(t, err) +} diff --git a/pkg/driver/kubernetes/driver.go b/pkg/driver/kubernetes/driver.go index fc08684e5..3d6bc4b8c 100644 --- a/pkg/driver/kubernetes/driver.go +++ b/pkg/driver/kubernetes/driver.go @@ -58,6 +58,15 @@ func (k *KubernetesDriver) CanReprovision() bool { return true } +func (k *KubernetesDriver) SupportsMountType(mountType string) bool { + switch mountType { + case driver.MountTypeBind, driver.MountTypeVolume, driver.MountTypeTmpfs: + return true + default: + return false + } +} + func (k *KubernetesDriver) getDevContainerPvc( ctx context.Context, id string, diff --git a/pkg/driver/kubernetes/run.go b/pkg/driver/kubernetes/run.go index 5696bc96e..f7ad8d076 100644 --- a/pkg/driver/kubernetes/run.go +++ b/pkg/driver/kubernetes/run.go @@ -149,11 +149,26 @@ func (k *KubernetesDriver) runContainer( // loop over volume mounts volumeMounts := []corev1.VolumeMount{getVolumeMount(0, mount)} + var tmpfsVolumes []corev1.Volume for idx, mount := range options.Mounts { - volumeMount := getVolumeMount(idx+1, mount) - if mount.Type == "bind" || mount.Type == "volume" { - volumeMounts = append(volumeMounts, volumeMount) - } else { + switch mount.Type { + case driver.MountTypeBind, driver.MountTypeVolume: + volumeMounts = append(volumeMounts, getVolumeMount(idx+1, mount)) + case driver.MountTypeTmpfs: + name := fmt.Sprintf("%s-tmpfs-%d", DevContainerName, idx+1) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: name, + MountPath: mount.Target, + }) + tmpfsVolumes = append(tmpfsVolumes, corev1.Volume{ + Name: name, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + Medium: corev1.StorageMediumMemory, + }, + }, + }) + default: log.Warnf( "Unsupported mount type %q in mount %q, will skip", mount.Type, @@ -260,7 +275,7 @@ func (k *KubernetesDriver) runContainer( k.options.StrictSecurity, daemonConfigSecretName, ) - pod.Spec.Volumes = getVolumes(pod, id, daemonConfigSecretName) + pod.Spec.Volumes = append(getVolumes(pod, id, daemonConfigSecretName), tmpfsVolumes...) // avoids a problem where attaching volumes with large repositories would cause an extremely long pod startup time // because changing the ownership of all files takes longer than the kubelet expects it to if pod.Spec.SecurityContext == nil { diff --git a/pkg/driver/types.go b/pkg/driver/types.go index baf9b876a..a0c01fba9 100644 --- a/pkg/driver/types.go +++ b/pkg/driver/types.go @@ -46,6 +46,33 @@ type ReprovisioningDriver interface { CanReprovision() bool } +// Mount types understood by the drivers. +const ( + MountTypeBind = "bind" + MountTypeVolume = "volume" + MountTypeTmpfs = "tmpfs" +) + +// MountCapableDriver is implemented by drivers that can report which mount +// types they support. Drivers that do not implement it are assumed to support +// the bind/volume/tmpfs types the docker driver has always handled. +type MountCapableDriver interface { + Driver + + // SupportsMountType reports whether the driver can honor the given mount type. + SupportsMountType(mountType string) bool +} + +// DriverSupportsMountType reports whether the driver can honor mountType, +// defaulting to true for drivers that do not advertise the capability. +func DriverSupportsMountType(d Driver, mountType string) bool { + if mc, ok := d.(MountCapableDriver); ok { + return mc.SupportsMountType(mountType) + } + + return true +} + // CommandParams holds the parameters for running a command inside a devcontainer. type CommandParams struct { WorkspaceID string diff --git a/pkg/driver/types_test.go b/pkg/driver/types_test.go new file mode 100644 index 000000000..822cd660a --- /dev/null +++ b/pkg/driver/types_test.go @@ -0,0 +1,31 @@ +package driver + +import "testing" + +type mountCapableStub struct { + Driver + tmpfs bool +} + +func (m mountCapableStub) SupportsMountType(mountType string) bool { + return mountType == MountTypeTmpfs && m.tmpfs +} + +type plainDriverStub struct { + Driver +} + +func TestDriverSupportsMountType_DefaultsToTrue(t *testing.T) { + if !DriverSupportsMountType(plainDriverStub{}, MountTypeTmpfs) { + t.Fatal("drivers without the capability should default to supported") + } +} + +func TestDriverSupportsMountType_RespectsCapability(t *testing.T) { + if !DriverSupportsMountType(mountCapableStub{tmpfs: true}, MountTypeTmpfs) { + t.Fatal("tmpfs-capable driver should report supported") + } + if DriverSupportsMountType(mountCapableStub{tmpfs: false}, MountTypeTmpfs) { + t.Fatal("non-tmpfs driver should report unsupported") + } +} diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index 08202c4bb..840049124 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -12,6 +12,7 @@ func FlagFalse(name string) string { return FlagValue(name, "false") } // Workspace. const ( + BuildSecret = "build-secret" CacheFrom = "cache-from" ContainerDataFolder = "container-data-folder" ContainerStatus = "container-status" @@ -23,6 +24,7 @@ const ( DevContainerOverlay = "devcontainer-overlay" DevContainerPath = "devcontainer-path" DisableDaemon = "disable-daemon" + Env = "env" FallbackImage = "fallback-image" Features = "features" FeatureSecretsFile = "feature-secrets-file" @@ -52,6 +54,7 @@ const ( RemoteUser = "remote-user" RemoveVolumes = "remove-volumes" Reset = "reset" + Secret = "secret" SecretsFile = "secrets-file" SkipPro = "skip-pro" Source = "source" @@ -101,6 +104,8 @@ const ( GitRecurseSubmodules = "git-recurse-submodules" GitLFSMode = "git-lfs-mode" GitSSHSigningKey = "git-ssh-signing-key" + GitToken = "git-token" + GitTokenUsername = "git-token-username" ) // Lifecycle. @@ -175,6 +180,13 @@ const ( ForceDockerless = "force-dockerless" ) +// Secret / env values. +const ( + Value = "value" + FromFile = "from-file" + Stdin = "stdin" +) + // Miscellaneous. const ( Debug = "debug" diff --git a/pkg/flags/names/names_test.go b/pkg/flags/names/names_test.go index 79e52d973..23ad3852e 100644 --- a/pkg/flags/names/names_test.go +++ b/pkg/flags/names/names_test.go @@ -138,6 +138,9 @@ var wireNames = []struct { {names.ResultFormat, "result-format"}, {names.SetupInfo, "setup-info"}, {names.SecretsEnv, "secrets-env"}, + {names.Value, "value"}, + {names.FromFile, "from-file"}, + {names.Stdin, "stdin"}, {names.OwnerTrust, "ownertrust"}, {names.SocketPath, "socketpath"}, {names.GitKey, "gitkey"}, diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 92d813df6..019e69b09 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -202,6 +202,14 @@ type AgentWorkspaceInfo struct { RegistryCache string `json:"registryCache,omitempty"` } +// GitToken is a resolved access token for cloning a private HTTP repository. +// It is scoped to a host so it is never offered to other hosts git contacts. +type GitToken struct { + Host string `json:"host,omitempty"` + Username string `json:"username,omitempty"` + Token string `json:"token,omitempty"` +} + type CLIOptions struct { // Platform are the platform options Platform devsy.PlatformOptions `json:"platformOptions"` @@ -219,6 +227,9 @@ type CLIOptions struct { WorkspaceEnv []string `json:"workspaceEnv,omitempty"` WorkspaceEnvFile []string `json:"workspaceEnvFile,omitempty"` SecretsEnv []string `json:"secretsEnv,omitempty"` + SecretsMount []string `json:"secretsMount,omitempty"` + BuildSecrets []string `json:"buildSecrets,omitempty"` + GitToken *GitToken `json:"gitToken,omitempty"` FeatureSecretsFile string `json:"featureSecretsFile,omitempty"` InitEnv []string `json:"initEnv,omitempty"` Recreate bool `json:"recreate,omitempty"` diff --git a/pkg/secrets/atomicwrite.go b/pkg/secrets/atomicwrite.go new file mode 100644 index 000000000..36206a5b0 --- /dev/null +++ b/pkg/secrets/atomicwrite.go @@ -0,0 +1,40 @@ +package secrets + +import ( + "fmt" + "os" + "path/filepath" +) + +// atomicWriteFile writes via a same-dir temp file then rename, so a crash +// mid-write cannot leave a truncated store file. +func atomicWriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create secrets dir: %w", err) + } + + tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("create temp secrets file: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + return fmt.Errorf("chmod temp secrets file: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp secrets file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp secrets file: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("replace secrets file: %w", err) + } + + return nil +} diff --git a/pkg/secrets/file_backend.go b/pkg/secrets/file_backend.go new file mode 100644 index 000000000..e5a9d78fc --- /dev/null +++ b/pkg/secrets/file_backend.go @@ -0,0 +1,107 @@ +package secrets + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + + "filippo.io/age" +) + +// fileBackend stores all values in one age-encrypted file, re-encrypting the +// whole map on each write. +type fileBackend struct { + path string + key *fileKey +} + +func newFileBackend(path string, key *fileKey) *fileBackend { + return &fileBackend{path: path, key: key} +} + +func (f *fileBackend) load() (map[string]string, error) { + raw, err := os.ReadFile(f.path) // #nosec G304 -- path derived from config dir. + if err != nil { + if os.IsNotExist(err) { + return map[string]string{}, nil + } + + return nil, fmt.Errorf("read secrets file: %w", err) + } + + reader, err := age.Decrypt(bytes.NewReader(raw), f.key.identity) + if err != nil { + return nil, fmt.Errorf("decrypt secrets file: %w", err) + } + + plaintext, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read decrypted secrets: %w", err) + } + + values := map[string]string{} + if err := json.Unmarshal(plaintext, &values); err != nil { + return nil, fmt.Errorf("parse secrets file: %w", err) + } + + return values, nil +} + +func (f *fileBackend) store(values map[string]string) error { + plaintext, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("marshal secrets: %w", err) + } + + var buf bytes.Buffer + w, err := age.Encrypt(&buf, f.key.recipient) + if err != nil { + return fmt.Errorf("encrypt secrets: %w", err) + } + if _, err := w.Write(plaintext); err != nil { + return fmt.Errorf("encrypt secrets: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("finalize encrypted secrets: %w", err) + } + + return atomicWriteFile(f.path, buf.Bytes(), 0o600) +} + +func (f *fileBackend) set(key, value string) error { + values, err := f.load() + if err != nil { + return err + } + values[key] = value + + return f.store(values) +} + +func (f *fileBackend) get(key string) (string, error) { + values, err := f.load() + if err != nil { + return "", err + } + value, ok := values[key] + if !ok { + return "", ErrSecretNotFound + } + + return value, nil +} + +func (f *fileBackend) remove(key string) error { + values, err := f.load() + if err != nil { + return err + } + if _, ok := values[key]; !ok { + return nil + } + delete(values, key) + + return f.store(values) +} diff --git a/pkg/secrets/filekey.go b/pkg/secrets/filekey.go new file mode 100644 index 000000000..0ec69f0ac --- /dev/null +++ b/pkg/secrets/filekey.go @@ -0,0 +1,164 @@ +package secrets + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "filippo.io/age" + keyring "github.com/zalando/go-keyring" +) + +const KeyFileName = "secrets.key" + +const keyringKeyUser = "__filekey__" + +type keySource string + +const ( + keySourcePassphrase keySource = "passphrase" + keySourceKeyring keySource = "keyring" + keySourceAutoFile keySource = "file" +) + +type fileKey struct { + recipient age.Recipient + identity age.Identity + source keySource +} + +// resolveFileKey applies precedence: passphrase, then an auto-generated key in +// the OS keyring, then an auto-generated key in a local file. +func resolveFileKey(dir, passphrase string) (*fileKey, error) { + if passphrase != "" { + recipient, err := age.NewScryptRecipient(passphrase) + if err != nil { + return nil, fmt.Errorf("derive secrets key: %w", err) + } + identity, err := age.NewScryptIdentity(passphrase) + if err != nil { + return nil, fmt.Errorf("derive secrets key: %w", err) + } + + return &fileKey{recipient: recipient, identity: identity, source: keySourcePassphrase}, nil + } + + if keyringAvailable() { + return resolveAutoKey(dir, keyringKeyStore{}, keySourceKeyring) + } + + return resolveAutoKey( + dir, + fileKeyStore{path: filepath.Join(dir, KeyFileName)}, + keySourceAutoFile, + ) +} + +// resolveAutoKey reuses the persisted identity or creates one; it must never +// regenerate an existing key or previously encrypted secrets become unreadable. +// The read-generate-save-reload sequence is locked so concurrent first-time +// initializers converge on one persisted key. +func resolveAutoKey(dir string, store keyStore, source keySource) (*fileKey, error) { + unlock, err := acquireFlock(dir, KeyFileName+".lock") + if err != nil { + return nil, err + } + defer unlock() + + encoded, err := store.load() + if err != nil { + return nil, err + } + + if encoded == "" { + identity, gErr := age.GenerateX25519Identity() + if gErr != nil { + return nil, fmt.Errorf("generate secrets key: %w", gErr) + } + if err := store.save(identity.String()); err != nil { + return nil, err + } + if encoded, err = store.load(); err != nil { + return nil, err + } + } + + identity, err := age.ParseX25519Identity(strings.TrimSpace(encoded)) + if err != nil { + return nil, fmt.Errorf("parse stored secrets key: %w", err) + } + + return &fileKey{recipient: identity.Recipient(), identity: identity, source: source}, nil +} + +type keyStore interface { + load() (string, error) // returns "" when no key is stored yet + save(value string) error +} + +type keyringKeyStore struct{} + +func (keyringKeyStore) load() (string, error) { + value, err := keyring.Get(keyringService, keyringKeyUser) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return "", nil + } + + return "", fmt.Errorf("read secrets key from keyring: %w", err) + } + + return value, nil +} + +func (keyringKeyStore) save(value string) error { + if err := keyring.Set(keyringService, keyringKeyUser, value); err != nil { + return fmt.Errorf("write secrets key to keyring: %w", err) + } + + return nil +} + +type fileKeyStore struct { + path string +} + +func (s fileKeyStore) load() (string, error) { + data, err := os.ReadFile(s.path) // #nosec G304 -- path derived from config dir. + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + + return "", fmt.Errorf("read secrets key file: %w", err) + } + + return string(data), nil +} + +// save writes the key with O_EXCL so a losing concurrent initializer leaves the +// winner's key intact. +func (s fileKeyStore) save(value string) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("create secrets dir: %w", err) + } + f, err := os.OpenFile( + s.path, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0o600, + ) // #nosec G304 -- path derived from config dir. + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil + } + return fmt.Errorf("write secrets key file: %w", err) + } + defer func() { _ = f.Close() }() + if _, err := f.WriteString(value); err != nil { + return fmt.Errorf("write secrets key file: %w", err) + } + + return nil +} diff --git a/pkg/secrets/index.go b/pkg/secrets/index.go new file mode 100644 index 000000000..a95d2e26e --- /dev/null +++ b/pkg/secrets/index.go @@ -0,0 +1,104 @@ +package secrets + +import ( + "fmt" + "os" + "sort" + + "sigs.k8s.io/yaml" +) + +type indexData struct { + Contexts map[string]map[string]SecretMeta `json:"contexts"` + + KeySource string `json:"keySource,omitempty"` +} + +// index is a YAML metadata cache; it exists because OS keyrings cannot +// enumerate entries. The backend remains the source of truth. +type index struct { + path string + data indexData +} + +func loadIndex(path string) (*index, error) { + idx := &index{path: path, data: indexData{Contexts: map[string]map[string]SecretMeta{}}} + + raw, err := os.ReadFile(path) // #nosec G304 -- path derived from config dir. + if err != nil { + if os.IsNotExist(err) { + return idx, nil + } + + return nil, fmt.Errorf("read secrets index: %w", err) + } + + if err := yaml.Unmarshal(raw, &idx.data); err != nil { + return nil, fmt.Errorf("parse secrets index: %w", err) + } + if idx.data.Contexts == nil { + idx.data.Contexts = map[string]map[string]SecretMeta{} + } + idx.normalizeKinds() + + return idx, nil +} + +// normalizeKinds fails safe on an unset Kind by treating the entry as a secret, +// so an older/hand-edited entry is never read as a plaintext env var. +func (i *index) normalizeKinds() { + for _, entries := range i.data.Contexts { + for name, meta := range entries { + if meta.Kind != KindSecret && meta.Kind != KindEnv { + meta.Kind = KindSecret + meta.Value = "" + entries[name] = meta + } + } + } +} + +func (i *index) save() error { + out, err := yaml.Marshal(i.data) + if err != nil { + return fmt.Errorf("marshal secrets index: %w", err) + } + + return atomicWriteFile(i.path, out, 0o600) +} + +func (i *index) put(meta SecretMeta) { + if i.data.Contexts[meta.Context] == nil { + i.data.Contexts[meta.Context] = map[string]SecretMeta{} + } + meta.Orphaned = false + // A sensitive value must never be persisted inline; it lives in the backend. + if meta.Sensitive() { + meta.Value = "" + } + i.data.Contexts[meta.Context][meta.Name] = meta +} + +func (i *index) get(context, name string) (SecretMeta, bool) { + meta, ok := i.data.Contexts[context][name] + return meta, ok +} + +func (i *index) remove(context, name string) { + delete(i.data.Contexts[context], name) + if len(i.data.Contexts[context]) == 0 { + delete(i.data.Contexts, context) + } +} + +func (i *index) list(context string) []SecretMeta { + entries := make([]SecretMeta, 0, len(i.data.Contexts[context])) + for _, meta := range i.data.Contexts[context] { + entries = append(entries, meta) + } + sort.Slice(entries, func(a, b int) bool { + return entries[a].Name < entries[b].Name + }) + + return entries +} diff --git a/pkg/secrets/keyring_backend.go b/pkg/secrets/keyring_backend.go new file mode 100644 index 000000000..d934d2c6e --- /dev/null +++ b/pkg/secrets/keyring_backend.go @@ -0,0 +1,57 @@ +package secrets + +import ( + "errors" + "fmt" + + keyring "github.com/zalando/go-keyring" +) + +const keyringService = "sh.devsy.secrets" + +type keyringBackend struct{} + +func (keyringBackend) set(key, value string) error { + if err := keyring.Set(keyringService, key, value); err != nil { + return fmt.Errorf("write to keyring: %w", err) + } + + return nil +} + +func (keyringBackend) get(key string) (string, error) { + value, err := keyring.Get(keyringService, key) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return "", ErrSecretNotFound + } + + return "", fmt.Errorf("read from keyring: %w", err) + } + + return value, nil +} + +func (keyringBackend) remove(key string) error { + if err := keyring.Delete(keyringService, key); err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil + } + + return fmt.Errorf("delete from keyring: %w", err) + } + + return nil +} + +// keyringAvailable probes with a sentinel round-trip, since the keyring can be +// present but unusable (e.g. no D-Bus session on headless Linux). +func keyringAvailable() bool { + const probeKey = "__devsy_probe__" + if err := keyring.Set(keyringService, probeKey, "1"); err != nil { + return false + } + _ = keyring.Delete(keyringService, probeKey) + + return true +} diff --git a/pkg/secrets/local_store.go b/pkg/secrets/local_store.go new file mode 100644 index 000000000..5669745ff --- /dev/null +++ b/pkg/secrets/local_store.go @@ -0,0 +1,332 @@ +package secrets + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/devsy-org/devsy/pkg/config" +) + +const IndexFileName = "secrets.yaml" + +const EncryptedFileName = "secrets.enc" + +const EnvPassphrase = "DEVSY_SECRETS_PASSPHRASE" // #nosec G101 -- env var name, not a credential. + +const EnvBackend = "DEVSY_SECRETS_BACKEND" + +type Backend string + +const ( + BackendAuto Backend = "auto" + BackendKeyring Backend = "keyring" + BackendFile Backend = "file" +) + +type localStore struct { + backend backend + indexPath string + now func() time.Time + + // keySource is empty for the keyring backend; checked against the index so a + // key-source change is a clear error rather than a decryption failure. + keySource keySource +} + +func NewStoreForConfig(devsyConfig *config.Config) (Store, error) { + configPath, err := config.GetConfigPath() + if err != nil { + return nil, err + } + dir := filepath.Dir(configPath) + indexPath := filepath.Join(dir, IndexFileName) + + switch resolveBackend(devsyConfig) { + case BackendKeyring: + return newLocalStore(keyringBackend{}, indexPath), nil + case BackendFile: + return newFileStore(dir, indexPath) + default: + if keyringAvailable() { + return newLocalStore(keyringBackend{}, indexPath), nil + } + + return newFileStore(dir, indexPath) + } +} + +func resolveBackend(devsyConfig *config.Config) Backend { + if b := normalizeBackend(os.Getenv(EnvBackend)); b != "" { + return b + } + if devsyConfig != nil { + configured := devsyConfig.ContextOption(config.ContextOptionSecretsBackend) + if b := normalizeBackend(configured); b != "" { + return b + } + } + + return BackendAuto +} + +func normalizeBackend(value string) Backend { + switch Backend(value) { + case BackendKeyring, BackendFile, BackendAuto: + return Backend(value) + default: + return "" + } +} + +func newFileStore(dir, indexPath string) (Store, error) { + key, err := resolveFileKey(dir, os.Getenv(EnvPassphrase)) + if err != nil { + return nil, err + } + + fb := newFileBackend(filepath.Join(dir, EncryptedFileName), key) + s := newLocalStore(fb, indexPath) + s.keySource = key.source + + return s, nil +} + +func newLocalStore(b backend, indexPath string) *localStore { + return &localStore{backend: b, indexPath: indexPath, now: time.Now} +} + +func (s *localStore) Set(context, name, value string, kind Kind) error { + if err := ValidateName(name); err != nil { + return err + } + + unlock, err := s.lock() + if err != nil { + return err + } + defer unlock() + + idx, err := loadIndex(s.indexPath) + if err != nil { + return err + } + + meta, exists := idx.get(context, name) + if !exists { + meta = SecretMeta{Name: name, Context: context, Created: s.now().UTC()} + } + wasSensitive := exists && meta.Sensitive() + meta.Kind = kind + meta.Value = value + + if err := s.persistValue(idx, &meta, value, wasSensitive); err != nil { + return err + } + if meta.Sensitive() { + meta.Value = "" + } + + idx.put(meta) + return idx.save() +} + +func (s *localStore) Get(context, name string) (string, error) { + if err := ValidateName(name); err != nil { + return "", err + } + + idx, err := loadIndex(s.indexPath) + if err != nil { + return "", err + } + + meta, ok := idx.get(context, name) + if !ok { + return "", ErrSecretNotFound + } + + var value string + if meta.Sensitive() { + if err := s.checkKeySource(idx); err != nil { + return "", err + } + if value, err = s.backend.get(backendKey(context, name)); err != nil { + return "", err + } + } else { + value = meta.Value + } + + s.touchLastUsed(context, name) + + return value, nil +} + +func (s *localStore) Delete(context, name string) error { + if err := ValidateName(name); err != nil { + return err + } + + unlock, err := s.lock() + if err != nil { + return err + } + defer unlock() + + idx, err := loadIndex(s.indexPath) + if err != nil { + return err + } + + if meta, ok := idx.get(context, name); ok && meta.Sensitive() { + if err := s.checkKeySource(idx); err != nil { + return err + } + if err := s.backend.remove(backendKey(context, name)); err != nil { + return err + } + } + idx.remove(context, name) + + return idx.save() +} + +func (s *localStore) Meta(context, name string) (SecretMeta, error) { + idx, err := loadIndex(s.indexPath) + if err != nil { + return SecretMeta{}, err + } + meta, ok := idx.get(context, name) + if !ok { + return SecretMeta{}, ErrSecretNotFound + } + meta.Value = "" + + return meta, nil +} + +func (s *localStore) List(context string) ([]SecretMeta, error) { + return s.Reconcile(context) +} + +func (s *localStore) Reconcile(context string) ([]SecretMeta, error) { + idx, err := loadIndex(s.indexPath) + if err != nil { + return nil, err + } + + entries := idx.list(context) + if !anySensitive(entries) { + return entries, nil + } + if err := s.checkKeySource(idx); err != nil { + return nil, err + } + + for i := range entries { + if !entries[i].Sensitive() { + continue + } + orphaned, err := s.isOrphaned(context, entries[i].Name) + if err != nil { + return nil, err + } + entries[i].Orphaned = orphaned + } + + return entries, nil +} + +// lock serializes store read-modify-write. NOT reentrant: a lock-holding method +// must never call another locking method (see acquireFlock). +func (s *localStore) lock() (func(), error) { + return acquireFlock(filepath.Dir(s.indexPath), IndexFileName+".lock") +} + +// touchLastUsed records access time under the store lock; best-effort, errors ignored. +func (s *localStore) touchLastUsed(context, name string) { + unlock, err := s.lock() + if err != nil { + return + } + defer unlock() + + idx, err := loadIndex(s.indexPath) + if err != nil { + return + } + meta, ok := idx.get(context, name) + if !ok { + return + } + meta.LastUsed = s.now().UTC() + idx.put(meta) + _ = idx.save() +} + +func anySensitive(entries []SecretMeta) bool { + for i := range entries { + if entries[i].Sensitive() { + return true + } + } + return false +} + +func (s *localStore) isOrphaned(context, name string) (bool, error) { + _, err := s.backend.get(backendKey(context, name)) + switch { + case err == nil: + return false, nil + case errors.Is(err, ErrSecretNotFound): + return true, nil + default: + return false, fmt.Errorf("reconcile secret %q: %w", name, err) + } +} + +// persistValue writes a sensitive value to the backend, or removes a stale one +// when an entry becomes non-sensitive. +func (s *localStore) persistValue( + idx *index, meta *SecretMeta, value string, wasSensitive bool, +) error { + key := backendKey(meta.Context, meta.Name) + if meta.Sensitive() || wasSensitive { + if err := s.checkKeySource(idx); err != nil { + return err + } + } + if meta.Sensitive() { + if err := s.backend.set(key, value); err != nil { + return err + } + if s.keySource != "" { + idx.data.KeySource = string(s.keySource) + } + return nil + } + if wasSensitive { + return s.backend.remove(key) + } + return nil +} + +// checkKeySource rejects a key-source change with a clear error instead of a +// downstream decryption failure. +func (s *localStore) checkKeySource(idx *index) error { + if s.keySource == "" || idx.data.KeySource == "" { + return nil + } + if idx.data.KeySource != string(s.keySource) { + return fmt.Errorf( + "secrets were encrypted with the %q key source but the current source is %q; "+ + "restore the original DEVSY_SECRETS_PASSPHRASE (or unset it) or re-create the secrets", + idx.data.KeySource, s.keySource, + ) + } + + return nil +} diff --git a/pkg/secrets/lock.go b/pkg/secrets/lock.go new file mode 100644 index 000000000..680ef400d --- /dev/null +++ b/pkg/secrets/lock.go @@ -0,0 +1,26 @@ +package secrets + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gofrs/flock" +) + +// acquireFlock takes an exclusive cross-process lock on / and returns +// a release func that is always safe to call. +// +// NOT reentrant: each call opens its own flock handle, so taking a lock while +// one is already held on the same path in this process self-deadlocks. +func acquireFlock(dir, name string) (func(), error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("create secrets dir: %w", err) + } + l := flock.New(filepath.Join(dir, name)) + if err := l.Lock(); err != nil { + return nil, fmt.Errorf("lock %s: %w", name, err) + } + + return func() { _ = l.Unlock() }, nil +} diff --git a/pkg/secrets/redact.go b/pkg/secrets/redact.go new file mode 100644 index 000000000..5060bd54e --- /dev/null +++ b/pkg/secrets/redact.go @@ -0,0 +1,34 @@ +package secrets + +import "strings" + +const redactMask = "***" + +type Redactor struct { + replacer *strings.Replacer +} + +// NewRedactor masks the values (not keys) of KEY=VALUE entries; empty values are ignored. +func NewRedactor(secretsEnv []string) *Redactor { + pairs := make([]string, 0, len(secretsEnv)*2) + for _, entry := range secretsEnv { + _, value, ok := strings.Cut(entry, "=") + if !ok || value == "" { + continue + } + pairs = append(pairs, value, redactMask) + } + if len(pairs) == 0 { + return &Redactor{} + } + + return &Redactor{replacer: strings.NewReplacer(pairs...)} +} + +func (r *Redactor) Redact(s string) string { + if r == nil || r.replacer == nil { + return s + } + + return r.replacer.Replace(s) +} diff --git a/pkg/secrets/redact_test.go b/pkg/secrets/redact_test.go new file mode 100644 index 000000000..1a84548f6 --- /dev/null +++ b/pkg/secrets/redact_test.go @@ -0,0 +1,47 @@ +package secrets_test + +import ( + "testing" + + "github.com/devsy-org/devsy/pkg/secrets" +) + +func TestRedactor_MasksValues(t *testing.T) { + r := secrets.NewRedactor([]string{"DB_PASSWORD=hunter2", "TOKEN=abc123"}) + + got := r.Redact("connecting with hunter2 and token abc123") + want := "connecting with *** and token ***" + if got != want { + t.Errorf("Redact = %q, want %q", got, want) + } +} + +func TestRedactor_IgnoresEmptyValues(t *testing.T) { + r := secrets.NewRedactor([]string{"EMPTY=", "NO_EQUALS"}) + + got := r.Redact("nothing to mask here") + if got != "nothing to mask here" { + t.Errorf("Redact = %q, want unchanged", got) + } +} + +func TestRedactor_NilAndEmpty(t *testing.T) { + var r *secrets.Redactor + if got := r.Redact("passthrough"); got != "passthrough" { + t.Errorf("nil Redactor changed input: %q", got) + } + + empty := secrets.NewRedactor(nil) + if got := empty.Redact("passthrough"); got != "passthrough" { + t.Errorf("empty Redactor changed input: %q", got) + } +} + +func TestRedactor_ValueWithEquals(t *testing.T) { + r := secrets.NewRedactor([]string{"CONN=user=admin;pw=secret"}) + + got := r.Redact("dsn: user=admin;pw=secret end") + if got != "dsn: *** end" { + t.Errorf("Redact = %q, want masked full value", got) + } +} diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index 9acc43a07..10c387ddc 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -1,50 +1,21 @@ package secrets import ( - "bufio" + "encoding/json" "fmt" "os" - "strings" ) -// ParseSecretsFile reads a dotenv-style secrets file and returns the key-value -// pairs as a map. Lines starting with # are comments, blank lines are ignored, -// and values are split on the first = only (values may contain =). Whitespace -// around keys is trimmed. func ParseSecretsFile(path string) (map[string]string, error) { - f, err := os.Open(path) // #nosec G304 -- User-specified secrets file path is intentional. + // #nosec G304 -- User-specified secrets file path is intentional. + data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("open secrets file: %w", err) + return nil, fmt.Errorf("read secrets file: %w", err) } - defer func() { _ = f.Close() }() secrets := map[string]string{} - scanner := bufio.NewScanner(f) - lineNum := 0 - for scanner.Scan() { - lineNum++ - line := scanner.Text() - - // Skip blank lines and comments. - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - - key, value, found := strings.Cut(trimmed, "=") - if !found { - return nil, fmt.Errorf("secrets file %s: line %d: missing '=' separator", path, lineNum) - } - - key = strings.TrimSpace(key) - if key == "" { - return nil, fmt.Errorf("secrets file %s: line %d: empty key", path, lineNum) - } - - secrets[key] = value - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read secrets file %s: %w", path, err) + if err := json.Unmarshal(data, &secrets); err != nil { + return nil, fmt.Errorf("parse secrets file %s: %w", path, err) } return secrets, nil diff --git a/pkg/secrets/secrets_test.go b/pkg/secrets/secrets_test.go index bde054450..ec73f40af 100644 --- a/pkg/secrets/secrets_test.go +++ b/pkg/secrets/secrets_test.go @@ -9,13 +9,11 @@ import ( ) func TestParseSecretsFile(t *testing.T) { - content := `# Database credentials -DB_HOST=localhost -DB_PASSWORD=s3cr3t - -# API key -API_KEY=abc123 -` + content := `{ + "DB_HOST": "localhost", + "DB_PASSWORD": "s3cr3t", + "API_KEY": "abc123" +}` path := writeTemp(t, content) got, err := secrets.ParseSecretsFile(path) @@ -36,59 +34,24 @@ API_KEY=abc123 } } -func TestParseSecretsFile_CommentsAndBlankLines(t *testing.T) { - content := ` -# This is a comment - -KEY=value - # indented comment - -OTHER=val -` - path := writeTemp(t, content) - - got, err := secrets.ParseSecretsFile(path) - if err != nil { - t.Fatal(err) - } - if len(got) != 2 { - t.Fatalf("expected 2 entries, got %d", len(got)) - } - if got["KEY"] != "value" { - t.Errorf("KEY = %q, want %q", got["KEY"], "value") - } - if got["OTHER"] != "val" { - t.Errorf("OTHER = %q, want %q", got["OTHER"], "val") - } -} - -func TestParseSecretsFile_ValueWithEquals(t *testing.T) { - content := `CONNECTION_STRING=host=db port=5432 user=admin password=p=ss -TOKEN=abc==def -` +func TestParseSecretsFile_ValueWithSpecialChars(t *testing.T) { + content := `{"CONNECTION_STRING": "host=db port=5432 password=p=ss", "MULTILINE": "line1\nline2"}` path := writeTemp(t, content) got, err := secrets.ParseSecretsFile(path) if err != nil { t.Fatal(err) } - if got["CONNECTION_STRING"] != "host=db port=5432 user=admin password=p=ss" { + if got["CONNECTION_STRING"] != "host=db port=5432 password=p=ss" { t.Errorf("CONNECTION_STRING = %q", got["CONNECTION_STRING"]) } - if got["TOKEN"] != "abc==def" { - t.Errorf("TOKEN = %q, want %q", got["TOKEN"], "abc==def") - } -} - -func TestParseSecretsFile_MissingFile(t *testing.T) { - _, err := secrets.ParseSecretsFile("/nonexistent/path/secrets.env") - if err == nil { - t.Fatal("expected error for missing file") + if got["MULTILINE"] != "line1\nline2" { + t.Errorf("MULTILINE = %q, want two lines", got["MULTILINE"]) } } -func TestParseSecretsFile_EmptyFile(t *testing.T) { - path := writeTemp(t, "") +func TestParseSecretsFile_EmptyObject(t *testing.T) { + path := writeTemp(t, "{}") got, err := secrets.ParseSecretsFile(path) if err != nil { @@ -99,45 +62,25 @@ func TestParseSecretsFile_EmptyFile(t *testing.T) { } } -func TestParseSecretsFile_WhitespaceAroundKey(t *testing.T) { - content := " MY_KEY =some_value\n" - path := writeTemp(t, content) - - got, err := secrets.ParseSecretsFile(path) - if err != nil { - t.Fatal(err) - } - if got["MY_KEY"] != "some_value" { - t.Errorf("MY_KEY = %q, want %q", got["MY_KEY"], "some_value") +func TestParseSecretsFile_MissingFile(t *testing.T) { + _, err := secrets.ParseSecretsFile("/nonexistent/path/secrets.json") + if err == nil { + t.Fatal("expected error for missing file") } } -func TestParseSecretsFile_MissingSeparator(t *testing.T) { - content := "INVALID_LINE\n" - path := writeTemp(t, content) +func TestParseSecretsFile_InvalidJSON(t *testing.T) { + path := writeTemp(t, "{not valid json") _, err := secrets.ParseSecretsFile(path) if err == nil { - t.Fatal("expected error for line without = separator") - } -} - -func TestParseSecretsFile_EmptyValue(t *testing.T) { - content := "EMPTY_VAL=\n" - path := writeTemp(t, content) - - got, err := secrets.ParseSecretsFile(path) - if err != nil { - t.Fatal(err) - } - if got["EMPTY_VAL"] != "" { - t.Errorf("EMPTY_VAL = %q, want empty string", got["EMPTY_VAL"]) + t.Fatal("expected error for invalid JSON") } } func writeTemp(t *testing.T, content string) string { t.Helper() - path := filepath.Join(t.TempDir(), "secrets.env") + path := filepath.Join(t.TempDir(), "secrets.json") if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } diff --git a/pkg/secrets/store.go b/pkg/secrets/store.go new file mode 100644 index 000000000..be432e1c8 --- /dev/null +++ b/pkg/secrets/store.go @@ -0,0 +1,67 @@ +package secrets + +import ( + "errors" + "fmt" + "regexp" + "time" +) + +var ErrSecretNotFound = errors.New("secret not found") + +var namePattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + +func ValidateName(name string) error { + if name == "" { + return errors.New("secret name must not be empty") + } + if !namePattern.MatchString(name) { + return fmt.Errorf( + "invalid secret name %q: only letters, digits, and underscores are allowed", + name, + ) + } + + return nil +} + +// Kind distinguishes secrets (values in the backend) from env vars (inline). +type Kind string + +const ( + KindSecret Kind = "secret" + KindEnv Kind = "env" +) + +type SecretMeta struct { + Name string `json:"name"` + Context string `json:"context"` + Kind Kind `json:"kind"` + Value string `json:"value,omitempty"` + Created time.Time `json:"created"` + LastUsed time.Time `json:"lastUsed,omitzero"` + + Orphaned bool `json:"-"` +} + +// Sensitive reports whether the entry is a secret rather than an env var. +func (m SecretMeta) Sensitive() bool { return m.Kind == KindSecret } + +type Store interface { + Set(context, name, value string, kind Kind) error + Get(context, name string) (string, error) + Meta(context, name string) (SecretMeta, error) + List(context string) ([]SecretMeta, error) + Delete(context, name string) error + Reconcile(context string) ([]SecretMeta, error) +} + +type backend interface { + set(key, value string) error + get(key string) (string, error) // returns ErrSecretNotFound when absent + remove(key string) error // idempotent +} + +func backendKey(context, name string) string { + return context + "/" + name +} diff --git a/pkg/secrets/store_internal_test.go b/pkg/secrets/store_internal_test.go new file mode 100644 index 000000000..3f5e5dce2 --- /dev/null +++ b/pkg/secrets/store_internal_test.go @@ -0,0 +1,540 @@ +package secrets + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "filippo.io/age" + "github.com/devsy-org/devsy/pkg/config" +) + +const testContext = "default" + +func TestResolveBackend_EnvOverridesContextOption(t *testing.T) { + t.Setenv(EnvBackend, "keyring") + cfg := &config.Config{ + DefaultContext: testContext, + Contexts: map[string]*config.ContextConfig{ + testContext: {Options: map[string]config.OptionValue{ + config.ContextOptionSecretsBackend: {Value: "file"}, + }}, + }, + } + if got := resolveBackend(cfg); got != BackendKeyring { + t.Fatalf("env override = %q, want keyring", got) + } +} + +func TestResolveBackend_ContextOption(t *testing.T) { + t.Setenv(EnvBackend, "") + cfg := &config.Config{ + DefaultContext: testContext, + Contexts: map[string]*config.ContextConfig{ + testContext: {Options: map[string]config.OptionValue{ + config.ContextOptionSecretsBackend: {Value: "file"}, + }}, + }, + } + if got := resolveBackend(cfg); got != BackendFile { + t.Fatalf("context option = %q, want file", got) + } +} + +func TestResolveBackend_DefaultsToAuto(t *testing.T) { + t.Setenv(EnvBackend, "") + cfg := &config.Config{ + DefaultContext: testContext, + Contexts: map[string]*config.ContextConfig{testContext: {}}, + } + if got := resolveBackend(cfg); got != BackendAuto { + t.Fatalf("empty preference = %q, want auto", got) + } +} + +func TestResolveBackend_IgnoresGarbageEnv(t *testing.T) { + t.Setenv(EnvBackend, "nonsense") + cfg := &config.Config{ + DefaultContext: testContext, + Contexts: map[string]*config.ContextConfig{ + testContext: {Options: map[string]config.OptionValue{ + config.ContextOptionSecretsBackend: {Value: "keyring"}, + }}, + }, + } + if got := resolveBackend(cfg); got != BackendKeyring { + t.Fatalf("garbage env should fall through to context option, got %q", got) + } +} + +type mapBackend struct { + values map[string]string +} + +func newMapBackend() *mapBackend { return &mapBackend{values: map[string]string{}} } + +func (m *mapBackend) set(key, value string) error { + m.values[key] = value + return nil +} + +func (m *mapBackend) get(key string) (string, error) { + v, ok := m.values[key] + if !ok { + return "", ErrSecretNotFound + } + return v, nil +} + +func (m *mapBackend) remove(key string) error { + delete(m.values, key) + return nil +} + +func newTestStore(t *testing.T, b backend) *localStore { + t.Helper() + return newLocalStore(b, filepath.Join(t.TempDir(), IndexFileName)) +} + +func TestStore_SetGetDelete(t *testing.T) { + s := newTestStore(t, newMapBackend()) + + if err := s.Set("default", "API_KEY", "abc123", KindSecret); err != nil { + t.Fatal(err) + } + + got, err := s.Get("default", "API_KEY") + if err != nil { + t.Fatal(err) + } + if got != "abc123" { + t.Fatalf("Get = %q, want %q", got, "abc123") + } + + if err := s.Delete("default", "API_KEY"); err != nil { + t.Fatal(err) + } + if _, err := s.Get("default", "API_KEY"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("Get after delete = %v, want ErrSecretNotFound", err) + } +} + +func TestStore_GetMissing(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if _, err := s.Get("default", "NOPE"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("Get missing = %v, want ErrSecretNotFound", err) + } +} + +func TestStore_DeleteIdempotent(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Delete("default", "NEVER_EXISTED"); err != nil { + t.Fatalf("Delete of absent secret should be nil, got %v", err) + } +} + +func TestStore_ContextIsolation(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set("ctx-a", "TOKEN", "a", KindSecret); err != nil { + t.Fatal(err) + } + if err := s.Set("ctx-b", "TOKEN", "b", KindSecret); err != nil { + t.Fatal(err) + } + + a, _ := s.Get("ctx-a", "TOKEN") + b, _ := s.Get("ctx-b", "TOKEN") + if a != "a" || b != "b" { + t.Fatalf("context isolation broken: a=%q b=%q", a, b) + } + + list, err := s.List("ctx-a") + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].Name != "TOKEN" { + t.Fatalf("List(ctx-a) = %+v, want single TOKEN", list) + } +} + +func TestStore_ListSorted(t *testing.T) { + s := newTestStore(t, newMapBackend()) + for _, n := range []string{"ZED", "ALPHA", "MIKE"} { + if err := s.Set("default", n, "v", KindSecret); err != nil { + t.Fatal(err) + } + } + + list, err := s.List("default") + if err != nil { + t.Fatal(err) + } + want := []string{"ALPHA", "MIKE", "ZED"} + for i, meta := range list { + if meta.Name != want[i] { + t.Fatalf("List[%d] = %q, want %q", i, meta.Name, want[i]) + } + } +} + +func TestStore_ReconcileFlagsOrphans(t *testing.T) { + mb := newMapBackend() + s := newTestStore(t, mb) + + if err := s.Set("default", "PRESENT", "v", KindSecret); err != nil { + t.Fatal(err) + } + if err := s.Set("default", "GONE", "v", KindSecret); err != nil { + t.Fatal(err) + } + + delete(mb.values, backendKey("default", "GONE")) + + list, err := s.Reconcile("default") + if err != nil { + t.Fatal(err) + } + + byName := map[string]SecretMeta{} + for _, m := range list { + byName[m.Name] = m + } + if byName["PRESENT"].Orphaned { + t.Error("PRESENT should not be orphaned") + } + if !byName["GONE"].Orphaned { + t.Error("GONE should be flagged orphaned") + } +} + +func TestStore_SetUpdatePreservesCreated(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set("default", "K", "v1", KindSecret); err != nil { + t.Fatal(err) + } + first, _ := s.List("default") + created := first[0].Created + + if err := s.Set("default", "K", "v2", KindSecret); err != nil { + t.Fatal(err) + } + second, _ := s.List("default") + if !second[0].Created.Equal(created) { + t.Errorf("Created changed on update: %v != %v", second[0].Created, created) + } + if v, _ := s.Get("default", "K"); v != "v2" { + t.Errorf("value not updated: %q", v) + } +} + +func TestValidateName(t *testing.T) { + valid := []string{"API_KEY", "db_password", "TOKEN2", "a"} + for _, n := range valid { + if err := ValidateName(n); err != nil { + t.Errorf("ValidateName(%q) = %v, want nil", n, err) + } + } + + invalid := []string{"", "with-dash", "with space", "ctx/name", "dollar$", "emoji😀"} + for _, n := range invalid { + if err := ValidateName(n); err == nil { + t.Errorf("ValidateName(%q) = nil, want error", n) + } + } +} + +func TestStore_InvalidNameRejected(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set("default", "bad name", "v", KindSecret); err == nil { + t.Fatal("Set with invalid name should error") + } +} + +func newPassphraseBackend(t *testing.T, path, passphrase string) *fileBackend { + t.Helper() + key, err := resolveFileKey(t.TempDir(), passphrase) + if err != nil { + t.Fatal(err) + } + return newFileBackend(path, key) +} + +func TestFileBackend_RoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), EncryptedFileName) + fb := newPassphraseBackend(t, path, "correct horse battery staple") + + if err := fb.set("default/K", "value"); err != nil { + t.Fatal(err) + } + got, err := fb.get("default/K") + if err != nil { + t.Fatal(err) + } + if got != "value" { + t.Fatalf("get = %q, want %q", got, "value") + } + + wrong := newPassphraseBackend(t, path, "wrong passphrase") + if _, err := wrong.get("default/K"); err == nil { + t.Fatal("get with wrong passphrase should fail") + } + + if err := fb.remove("default/K"); err != nil { + t.Fatal(err) + } + if _, err := fb.get("default/K"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("get after remove = %v, want ErrSecretNotFound", err) + } +} + +func TestResolveFileKey_PassphraseSource(t *testing.T) { + key, err := resolveFileKey(t.TempDir(), "s3cr3t") + if err != nil { + t.Fatal(err) + } + if key.source != keySourcePassphrase { + t.Fatalf("source = %q, want %q", key.source, keySourcePassphrase) + } +} + +func TestResolveAutoKey_CreatesThenReuses(t *testing.T) { + dir := t.TempDir() + store := fileKeyStore{path: filepath.Join(dir, KeyFileName)} + + first, err := resolveAutoKey(dir, store, keySourceAutoFile) + if err != nil { + t.Fatal(err) + } + if _, statErr := os.Stat(store.path); statErr != nil { + t.Fatalf("expected key file to be created: %v", statErr) + } + + second, err := resolveAutoKey(dir, store, keySourceAutoFile) + if err != nil { + t.Fatal(err) + } + + // The same persisted identity must be reused, or previously encrypted + // secrets would become permanently undecryptable. + if identityString(t, first) != identityString(t, second) { + t.Fatal("auto key was regenerated on second resolve; must be stable") + } +} + +func TestFileBackend_AutoKeyRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, EncryptedFileName) + store := fileKeyStore{path: filepath.Join(dir, KeyFileName)} + + key, err := resolveAutoKey(dir, store, keySourceAutoFile) + if err != nil { + t.Fatal(err) + } + if err := newFileBackend(path, key).set("default/K", "auto-value"); err != nil { + t.Fatal(err) + } + + key2, err := resolveAutoKey(dir, store, keySourceAutoFile) + if err != nil { + t.Fatal(err) + } + got, err := newFileBackend(path, key2).get("default/K") + if err != nil { + t.Fatal(err) + } + if got != "auto-value" { + t.Fatalf("get = %q, want %q", got, "auto-value") + } +} + +// An index entry with an unset Kind (e.g. written by an older layout) must be +// read as a secret, never silently downgraded to a plaintext env var. +func TestLoadIndex_UnsetKindNormalizesToSecret(t *testing.T) { + path := filepath.Join(t.TempDir(), IndexFileName) + raw := "contexts:\n default:\n LEGACY:\n name: LEGACY\n context: default\n" + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + + idx, err := loadIndex(path) + if err != nil { + t.Fatal(err) + } + meta, ok := idx.get("default", "LEGACY") + if !ok { + t.Fatal("expected entry to load") + } + if !meta.Sensitive() { + t.Fatalf("unset Kind must normalize to secret, got %q", meta.Kind) + } +} + +// A blank-kind entry carrying an inline value (e.g. hand-edited) must be +// normalized to a secret with the inline plaintext cleared, upholding the +// invariant that sensitive values never persist inline. +func TestLoadIndex_UnsetKindClearsInlineValue(t *testing.T) { + path := filepath.Join(t.TempDir(), IndexFileName) + raw := "contexts:\n default:\n LEGACY:\n name: LEGACY\n" + + " context: default\n value: leaked\n" + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + + idx, err := loadIndex(path) + if err != nil { + t.Fatal(err) + } + meta, _ := idx.get("default", "LEGACY") + if !meta.Sensitive() { + t.Fatalf("unset Kind must normalize to secret, got %q", meta.Kind) + } + if meta.Value != "" { + t.Fatalf("inline value must be cleared on normalize, got %q", meta.Value) + } +} + +// A key file that already exists must never be overwritten, so a second +// initializer that generated its own identity still adopts the persisted one. +func TestFileKeyStore_SaveDoesNotOverwrite(t *testing.T) { + store := fileKeyStore{path: filepath.Join(t.TempDir(), KeyFileName)} + + if err := store.save("first-key"); err != nil { + t.Fatal(err) + } + if err := store.save("second-key"); err != nil { + t.Fatal(err) + } + + got, err := store.load() + if err != nil { + t.Fatal(err) + } + if got != "first-key" { + t.Fatalf("load = %q, want the first persisted key %q", got, "first-key") + } +} + +func identityString(t *testing.T, key *fileKey) string { + t.Helper() + id, ok := key.identity.(*age.X25519Identity) + if !ok { + t.Fatalf("identity is not X25519: %T", key.identity) + } + return id.String() +} + +func TestStore_KeySourceMismatchIsClearError(t *testing.T) { + indexPath := filepath.Join(t.TempDir(), IndexFileName) + + s := newLocalStore(newMapBackend(), indexPath) + s.keySource = keySourceAutoFile + if err := s.Set(testContext, "K", "v", KindSecret); err != nil { + t.Fatal(err) + } + + s2 := newLocalStore(newMapBackend(), indexPath) + s2.keySource = keySourcePassphrase + _, err := s2.Get(testContext, "K") + if err == nil { + t.Fatal("expected key-source mismatch error") + } + if !strings.Contains(err.Error(), "key source") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestStore_NonSensitiveStoredInline(t *testing.T) { + mb := newMapBackend() + s := newTestStore(t, mb) + + if err := s.Set(testContext, "FOO", "bar", KindEnv); err != nil { + t.Fatal(err) + } + + // Non-sensitive values must NOT touch the backend. + if _, ok := mb.values[backendKey(testContext, "FOO")]; ok { + t.Fatal("non-sensitive value must not be written to the backend") + } + + got, err := s.Get(testContext, "FOO") + if err != nil { + t.Fatal(err) + } + if got != "bar" { + t.Fatalf("Get = %q, want %q", got, "bar") + } +} + +func TestStore_NonSensitiveNeverOrphaned(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set(testContext, "FOO", "bar", KindEnv); err != nil { + t.Fatal(err) + } + + list, err := s.List(testContext) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].Orphaned { + t.Fatalf("non-sensitive entry should never be orphaned: %+v", list) + } +} + +func TestStore_DeleteNonSensitive(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set(testContext, "FOO", "bar", KindEnv); err != nil { + t.Fatal(err) + } + if err := s.Delete(testContext, "FOO"); err != nil { + t.Fatal(err) + } + if _, err := s.Get(testContext, "FOO"); !errors.Is(err, ErrSecretNotFound) { + t.Fatalf("Get after delete = %v, want ErrSecretNotFound", err) + } +} + +func TestStore_SensitiveToNonSensitiveClearsBackend(t *testing.T) { + mb := newMapBackend() + s := newTestStore(t, mb) + + if err := s.Set(testContext, "TOKEN", "secret123", KindSecret); err != nil { + t.Fatal(err) + } + if _, ok := mb.values[backendKey(testContext, "TOKEN")]; !ok { + t.Fatal("sensitive value should be in the backend") + } + + // Re-set as non-sensitive: the stale backend value must be removed. + if err := s.Set(testContext, "TOKEN", "plaintext", KindEnv); err != nil { + t.Fatal(err) + } + if _, ok := mb.values[backendKey(testContext, "TOKEN")]; ok { + t.Fatal("stale sensitive value must be removed from the backend") + } + got, err := s.Get(testContext, "TOKEN") + if err != nil { + t.Fatal(err) + } + if got != "plaintext" { + t.Fatalf("Get = %q, want %q", got, "plaintext") + } +} + +func TestStore_MetaHidesValueAndReportsKind(t *testing.T) { + s := newTestStore(t, newMapBackend()) + if err := s.Set(testContext, "ENVVAR", "v", KindEnv); err != nil { + t.Fatal(err) + } + meta, err := s.Meta(testContext, "ENVVAR") + if err != nil { + t.Fatal(err) + } + if meta.Sensitive() { + t.Error("env var should not be sensitive") + } + if meta.Value != "" { + t.Error("Meta must not return the value") + } +} diff --git a/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx new file mode 100644 index 000000000..45301457d --- /dev/null +++ b/sites/docs-devsy-sh/pages/developing-in-workspaces/secrets.mdx @@ -0,0 +1,188 @@ +--- +title: Secrets in a Workspace +sidebar_label: Secrets +--- + +## Storing Secrets + +Devsy can store named secrets locally and inject them into your workspace's +lifecycle commands as environment variables. This lets you keep credentials out +of your `devcontainer.json`, shell history, and dotenv files. + +Secret **values** are stored in your operating system's keyring: + +- **macOS** — Keychain +- **Windows** — Credential Manager +- **Linux** — Secret Service (libsecret / GNOME Keyring / KWallet) + +Only non-sensitive metadata (the secret's name and timestamps) is written to the +Devsy config directory. Secrets are scoped to the active [context](../managing-providers/what-are-providers). + +### Creating a Secret + +``` +devsy secret set DB_PASSWORD +``` + +You will be prompted for the value without echoing it to the terminal. You can +also supply the value non-interactively: + +``` +# From standard input (recommended for scripts) +printf '%s' "$MY_VALUE" | devsy secret set DB_PASSWORD --stdin + +# From a file +devsy secret set TLS_KEY --from-file ./tls.key +``` + +Secret names may contain only letters, digits, and underscores, since they are +used directly as environment-variable names. + +### Listing and Reading Secrets + +``` +devsy secret list +devsy secret get DB_PASSWORD +``` + +`list` never prints values. A secret whose value has been removed from the +keyring out-of-band is shown with the `orphaned` status. + +### Deleting a Secret + +``` +devsy secret delete DB_PASSWORD +``` + +## Using Secrets in a Workspace + +### Per Workspace + +Reference a stored secret by name when bringing up a workspace with `--secret` +(repeatable). By default the secret is injected as an environment variable into +lifecycle commands: + +``` +devsy workspace up https://github.com/example/repo --secret DB_PASSWORD +``` + +Each `--secret` accepts options as `NAME[,type=env|mount][,target=X]`: + +- `type=env` (default) sets an environment variable; `target` overrides the + variable name. +- `type=mount` writes the value to a file at `/run/secrets/` on an + in-memory filesystem, matching the Docker/Podman convention. `target` + defaults to `NAME`. On Kubernetes this uses an in-memory `emptyDir`; providers + that cannot mount an in-memory filesystem reject `type=mount` with a clear + error, so use `type=env` there. + +``` +# Inject under a different environment-variable name +devsy workspace up ... --secret DB_PASSWORD,target=DATABASE_PASSWORD + +# Mount as a file at /run/secrets/tls.key +devsy workspace up ... --secret TLS_KEY,type=mount,target=tls.key +``` + +### For all Workspaces + +Bind a secret to the active context so it is injected automatically on every +`up`, without repeating `--secret`: + +``` +devsy secret attach DB_PASSWORD +devsy secret detach DB_PASSWORD +``` + +Bound secret names are stored in your context configuration; deleting a context +also deletes its secrets from the keyring. + +If a requested or bound secret cannot be found when a workspace is created, the +`up` fails rather than silently continuing without it. + +## How Secrets Are Protected + +Injected secret **values** are masked (`***`) in lifecycle-hook log output, so a +secret echoed by a `postCreateCommand` will not appear in the workspace logs. +Secret values are delivered to the workspace over Devsy's encrypted agent +tunnel, not embedded in any command line, so they are not exposed in process +listings on the host or inside the container. + +Secrets delivered with `type=mount` are written to an in-memory `tmpfs` at +`/run/secrets`, so they are never persisted to a container image layer or to +disk. + +## Choosing a Storage Backend + +Devsy supports two backends for secret values: + +- `keyring` — the OS keyring (Keychain / Credential Manager / libsecret). +- `file` — an [age](https://age-encryption.org)-encrypted file + (`secrets.enc`) stored alongside the Devsy config. + +By default the backend is `auto`: Devsy uses the keyring when one is available +and falls back to the encrypted file otherwise. + +The `file` backend works with no configuration: Devsy generates and manages an +encryption key for you (stored in the OS keyring when available, otherwise in +`secrets.key` next to `secrets.enc`, mode `0600`). This protects secrets from +accidental disclosure and casual inspection, but a key stored in `secrets.key` +offers no protection against someone who can read your Devsy config directory +(they have both the key and the ciphertext). For stronger at-rest protection — +for example against a stolen backup — set a passphrase (see below), which is +never written to disk. + +Set a persistent preference per context with the `SECRETS_BACKEND` option: + +``` +devsy context set -o SECRETS_BACKEND=file +``` + +Allowed values are `auto`, `keyring`, and `file`. To override the preference for +a single command, set the `DEVSY_SECRETS_BACKEND` environment variable, which +takes precedence over the context option. + +## Headless and CI Environments + +When the `file` backend is used (either by preference or because no OS keyring +is available, for example on headless Linux, containers, or CI), Devsy uses an +auto-generated key by default, so no setup is required. For at-rest protection +against a stolen backup or disk, set a passphrase — the encryption key is then +derived from it and never stored: + +``` +export DEVSY_SECRETS_PASSPHRASE="…" +devsy secret set DEPLOY_TOKEN --stdin < token.txt +``` + +The key source (auto-generated vs. passphrase) is recorded with your secrets. If +you later change it — for example by setting a passphrase for secrets that were +stored with an auto-generated key — Devsy reports a clear error rather than +failing to decrypt. Restore the original setting, or re-create the secrets under +the new one. + +## Managed Environment Variables + +For non-sensitive configuration you can store managed environment variables with +`devsy env`. They share the same per-context store as secrets, but their values +are kept in plaintext in the Devsy config directory (no keyring, no encryption) +and are freely readable: + +``` +devsy env set LOG_LEVEL=debug +devsy env set REGION --value us-east-1 +devsy env list +devsy env get LOG_LEVEL +devsy env delete LOG_LEVEL +``` + +Inject them into a workspace with `--env` (repeatable), optionally remapping the +variable name with `NAME=TARGET`: + +``` +devsy workspace up ... --env LOG_LEVEL --env REGION=AWS_REGION +``` + +Use `devsy secret` (not `devsy env`) for anything sensitive: secrets are stored +in the OS keyring or an encrypted file, masked in logs, and delivered over the +agent tunnel. diff --git a/sites/docs-devsy-sh/sidebars.js b/sites/docs-devsy-sh/sidebars.js index a39a69540..2bea10aae 100644 --- a/sites/docs-devsy-sh/sidebars.js +++ b/sites/docs-devsy-sh/sidebars.js @@ -65,6 +65,10 @@ module.exports = { type: "doc", id: "developing-in-workspaces/credentials", }, + { + type: "doc", + id: "developing-in-workspaces/secrets", + }, { type: "doc", id: "developing-in-workspaces/inactivity-timeout",