From f60ac9e746d8d032eab4dc7853f4807398a5b093 Mon Sep 17 00:00:00 2001 From: "Theodor S. Midtlien" Date: Thu, 23 Jul 2026 20:27:06 +0200 Subject: [PATCH] Wire up json socket as a named pipe with metdata exchange to daemon --- client/cmd/root.go | 35 ++++++++++++-------- client/cmd/service.go | 2 +- client/cmd/service_controller.go | 2 +- client/cmd/service_json_gateway.go | 20 ++++++----- client/cmd/service_socket.go | 2 +- client/internal/ipcauth/creds_stub.go | 13 +++++++- client/internal/ipcauth/creds_unix.go | 9 ++++- client/internal/ipcauth/creds_windows.go | 18 ++++++---- client/internal/ipcauth/forward.go | 37 ++++++++++++++++----- client/internal/ipcauth/forward_test.go | 42 ++++++++++++++++++++++++ 10 files changed, 139 insertions(+), 41 deletions(-) create mode 100644 client/internal/ipcauth/forward_test.go diff --git a/client/cmd/root.go b/client/cmd/root.go index 47b06077d..eddf4e543 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -269,18 +269,17 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string { } // DialClientGRPCServer returns client connection to the daemon server. -func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { - ctx, cancel := context.WithTimeout(ctx, time.Second*10) - defer cancel() - - opts = append([]grpc.DialOption{ - grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithBlock()}, opts...) - - // The daemon reads the caller's kernel identity from the transport - // (SO_PEERCRED on a Unix socket, the client token on a Windows named pipe), - // so the client stays insecure. For npipe we install a context dialer since - // gRPC's resolver does not understand Windows named pipes. +// daemonDialTarget returns the gRPC dial target and base options for the daemon +// address, handling the npipe scheme (Windows named pipe, via a context dialer) +// and unix/tcp. It sets insecure transport credentials but NOT WithBlock, so it +// serves both the blocking CLI dial and the JSON gateway's lazy client. +// +// The daemon reads the caller's kernel identity from the transport (SO_PEERCRED +// on a Unix socket, the client token on a Windows named pipe), so the client +// stays insecure. gRPC's resolver does not understand Windows named pipes, hence +// the context dialer. +func daemonDialTarget(addr string) (string, []grpc.DialOption) { + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} target := strings.TrimPrefix(addr, "tcp://") if strings.HasPrefix(addr, "npipe://") { path := pipePath(strings.TrimPrefix(addr, "npipe://")) @@ -289,8 +288,18 @@ func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOpt })) target = "passthrough:///netbird-daemon-pipe" } + return target, opts +} - return grpc.DialContext(ctx, target, opts...) +func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + + target, dialOpts := daemonDialTarget(addr) + dialOpts = append(dialOpts, grpc.WithBlock()) + dialOpts = append(dialOpts, opts...) + + return grpc.DialContext(ctx, target, dialOpts...) } // WithBackOff execute function in backoff cycle. diff --git a/client/cmd/service.go b/client/cmd/service.go index b0a56c71a..cb79c43dc 100644 --- a/client/cmd/service.go +++ b/client/cmd/service.go @@ -54,7 +54,7 @@ func init() { serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture") serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks") serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket") - serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket") + serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp|npipe]://[path|host:port|name]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket") rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name") serviceEnvDesc := `Sets extra environment variables for the service. ` + diff --git a/client/cmd/service_controller.go b/client/cmd/service_controller.go index 10a39d23d..a74ffaada 100644 --- a/client/cmd/service_controller.go +++ b/client/cmd/service_controller.go @@ -121,7 +121,7 @@ func (p *program) Start(svc service.Service) error { p.serverInstanceMu.Unlock() if jsonListener != nil { - log.Warnf("JSON gateway (--enable-json-socket) re-dials the daemon locally; the HTTP client's identity is forwarded so per-caller authorization still applies, but restrict access to %s appropriately", jsonSocket) + log.Warnf("JSON gateway (--enable-json-socket) re-dials the daemon locally. The HTTP client's identity is forwarded so per-caller authorization still applies, but restrict access to %s appropriately", jsonSocket) if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil { log.Fatalf("failed to start daemon JSON server: %v", err) } diff --git a/client/cmd/service_json_gateway.go b/client/cmd/service_json_gateway.go index 8c39c5f45..1473da688 100644 --- a/client/cmd/service_json_gateway.go +++ b/client/cmd/service_json_gateway.go @@ -5,15 +5,14 @@ package cmd import ( "context" "errors" + "fmt" "net" "net/http" - "strings" "time" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" log "github.com/sirupsen/logrus" "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "github.com/netbirdio/netbird/client/internal/ipcauth" @@ -28,7 +27,7 @@ type jsonPeerCtxKey struct{} // gateway re-dials the daemon as the daemon's own identity, so without this the // daemon would see every JSON request as privileged. func jsonConnContext(ctx context.Context, c net.Conn) context.Context { - id, err := ipcauth.PeerIdentity(c) + id, err := ipcauth.ConnIdentity(c) if err != nil { log.Debugf("json gateway: cannot read HTTP client identity, requests won't carry it: %v", err) return ctx @@ -47,14 +46,17 @@ func jsonForwardIdentity(ctx context.Context, _ *http.Request) metadata.MD { return ipcauth.ForwardIdentityMetadata(id) } -func grpcGatewayEndpoint(addr string) string { - return strings.TrimPrefix(addr, "tcp://") -} - func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error { mux := runtime.NewServeMux(runtime.WithMetadata(jsonForwardIdentity)) - opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} - if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil { + + // Lazy client to the daemon, npipe-aware (grpc.NewClient does not connect + // until the first request, so this does not block startup before Serve). + target, opts := daemonDialTarget(daemonEndpoint) + conn, err := grpc.NewClient(target, opts...) + if err != nil { + return fmt.Errorf("create daemon client for JSON gateway: %w", err) + } + if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil { return err } diff --git a/client/cmd/service_socket.go b/client/cmd/service_socket.go index 511b1364b..99ed96a03 100644 --- a/client/cmd/service_socket.go +++ b/client/cmd/service_socket.go @@ -50,7 +50,7 @@ func listenOnAddress(addr string) (*socketListener, error) { func parseListenAddress(addr string) (string, string, error) { network, address, ok := strings.Cut(addr, "://") if !ok || network == "" || address == "" { - return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr) + return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr) } switch network { diff --git a/client/internal/ipcauth/creds_stub.go b/client/internal/ipcauth/creds_stub.go index 9af2cd695..d4d5893ce 100644 --- a/client/internal/ipcauth/creds_stub.go +++ b/client/internal/ipcauth/creds_stub.go @@ -2,7 +2,13 @@ package ipcauth -import "google.golang.org/grpc/credentials" +import ( + "fmt" + "net" + "runtime" + + "google.golang.org/grpc/credentials" +) // NewTransportCredentials returns nil on platforms without a peer-identity // primitive. The daemon falls back to insecure credentials and skips per-RPC @@ -10,3 +16,8 @@ import "google.golang.org/grpc/credentials" func NewTransportCredentials() credentials.TransportCredentials { return nil } + +// ConnIdentity is unsupported on platforms without a peer-identity primitive. +func ConnIdentity(net.Conn) (Identity, error) { + return Identity{}, fmt.Errorf("peer identity not supported on %s", runtime.GOOS) +} diff --git a/client/internal/ipcauth/creds_unix.go b/client/internal/ipcauth/creds_unix.go index b63e2ed60..3cddc531d 100644 --- a/client/internal/ipcauth/creds_unix.go +++ b/client/internal/ipcauth/creds_unix.go @@ -27,9 +27,16 @@ func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (ne return conn, AuthInfo{}, nil } +// ConnIdentity extracts the caller's identity from an accepted local IPC +// connection. On Unix it reads peer credentials from the socket. It is shared by +// the gRPC transport credentials and the JSON gateway (which forwards it). +func ConnIdentity(conn net.Conn) (Identity, error) { + return PeerIdentity(conn) +} + // ServerHandshake extracts the peer identity and fails closed if it cannot be read. func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { - id, err := PeerIdentity(conn) + id, err := ConnIdentity(conn) if err != nil { return nil, nil, err } diff --git a/client/internal/ipcauth/creds_windows.go b/client/internal/ipcauth/creds_windows.go index 6151a050d..6b10ac5ac 100644 --- a/client/internal/ipcauth/creds_windows.go +++ b/client/internal/ipcauth/creds_windows.go @@ -42,17 +42,23 @@ func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) return conn, AuthInfo{}, nil } -// ServerHandshake extracts the connecting client's identity from the pipe. Fails -// closed if the handle or token cannot be read. -func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { +// ConnIdentity extracts the caller's identity from an accepted named-pipe +// connection by impersonating the pipe client and reading its token. It is +// shared by the gRPC transport credentials and the JSON gateway (which forwards +// it). Requires the client to have connected at SECURITY_IDENTIFICATION. +func ConnIdentity(conn net.Conn) (Identity, error) { // go-winio's pipe connection embeds *win32File, which exposes Fd(). fdConn, ok := conn.(interface{ Fd() uintptr }) if !ok { - return nil, nil, fmt.Errorf("connection %T does not expose a pipe handle", conn) + return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn) } - handle := windows.Handle(fdConn.Fd()) + return pipeClientIdentity(windows.Handle(fdConn.Fd())) +} - id, err := pipeClientIdentity(handle) +// ServerHandshake extracts the connecting client's identity from the pipe. Fails +// closed if the handle or token cannot be read. +func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) { + id, err := ConnIdentity(conn) if err != nil { return nil, nil, err } diff --git a/client/internal/ipcauth/forward.go b/client/internal/ipcauth/forward.go index c1b932040..6aec00650 100644 --- a/client/internal/ipcauth/forward.go +++ b/client/internal/ipcauth/forward.go @@ -12,16 +12,28 @@ import ( // itself the daemon (self/privileged) — i.e. the loopback gateway — so a direct // gRPC caller cannot forge them. const ( - mdFwdUID = "x-netbird-fwd-uid" - mdFwdGID = "x-netbird-fwd-gid" + mdFwdUID = "x-netbird-fwd-uid" // Unix + mdFwdGID = "x-netbird-fwd-gid" // Unix + mdFwdSID = "x-netbird-fwd-sid" // Windows user SID + mdFwdGroup = "x-netbird-fwd-group" // Windows group SID (repeated) + mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" if elevated ) -// ForwardIdentityMetadata encodes a Unix identity for the gateway to forward to -// the daemon. Windows identities are not forwarded (the gateway cannot read a -// pipe token for an HTTP client); nil is returned in that case. +// ForwardIdentityMetadata encodes an identity for the gateway to forward to the +// daemon — Unix uid/gid, or the Windows user SID + enabled group SIDs + +// elevation. Both are supported so the gateway works whether the JSON socket is +// a Unix socket or a named pipe. func ForwardIdentityMetadata(id Identity) metadata.MD { if id.IsWindows() { - return nil + md := metadata.MD{} + md.Set(mdFwdSID, id.SID) + if len(id.Groups) > 0 { + md.Set(mdFwdGroup, id.Groups...) + } + if id.Elevated { + md.Set(mdFwdElevated, "1") + } + return md } return metadata.Pairs( mdFwdUID, strconv.FormatUint(uint64(id.UID), 10), @@ -29,13 +41,22 @@ func ForwardIdentityMetadata(id Identity) metadata.MD { ) } -// forwardedIdentity extracts a forwarded Unix identity from incoming gRPC -// metadata, if present and well-formed. +// forwardedIdentity extracts a forwarded identity from incoming gRPC metadata, +// if present and well-formed. Windows (SID) takes precedence over Unix (uid). func forwardedIdentity(ctx context.Context) (Identity, bool) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return Identity{}, false } + + if sid := mdFirst(md, mdFwdSID); sid != "" { + return Identity{ + SID: sid, + Groups: md.Get(mdFwdGroup), + Elevated: mdFirst(md, mdFwdElevated) == "1", + }, true + } + uidStr := mdFirst(md, mdFwdUID) if uidStr == "" { return Identity{}, false diff --git a/client/internal/ipcauth/forward_test.go b/client/internal/ipcauth/forward_test.go new file mode 100644 index 000000000..ce9596f1d --- /dev/null +++ b/client/internal/ipcauth/forward_test.go @@ -0,0 +1,42 @@ +package ipcauth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/grpc/metadata" +) + +func TestForwardIdentityRoundTrip(t *testing.T) { + cases := []struct { + name string + id Identity + }{ + {"unix uid/gid", Identity{UID: 1000, GID: 1000}}, + {"windows sid+groups+elevated", Identity{ + SID: "S-1-5-21-1-2-3-1001", + Groups: []string{"S-1-5-32-544", "S-1-1-0"}, + Elevated: true, + }}, + {"windows sid only", Identity{SID: "S-1-5-21-9"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := metadata.NewIncomingContext(context.Background(), ForwardIdentityMetadata(tc.id)) + got, ok := forwardedIdentity(ctx) + assert.True(t, ok) + assert.Equal(t, tc.id, got) + }) + } +} + +func TestForwardedIdentity_None(t *testing.T) { + _, ok := forwardedIdentity(context.Background()) + assert.False(t, ok, "no metadata → no forwarded identity") + + // Empty metadata (no forwarding keys) → none. + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("other", "x")) + _, ok = forwardedIdentity(ctx) + assert.False(t, ok) +}