mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 03:59:56 +00:00
442 lines
13 KiB
Go
442 lines
13 KiB
Go
// Package admincmd provides reusable cobra commands for self-hosted administrator helpers.
|
|
// Both the management and combined binaries use these commands, each providing
|
|
// their own opener to handle config loading and storage initialization.
|
|
package admincmd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/dexidp/dex/storage"
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
nbdex "github.com/netbirdio/netbird/idp/dex"
|
|
"github.com/netbirdio/netbird/management/server"
|
|
"github.com/netbirdio/netbird/management/server/idp"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
)
|
|
|
|
const (
|
|
localConnectorID = "local"
|
|
dashboardClientID = "netbird-dashboard"
|
|
cliClientID = "netbird-cli"
|
|
defaultTOTPAuthenticatorID = "default-totp"
|
|
)
|
|
|
|
// Resources contains the storages required by the admin commands.
|
|
type Resources struct {
|
|
Store store.Store
|
|
IDPStorage storage.Storage
|
|
}
|
|
|
|
// Opener initializes command resources from the command context and calls fn.
|
|
type Opener func(cmd *cobra.Command, fn func(ctx context.Context, resources Resources) error) error
|
|
|
|
type userSelector struct {
|
|
email string
|
|
userID string
|
|
}
|
|
|
|
func (s userSelector) normalized() userSelector {
|
|
return userSelector{
|
|
email: strings.TrimSpace(s.email),
|
|
userID: strings.TrimSpace(s.userID),
|
|
}
|
|
}
|
|
|
|
func (s userSelector) validate() error {
|
|
s = s.normalized()
|
|
if (s.email == "") == (s.userID == "") {
|
|
return fmt.Errorf("provide exactly one of --email or --user-id")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewCommands creates the admin command tree with the given resource opener.
|
|
func NewCommands(opener Opener) *cobra.Command {
|
|
adminCmd := &cobra.Command{
|
|
Use: "admin",
|
|
Short: "Self-hosted administrator helpers",
|
|
Long: "Administrative helpers for self-hosted deployments using the embedded identity provider.",
|
|
}
|
|
|
|
userCmd := &cobra.Command{
|
|
Use: "user",
|
|
Short: "Manage local embedded IdP users",
|
|
}
|
|
|
|
var passwordSelector userSelector
|
|
var password string
|
|
var passwordFile string
|
|
passwordCmd := &cobra.Command{
|
|
Use: "change-password (--email email | --user-id id) (--password password | --password-file path)",
|
|
Aliases: []string{"set-password"},
|
|
Short: "Change a local user's password",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
newPassword, err := resolvePasswordInput(cmd, password, passwordFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return opener(cmd, func(ctx context.Context, resources Resources) error {
|
|
return runChangePassword(ctx, resources.IDPStorage, cmd.OutOrStdout(), passwordSelector, newPassword)
|
|
})
|
|
},
|
|
}
|
|
addUserSelectorFlags(passwordCmd, &passwordSelector)
|
|
passwordCmd.Flags().StringVar(&password, "password", "", "New password for the user")
|
|
passwordCmd.Flags().StringVar(&passwordFile, "password-file", "", "Read new password from file ('-' for stdin)")
|
|
|
|
var resetSelector userSelector
|
|
resetMFACmd := &cobra.Command{
|
|
Use: "reset-mfa (--email email | --user-id id)",
|
|
Short: "Reset a local user's MFA enrollment",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return opener(cmd, func(ctx context.Context, resources Resources) error {
|
|
return runResetMFA(ctx, resources.IDPStorage, cmd.OutOrStdout(), resetSelector)
|
|
})
|
|
},
|
|
}
|
|
addUserSelectorFlags(resetMFACmd, &resetSelector)
|
|
|
|
userCmd.AddCommand(passwordCmd, resetMFACmd)
|
|
|
|
mfaCmd := &cobra.Command{
|
|
Use: "mfa",
|
|
Short: "Manage local MFA for embedded IdP users",
|
|
}
|
|
|
|
enableCmd := &cobra.Command{
|
|
Use: "enable",
|
|
Short: "Enable MFA for local embedded IdP users",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return opener(cmd, func(ctx context.Context, resources Resources) error {
|
|
return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), true)
|
|
})
|
|
},
|
|
}
|
|
|
|
disableCmd := &cobra.Command{
|
|
Use: "disable",
|
|
Short: "Disable MFA for local embedded IdP users",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return opener(cmd, func(ctx context.Context, resources Resources) error {
|
|
return runSetMFAEnabled(ctx, resources, cmd.OutOrStdout(), false)
|
|
})
|
|
},
|
|
}
|
|
|
|
statusCmd := &cobra.Command{
|
|
Use: "status",
|
|
Short: "Show local MFA status",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
return opener(cmd, func(ctx context.Context, resources Resources) error {
|
|
return runMFAStatus(ctx, resources, cmd.OutOrStdout())
|
|
})
|
|
},
|
|
}
|
|
|
|
mfaCmd.AddCommand(enableCmd, disableCmd, statusCmd)
|
|
adminCmd.AddCommand(userCmd, mfaCmd)
|
|
return adminCmd
|
|
}
|
|
|
|
// OpenEmbeddedIDPStorage opens the Dex storage configured for the embedded IdP.
|
|
func OpenEmbeddedIDPStorage(cfg *idp.EmbeddedIdPConfig) (storage.Storage, error) {
|
|
if cfg == nil || !cfg.Enabled {
|
|
return nil, fmt.Errorf("admin commands require the embedded IdP to be enabled")
|
|
}
|
|
|
|
yamlConfig, err := cfg.ToYAMLConfig()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build embedded IdP config: %w", err)
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
st, err := yamlConfig.Storage.OpenStorage(logger)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open embedded IdP storage: %w", err)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
func addUserSelectorFlags(cmd *cobra.Command, selector *userSelector) {
|
|
cmd.Flags().StringVar(&selector.email, "email", "", "User email")
|
|
cmd.Flags().StringVar(&selector.userID, "user-id", "", "User ID")
|
|
}
|
|
|
|
func resolvePasswordInput(cmd *cobra.Command, password, passwordFile string) (string, error) {
|
|
if password != "" && passwordFile != "" {
|
|
return "", fmt.Errorf("provide only one of --password or --password-file")
|
|
}
|
|
if passwordFile == "" {
|
|
return password, nil
|
|
}
|
|
|
|
var data []byte
|
|
var err error
|
|
if passwordFile == "-" {
|
|
data, err = io.ReadAll(cmd.InOrStdin())
|
|
} else {
|
|
data, err = os.ReadFile(passwordFile)
|
|
}
|
|
if err != nil {
|
|
return "", fmt.Errorf("read password: %w", err)
|
|
}
|
|
return strings.TrimRight(string(data), "\r\n"), nil
|
|
}
|
|
|
|
func runChangePassword(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector, password string) error {
|
|
if idpStorage == nil {
|
|
return fmt.Errorf("embedded IdP storage is required")
|
|
}
|
|
selector = selector.normalized()
|
|
if err := selector.validate(); err != nil {
|
|
return err
|
|
}
|
|
if password == "" {
|
|
return fmt.Errorf("password is required")
|
|
}
|
|
if err := server.ValidatePassword(password); err != nil {
|
|
return fmt.Errorf("invalid password: %w", err)
|
|
}
|
|
|
|
user, err := findLocalUser(ctx, idpStorage, selector)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
if err := idpStorage.UpdatePassword(ctx, user.Email, func(old storage.Password) (storage.Password, error) {
|
|
old.Hash = hash
|
|
return old, nil
|
|
}); err != nil {
|
|
return fmt.Errorf("update password for %s: %w", user.Email, err)
|
|
}
|
|
|
|
if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(w, "Password updated for %s.\n", user.Email)
|
|
return nil
|
|
}
|
|
|
|
func runResetMFA(ctx context.Context, idpStorage storage.Storage, w io.Writer, selector userSelector) error {
|
|
if idpStorage == nil {
|
|
return fmt.Errorf("embedded IdP storage is required")
|
|
}
|
|
selector = selector.normalized()
|
|
if err := selector.validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
user, err := findLocalUser(ctx, idpStorage, selector)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
reset := false
|
|
err = idpStorage.UpdateUserIdentity(ctx, user.UserID, localConnectorID, func(old storage.UserIdentity) (storage.UserIdentity, error) {
|
|
reset = reset || len(old.MFASecrets) > 0 || len(old.WebAuthnCredentials) > 0
|
|
old.MFASecrets = map[string]*storage.MFASecret{}
|
|
old.WebAuthnCredentials = map[string][]storage.WebAuthnCredential{}
|
|
return old, nil
|
|
})
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
|
|
return err
|
|
}
|
|
_, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email)
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("reset MFA for %s: %w", user.Email, err)
|
|
}
|
|
|
|
if err := deleteLocalAuthSession(ctx, idpStorage, user.UserID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if reset {
|
|
_, _ = fmt.Fprintf(w, "MFA reset for %s. The user will re-enroll at next login.\n", user.Email)
|
|
} else {
|
|
_, _ = fmt.Fprintf(w, "No MFA enrollment found for %s.\n", user.Email)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runSetMFAEnabled(ctx context.Context, resources Resources, w io.Writer, enabled bool) error {
|
|
if resources.Store == nil {
|
|
return fmt.Errorf("management store is required")
|
|
}
|
|
if resources.IDPStorage == nil {
|
|
return fmt.Errorf("embedded IdP storage is required")
|
|
}
|
|
|
|
accounts := resources.Store.GetAllAccounts(ctx)
|
|
if len(accounts) != 1 {
|
|
return fmt.Errorf("expected exactly one account, got %d; local MFA is supported only in single-account embedded IdP deployments", len(accounts))
|
|
}
|
|
|
|
settings := &types.Settings{}
|
|
if accounts[0].Settings != nil {
|
|
settings = accounts[0].Settings.Copy()
|
|
}
|
|
settings.LocalMfaEnabled = enabled
|
|
if err := resources.Store.SaveAccountSettings(ctx, accounts[0].Id, settings); err != nil {
|
|
return fmt.Errorf("save local MFA account setting: %w", err)
|
|
}
|
|
|
|
if err := setIDPClientsMFA(ctx, resources.IDPStorage, enabled); err != nil {
|
|
return err
|
|
}
|
|
|
|
state := "disabled"
|
|
if enabled {
|
|
state = "enabled"
|
|
}
|
|
_, _ = fmt.Fprintf(w, "Local MFA %s.\n", state)
|
|
return nil
|
|
}
|
|
|
|
func runMFAStatus(ctx context.Context, resources Resources, w io.Writer) error {
|
|
if resources.Store == nil {
|
|
return fmt.Errorf("management store is required")
|
|
}
|
|
if resources.IDPStorage == nil {
|
|
return fmt.Errorf("embedded IdP storage is required")
|
|
}
|
|
|
|
accounts := resources.Store.GetAllAccounts(ctx)
|
|
accountStatus := "unknown"
|
|
if len(accounts) == 1 && accounts[0].Settings != nil {
|
|
accountStatus = "disabled"
|
|
if accounts[0].Settings.LocalMfaEnabled {
|
|
accountStatus = "enabled"
|
|
}
|
|
}
|
|
|
|
clientStatus, err := idpClientsMFAStatus(ctx, resources.IDPStorage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(w, "Account setting: %s\n", accountStatus)
|
|
_, _ = fmt.Fprintf(w, "Embedded IdP clients: %s\n", clientStatus)
|
|
return nil
|
|
}
|
|
|
|
func findLocalUser(ctx context.Context, idpStorage storage.Storage, selector userSelector) (storage.Password, error) {
|
|
selector = selector.normalized()
|
|
if err := selector.validate(); err != nil {
|
|
return storage.Password{}, err
|
|
}
|
|
|
|
if selector.email != "" {
|
|
user, err := idpStorage.GetPassword(ctx, selector.email)
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
return storage.Password{}, fmt.Errorf("local user with email %q not found", selector.email)
|
|
}
|
|
if err != nil {
|
|
return storage.Password{}, fmt.Errorf("get local user by email %q: %w", selector.email, err)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
rawUserID := selector.userID
|
|
if decodedUserID, _, err := nbdex.DecodeDexUserID(selector.userID); err == nil && decodedUserID != "" {
|
|
rawUserID = decodedUserID
|
|
}
|
|
|
|
users, err := idpStorage.ListPasswords(ctx)
|
|
if err != nil {
|
|
return storage.Password{}, fmt.Errorf("list local users: %w", err)
|
|
}
|
|
for _, user := range users {
|
|
if user.UserID == rawUserID || user.UserID == selector.userID {
|
|
return user, nil
|
|
}
|
|
}
|
|
|
|
return storage.Password{}, fmt.Errorf("local user with ID %q not found", selector.userID)
|
|
}
|
|
|
|
func deleteLocalAuthSession(ctx context.Context, idpStorage storage.Storage, userID string) error {
|
|
err := idpStorage.DeleteAuthSession(ctx, userID, localConnectorID)
|
|
if err == nil || errors.Is(err, storage.ErrNotFound) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("delete local auth session for user %s: %w", userID, err)
|
|
}
|
|
|
|
func setIDPClientsMFA(ctx context.Context, idpStorage storage.Storage, enabled bool) error {
|
|
var mfaChain []string
|
|
if enabled {
|
|
mfaChain = []string{defaultTOTPAuthenticatorID}
|
|
}
|
|
|
|
for _, clientID := range []string{cliClientID, dashboardClientID} {
|
|
if err := idpStorage.UpdateClient(ctx, clientID, func(old storage.Client) (storage.Client, error) {
|
|
old.MFAChain = mfaChain
|
|
return old, nil
|
|
}); err != nil {
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
return fmt.Errorf("embedded IdP client %q not found; start the management server once before toggling MFA", clientID)
|
|
}
|
|
return fmt.Errorf("update MFA chain on embedded IdP client %q: %w", clientID, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func idpClientsMFAStatus(ctx context.Context, idpStorage storage.Storage) (string, error) {
|
|
clientIDs := []string{cliClientID, dashboardClientID}
|
|
enabledCount := 0
|
|
for _, clientID := range clientIDs {
|
|
client, err := idpStorage.GetClient(ctx, clientID)
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
return "unknown", fmt.Errorf("embedded IdP client %q not found", clientID)
|
|
}
|
|
if err != nil {
|
|
return "unknown", fmt.Errorf("get embedded IdP client %q: %w", clientID, err)
|
|
}
|
|
if hasAuthenticator(client.MFAChain, defaultTOTPAuthenticatorID) {
|
|
enabledCount++
|
|
}
|
|
}
|
|
|
|
switch enabledCount {
|
|
case 0:
|
|
return "disabled", nil
|
|
case len(clientIDs):
|
|
return "enabled", nil
|
|
default:
|
|
return "partially enabled", nil
|
|
}
|
|
}
|
|
|
|
func hasAuthenticator(chain []string, authenticatorID string) bool {
|
|
for _, id := range chain {
|
|
if id == authenticatorID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|