Files
netbird/client/internal/ipcauth/privileged.go
T
2026-09-17 14:36:56 +02:00

293 lines
11 KiB
Go

package ipcauth
import (
"fmt"
"os"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it
// raises for an operation that requires root/administrator. Clients match on
// Reason and Domain rather than on the message text, and render the summary and
// command themselves so the user gets guidance instead of a gRPC error dump.
const (
// ErrorReasonPrivilegeRequired identifies the detail.
ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED"
// ErrorDomain scopes the reason to the NetBird daemon.
ErrorDomain = "daemon.netbird.io"
// ErrorMetaSummary is the one-sentence explanation of what was refused.
ErrorMetaSummary = "summary"
// ErrorMetaCommand is the command that performs the same operation with the
// privileges it needs, ready to copy and run.
ErrorMetaCommand = "command"
// ErrorReasonSessionHeld identifies a refusal caused by another user's live
// connection. Nothing the caller can run satisfies it, since the session is
// not theirs to end, so the detail carries no command.
ErrorReasonSessionHeld = "SESSION_HELD"
// ErrorReasonNotProfileOwner identifies a refusal caused by the profile
// belonging to another account. It carries no command either: privilege is
// not what the method asked for, so telling the caller to elevate would send
// them the wrong way.
ErrorReasonNotProfileOwner = "NOT_PROFILE_OWNER"
)
// The identity of the process evaluating callers, captured once because it cannot
// change. It stays the zero Identity when it could not be read, and the zero
// Identity is not Known, so nothing is ever treated as this process.
// selfMayDelegate additionally requires this process to be unprivileged: see
// IsPrivilegedCaller.
var (
selfIdentity Identity
selfMayDelegate bool
// selfPID is this process's PID, used to recognise the daemon dialling itself.
selfPID = os.Getpid()
)
func init() {
id, err := CurrentProcessIdentity()
if err != nil {
return
}
selfIdentity = id
// Only an unprivileged daemon delegates its authority to its own identity.
// When it is root or LocalSystem, sharing its identity does not mean sharing
// its power: on Windows a filtered and a full token carry the same SID, so
// matching there would let a non-elevated shell of an administrator account
// act as an administrator, which is the boundary the token check exists to
// keep.
selfMayDelegate = !id.IsPrivileged()
}
// IsDaemonSelf reports whether an identity is this very process. The JSON gateway
// runs inside the daemon and re-dials it locally, so this is what distinguishes
// the gateway from any other caller, whatever user the daemon runs as.
func IsDaemonSelf(id Identity) bool {
// An identity the kernel did not vouch for is nobody, least of all us: the
// zero Identity carries uid 0, which would otherwise match a root daemon.
if !id.Known() || !selfIdentity.Known() {
return false
}
if id.IsWindows() != selfIdentity.IsWindows() {
return false
}
if id.IsWindows() {
return id.SID != "" && id.SID == selfIdentity.SID
}
return id.UID == selfIdentity.UID
}
// IsPrivilegedCaller reports whether an identity may make the changes the daemon
// restricts to the platform administrator. This is the daemon's own rule and
// cannot be evaluated by a client, which does not know what the daemon runs as.
//
// Beyond root/administrator it accepts a caller running as the daemon's own
// identity when the daemon is itself unprivileged. That keeps a rootless container
// working, where there is no uid 0 at all, and a Windows daemon in netstack mode,
// which needs no administrator rights. In those setups a caller sharing the
// daemon's identity can already rewrite the config files it reads and replace the
// binary it runs, so refusing it a config change would protect nothing; and an
// unprivileged daemon cannot hand out a root shell in the first place.
func IsPrivilegedCaller(id Identity) bool {
if id.IsPrivileged() {
return true
}
return selfMayDelegate && IsDaemonSelf(id)
}
// SelfDelegatesTo returns the identity this process delegates its authority to,
// and whether it delegates at all. Only an unprivileged daemon does: see
// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the
// operation, because on such a host root is neither required nor necessarily
// available.
func SelfDelegatesTo() (Identity, bool) {
if !selfIdentity.Known() || !selfMayDelegate {
return Identity{}, false
}
return selfIdentity, true
}
// The values PrivilegedActorKey returns.
const (
ActorKeyAdministrator = "administrator"
ActorKeyRoot = "root"
)
// PrivilegedActor names the principal a privileged operation requires, for use
// in messages shown to the user.
func PrivilegedActor() string {
if runtime.GOOS == "windows" {
return "administrator privileges"
}
return "root"
}
// PrivilegedActorKey identifies that principal without wording it, for a client
// that writes its own message in the user's language. The words PrivilegedActor
// returns are English, and a translated sentence cannot borrow them.
func PrivilegedActorKey() string {
if runtime.GOOS == "windows" {
return ActorKeyAdministrator
}
return ActorKeyRoot
}
// ElevatedCommand renders a command so that running it grants the privileges the
// operation needs. Windows has no in-line equivalent of sudo, so the command is
// returned unchanged and the user is expected to run it from an elevated
// terminal.
func ElevatedCommand(command string) string {
if runtime.GOOS == "windows" {
return command
}
return "sudo " + command
}
// UpCommand renders an elevated `netbird up` with the given flags, preceded by a
// `down`. The down is what makes the command work on a connected client: `netbird
// up` prints "Already connected" and returns without applying any config flag, so
// on its own the command would appear to do nothing. It is a no-op, exit 0, when
// the client is not connected.
//
// ";" rather than "&&" so the line can be pasted into any of the shells a user
// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&"
// as a syntax error.
func UpCommand(flags string) string {
return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags)
}
// Denial is a refusal the daemon explained, read back off the error it raised.
// The reason identifies which refusal it was, so a consumer can present each one
// in its own way without matching on message text.
type Denial struct {
Reason string
Summary string
Command string
}
// DenialFrom returns the refusal a daemon error explains, if it explains one.
func DenialFrom(err error) (Denial, bool) {
if err == nil {
return Denial{}, false
}
st := status.Convert(err)
for _, detail := range st.Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok || info.GetDomain() != ErrorDomain {
continue
}
summary := info.GetMetadata()[ErrorMetaSummary]
if summary == "" {
// A detail with no summary still refused something. The status
// message carries the same sentence, and showing it beats showing
// a consumer nothing.
summary = strings.TrimSpace(st.Message())
}
return Denial{
Reason: info.GetReason(),
Summary: summary,
Command: info.GetMetadata()[ErrorMetaCommand],
}, true
}
return Denial{}, false
}
// PrivilegeError builds the PermissionDenied carrying summary and command.
func PrivilegeError(summary, command string) error {
return denialError(ErrorReasonPrivilegeRequired, summary, command)
}
// SessionHeldError refuses an operation because another user has the machine
// connected.
func SessionHeldError(action string) error {
return denialError(ErrorReasonSessionHeld, sessionHeldSummary(action), ElevatedCommand("netbird down"))
}
// NotOwnerError refuses an operation because the profile it addresses belongs to
// somebody else.
func NotOwnerError(action string) error {
return denialError(ErrorReasonNotProfileOwner, notOwnerSummary(action), "")
}
// sessionHeldSummary says whose the connection is and why that settles it.
func sessionHeldSummary(action string) string {
return refusedSubject(action) + " refused while another user has this machine connected. " +
"The active profile and the connection on it belong to the user who brought it up, " +
"so the connection has to come down before anyone else can use the machine."
}
// notOwnerSummary says who the profile belongs to and why that settles it.
func notOwnerSummary(action string) string {
return refusedSubject(action) + " refused because the profile it addresses belongs to another user. " +
"A profile and the configuration on it stay with the account that created or claimed it, " +
"so use one of your own or ask an administrator to hand this one over."
}
// refusedSubject opens a refusal with what was refused, falling back to the
// command itself for a method that names no action.
func refusedSubject(action string) string {
if action == "" {
return "This command is"
}
return capitalize(action) + " is"
}
// denialError builds a PermissionDenied carrying a summary a client can render,
// and a command when there is one to give.
func denialError(reason, summary, command string) error {
message := summary
metadata := map[string]string{ErrorMetaSummary: summary}
if command != "" {
message = fmt.Sprintf("%s\n\n%s", summary, command)
metadata[ErrorMetaCommand] = command
}
st := status.New(codes.PermissionDenied, message)
detailed, err := st.WithDetails(&errdetails.ErrorInfo{
Reason: reason,
Domain: ErrorDomain,
Metadata: metadata,
})
if err != nil {
log.Debugf("attach %s error detail: %v", reason, err)
return st.Err()
}
return detailed.Err()
}
// RequiredActor names who may perform the operation and adjusts the command to
// match. A daemon that is not itself privileged delegates to its own identity, so
// telling that host's user to become root is wrong twice over: root is not what the
// daemon checks for, and a rootless container has neither root nor sudo.
func RequiredActor(command string) (string, string) {
self, delegates := SelfDelegatesTo()
if !delegates {
return PrivilegedActor(), command
}
return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "")
}
// PrivilegeSummary states what is refused and what it needs, in one sentence
// that reads the same in a dialog and in a terminal.
func PrivilegeSummary(action, actor string) string {
return fmt.Sprintf("%s requires %s.", capitalize(action), actor)
}
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}