mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-24 01:11:29 +02:00
## Summary Adds a unified `admin` CLI for self-hosted instance administrators in both the management and combined binaries. ## User Management ### `admin user change-password` - Changes a local embedded IdP user's password. - Selects the user with `--email` or `--user-id`. - Reads the new password from `--password` or `--password-file`. - Clears the user's local authentication session so the new password is required on the next login. - **Alias:** `admin user set-password`. ### `admin user reset-mfa` - Resets a local embedded IdP user's MFA enrollment. - Selects the user with `--email` or `--user-id`. - Clears TOTP/WebAuthn enrollment data and removes the local authentication session. - The user will re-enroll MFA on the next login. ## MFA Management ### `admin mfa status` - Shows whether local MFA is enabled in the account settings. - Checks the embedded IdP client configuration and reports whether MFA is enabled there. ### `admin mfa enable` - Enables local MFA for embedded IdP users. - Updates embedded IdP clients and saves the account MFA setting. - Records an audit event on a best-effort basis. ### `admin mfa disable` - Disables local MFA for embedded IdP users. - Updates embedded IdP clients and saves the account MFA setting. - Records an audit event on a best-effort basis. ## Reverse Proxy Tokens ### `admin token create --name <name> [--expires-in <duration>]` - Creates a reverse proxy access token. - Prints the plaintext token once, along with the token ID. - `--expires-in` supports values such as `24h`, `30d`, or `365d`. If omitted, the token never expires. ### `admin token list` - Lists reverse proxy access tokens. - Shows the token ID, name, creation date, expiration, last-used time, and revocation status. - **Alias:** `admin token ls`. ### `admin token revoke <token-id>` - Revokes a reverse proxy access token. - Revoked tokens can no longer authenticate reverse proxy instances. ## Reverse Proxy Management ### `admin proxy disconnect-all` - Lists registered reverse proxy instances and force-marks all connected instances as disconnected. - Useful for repairing stale proxy state after an unclean management server shutdown. - Prompts for confirmation by default. - `--dry-run` previews the changes without applying them. - `--force` skips the confirmation prompt. - Live proxies may appear again after their next heartbeat, reconnect, or re-registration. ## Compatibility Commands ### `token ...` - Deprecated top-level compatibility path. - Behaves the same as `admin token ...`. - Retained so existing scripts using `token create`, `token list`, or `token revoke` continue to work. ## Changes - Adds reusable `management/cmd/admin` command package. - Wires `admin` into `netbird-mgmt` and `combined`. - Adds local user password reset with existing password strength validation. - Adds local MFA enrollment reset by clearing Dex TOTP/WebAuthn credentials and local auth sessions. - Adds local MFA enable/disable/status helpers for embedded IdP deployments. - Moves proxy access token commands under `admin token` for a single admin-focused CLI entry point. - Exports `server.ValidatePassword` for reuse by CLI helpers. ## Tests ```bash go test ./management/cmd/... go test ./management/cmd/admin ./management/cmd ./combined/cmd go test ./management/server -run TestValidatePassword ``` Pre-push lint also passed. ### Checklist - [ ] Is it a bug fix - [ ] Is a typo/documentation fix - [x] Is a feature enhancement - [ ] It is a refactor - [ ] Created tests that fail without the change (if possible) - [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [x] I added/updated documentation for this change - [ ] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/832 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added self-hosted admin CLI commands for changing passwords, resetting MFA (including WebAuthn), and managing embedded IdP client MFA (enable/disable/status). * Introduced a unified admin command entry point and improved data-directory handling for embedded IdP storage. * **Refactor** * Centralized password strength validation into a shared exported validator. * **Tests** * Added a comprehensive admin command test suite covering password input, selectors, MFA reset, and client MFA state handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
181 lines
5.4 KiB
Go
181 lines
5.4 KiB
Go
package proxycmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
rpproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) store.Store {
|
|
t.Helper()
|
|
|
|
s, cleanup, err := store.NewTestStoreFromSQL(context.Background(), "", t.TempDir())
|
|
require.NoError(t, err)
|
|
t.Cleanup(cleanup)
|
|
|
|
return s
|
|
}
|
|
|
|
func seedProxies(t *testing.T, ctx context.Context, s store.Store) {
|
|
t.Helper()
|
|
|
|
accountID := "account-1"
|
|
alreadyDisconnectedAt := time.Now().Add(-time.Hour)
|
|
seed := []*rpproxy.Proxy{
|
|
{
|
|
ID: "proxy-1",
|
|
SessionID: "session-1",
|
|
ClusterAddress: "cluster-a.example.com",
|
|
IPAddress: "10.0.0.1",
|
|
LastSeen: time.Now(),
|
|
Status: rpproxy.StatusConnected,
|
|
},
|
|
{
|
|
ID: "proxy-2",
|
|
SessionID: "session-2",
|
|
ClusterAddress: "cluster-b.example.com",
|
|
IPAddress: "10.0.0.2",
|
|
AccountID: &accountID,
|
|
LastSeen: time.Now(),
|
|
Status: rpproxy.StatusConnected,
|
|
},
|
|
{
|
|
ID: "proxy-3",
|
|
SessionID: "session-3",
|
|
ClusterAddress: "cluster-a.example.com",
|
|
IPAddress: "10.0.0.3",
|
|
LastSeen: time.Now().Add(-time.Hour),
|
|
Status: rpproxy.StatusDisconnected,
|
|
DisconnectedAt: &alreadyDisconnectedAt,
|
|
},
|
|
}
|
|
for _, p := range seed {
|
|
require.NoError(t, s.SaveProxy(ctx, p))
|
|
}
|
|
}
|
|
|
|
func proxiesByID(t *testing.T, ctx context.Context, s store.Store) map[string]*rpproxy.Proxy {
|
|
t.Helper()
|
|
|
|
proxies, err := s.GetAllProxies(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, proxies, 3)
|
|
|
|
byID := make(map[string]*rpproxy.Proxy, len(proxies))
|
|
for _, p := range proxies {
|
|
byID[p.ID] = p
|
|
}
|
|
return byID
|
|
}
|
|
|
|
func TestRunDisconnectAllWithConfirmation(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
seedProxies(t, ctx, s)
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(disconnectAllConfirmation+"\n"), false, false))
|
|
|
|
output := out.String()
|
|
require.Contains(t, output, "proxy-1")
|
|
require.Contains(t, output, "proxy-2")
|
|
require.Contains(t, output, "proxy-3")
|
|
require.Contains(t, output, "cluster-a.example.com")
|
|
require.Contains(t, output, "account-1")
|
|
require.Contains(t, output, "Type \"disconnect all proxies\" to continue")
|
|
require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.")
|
|
|
|
for _, p := range proxiesByID(t, ctx, s) {
|
|
require.Equal(t, rpproxy.StatusDisconnected, p.Status, "proxy %s should be disconnected", p.ID)
|
|
require.NotNil(t, p.DisconnectedAt, "proxy %s should have a disconnected timestamp", p.ID)
|
|
}
|
|
}
|
|
|
|
func TestRunDisconnectAllForceSkipsConfirmation(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
seedProxies(t, ctx, s)
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, true))
|
|
|
|
output := out.String()
|
|
require.NotContains(t, output, "Type \"disconnect all proxies\" to continue")
|
|
require.Contains(t, output, "Force-marked 2 of 3 reverse proxy instance(s) as disconnected.")
|
|
}
|
|
|
|
func TestRunDisconnectAllAbortLeavesProxiesUnchanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
seedProxies(t, ctx, s)
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader("no\n"), false, false))
|
|
|
|
output := out.String()
|
|
require.Contains(t, output, "Type \"disconnect all proxies\" to continue")
|
|
require.Contains(t, output, "Aborted. No reverse proxy instances were changed.")
|
|
|
|
byID := proxiesByID(t, ctx, s)
|
|
require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status)
|
|
require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status)
|
|
require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status)
|
|
}
|
|
|
|
func TestRunDisconnectAllDryRunLeavesProxiesUnchanged(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
seedProxies(t, ctx, s)
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), true, false))
|
|
|
|
output := out.String()
|
|
require.Contains(t, output, "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.")
|
|
require.NotContains(t, output, "Type \"disconnect all proxies\" to continue")
|
|
|
|
byID := proxiesByID(t, ctx, s)
|
|
require.Equal(t, rpproxy.StatusConnected, byID["proxy-1"].Status)
|
|
require.Equal(t, rpproxy.StatusConnected, byID["proxy-2"].Status)
|
|
require.Equal(t, rpproxy.StatusDisconnected, byID["proxy-3"].Status)
|
|
}
|
|
|
|
func TestNewCommandsDisconnectAllDryRun(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
seedProxies(t, ctx, s)
|
|
|
|
opened := false
|
|
cmd := NewCommands(func(cmd *cobra.Command, fn func(ctx context.Context, s store.Store) error) error {
|
|
opened = true
|
|
return fn(cmd.Context(), s)
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetErr(&out)
|
|
cmd.SetIn(strings.NewReader(""))
|
|
cmd.SetArgs([]string{"disconnect-all", "--dry-run"})
|
|
|
|
require.NoError(t, cmd.ExecuteContext(ctx))
|
|
require.True(t, opened)
|
|
require.Contains(t, out.String(), "Dry run: would force-mark 2 of 3 reverse proxy instance(s) as disconnected.")
|
|
}
|
|
|
|
func TestRunDisconnectAllEmpty(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, runDisconnectAll(ctx, s, &out, strings.NewReader(""), false, false))
|
|
require.Contains(t, out.String(), "No reverse proxy instances found.")
|
|
}
|