mirror of
https://github.com/slackhq/nebula.git
synced 2026-11-15 19:26:52 +01:00
Add a local control socket and nebula ctl (#1975)
Add support for accessing the previously ssh-only debug commands via `nebula ctl <command>.` - unix socket (named pipe on windows) for communication - sockets scoped to the user + an optional group - in cmd/nebula and cmd/nebula-service, but not nebula.Main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a5bdb508e
commit
0f767ce529
54 files changed
+7581
-925
No files matched your search
@@ -0,0 +1,255 @@
|
||||
//go:build windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Microsoft/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// pipePrefix is where windows named pipes live. The pipe's own security descriptor
|
||||
// carries the access control that the socket's mode carries on unix.
|
||||
const pipePrefix = `\\.\pipe\`
|
||||
|
||||
// protectedPrefix is the pipe namespace that only administrators and SYSTEM may create
|
||||
// names under; the kernel refuses everyone else. The default name lives there, so the
|
||||
// name itself says who could have created the pipe answering it: no ordinary user can
|
||||
// have put anything under it ahead of the daemon. Tailscale, WireGuard and NetBird keep
|
||||
// their daemon pipes there for the same reason. nebula on windows is already an
|
||||
// administrator, since creating the tun adapter requires it.
|
||||
const protectedPrefix = pipePrefix + `ProtectedPrefix\Administrators\`
|
||||
|
||||
const network = "pipe"
|
||||
|
||||
// currentUID is the identity the unix owner checks are made for. There is no uid here
|
||||
// and no per-connection check that would read one, so the value is never consulted; it
|
||||
// exists so a Server is built the same way on every platform.
|
||||
var currentUID = func() uint32 { return 0 }
|
||||
|
||||
// listen creates the named pipe at path with a security descriptor that admits the user
|
||||
// nebula runs as, SYSTEM and Administrators, and the group acc names, if any, for
|
||||
// reading and writing, which is what connecting to a pipe takes. acc's mode means
|
||||
// nothing to a pipe.
|
||||
//
|
||||
// The first instance is created, never opened, so a name some other process already
|
||||
// holds is refused rather than joined: that process would otherwise answer clients in
|
||||
// nebula's place. Under protectedPrefix only an administrator could hold it; anywhere
|
||||
// else any account could. Either way the refusal is reported as errEndpointHeld for the
|
||||
// caller to log as the event it is, except when the refusal is the namespace's own: a
|
||||
// daemon that is not an administrator cannot create under protectedPrefix at all, and
|
||||
// that is told apart so the operator is sent to elevate or to name a pipe elsewhere
|
||||
// rather than to look for a process that is not there.
|
||||
func listen(path string, acc Access) (net.Listener, error) {
|
||||
sddl, err := sddlFor(acc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not build a security descriptor for %s: %w", path, err)
|
||||
}
|
||||
|
||||
ln, err := winio.ListenPipe(path, &winio.PipeConfig{
|
||||
SecurityDescriptor: sddl,
|
||||
InputBufferSize: int32(MaxRequestBytes),
|
||||
OutputBufferSize: 64 * 1024,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, listenRefused(path, isAdministrator(), err)
|
||||
}
|
||||
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
// listenRefused turns a failure to create the pipe at path into the error the caller
|
||||
// logs. admin says whether this process may create under protectedPrefix.
|
||||
//
|
||||
// ERROR_ACCESS_DENIED is ambiguous: it is what the kernel says to a non-administrator
|
||||
// creating under protectedPrefix, and also what an existing instance whose DACL does
|
||||
// not admit us says to a second create. A process that is an administrator cannot be
|
||||
// getting the first, so for it, and for every name outside the prefix, access denied
|
||||
// means held.
|
||||
func listenRefused(path string, admin bool, err error) error {
|
||||
if errors.Is(err, windows.ERROR_ACCESS_DENIED) && isProtectedPath(path) && !admin {
|
||||
return fmt.Errorf("creating %s requires an administrator, as only administrators may create pipes under %s; run nebula elevated, or set %s to a pipe name outside it: %w", path, protectedPrefix, CtlSocketKey, err)
|
||||
}
|
||||
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) || errors.Is(err, windows.ERROR_ACCESS_DENIED) {
|
||||
return fmt.Errorf("%w: %s: %w", errEndpointHeld, path, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// isProtectedPath says whether path is a name under protectedPrefix. Pipe names are
|
||||
// case insensitive, so the comparison is too.
|
||||
func isProtectedPath(path string) bool {
|
||||
return len(path) > len(protectedPrefix) && strings.EqualFold(path[:len(protectedPrefix)], protectedPrefix)
|
||||
}
|
||||
|
||||
// isAdministrator says whether this process holds Administrators as an enabled group,
|
||||
// which is what creating under protectedPrefix takes. SYSTEM holds it; an elevated
|
||||
// administrator holds it; an administrator's unelevated token carries it deny-only,
|
||||
// which the membership check correctly reads as no. The membership is checked against
|
||||
// the thread's own token: CheckTokenMembership wants an impersonation token and makes
|
||||
// one from the primary token when handed none. A check that cannot be made is read as
|
||||
// not an administrator, which only changes the wording of a failure that has already
|
||||
// happened.
|
||||
func isAdministrator() bool {
|
||||
admins, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
member, err := windows.Token(0).IsMember(admins)
|
||||
return err == nil && member
|
||||
}
|
||||
|
||||
// sddlFor grants full access to the account nebula is running as, to SYSTEM and to
|
||||
// Administrators, and pipeClientAccess to the group acc names, if any; nobody else. P
|
||||
// makes the DACL protected, so it does not inherit entries that would widen it. It is
|
||||
// dockerd's descriptor for its pipe, with its -G group in the same place, except for
|
||||
// the group's mask; see pipeClientAccess.
|
||||
//
|
||||
// Administrators is the counterpart of the uid 0 allowance in the unix peer check. The
|
||||
// service normally runs as LocalSystem, so without it an operator in an elevated shell
|
||||
// could not reach the daemon at all; and an administrator can already stop the service,
|
||||
// so this grants no authority they did not have.
|
||||
func sddlFor(acc Access) (string, error) {
|
||||
token := windows.GetCurrentProcessToken()
|
||||
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sddl := fmt.Sprintf("D:P(A;;GA;;;%s)(A;;GA;;;SY)(A;;GA;;;BA)", user.User.Sid.String())
|
||||
if acc.shared() {
|
||||
sddl += fmt.Sprintf("(A;;0x%x;;;%s)", pipeClientAccess, acc.Group.ID)
|
||||
}
|
||||
return sddl, nil
|
||||
}
|
||||
|
||||
// pipeClientAccess is what a client needs on the pipe to talk to the daemon, and all a
|
||||
// configured group is granted: read and write the data, read the pipe's attributes
|
||||
// and its security descriptor, which the owner check needs, and wait on it. Not the
|
||||
// generic rights. On a named pipe GENERIC_WRITE carries FILE_CREATE_PIPE_INSTANCE, the
|
||||
// right to put a second instance under the same name, and a group member with that
|
||||
// could sit on the name and answer other clients in the daemon's place; the client's
|
||||
// owner check would not catch it, since the instance it reached would be the
|
||||
// member's. A client therefore has to ask for exactly this, as dial does, and a
|
||||
// program that opens the info pipe with generic read and write is refused.
|
||||
const pipeClientAccess uint32 = windows.FILE_READ_DATA | windows.FILE_WRITE_DATA |
|
||||
windows.FILE_READ_ATTRIBUTES | windows.READ_CONTROL | windows.SYNCHRONIZE
|
||||
|
||||
// applyAccess cannot change a pipe's access in place: its security descriptor is fixed
|
||||
// when it is created, so a change means creating it again, which the caller does.
|
||||
func applyAccess(path string, acc Access) error { return errReopenForAccess }
|
||||
|
||||
// removeSocket is a no-op: a named pipe disappears when its last handle closes, so
|
||||
// there is nothing to unlink and no stale instance to clean up.
|
||||
func removeSocket(path string) {}
|
||||
|
||||
// dial connects to the pipe and, before the connection carries anything, checks who
|
||||
// serves it. The request names a command and its arguments, so it must not reach a
|
||||
// pipe that some other account put under nebula's name. For the default name the
|
||||
// namespace already promises that, since only an administrator could have created it;
|
||||
// the check is what covers a CtlSocketKey override outside protectedPrefix, and costs
|
||||
// nothing to make on every name.
|
||||
func dial(path string, timeout time.Duration) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
conn, err := winio.DialPipeAccess(ctx, path, pipeClientAccess)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := checkPipeOwner(conn, path); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// checkPipeOwner refuses a pipe whose owner is not an account that could be running
|
||||
// nebula. It is the unix owner check made from the other side: the handle winio dialed
|
||||
// exposes the pipe object, and the object's owner is whoever created it.
|
||||
//
|
||||
// The owner is what makes this sound. An account can only create an object owned by
|
||||
// itself, or by a SID it holds with the owner flag, unless it has SeRestorePrivilege,
|
||||
// which ordinary users do not; so a pipe owned by SYSTEM, Administrators or a service
|
||||
// account was put there by one of them, and never by a user squatting the name. winio
|
||||
// dials with SECURITY_ANONYMOUS, so the impostor could not have impersonated the client
|
||||
// either, but it could have read the command and answered it with whatever it liked.
|
||||
func checkPipeOwner(conn net.Conn, path string) error {
|
||||
h, ok := conn.(interface{ Fd() uintptr })
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: cannot identify who serves the pipe", path)
|
||||
}
|
||||
|
||||
sd, err := windows.GetSecurityInfo(windows.Handle(h.Fd()), windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: could not read the pipe's owner: %w", path, err)
|
||||
}
|
||||
|
||||
owner, _, err := sd.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: could not read the pipe's owner: %w", path, err)
|
||||
}
|
||||
if owner == nil {
|
||||
return fmt.Errorf("%s has no owner; refusing to send to it", path)
|
||||
}
|
||||
|
||||
me, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: could not read own identity: %w", path, err)
|
||||
}
|
||||
|
||||
if ownerAccepted(owner, me.User.Sid) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s is served by %s, not by nebula; run nebula ctl as that user, or pass -config or -socket to name the daemon's pipe", path, describeSID(owner))
|
||||
}
|
||||
|
||||
// ownerAccepted says whether a pipe owned by owner may be sent a request by a client
|
||||
// running as me: the client's own account, and the two accounts a daemon runs under
|
||||
// that no ordinary user can create an object as, SYSTEM and Administrators. Another
|
||||
// account is refused, whoever the client is; the daemon's DACL decides who may connect,
|
||||
// and this decides who may answer.
|
||||
func ownerAccepted(owner, me *windows.SID) bool {
|
||||
return owner.Equals(me) ||
|
||||
owner.IsWellKnown(windows.WinLocalSystemSid) ||
|
||||
owner.IsWellKnown(windows.WinBuiltinAdministratorsSid)
|
||||
}
|
||||
|
||||
// describeSID renders a SID with its account name when the host can say what it is, so
|
||||
// a message about somebody else's pipe tells the operator who that is.
|
||||
func describeSID(sid *windows.SID) string {
|
||||
account, domain, _, err := sid.LookupAccount("")
|
||||
if err != nil || account == "" {
|
||||
return sid.String()
|
||||
}
|
||||
if domain == "" {
|
||||
return account
|
||||
}
|
||||
return domain + `\` + account
|
||||
}
|
||||
|
||||
// DefaultSocketPath is the control pipe when the config names none. It is under
|
||||
// protectedPrefix, so whatever answers it was created by an administrator or SYSTEM; see
|
||||
// there.
|
||||
const DefaultSocketPath = protectedPrefix + "nebula-ctl"
|
||||
|
||||
// checkSocketPath refuses a configured name that is not a full pipe name. A pipe lives
|
||||
// under pipePrefix and nowhere else, so a bare name has no meaning here, and one under
|
||||
// pipePrefix is used as given. An override outside protectedPrefix is a name any
|
||||
// account could have created first; the owner check in dial is what protects a client
|
||||
// of one.
|
||||
func checkSocketPath(key, value string) error {
|
||||
if len(value) <= len(pipePrefix) || !strings.EqualFold(value[:len(pipePrefix)], pipePrefix) {
|
||||
return fmt.Errorf(`%s must be a full pipe name under %s, got %q`, key, pipePrefix, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user