mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-30 03:21:29 +02:00
Authorize daemon IPC callers by their local identity
This commit is contained in:
282
client/server/ssh_gate.go
Normal file
282
client/server/ssh_gate.go
Normal file
@@ -0,0 +1,282 @@
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user