mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 07:16:38 +00:00
450 lines
14 KiB
Go
450 lines
14 KiB
Go
// Package main provides a standalone CLI tool to migrate user IDs from an
|
|
// external IdP format to the embedded Dex IdP format used by NetBird >= v0.62.0.
|
|
//
|
|
// This tool reads management.json to auto-detect the current external IdP
|
|
// configuration (issuer, clientID, clientSecret, type) and re-encodes all user
|
|
// IDs in the database to the Dex protobuf-encoded format. It works independently
|
|
// of migrate.sh and the combined server, allowing operators to migrate their
|
|
// database before switching to the combined server.
|
|
//
|
|
// Usage:
|
|
//
|
|
// netbird-idp-migrate --config /etc/netbird/management.json [--dry-run] [--force]
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/idp/dex"
|
|
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
|
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
|
"github.com/netbirdio/netbird/management/server/idp"
|
|
"github.com/netbirdio/netbird/management/server/idp/migration"
|
|
"github.com/netbirdio/netbird/management/server/store"
|
|
"github.com/netbirdio/netbird/management/server/types"
|
|
"github.com/netbirdio/netbird/util"
|
|
"github.com/netbirdio/netbird/util/crypt"
|
|
)
|
|
|
|
// migrationServer implements migration.Server by wrapping the migration-specific interfaces.
|
|
type migrationServer struct {
|
|
store migration.Store
|
|
eventStore migration.EventStore
|
|
}
|
|
|
|
func (s *migrationServer) Store() migration.Store { return s.store }
|
|
func (s *migrationServer) EventStore() migration.EventStore { return s.eventStore }
|
|
|
|
func main() {
|
|
cfg, err := config()
|
|
if err != nil {
|
|
log.Fatalf("config error: %v", err)
|
|
}
|
|
|
|
if err := run(cfg); err != nil {
|
|
log.Fatalf("migration failed: %v", err)
|
|
}
|
|
|
|
if !cfg.dryRun {
|
|
printPostMigrationInstructions(cfg)
|
|
}
|
|
}
|
|
|
|
func run(cfg *migrationConfig) error {
|
|
mgmtConfig := &nbconfig.Config{}
|
|
if _, err := util.ReadJsonWithEnvSub(cfg.configPath, mgmtConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate the database schema before attempting any operations.
|
|
if err := validateSchema(mgmtConfig, cfg.dataDir); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !cfg.skipPopulateUserInfo {
|
|
err := populateUserInfoFromIDP(cfg, mgmtConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("populate user info: %w", err)
|
|
}
|
|
}
|
|
|
|
connectorConfig, err := decodeConnectorConfig(cfg.idpSeedInfo)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve connector: %w", err)
|
|
}
|
|
|
|
log.Infof(
|
|
"resolved connector: type=%s, id=%s, name=%s",
|
|
connectorConfig.Type,
|
|
connectorConfig.ID,
|
|
connectorConfig.Name,
|
|
)
|
|
|
|
if err := migrateDB(cfg, mgmtConfig, connectorConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
if cfg.skipConfig {
|
|
log.Info("skipping config generation (--skip-config)")
|
|
return nil
|
|
}
|
|
|
|
return generateConfig(cfg, connectorConfig)
|
|
}
|
|
|
|
// validateSchema opens the store and checks that all required tables and columns
|
|
// exist. If anything is missing, it returns a descriptive error telling the user
|
|
// to upgrade their management server.
|
|
func validateSchema(mgmtConfig *nbconfig.Config, dataDir string) error {
|
|
ctx := context.Background()
|
|
migStore, migEventStore, cleanup, err := openStores(ctx, mgmtConfig, dataDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
errs := migStore.CheckSchema(migration.RequiredSchema)
|
|
if len(errs) > 0 {
|
|
return fmt.Errorf("%s", formatSchemaErrors(errs))
|
|
}
|
|
|
|
if migEventStore != nil {
|
|
eventErrs := migEventStore.CheckSchema(migration.RequiredEventSchema)
|
|
if len(eventErrs) > 0 {
|
|
return fmt.Errorf("activity store schema check failed (upgrade management server first):\n%s", formatSchemaErrors(eventErrs))
|
|
}
|
|
}
|
|
|
|
log.Info("database schema check passed")
|
|
return nil
|
|
}
|
|
|
|
// formatSchemaErrors returns a user-friendly message listing all missing schema
|
|
// elements and instructing the operator to upgrade.
|
|
func formatSchemaErrors(errs []migration.SchemaError) string {
|
|
var b strings.Builder
|
|
b.WriteString("database schema is incomplete — the following tables/columns are missing:\n")
|
|
for _, e := range errs {
|
|
fmt.Fprintf(&b, " - %s\n", e.String())
|
|
}
|
|
b.WriteString("\nPlease start the NetBird management server (v0.66.4+) at least once so that automatic database migrations create the required schema, then re-run this tool.\n")
|
|
return b.String()
|
|
}
|
|
|
|
// populateUserInfoFromIDP creates an IDP manager from the config, fetches all
|
|
// user data (email, name) from the external IDP, and updates the store for users
|
|
// that are missing this information.
|
|
func populateUserInfoFromIDP(cfg *migrationConfig, mgmtConfig *nbconfig.Config) error {
|
|
ctx := context.Background()
|
|
|
|
if mgmtConfig.IdpManagerConfig == nil {
|
|
return fmt.Errorf("IdpManagerConfig is not set in management.json; cannot fetch user info from IDP")
|
|
}
|
|
|
|
idpManager, err := idp.NewManager(ctx, *mgmtConfig.IdpManagerConfig, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create IDP manager: %w", err)
|
|
}
|
|
if idpManager == nil {
|
|
return fmt.Errorf("IDP manager type is 'none' or empty; cannot fetch user info")
|
|
}
|
|
|
|
log.Infof("created IDP manager (type: %s)", mgmtConfig.IdpManagerConfig.ManagerType)
|
|
|
|
migStore, _, cleanup, err := openStores(ctx, mgmtConfig, cfg.dataDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
srv := &migrationServer{store: migStore}
|
|
return migration.PopulateUserInfo(srv, idpManager, cfg.dryRun)
|
|
}
|
|
|
|
// openStores opens the main and activity stores, returning migration-specific interfaces.
|
|
// The caller must call the returned cleanup function to close the stores.
|
|
func openStores(ctx context.Context, cfg *nbconfig.Config, dataDir string) (migration.Store, migration.EventStore, func(), error) {
|
|
engine := cfg.StoreConfig.Engine
|
|
if engine == "" {
|
|
engine = types.SqliteStoreEngine
|
|
}
|
|
|
|
mainStore, err := store.NewStore(ctx, engine, dataDir, nil, true)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("open main store: %w", err)
|
|
}
|
|
|
|
if cfg.DataStoreEncryptionKey != "" {
|
|
fieldEncrypt, err := crypt.NewFieldEncrypt(cfg.DataStoreEncryptionKey)
|
|
if err != nil {
|
|
_ = mainStore.Close(ctx)
|
|
return nil, nil, nil, fmt.Errorf("init field encryption: %w", err)
|
|
}
|
|
mainStore.SetFieldEncrypt(fieldEncrypt)
|
|
}
|
|
|
|
migStore, ok := mainStore.(migration.Store)
|
|
if !ok {
|
|
_ = mainStore.Close(ctx)
|
|
return nil, nil, nil, fmt.Errorf("store does not support migration operations (ListUsers/UpdateUserID)")
|
|
}
|
|
|
|
cleanup := func() { _ = mainStore.Close(ctx) }
|
|
|
|
var migEventStore migration.EventStore
|
|
actStore, err := activitystore.NewSqlStore(ctx, dataDir, cfg.DataStoreEncryptionKey)
|
|
if err != nil {
|
|
log.Warnf("could not open activity store (events.db may not exist): %v", err)
|
|
} else {
|
|
migEventStore = actStore
|
|
prevCleanup := cleanup
|
|
cleanup = func() { _ = actStore.Close(ctx); prevCleanup() }
|
|
}
|
|
|
|
return migStore, migEventStore, cleanup, nil
|
|
}
|
|
|
|
// migrateDB opens the stores, previews pending users, and runs the DB migration.
|
|
func migrateDB(cfg *migrationConfig, mgmtConfig *nbconfig.Config, connectorConfig *dex.Connector) error {
|
|
ctx := context.Background()
|
|
|
|
migStore, migEventStore, cleanup, err := openStores(ctx, mgmtConfig, cfg.dataDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
pending, err := previewUsers(ctx, migStore)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if cfg.dryRun {
|
|
if err := os.Setenv("NB_IDP_MIGRATION_DRY_RUN", "true"); err != nil {
|
|
return fmt.Errorf("set dry-run env: %w", err)
|
|
}
|
|
defer os.Unsetenv("NB_IDP_MIGRATION_DRY_RUN") //nolint:errcheck
|
|
}
|
|
|
|
if !cfg.dryRun && !cfg.force {
|
|
if !confirmPrompt(pending) {
|
|
log.Info("migration cancelled by user")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
srv := &migrationServer{store: migStore, eventStore: migEventStore}
|
|
if err := migration.MigrateUsersToStaticConnectors(srv, connectorConfig); err != nil {
|
|
return fmt.Errorf("migrate users: %w", err)
|
|
}
|
|
|
|
if !cfg.dryRun {
|
|
log.Info("DB migration completed successfully")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// previewUsers counts pending vs already-migrated users and logs a summary.
|
|
// Returns the number of users still needing migration.
|
|
func previewUsers(ctx context.Context, migStore migration.Store) (int, error) {
|
|
users, err := migStore.ListUsers(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("list users: %w", err)
|
|
}
|
|
|
|
var pending, alreadyMigrated int
|
|
for _, u := range users {
|
|
if _, _, decErr := dex.DecodeDexUserID(u.Id); decErr == nil {
|
|
alreadyMigrated++
|
|
} else {
|
|
pending++
|
|
}
|
|
}
|
|
|
|
log.Infof("found %d total users: %d pending migration, %d already migrated", len(users), pending, alreadyMigrated)
|
|
return pending, nil
|
|
}
|
|
|
|
// confirmPrompt asks the user for interactive confirmation. Returns true if they accept.
|
|
func confirmPrompt(pending int) bool {
|
|
log.Infof("About to migrate %d users. This cannot be easily undone. Continue? [y/N] ", pending)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
answer, _ := reader.ReadString('\n')
|
|
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
return answer == "y" || answer == "yes"
|
|
}
|
|
|
|
// decodeConnectorConfig base64-decodes and JSON-unmarshals a connector.
|
|
func decodeConnectorConfig(encoded string) (*dex.Connector, error) {
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("base64 decode: %w", err)
|
|
}
|
|
|
|
var conn dex.Connector
|
|
if err := json.Unmarshal(decoded, &conn); err != nil {
|
|
return nil, fmt.Errorf("json unmarshal: %w", err)
|
|
}
|
|
|
|
if conn.ID == "" {
|
|
return nil, fmt.Errorf("connector ID is empty")
|
|
}
|
|
|
|
return &conn, nil
|
|
}
|
|
|
|
// generateConfig reads the existing management.json as raw JSON, removes
|
|
// IdpManagerConfig, adds EmbeddedIdP, updates HttpConfig fields, and writes
|
|
// the result. In dry-run mode, it prints the new config to stdout instead.
|
|
func generateConfig(cfg *migrationConfig, connectorConfig *dex.Connector) error {
|
|
// Read existing config as raw JSON to preserve all fields
|
|
raw, err := os.ReadFile(cfg.configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read config file: %w", err)
|
|
}
|
|
|
|
var configMap map[string]any
|
|
if err := json.Unmarshal(raw, &configMap); err != nil {
|
|
return fmt.Errorf("parse config JSON: %w", err)
|
|
}
|
|
|
|
// Remove unused information
|
|
delete(configMap, "IdpManagerConfig")
|
|
delete(configMap, "PKCEAuthorizationFlow")
|
|
delete(configMap, "DeviceAuthorizationFlow")
|
|
|
|
httpConfig, ok := configMap["HttpConfig"].(map[string]any)
|
|
if httpConfig != nil && ok {
|
|
certFilePath := httpConfig["CertFile"]
|
|
certKeyPath := httpConfig["CertKey"]
|
|
|
|
delete(configMap, "HttpConfig")
|
|
|
|
configMap["HttpConfig"] = map[string]any{
|
|
"CertFile": certFilePath,
|
|
"CertKey": certKeyPath,
|
|
}
|
|
}
|
|
|
|
// Ensure the connector's redirectURI points to the management server (Dex callback),
|
|
// not the external IdP. The auto-detection may have used the IdP issuer URL.
|
|
connConfig := make(map[string]any, len(connectorConfig.Config))
|
|
maps.Copy(connConfig, connectorConfig.Config)
|
|
|
|
redirectURI, err := buildURL(cfg.apiURL, "/oauth2/callback")
|
|
if err != nil {
|
|
return fmt.Errorf("build redirect URI: %w", err)
|
|
}
|
|
connConfig["redirectURI"] = redirectURI
|
|
|
|
issuer, err := buildURL(cfg.apiURL, "/oauth2")
|
|
if err != nil {
|
|
return fmt.Errorf("build issuer URL: %w", err)
|
|
}
|
|
|
|
dashboardRedirectURL, err := buildURL(cfg.dashboardURL, "/nb-auth")
|
|
if err != nil {
|
|
return fmt.Errorf("build dashboard redirect URL: %w", err)
|
|
}
|
|
|
|
dashboardSilentRedirectURL, err := buildURL(cfg.dashboardURL, "/nb-silent-auth")
|
|
if err != nil {
|
|
return fmt.Errorf("build dashboard silent redirect URL: %w", err)
|
|
}
|
|
|
|
// Add minimal EmbeddedIdP section
|
|
configMap["EmbeddedIdP"] = map[string]any{
|
|
"Enabled": true,
|
|
"Issuer": issuer,
|
|
"DashboardRedirectURIs": []string{
|
|
dashboardRedirectURL,
|
|
dashboardSilentRedirectURL,
|
|
},
|
|
"StaticConnectors": []any{
|
|
map[string]any{
|
|
"type": connectorConfig.Type,
|
|
"name": connectorConfig.Name,
|
|
"id": connectorConfig.ID,
|
|
"config": connConfig,
|
|
},
|
|
},
|
|
}
|
|
|
|
newJSON, err := json.MarshalIndent(configMap, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal new config: %w", err)
|
|
}
|
|
|
|
if cfg.dryRun {
|
|
log.Info("[DRY RUN] new management.json would be:")
|
|
log.Infoln(string(newJSON))
|
|
return nil
|
|
}
|
|
|
|
// Backup original
|
|
backupPath := cfg.configPath + ".bak"
|
|
if err := os.WriteFile(backupPath, raw, 0o600); err != nil {
|
|
return fmt.Errorf("write backup: %w", err)
|
|
}
|
|
log.Infof("backed up original config to %s", backupPath)
|
|
|
|
// Write new config
|
|
if err := os.WriteFile(cfg.configPath, newJSON, 0o600); err != nil {
|
|
return fmt.Errorf("write new config: %w", err)
|
|
}
|
|
log.Infof("wrote new config to %s", cfg.configPath)
|
|
|
|
return nil
|
|
}
|
|
|
|
func buildURL(uri, path string) (string, error) {
|
|
// Case for domain without scheme, e.g. "example.com" or "example.com:8080"
|
|
if !strings.HasPrefix(uri, "http://") && !strings.HasPrefix(uri, "https://") {
|
|
uri = "https://" + uri
|
|
}
|
|
|
|
val, err := url.JoinPath(uri, path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return val, nil
|
|
}
|
|
|
|
func printPostMigrationInstructions(cfg *migrationConfig) {
|
|
authAuthority, err := buildURL(cfg.apiURL, "/oauth2")
|
|
if err != nil {
|
|
authAuthority = "https://<your-domain>/oauth2"
|
|
}
|
|
|
|
log.Info("Congratulations! You have successfully migrated your NetBird management server to the embedded Dex IdP.")
|
|
log.Info("Next steps:")
|
|
log.Info("1. Make sure the following environment variables are set for your dashboard server:")
|
|
log.Infof(`
|
|
AUTH_AUDIENCE=netbird-dashboard
|
|
AUTH_CLIENT_ID=netbird-dashboard
|
|
AUTH_AUTHORITY=%s
|
|
AUTH_SUPPORTED_SCOPES=openid profile email groups
|
|
AUTH_REDIRECT_URI=/nb-auth
|
|
AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
|
|
`,
|
|
authAuthority,
|
|
)
|
|
log.Info("2. Make sure you restart the dashboard & management servers to pick up the new config and environment variables.")
|
|
log.Info("eg. docker compose up -d --force-recreate management dashboard")
|
|
log.Info("3. Optional: If you have a reverse proxy configured, make sure the path `/oauth2/*` points to the management api server.")
|
|
}
|
|
|
|
// Compile-time check that migrationServer implements migration.Server.
|
|
var _ migration.Server = (*migrationServer)(nil)
|