mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-08 07:51:28 +02:00
* [client] Resolve profiles for the sudo invoking user instead of root
The SSH server flags force `netbird up` through sudo, but the CLI resolved
every per-user path with the process user. As root that reads root's own
(empty) local state, so a `sudo netbird up` silently switched the daemon from
the user's profile to the default one — cancelling any login already waiting
in the browser — and then ran an SSO login for the default profile's config.
Whichever account that login returned, the default profile's peer belongs to
someone else, so every attempt ended in "peer is already registered by a
different User or a Setup Key", with nothing telling the user why.
Resolve the acting user through SUDO_USER when running as root: the active
profile, the profile config paths and the stored account email now come from
the invoking user's directories. Privilege decisions are untouched — they stay
on the kernel credentials of the daemon connection, which an environment
variable can never influence; a forged SUDO_USER only selects a profile root
could select anyway.
The invoking user's directories are strictly read-only under sudo. Anything
root wrote there would be root-owned and break the user's own runs, so instead
of chowning files back, the local writes are skipped: the active-profile
bookkeeping and the account-email state simply do not update from a sudo run
(the daemon records the switch on its side; a skipped email write costs at
most one extra account prompt later).
Plain root — no sudo context — has no user to act for, so the ambiguity is
refused instead of guessed at: when the daemon's active profile differs from
what root resolves and no --profile was given, up fails with a message naming
both profiles, instead of silently switching the daemon and failing later with
the ownership error.
* [client] Act on the daemon-resolved profile and fail closed in the root guard
Under sudo the local active-profile mirror is not updated, so up/login
re-reading it after a profile switch acted on the previous profile; use
the daemon-resolved ID directly instead. The plain-root guard now runs
after the readiness wait, denies on lookup errors and empty responses,
and matches the owning username as well; an unowned profile (fresh
install) and a daemon predating the RPC stay allowed. Write-skip
decisions key off the sudo environment alone so a transient user lookup
failure cannot turn a run into writing root-owned files into the user's
directory, and RemoveProfileState honors the read-only rule too.
* [client] Return a wrapped error instead of double-reporting the dial failure
* [client] Read the profile from the daemon when the local mirror is not authoritative
Under sudo without --profile, `up` took the active profile from the invoking
user's local active_profile.txt mirror and drove the daemon to it. But that
mirror is never written under sudo (the SwitchProfile write is a no-op), so it
goes stale after any --profile run and silently switches the daemon back to the
mirror's default. The plain-root guard was meant to refuse exactly this
ambiguity but only ran for plain root, never for the sudo case the fix targets.
When there is no --profile and the mirror is not authoritative (sudo or plain
root), take the profile the daemon already holds for the invoking user instead
of the stale mirror: stay on the user's current profile when the daemon owns it
(or it is unowned, as on a fresh install), and refuse with a --profile hint when
the daemon is on another user's profile. A daemon predating the RPC keeps the
mirror-derived profile.
Reproduce (before this change):
1. As a non-root user misha, with the daemon installed and running:
sudo netbird up --profile work
misha connects on the `work` profile.
2. Because the local mirror write is skipped under sudo,
~misha/.config/netbird/active_profile.txt still says `default` (or is still
absent, which also resolves to `default`).
3. Run a bare:
sudo netbird up
The CLI reads `default` from the frozen mirror and sends ProfileName=default;
the daemon silently switches away from `work` and brings the tunnel up on
`default` — a different account/peer than the one last chosen, with no
warning. After this change step 3 stays on `work`.
* [client] Return a sentinel error instead of nil-nil for the missing daemon RPC
* [client] Load the extend-session hint from the resolved profile
* [client] Fail closed instead of reading root's config when the sudo user lookup fails
* [client] Fail closed in InvokingUser when the sudo user lookup fails
A previous change made baseConfigDir fail closed when SUDO_USER cannot be
resolved, but InvokingUser still fell through to user.Current(). Those two
guards disagreed: the active-profile mirror and the email state refused to
read root's directory, while every profile-path caller happily resolved as
root.
The consequence of a transient NSS failure under sudo was that
Profile.FilePath resolved through getConfigDirForUser("root"), creating
/var/lib/netbird/root and reading the profile JSON from there, and the CLI
sent Username "root" to the daemon in SetConfig and ListProfiles, so the
daemon resolved the same phantom namespace. The invoking user was silently
moved onto a root-owned profile instead of being told the lookup failed.
Fail closed at the single source of the fallback. getConfigDirForUser is
left alone on purpose: it is a pure path helper that also serves
daemon-supplied usernames, and under sudo with a successful lookup it must
still create the invoking user's own profile directory.
123 lines
3.9 KiB
Go
123 lines
3.9 KiB
Go
package profilemanager
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/util"
|
|
)
|
|
|
|
type ProfileState struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// GetProfileState reads the per-profile state file keyed by profile ID.
|
|
// The state file lives in the user's config directory. Legacy state files
|
|
// keyed by the old profile name remain readable.
|
|
func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
|
|
configDir, err := getConfigDir()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get config directory: %w", err)
|
|
}
|
|
|
|
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
|
return nil, fmt.Errorf("invalid profile ID: %q", id)
|
|
}
|
|
|
|
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
|
stateFileExists, err := fileExists(stateFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
|
|
}
|
|
if !stateFileExists {
|
|
return nil, errors.New("profile state file does not exist")
|
|
}
|
|
|
|
var state ProfileState
|
|
_, err = util.ReadJson(stateFile, &state)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read profile state: %w", err)
|
|
}
|
|
|
|
return &state, nil
|
|
}
|
|
|
|
// SetProfileState writes the state file of the profile identified by id. Prefer
|
|
// it over SetActiveProfileState whenever the caller knows which profile the data
|
|
// belongs to: an SSO login spans seconds of user interaction, and the active
|
|
// profile can change during it, which would file the account email under
|
|
// whichever profile happened to be active when the flow returned.
|
|
func (pm *ProfileManager) SetProfileState(id ID, state *ProfileState) error {
|
|
configDir, err := getConfigDir()
|
|
if err != nil {
|
|
return fmt.Errorf("get config directory: %w", err)
|
|
}
|
|
|
|
if id == "" {
|
|
return fmt.Errorf("empty profile ID")
|
|
}
|
|
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
|
|
return fmt.Errorf("invalid profile ID: %q", id)
|
|
}
|
|
|
|
// The invoking user's state is read-only under sudo. The file only carries
|
|
// the account email for the login hint and display, so skipping the write
|
|
// costs at most one extra account prompt later — a root-owned file in the
|
|
// user's directory would cost every later update instead.
|
|
if sudoActive() {
|
|
log.Debugf("running under sudo: not persisting profile state for user %s", os.Getenv(envSudoUser))
|
|
return nil
|
|
}
|
|
|
|
stateFile := filepath.Join(configDir, id.String()+".state.json")
|
|
if err := util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state); err != nil {
|
|
return fmt.Errorf("write profile state: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetActiveProfileState writes the state file of whichever profile is active at
|
|
// call time. Use SetProfileState when the target profile is known.
|
|
func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
|
activeProf, err := pm.GetActiveProfile()
|
|
if err != nil {
|
|
if errors.Is(err, ErrNoActiveProfile) {
|
|
return fmt.Errorf("no active profile set: %w", err)
|
|
}
|
|
return fmt.Errorf("get active profile: %w", err)
|
|
}
|
|
|
|
return pm.SetProfileState(activeProf.ID, state)
|
|
}
|
|
|
|
// RemoveProfileState deletes the per-profile state file (which holds the
|
|
// account email used for the SSO login hint and the UI display). Called after
|
|
// profile removal; logout keeps the file so the next login can pass the email
|
|
// as the login_hint. The state file only stores the email, so deleting it is
|
|
// equivalent to clearing it; the next SSO login recreates it. A missing file
|
|
// is not an error.
|
|
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
|
|
if sudoActive() {
|
|
log.Debugf("running under sudo: not removing profile state for user %s", os.Getenv(envSudoUser))
|
|
return nil
|
|
}
|
|
|
|
configDir, err := getConfigDir()
|
|
if err != nil {
|
|
return fmt.Errorf("get config directory: %w", err)
|
|
}
|
|
|
|
stateFile := filepath.Join(configDir, profileName+".state.json")
|
|
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("remove profile state: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|