mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-30 03:12:36 -04:00
283 lines
11 KiB
Go
283 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"runtime"
|
|
"strings"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
|
"google.golang.org/grpc/codes"
|
|
gstatus "google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/daemonaddr"
|
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
"github.com/netbirdio/netbird/util"
|
|
)
|
|
|
|
// The daemon runs as root/LocalSystem, so a handful of config changes cross the
|
|
// user-to-root boundary and are restricted to privileged callers:
|
|
//
|
|
// - Enabling SSH root login, or disabling SSH authentication, turns the
|
|
// daemon's SSH server into a root (or unauthenticated) shell.
|
|
// - Enabling the SSH server at all is what makes the above reachable, and a
|
|
// profile the caller owns is not a privilege they hold.
|
|
// - While the SSH server is enabled, repointing the profile at another
|
|
// management identity hands SSH authorization decisions, including which
|
|
// keys and users are accepted, to whoever controls that identity. Changing
|
|
// the management URL and deregistering the peer are both ways to do that.
|
|
//
|
|
// Everything else stays unauthenticated, so this is not an authorization model:
|
|
// it only refuses the changes that would let a local user become root. A caller
|
|
// whose identity cannot be established is refused as well.
|
|
|
|
// privilegedConfigChange is the subset of a config request that crosses the
|
|
// user-to-root boundary. Fields are nil or empty when the request leaves them
|
|
// untouched.
|
|
type privilegedConfigChange struct {
|
|
managementURL string
|
|
serverSSHAllowed *bool
|
|
enableSSHRoot *bool
|
|
disableSSHAuth *bool
|
|
}
|
|
|
|
func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange {
|
|
return privilegedConfigChange{
|
|
managementURL: msg.GetManagementUrl(),
|
|
serverSSHAllowed: msg.ServerSSHAllowed,
|
|
enableSSHRoot: msg.EnableSSHRoot,
|
|
disableSSHAuth: msg.DisableSSHAuth,
|
|
}
|
|
}
|
|
|
|
func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
|
|
return privilegedConfigChange{
|
|
managementURL: msg.GetManagementUrl(),
|
|
serverSSHAllowed: msg.ServerSSHAllowed,
|
|
enableSSHRoot: msg.EnableSSHRoot,
|
|
disableSSHAuth: msg.DisableSSHAuth,
|
|
}
|
|
}
|
|
|
|
// requirePrivilegeForConfigChange refuses the privileged parts of a config
|
|
// change when the caller is not root/administrator. stored is the profile's
|
|
// current config, or nil when it has none yet.
|
|
//
|
|
// Each check compares against the stored value so that a request restating a
|
|
// value it does not change is never refused: a UI that submits the whole
|
|
// settings form must not start failing once an administrator has enabled SSH.
|
|
func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error {
|
|
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) {
|
|
return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root"))
|
|
}
|
|
|
|
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) {
|
|
return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth"))
|
|
}
|
|
|
|
if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) {
|
|
return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
|
|
}
|
|
|
|
// Only guard the management binding while the SSH server is enabled: that is
|
|
// when the management identity decides who may open a shell here.
|
|
if !sshServerEnabled(stored) {
|
|
return nil
|
|
}
|
|
|
|
if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) {
|
|
return denyPrivileged(ctx,
|
|
"changing the management URL while the NetBird SSH server is enabled",
|
|
ipcauth.UpCommand("-m "+change.managementURL))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// requirePrivilegeForDeregistration refuses to deregister the peer from the
|
|
// management server when the caller is not privileged and the profile has the
|
|
// SSH server enabled. Deregistering frees the peer's key to be registered
|
|
// against another management identity, which is the same handover the
|
|
// management URL check refuses.
|
|
//
|
|
// Callers that treat deregistration as best-effort (profile removal) continue
|
|
// without it; callers that were asked to deregister surface the error.
|
|
func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error {
|
|
if !sshServerEnabled(cfg) {
|
|
return nil
|
|
}
|
|
|
|
return denyPrivileged(ctx,
|
|
"deregistering this peer while the NetBird SSH server is enabled",
|
|
ipcauth.ElevatedCommand("netbird logout"))
|
|
}
|
|
|
|
// denyPrivileged returns nil when the caller is privileged, and otherwise a
|
|
// PermissionDenied whose message names the action and the command that performs
|
|
// it with the privileges it needs. The same summary and command ride along as an
|
|
// ErrorInfo detail so the CLI and the UI can present them without parsing text.
|
|
//
|
|
// action reads as the subject of a sentence ("enabling SSH root login"), and
|
|
// command is the equivalent command, already elevated for the platform.
|
|
func denyPrivileged(ctx context.Context, action, command string) error {
|
|
id, ok := ipcauth.CallerIdentity(ctx)
|
|
if !ok {
|
|
log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action)
|
|
return privilegeError(unidentifiedSummary(action), reinstallCommand())
|
|
}
|
|
|
|
if ipcauth.IsPrivilegedCaller(id) {
|
|
log.Infof("allowing %s for privileged caller %s", action, id)
|
|
return nil
|
|
}
|
|
|
|
log.Warnf("denying %s for unprivileged caller %s", action, id)
|
|
actor, command := requiredActor(command)
|
|
return privilegeError(privilegeSummary(action, actor), command)
|
|
}
|
|
|
|
// 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 := ipcauth.SelfDelegatesTo()
|
|
if !delegates {
|
|
return ipcauth.PrivilegedActor(), command
|
|
}
|
|
return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "")
|
|
}
|
|
|
|
// privilegeError builds the PermissionDenied carrying summary and command.
|
|
func privilegeError(summary, command string) error {
|
|
st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command))
|
|
|
|
detailed, err := st.WithDetails(&errdetails.ErrorInfo{
|
|
Reason: ipcauth.ErrorReasonPrivilegeRequired,
|
|
Domain: ipcauth.ErrorDomain,
|
|
Metadata: map[string]string{
|
|
ipcauth.ErrorMetaSummary: summary,
|
|
ipcauth.ErrorMetaCommand: command,
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Debugf("attach privilege error detail: %v", err)
|
|
return st.Err()
|
|
}
|
|
return detailed.Err()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// unidentifiedSummary covers a control channel that carries no caller identity.
|
|
// Elevating does not help there, so it points at the daemon's socket instead.
|
|
func unidentifiedSummary(action string) string {
|
|
return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+
|
|
"Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor())
|
|
}
|
|
|
|
// reinstallCommand is the command that moves the daemon onto a socket whose
|
|
// callers can be identified.
|
|
func reinstallCommand() string {
|
|
if runtime.GOOS == "windows" {
|
|
return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr)
|
|
}
|
|
return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock"
|
|
}
|
|
|
|
func capitalize(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
return strings.ToUpper(s[:1]) + s[1:]
|
|
}
|
|
|
|
// enables reports whether requested turns a flag on that is currently off. A
|
|
// request that restates the stored value, or turns the flag off, is not a
|
|
// privileged change.
|
|
func enables(stored, requested *bool) bool {
|
|
if requested == nil || !*requested {
|
|
return false
|
|
}
|
|
return stored == nil || !*stored
|
|
}
|
|
|
|
// storedFlag reads a flag from the stored config, tolerating a config that does
|
|
// not exist yet.
|
|
func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return get(cfg)
|
|
}
|
|
|
|
// sshServerEnabled reports whether the profile currently runs the SSH server.
|
|
//
|
|
// A nil flag means ON, matching what the engine does with the same config
|
|
// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written
|
|
// before the flag existed). Reading it as OFF here would open the management-URL
|
|
// and deregistration guards on exactly those legacy hosts, whose SSH server is
|
|
// running. Configs loaded through profilemanager have already been materialised by
|
|
// apply(), so this is the same answer by a route that does not depend on that.
|
|
func sshServerEnabled(cfg *profilemanager.Config) bool {
|
|
if cfg == nil {
|
|
return false
|
|
}
|
|
return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed)
|
|
}
|
|
|
|
// sshServerCurrentlyAllowed is the value an enable request is compared against. It
|
|
// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy
|
|
// config is correctly seen as no change.
|
|
func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool {
|
|
enabled := sshServerEnabled(cfg)
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return &enabled
|
|
}
|
|
|
|
// sameManagementURL reports whether requested addresses the same management
|
|
// server as stored, comparing scheme, host and effective port so that an
|
|
// equivalent spelling ("https://api.netbird.io" for a stored
|
|
// "https://api.netbird.io:443") is not treated as a change. It fails closed:
|
|
// anything unparseable counts as a change and therefore needs privilege.
|
|
func sameManagementURL(stored *url.URL, requested string) bool {
|
|
if stored == nil {
|
|
return false
|
|
}
|
|
|
|
// Normalise the requested URL through the config layer's own parser, so the
|
|
// comparison cannot drift from how the value would actually be stored.
|
|
parsed, err := profilemanager.ParseServiceURL("Management URL", requested)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
return stored.Scheme == parsed.Scheme &&
|
|
stored.Hostname() == parsed.Hostname() &&
|
|
effectivePort(stored) == effectivePort(parsed)
|
|
}
|
|
|
|
func effectivePort(u *url.URL) string {
|
|
if port := u.Port(); port != "" {
|
|
return port
|
|
}
|
|
switch u.Scheme {
|
|
case "https":
|
|
return "443"
|
|
case "http":
|
|
return "80"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|