Skip to content

[client] Migrate profile identity from display name to ID and allow renaming of profiles - #6367

Merged
riccardomanfrin merged 16 commits into
mainfrom
profile-id-name
Jun 18, 2026
Merged

riccardomanfrin merged 16 commits into
mainfrom
profile-id-name

Conversation

@theodorsm

@theodorsm theodorsm commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

Describe your changes

This PR decouples a profile's identity from its display name. Previously the profile name was used for its on-disk config filename, forcing unique, character-restricted names. Now each profile has a stable ID (the filename) while the name is free-form and stored inside the profile JSON. New rpc and cli cmd are added for renaming of profiles.

Changes

  • IDs: new profiles get a generated 32-char hex ID, legacy profiles keep their name as their ID, so existing files keep working with no migration.
  • Resolution: handles resolve server-side by exact ID > unique ID prefix > unique name. Ambiguous handles return a typed error with candidates.
  • Validation: filename stems are guarded against path traversal and restricted charset (id.go). Display names are UTF-8-validated, control-char stripped, and length-capped.
  • gRPC: Profile, GetActiveProfileResponse, and the Add/Switch/Remove responses gain an additive id field (name retained for older clients). New RenameProfile rpc. profileNames in profile requests acts as handles instead of only profile names (backward compat).
  • CLI: profile list --show-id adds an ID column: select/remove accept name, ID, or unique prefix, with disambiguation help on ambiguity. profile rename` allows users to rename profiles, even the default one.
image
  • UI: the tray client now keys profile operations (switch/remove/logout, config/login requests) on profile ID instead of name, logout confirmation still shows the display name. If there are multiple profiles with the same name, the id is added to the name to differentiate.
  • Android: profile manager updated to the ID model.

Compatibility: fully backward compatible with legacy profiles, existing state files, and older clients all keep working.

Tests: new service_test.go plus updates across profilemanager/server/cmd tests.
OS test: have done manual testing on linux, macos, windows and android as they all use the go implementation of profiles (ios uses a native swift implementation).

Related PR for android-client: netbirdio/android-client#189

Issue ticket number and link

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • 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.

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • 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:

netbirdio/docs#781

Summary by CodeRabbit

Summary

  • New Features

    • Profiles now use stable, shortable IDs end-to-end; the CLI can optionally display IDs with profile list --show-id.
    • Added netbird profile rename command.
    • Profile switch/add/remove operations now show the resolved (short) profile ID.
  • Bug Fixes

    • Improved profile resolution and disambiguation when handles/names are ambiguous.
    • Updated login, logout, and config flows to reliably target the active profile by ID.
    • Enhanced Android/UI profile selection to operate on IDs.
  • Tests

    • Updated and expanded coverage for ID-based profile lifecycle and validation.

@theodorsm

theodorsm commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor Author

This is the based on #6326, but a new PR due to rebase diff issues with main.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Profile identity is migrated from human-readable names to stable, filename-safe IDs across profilemanager internals, daemon protobufs/server handlers, CLI, Android, and UI clients; Add/Remove/Resolve, persisted active state, path resolution, and tests are updated to use IDs while preserving legacy compatibility.

Changes

Profile Identity Migration to ID-Based System

Layer / File(s) Summary
Profile ID type and validation system
client/internal/profilemanager/id.go, client/internal/profilemanager/config.go, client/internal/debug/debug_test.go
Introduces profilemanager.ID type with secure hex-encoded generation, filename-stem validation (rejecting unsafe characters and path traversal), display-name sanitization with control-character filtering and rune limits, short-ID display helpers. Adds Config.Name field with sanitization during apply(), and excludes Config.Name from debug-fields reflection check.
Core ProfileManager and active-state refactor
client/internal/profilemanager/profilemanager.go, client/internal/profilemanager/state.go, client/internal/profilemanager/profilemanager_test.go
Profile/ProfileManager now use ID for on-disk identity; GetActiveProfile returns *Profile with only ID populated, SwitchProfile accepts ID with validation, active profile persistence uses ID bytes, FilePath() resolves paths from ID with fallback, default-profile logic is ID-driven, GetLoginHint uses activeProf.ID, and state file paths derive from id.String()+".state.json".
Service manager operations and listing
client/internal/profilemanager/service.go, client/internal/profilemanager/service_test.go
ActiveProfileState migrates to ID field (JSON-compatible json:"name" tag); AddProfile generates unique IDs and stores display names in JSON; RenameProfile updates stored name field; RemoveProfile validates ID and deletes state files; loadAllProfiles enumerates by *.json stems, derives IDs from filenames, marks active by ID comparison, sorts deterministically; ResolveProfile matches exact ID → unique ID prefix → unique exact name with ErrAmbiguousHandle; GetStatePath derives from active ID.
Daemon protobuf and server integration
client/proto/daemon.proto, client/server/server.go, client/server/server_test.go, client/server/setconfig_test.go, client/server/login_overrides_test.go
Protobufs updated to return/accept profile id, document profileName/username as handles; server resolves handles via resolveProfileHandle, validates username requirements, switches/logs-out/removes using resolved ID, derives config paths from resolved profiles, returns IDs in add/remove/switch/get-active responses; test fixtures updated to use ActiveProfileState.ID.
CLI profile commands
client/cmd/profile.go, client/cmd/root.go
profile list renders tabular format with optional --show-id column; add prints short ID of created profile and warns on duplicate names; rename resolves handles, counts duplicate names; remove/select treat arguments as handles, wrap gRPC errors with wrapAmbiguityError (translating ambiguous/not-found to CLI-friendly messages); RenameProfile RPC wired into command tree.
CLI login and up flows
client/cmd/login.go, client/cmd/up.go, client/cmd/up_daemon_test.go, client/cmd/login_test.go
Login and up commands resolve profile handles via switchProfile helper, pass activeProf.ID.String() in daemon requests, fetch profile state using activeProf.ID for login hints, update local active profile state with resolved IDs, and thread ID through foreground/daemon modes.
Desktop UI and systray
client/ui/client_ui.go, client/ui/profile.go
UI populates Profile.ID from daemon ListProfiles; daemon requests set ProfileName to activeProf.ID.String(); profile switch/logout/remove use IDs or handles resolved by daemon; systray disambiguates duplicate display names by appending short ID via formatProfileLabel; profile list sorting breaks ties by ID after Name.
Android profile manager
client/android/profile_manager.go
Android Profile includes ID field; GetActiveProfile resolves active profile by ID via ServiceManager and returns full *Profile; SwitchProfile/LogoutProfile/RemoveProfile accept id parameter; config/state paths construct directly from id using filename-stem validation (default profile still maps to legacy netbird.cfg/state.json); GetActiveConfigPath/GetActiveStateFilePath use activeProfile.ID.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • netbirdio/netbird#4884: Prior Android profile-manager changes touching profile switching by name; related to this PR's migration to stable profile IDs.
  • netbirdio/netbird#6374: Both modify client/internal/profilemanager/config.go in the same code region, with this PR adding human-readable Config.Name and sanitization, while the related PR overlays MDM policy behavior in apply().

Suggested reviewers

  • doromaraujo
  • lixmal

"🐰 I hopped through code with nimble paws,
IDs now steady, no more name-clause;
Paths and switches, tests aligned,
Short prefixes shine where names entwined;
A tidy burrow where profiles bind. 🥕"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly summarizes the main changes: migrating profile identity from display name to a stable ID and enabling profile renaming.
Description check ✅ Passed The pull request description comprehensively covers the changes, including detailed sections on IDs, resolution, validation, gRPC updates, CLI changes, UI updates, Android changes, backward compatibility, testing, and documentation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch profile-id-name

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 8, 2026 •

Copy link
Copy Markdown

Release artifacts

Built for PR head 1740922 in workflow run #15934.

Artifact Link
All release artifacts Download
Linux packages Download
Windows packages Download
macOS packages Download
UI artifacts Download
UI macOS artifacts Download

GHCR images (amd64)

This comment is updated by the Release workflow. Artifact links expire according to the workflow retention policy.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@client/android/profile_manager.go`:
- Around line 205-230: getProfileConfigPath and GetStateFilePath currently
concatenate the provided id directly into filepath.Join which allows absolute
paths or path traversal (../) to escape profilesDir; validate and sanitize
non-default ids before joining (e.g., reject empty/DefaultProfileName as already
handled, then ensure id is a safe filename/stem with no path separators or
absolute path prefix and does not contain ".."), and reuse a single validated
helper (or add a validateProfileID(id string) used by getProfileConfigPath and
GetStateFilePath) so both GetConfigPath (via getProfileConfigPath) and
GetStateFilePath produce only paths inside pm.configDir/profilesSubdir for
non-default profiles.

In `@client/cmd/profile.go`:
- Around line 147-152: The code reads resp.Id before checking err after calling
daemonClient.AddProfile, which can cause a nil dereference; update the
AddProfile handling (the call to daemonClient.AddProfile in profile.go) to check
if err != nil immediately and return or handle the error before accessing resp
or converting resp.Id to profilemanager.ID, ensuring any logic that uses id
(profilemanager.ID(resp.Id)) only runs after err == nil.

In `@client/internal/profilemanager/config.go`:
- Around line 106-108: The Config.Name invariant (sanitized via
sanitizeDisplayName) must be enforced when loading/updating configs, so modify
Config.apply() to validate and normalize Name (call sanitizeDisplayName on
c.Name and either truncate/replace or return an error if invalid) before any
code reuses it; this ensures configs coming from ConfigFromJSON, ReadConfig, or
UpdateOrCreateConfig are normalized even if hand-edited, and keeps AddProfile's
existing sanitization as a creation-time guarantee.

In `@client/server/server.go`:
- Around line 1032-1041: The logoutFromProfile function calls
profilemanager.GetConfig(profile.Path) without applying the same default-path
normalization used by SetConfig/GetConfig, causing logout to fail for the
default profile; update logoutFromProfile (the function named logoutFromProfile)
to normalize profile.Path to profilemanager.DefaultConfigPath when profile.Path
== "" (or otherwise mirror the resolved.Path fallback behavior used by
GetConfig/SetConfig) before calling profilemanager.GetConfig so the
default-profile path lookup uses the same fallback.

In `@client/ui/profile.go`:
- Around line 468-470: The menu refresh comparison in updateMenu() currently
sorts only by Name which allows equal-name profiles to reorder and trigger
unnecessary rebuilds; modify the sort of both profiles and state inside
updateMenu() to use a stable total order by comparing Name first and, if equal,
comparing ID (e.g., if profiles[i].Name != profiles[j].Name { ... } else {
compare profiles[i].ID and profiles[j].ID }), so slices.Equal sees a
deterministic ordering and avoids spurious menu refreshes.
- Around line 302-305: Profiles can share the same Profile.Name so update the UI
to disambiguate entries by appending a short form of Profile.ID when duplicates
exist: add a helper like formatProfileLabel(profile, allProfiles) that checks
the slice of profiles for duplicate Names and returns profile.Name when unique
or `${profile.Name} (${shortID})` where shortID is the first ~8 chars of
Profile.ID when not unique; use this helper in both the profiles window renderer
and the tray/menu label builder so all places that currently render profile.Name
instead use formatProfileLabel(Profile, profiles).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 42277719-9455-4983-a599-b9fa741978c3

📥 Commits

Reviewing files that changed from the base of the PR and between 60d2fa0 and 23c82b3.

⛔ Files ignored due to path filters (1)
  • client/proto/daemon.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (21)
  • client/android/profile_manager.go
  • client/cmd/login.go
  • client/cmd/login_test.go
  • client/cmd/profile.go
  • client/cmd/up.go
  • client/cmd/up_daemon_test.go
  • client/internal/debug/debug_test.go
  • client/internal/profilemanager/config.go
  • client/internal/profilemanager/id.go
  • client/internal/profilemanager/profilemanager.go
  • client/internal/profilemanager/profilemanager_test.go
  • client/internal/profilemanager/service.go
  • client/internal/profilemanager/service_test.go
  • client/internal/profilemanager/state.go
  • client/proto/daemon.proto
  • client/server/login_overrides_test.go
  • client/server/server.go
  • client/server/server_test.go
  • client/server/setconfig_test.go
  • client/ui/client_ui.go
  • client/ui/profile.go

Comment thread client/android/profile_manager.go
Comment thread client/cmd/profile.go Outdated
Comment thread client/internal/profilemanager/config.go
Comment thread client/server/server.go
Comment thread client/ui/profile.go
Comment thread client/ui/profile.go
@theodorsm theodorsm changed the title [client] Migrate profile identity from display name to ID [client] Migrate profile identity from display name to ID and allow renaming of profiles Jun 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
client/cmd/profile.go (1)

314-327: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Include rename in the ambiguity hint, or make the hint command-agnostic.

wrapAmbiguityError is now shared by renameProfileFunc, but the message still says select|remove <id-prefix>. Ambiguous rename failures will point users at the wrong command.

💡 Suggested fix
-			return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then select by ID prefix:\n  netbird profile select|remove <id-prefix>")
+			return errors.New(msg + "\nRun `netbird profile list --show-id` to see IDs, then retry with a longer ID prefix.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/cmd/profile.go` around lines 314 - 327, wrapAmbiguityError currently
suggests only "select|remove" when an ambiguous ID is detected, which is
misleading for callers like renameProfileFunc; update the ambiguity hint in
wrapAmbiguityError to include "rename" (e.g., "... Run `netbird profile list
--show-id` to see IDs, then select by ID prefix:\n  netbird profile
select|remove|rename <id-prefix>") or replace the command list with a
command-agnostic instruction (e.g., "use the appropriate profile command such as
select/remove/rename with an <id-prefix>"); modify the message in function
wrapAmbiguityError so all consumers (including renameProfileFunc) get correct
guidance.
client/server/server.go (2)

771-774: ⚠️ Potential issue | 🟡 Minor

Return codes.InvalidArgument for missing username when a non-default profile handle is provided

switchProfileIfNeeded is used by Login, Up, and SwitchProfile, but this branch returns fmt.Errorf(...). In gRPC-Go, non-status errors are surfaced to clients as codes.Unknown (and status.FromError(err) has ok == false), so callers can’t reliably distinguish this as a validation error.

💡 Suggested fix
 	if handle != profilemanager.DefaultProfileName && (userName == nil || *userName == "") {
-		log.Errorf("profile name is set to %s, but username is not provided", handle)
-		return nil, fmt.Errorf("profile name is set to %s, but username is not provided", handle)
+		return nil, gstatus.Errorf(codes.InvalidArgument, "username must be provided when profile handle %q is specified", handle)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/server/server.go` around lines 771 - 774, The branch in
switchProfileIfNeeded that checks "if handle !=
profilemanager.DefaultProfileName && (userName == nil || *userName == "")"
currently returns a plain fmt.Errorf, causing gRPC to surface it as
codes.Unknown; change this to return a gRPC status error using
status.Errorf(codes.InvalidArgument, ...) (or
status.Error(codes.InvalidArgument, ...)) with the same descriptive message so
callers receive a proper InvalidArgument code for missing username when a
non-default profile handle is provided.

311-319: ⚠️ Potential issue | 🟠 Major

SetConfig/GetConfig: empty ProfileName no longer falls back to active profile
profilemanager.ResolveProfile(handle, username) returns an error when handle == "" (“profile handle is empty”), and Server.SetConfig / Server.GetConfig always call resolveProfileHandle(msg.ProfileName, msg.Username) without a fallback. This will break backward compatibility for any clients that previously sent an empty ProfileName to mean “current/active profile.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/server/server.go` around lines 311 - 319, When msg.ProfileName is
empty we must preserve the old behavior of using the active/current profile
instead of calling profilemanager.ResolveProfile with an empty handle; update
Server.SetConfig and Server.GetConfig to check if msg.ProfileName == "" and, in
that case, obtain the active profile handle (via the server's profile manager or
an existing helper that returns the active profile handle) and pass that into
resolveProfileHandle (or call profilemanager.ResolveProfile with that active
handle) so resolveProfileHandle is only invoked with a non-empty handle; retain
the existing profPath defaulting to profilemanager.DefaultConfigPath if
resolution yields an empty Path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@client/cmd/profile.go`:
- Around line 314-327: wrapAmbiguityError currently suggests only
"select|remove" when an ambiguous ID is detected, which is misleading for
callers like renameProfileFunc; update the ambiguity hint in wrapAmbiguityError
to include "rename" (e.g., "... Run `netbird profile list --show-id` to see IDs,
then select by ID prefix:\n  netbird profile select|remove|rename <id-prefix>")
or replace the command list with a command-agnostic instruction (e.g., "use the
appropriate profile command such as select/remove/rename with an <id-prefix>");
modify the message in function wrapAmbiguityError so all consumers (including
renameProfileFunc) get correct guidance.

In `@client/server/server.go`:
- Around line 771-774: The branch in switchProfileIfNeeded that checks "if
handle != profilemanager.DefaultProfileName && (userName == nil || *userName ==
"")" currently returns a plain fmt.Errorf, causing gRPC to surface it as
codes.Unknown; change this to return a gRPC status error using
status.Errorf(codes.InvalidArgument, ...) (or
status.Error(codes.InvalidArgument, ...)) with the same descriptive message so
callers receive a proper InvalidArgument code for missing username when a
non-default profile handle is provided.
- Around line 311-319: When msg.ProfileName is empty we must preserve the old
behavior of using the active/current profile instead of calling
profilemanager.ResolveProfile with an empty handle; update Server.SetConfig and
Server.GetConfig to check if msg.ProfileName == "" and, in that case, obtain the
active profile handle (via the server's profile manager or an existing helper
that returns the active profile handle) and pass that into resolveProfileHandle
(or call profilemanager.ResolveProfile with that active handle) so
resolveProfileHandle is only invoked with a non-empty handle; retain the
existing profPath defaulting to profilemanager.DefaultConfigPath if resolution
yields an empty Path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a847fa11-3779-461e-9588-0fe841ef8cff

📥 Commits

Reviewing files that changed from the base of the PR and between 0ffb9e8 and 2e7b5f7.

⛔ Files ignored due to path filters (2)
  • client/proto/daemon.pb.go is excluded by !**/*.pb.go
  • client/proto/daemon_grpc.pb.go is excluded by !**/*.pb.go
📒 Files selected for processing (5)
  • client/cmd/profile.go
  • client/cmd/root.go
  • client/internal/profilemanager/service.go
  • client/proto/daemon.proto
  • client/server/server.go
✅ Files skipped from review due to trivial changes (1)
  • client/cmd/root.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/internal/profilemanager/service.go

Comment thread client/android/profile_manager.go Outdated
Comment thread client/internal/profilemanager/id.go

@riccardomanfrin riccardomanfrin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made some comments around and there is the ui binary to remove

* Remove ui binary
* Fix getProfileConfigPath not validating id
@sonarqubecloud

Copy link
Copy Markdown

@riccardomanfrin riccardomanfrin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did few tests also removing default.json and renaming it ant it works fine

@riccardomanfrin
riccardomanfrin merged commit ee36096 into main Jun 18, 2026
47 of 48 checks passed
@riccardomanfrin
riccardomanfrin deleted the profile-id-name branch June 18, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants