Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ D8 provides comprehensive cluster management capabilities:
| [**backup**](internal/backup/) | Backup operations | ETCD snapshots, configuration backups, data export |
| [**mirror**](internal/mirror/) | Module mirroring | Registry operations, image synchronization, air-gapped deployments |
| [**system**](internal/system/) | System diagnostics | Debug info collection, logs analysis, troubleshooting |
| **user-operation** | Local user operations | Request `UserOperation` in `user-authn` (ResetPassword/Reset2FA/Lock/Unlock) |

### 🚀 Module Management

Expand Down Expand Up @@ -140,6 +141,27 @@ d8 --version

---

## 🧰 User operations (user-authn)

Request local user operations for Dex static users via `UserOperation` custom resources.

```bash
# Reset user's 2FA (TOTP)
d8 user-operation reset2fa test-user --timeout 5m

# Lock user for 10 minutes
d8 user-operation lock test-user --for 10m --timeout 5m

# Unlock user
d8 user-operation unlock test-user --timeout 5m

# Reset password (bcrypt hash is required)
HASH="$(echo -n 'Test12345!' | htpasswd -BinC 10 \"\" | cut -d: -f2 | tr -d '\n')"
d8 user-operation reset-password test-user --bcrypt-hash "$HASH" --timeout 5m
```

---

## 🤝 Contributing

We welcome contributions! Here's how you can help:
Expand Down
2 changes: 2 additions & 0 deletions cmd/d8/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
status "github.com/deckhouse/deckhouse-cli/internal/status/cmd"
system "github.com/deckhouse/deckhouse-cli/internal/system/cmd"
"github.com/deckhouse/deckhouse-cli/internal/tools"
useroperation "github.com/deckhouse/deckhouse-cli/internal/useroperation/cmd"
"github.com/deckhouse/deckhouse-cli/internal/version"
)

Expand Down Expand Up @@ -104,6 +105,7 @@ func (r *RootCommand) registerCommands() {
r.cmd.AddCommand(data.NewCommand())
r.cmd.AddCommand(mirror.NewCommand())
r.cmd.AddCommand(status.NewCommand())
r.cmd.AddCommand(useroperation.NewCommand())
r.cmd.AddCommand(tools.NewCommand())
r.cmd.AddCommand(commands.NewVirtualizationCommand())
r.cmd.AddCommand(commands.NewKubectlCommand())
Expand Down
87 changes: 87 additions & 0 deletions internal/useroperation/cmd/lock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package useroperation

import (
"fmt"
"time"

"github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func newLockCommand() *cobra.Command {
var forStr string

cmd := &cobra.Command{
Use: "lock <username> --for 10m",
Short: "Lock local user in Dex for a period of time",
Args: cobra.ExactArgs(1),
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
username := args[0]
if forStr == "" {
return fmt.Errorf("--for is required")
}
// Validate duration format (must be parseable by time.ParseDuration; supports s/m/h).
if _, err := time.ParseDuration(forStr); err != nil {
return fmt.Errorf("invalid --for duration %q: %w", forStr, err)
}

wf, err := getWaitFlags(cmd)
if err != nil {
return err
}

dyn, err := newDynamicClient(cmd)
if err != nil {
return err
}

name := fmt.Sprintf("op-lock-%d", time.Now().Unix())
obj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "deckhouse.io/v1",
"kind": "UserOperation",
"metadata": map[string]any{
"name": name,
},
"spec": map[string]any{
"user": username,
"type": "Lock",
"initiatorType": "admin",
"lock": map[string]any{
"for": forStr,
},
},
},
}

_, err = createUserOperation(cmd.Context(), dyn, obj)
if err != nil {
return fmt.Errorf("create UserOperation: %w", err)
}

if !wf.wait {
cmd.Printf("%s\n", name)
return nil
}

result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout)
if err != nil {
return fmt.Errorf("wait UserOperation: %w", err)
}

phase, _, _ := unstructured.NestedString(result.Object, "status", "phase")
message, _, _ := unstructured.NestedString(result.Object, "status", "message")
if phase == "Failed" {
return fmt.Errorf("Lock failed: %s", message)
}
cmd.Printf("Succeeded: %s\n", name)
return nil
},
}

cmd.Flags().StringVar(&forStr, "for", "", "Lock duration (e.g. 30s, 10m, 1h).")
addWaitFlags(cmd, waitFlags{wait: true, timeout: 5 * time.Minute})
return cmd
}
73 changes: 73 additions & 0 deletions internal/useroperation/cmd/reset2fa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package useroperation

import (
"fmt"
"time"

"github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func newReset2FACommand() *cobra.Command {
cmd := &cobra.Command{
Use: "reset2fa <username>",
Short: "Reset local user's 2FA (TOTP) in Dex",
Args: cobra.ExactArgs(1),
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
username := args[0]
wf, err := getWaitFlags(cmd)
if err != nil {
return err
}

dyn, err := newDynamicClient(cmd)
if err != nil {
return err
}

name := fmt.Sprintf("op-reset2fa-%d", time.Now().Unix())
obj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "deckhouse.io/v1",
"kind": "UserOperation",
"metadata": map[string]any{
"name": name,
},
"spec": map[string]any{
"user": username,
"type": "Reset2FA",
"initiatorType": "admin",
},
},
}

_, err = createUserOperation(cmd.Context(), dyn, obj)
if err != nil {
return fmt.Errorf("create UserOperation: %w", err)
}

if !wf.wait {
cmd.Printf("%s\n", name)
return nil
}

result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout)
if err != nil {
return fmt.Errorf("wait UserOperation: %w", err)
}

phase, _, _ := unstructured.NestedString(result.Object, "status", "phase")
message, _, _ := unstructured.NestedString(result.Object, "status", "message")
if phase == "Failed" {
return fmt.Errorf("Reset2FA failed: %s", message)
}
cmd.Printf("Succeeded: %s\n", name)
return nil
},
}

addWaitFlags(cmd, waitFlags{wait: true, timeout: 5 * time.Minute})
return cmd
}
83 changes: 83 additions & 0 deletions internal/useroperation/cmd/reset_password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package useroperation

import (
"fmt"
"time"

"github.com/spf13/cobra"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

func newResetPasswordCommand() *cobra.Command {
var bcryptHash string

cmd := &cobra.Command{
Use: "reset-password <username> --bcrypt-hash '<hash>'",
Aliases: []string{"resetpass"},
Short: "Reset local user's password in Dex (requires bcrypt hash)",
Args: cobra.ExactArgs(1),
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
username := args[0]
if bcryptHash == "" {
return fmt.Errorf("--bcrypt-hash is required")
}
wf, err := getWaitFlags(cmd)
if err != nil {
return err
}

dyn, err := newDynamicClient(cmd)
if err != nil {
return err
}

name := fmt.Sprintf("op-resetpass-%d", time.Now().Unix())
obj := &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "deckhouse.io/v1",
"kind": "UserOperation",
"metadata": map[string]any{
"name": name,
},
"spec": map[string]any{
"user": username,
"type": "ResetPassword",
"initiatorType": "admin",
"resetPassword": map[string]any{
"newPasswordHash": bcryptHash,
},
},
},
}

_, err = createUserOperation(cmd.Context(), dyn, obj)
if err != nil {
return fmt.Errorf("create UserOperation: %w", err)
}

if !wf.wait {
cmd.Printf("%s\n", name)
return nil
}

result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout)
if err != nil {
return fmt.Errorf("wait UserOperation: %w", err)
}

phase, _, _ := unstructured.NestedString(result.Object, "status", "phase")
message, _, _ := unstructured.NestedString(result.Object, "status", "message")
if phase == "Failed" {
return fmt.Errorf("ResetPassword failed: %s", message)
}
cmd.Printf("Succeeded: %s\n", name)
return nil
},
}

cmd.Flags().StringVar(&bcryptHash, "bcrypt-hash", "", "Bcrypt hash of the new password (as produced by htpasswd -BinC 10).")
addWaitFlags(cmd, waitFlags{wait: true, timeout: 5 * time.Minute})
return cmd
}
101 changes: 101 additions & 0 deletions internal/useroperation/cmd/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package useroperation

import (
"context"
"fmt"
"time"

"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/dynamic"

"github.com/deckhouse/deckhouse-cli/internal/utilk8s"
)

var userOperationGVR = schema.GroupVersionResource{
Group: "deckhouse.io",
Version: "v1",
Resource: "useroperations",
}

type waitFlags struct {
wait bool
timeout time.Duration
}

func addWaitFlags(cmd *cobra.Command, defaults waitFlags) {
cmd.Flags().Bool("wait", defaults.wait, "Wait for UserOperation completion and print result.")
cmd.Flags().Duration("timeout", defaults.timeout, "How long to wait for completion when --wait is enabled.")
}

func getWaitFlags(cmd *cobra.Command) (waitFlags, error) {
waitVal, err := cmd.Flags().GetBool("wait")
if err != nil {
return waitFlags{}, err
}
timeoutVal, err := cmd.Flags().GetDuration("timeout")
if err != nil {
return waitFlags{}, err
}
return waitFlags{wait: waitVal, timeout: timeoutVal}, nil
}

func getStringFlag(cmd *cobra.Command, name string) (string, error) {
if cmd.Flags().Lookup(name) != nil {
return cmd.Flags().GetString(name)
}
if cmd.InheritedFlags().Lookup(name) != nil {
return cmd.InheritedFlags().GetString(name)
}
return "", fmt.Errorf("flag %q not found", name)
}

func newDynamicClient(cmd *cobra.Command) (dynamic.Interface, error) {
kubeconfigPath, err := getStringFlag(cmd, "kubeconfig")
if err != nil {
return nil, fmt.Errorf("failed to get kubeconfig: %w", err)
}
contextName, err := getStringFlag(cmd, "context")
if err != nil {
return nil, fmt.Errorf("failed to get context: %w", err)
}
restConfig, _, err := utilk8s.SetupK8sClientSet(kubeconfigPath, contextName)
if err != nil {
return nil, fmt.Errorf("failed to setup Kubernetes client: %w", err)
}
dyn, err := dynamic.NewForConfig(restConfig)
if err != nil {
return nil, fmt.Errorf("failed to create dynamic client: %w", err)
}
return dyn, nil
}

func createUserOperation(ctx context.Context, dyn dynamic.Interface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
return dyn.Resource(userOperationGVR).Create(ctx, obj, metav1.CreateOptions{})
}

func waitUserOperation(ctx context.Context, dyn dynamic.Interface, name string, timeout time.Duration) (*unstructured.Unstructured, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

var last *unstructured.Unstructured
err := wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) {
obj, err := dyn.Resource(userOperationGVR).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return false, err
}
last = obj
phase, found, _ := unstructured.NestedString(obj.Object, "status", "phase")
if !found || phase == "" {
return false, nil
}
return true, nil
})
if err != nil {
return last, err
}
return last, nil
}
Loading