From fca6d1f16dc7681f99554c52e24cffdd2c72fda8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Tue, 13 Oct 2026 19:00:40 +0200 Subject: [PATCH 1/2] extpoints: Let extensions use a regular Engine API client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extensions had no supported way to call the Engine API. The configured hosts may be socket-activated or TLS-only TCP. Add the org.mobyproject.extension.daemon.api.v0 point. It returns the address of a private unix socket where the daemon serves its regular API handler. daemonapiv0.NewClient helper resolves the point and returns a *client.Client. Signed-off-by: Paweł Gronowski --- daemon/command/daemon.go | 8 + daemon/extensions.go | 37 +++- daemon/extensions_default.go | 4 + daemon/internal/extensionapi/api.go | 32 ++++ extpoints/daemonapi/v0/client.go | 25 +++ extpoints/daemonapi/v0/daemon_api.proto | 21 +++ extpoints/daemonapi/v0/daemonapi.go | 39 +++++ .../daemonapi/v0/protogen/daemon_api.pb.go | 165 ++++++++++++++++++ extpoints/daemonapi/v0/protogen/wire.gen.go | 156 +++++++++++++++++ integration/extension/apiclient_test.go | 52 ++++++ .../extension/testdata/apiclient/apiclient.go | 66 +++++++ .../testdata/apiclient/cmd/apiclient/main.go | 31 ++++ 12 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 daemon/internal/extensionapi/api.go create mode 100644 extpoints/daemonapi/v0/client.go create mode 100644 extpoints/daemonapi/v0/daemon_api.proto create mode 100644 extpoints/daemonapi/v0/daemonapi.go create mode 100644 extpoints/daemonapi/v0/protogen/daemon_api.pb.go create mode 100644 extpoints/daemonapi/v0/protogen/wire.gen.go create mode 100644 integration/extension/apiclient_test.go create mode 100644 integration/extension/testdata/apiclient/apiclient.go create mode 100644 integration/extension/testdata/apiclient/cmd/apiclient/main.go diff --git a/daemon/command/daemon.go b/daemon/command/daemon.go index 167d603870..da5bfc7c18 100644 --- a/daemon/command/daemon.go +++ b/daemon/command/daemon.go @@ -360,6 +360,14 @@ func (cli *daemonCLI) start(ctx context.Context) (retErr error) { if err != nil { return err } + // Serve the API to extensions on a private socket. + extLs, err := d.ExtensionAPIListener() + if err != nil { + return err + } + if extLs != nil { + lss = append(lss, extLs) + } httpServer.Protocols = &p httpServer.Handler = newHTTPHandler(ctx, gs, extProxy, apiServer.CreateMux(ctx, routers...)) diff --git a/daemon/extensions.go b/daemon/extensions.go index 32f0cdba06..c9b5f72567 100644 --- a/daemon/extensions.go +++ b/daemon/extensions.go @@ -2,7 +2,10 @@ package daemon import ( "context" + "errors" "fmt" + "io/fs" + "net" "os" "path/filepath" "slices" @@ -21,7 +24,7 @@ import ( // newExtensionHost builds the daemon's extension host. func (daemon *Daemon) newExtensionHost(ctx context.Context) (*host.Host, error) { cfg := daemon.Config() - runtimeDir := filepath.Join(cfg.ExecRoot, "extensions") + runtimeDir := extensionRuntimeDir(&cfg) if err := os.MkdirAll(runtimeDir, 0o700); err != nil { return nil, fmt.Errorf("create extension runtime directory: %w", err) } @@ -46,6 +49,38 @@ func (daemon *Daemon) newExtensionHost(ctx context.Context) (*host.Host, error) ) } +// extensionRuntimeDir holds the extension sockets. +// It is private to the daemon user. +func extensionRuntimeDir(cfg *config.Config) string { + return filepath.Join(cfg.ExecRoot, "extensions") +} + +// extensionAPISocket is the path of the API socket served for extensions. +func extensionAPISocket(cfg *config.Config) string { + return filepath.Join(extensionRuntimeDir(cfg), "api.sock") +} + +// ExtensionAPIListener listens on the API socket that the daemon.api point +// gives to extensions. +// It is separate from the configured hosts, which may be TLS-only TCP or +// socket-activated and so unusable by extensions. +// It returns nil if the daemon has no extension host. +func (daemon *Daemon) ExtensionAPIListener() (net.Listener, error) { + if daemon.extensionHost == nil { + return nil, nil + } + cfg := daemon.Config() + path := extensionAPISocket(&cfg) + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("remove stale extension API socket: %w", err) + } + ls, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("listen on extension API socket: %w", err) + } + return ls, nil +} + // extensionConfig converts daemon.json's extensions.configs entries to host form. func extensionConfig(cfg *config.Config) map[extensions.ExtensionID]extensions.Config { if len(cfg.Extensions.Configs) == 0 { diff --git a/daemon/extensions_default.go b/daemon/extensions_default.go index c27779abd0..abf2bd7ee9 100644 --- a/daemon/extensions_default.go +++ b/daemon/extensions_default.go @@ -10,11 +10,13 @@ import ( "github.com/moby/extensions/clientpoint" "github.com/moby/extensions/serverpoint" "github.com/moby/moby/v2/daemon/config" + "github.com/moby/moby/v2/daemon/internal/extensionapi" "github.com/moby/moby/v2/daemon/internal/extensionconfig" "github.com/moby/moby/v2/daemon/internal/extensionstorage/bbolt" containernamegeneratorpb "github.com/moby/moby/v2/extpoints/containernamegenerator/v0/protogen" containerstatepb "github.com/moby/moby/v2/extpoints/containerstate/v0/protogen" + daemonapipb "github.com/moby/moby/v2/extpoints/daemonapi/v0/protogen" daemonconfigpb "github.com/moby/moby/v2/extpoints/daemonconfig/v0/protogen" servicenamegeneratorpb "github.com/moby/moby/v2/extpoints/servicenamegenerator/v0/protogen" storagepb "github.com/moby/moby/v2/extpoints/storage/kv/v0/protogen" @@ -41,6 +43,7 @@ func (daemon *Daemon) daemonExtensions() []extensions.Extension { return []extensions.Extension{ extensionconfig.New(&cfg), newMonolithExtension(daemon), + extensionapi.New("unix://" + extensionAPISocket(&cfg)), } } @@ -96,5 +99,6 @@ func dependencyProviders() []serverpoint.Registration { storagepb.ServerPoint, daemonconfigpb.ServerPoint, containerstatepb.ServerPoint, + daemonapipb.ServerPoint, } } diff --git a/daemon/internal/extensionapi/api.go b/daemon/internal/extensionapi/api.go new file mode 100644 index 0000000000..d5b46d9085 --- /dev/null +++ b/daemon/internal/extensionapi/api.go @@ -0,0 +1,32 @@ +// Package extensionapi provides the daemon API endpoint to extensions. +// +// The endpoint is a temporary shortcut until extensions can depend on specific +// extension points for the daemon functionality they need. +// Give new functionality for extensions its own extension point instead of +// relying on this endpoint. +package extensionapi + +import ( + "context" + + "github.com/moby/extensions" + daemonapiv0 "github.com/moby/moby/v2/extpoints/daemonapi/v0" +) + +// New declares a provider for the API endpoint the daemon serves at host. +func New(host string) extensions.Extension { + return extensions.New(extensions.Declaration{ + ID: "org.mobyproject.daemon.api.v0", + Providers: []extensions.Provider{ + daemonapiv0.Point.Provide(endpoint{host: host}), + }, + }) +} + +type endpoint struct { + host string +} + +func (e endpoint) Get(context.Context, *daemonapiv0.GetRequest) (*daemonapiv0.GetResponse, error) { + return &daemonapiv0.GetResponse{Host: e.host}, nil +} diff --git a/extpoints/daemonapi/v0/client.go b/extpoints/daemonapi/v0/client.go new file mode 100644 index 0000000000..91a3839755 --- /dev/null +++ b/extpoints/daemonapi/v0/client.go @@ -0,0 +1,25 @@ +package daemonapiv0 + +import ( + "context" + "fmt" + + "github.com/moby/moby/client" +) + +// NewClient returns an Engine API client connected to the endpoint api +// reports. +// Resolve api with Point.Single during initialization, after declaring +// Point.Dependency(). +// opts are applied after the endpoint, so they may override it. +// +// The daemon serves the API only after every extension has initialized, so do +// not make API calls from Init; they would block daemon startup. +// The caller owns the returned client and must close it. +func NewClient(ctx context.Context, api DaemonAPI, opts ...client.Opt) (*client.Client, error) { + resp, err := api.Get(ctx, &GetRequest{}) + if err != nil { + return nil, fmt.Errorf("get daemon API endpoint: %w", err) + } + return client.New(append([]client.Opt{client.WithHost(resp.Host)}, opts...)...) +} diff --git a/extpoints/daemonapi/v0/daemon_api.proto b/extpoints/daemonapi/v0/daemon_api.proto new file mode 100644 index 0000000000..e787a8b3d0 --- /dev/null +++ b/extpoints/daemonapi/v0/daemon_api.proto @@ -0,0 +1,21 @@ +// Code generated by mobyextgen. DO NOT EDIT. + +syntax = "proto3"; + +package org.mobyproject.extension.daemon.api.v0; + +option go_package = "github.com/moby/moby/v2/extpoints/daemonapi/v0/protogen"; + +// DaemonAPI locates the Engine API endpoint the daemon serves for extensions. +// Use [NewClient] instead of calling it directly. +service DaemonAPI { + // Get returns the endpoint. + rpc Get(GetRequest) returns (GetResponse); +} + +message GetRequest { +} + +message GetResponse { + string host = 1; +} diff --git a/extpoints/daemonapi/v0/daemonapi.go b/extpoints/daemonapi/v0/daemonapi.go new file mode 100644 index 0000000000..0602cefd41 --- /dev/null +++ b/extpoints/daemonapi/v0/daemonapi.go @@ -0,0 +1,39 @@ +//go:generate go tool mobyextgen + +// Package daemonapiv0 gives Moby extensions access to the daemon's Engine API +// through a regular Moby API client. +// +// This is a temporary shortcut until Moby defines specific extension points +// for the daemon functionality extensions need. +// Once a specific point covers what an extension uses the API for, the +// extension should depend on that point instead. +package daemonapiv0 + +import ( + "context" + + "github.com/moby/extensions" +) + +// DaemonAPI locates the Engine API endpoint the daemon serves for extensions. +// Use [NewClient] instead of calling it directly. +type DaemonAPI interface { + // Get returns the endpoint. + Get(ctx context.Context, req *GetRequest) (*GetResponse, error) +} + +// Point is single-cardinality for the daemon API endpoint. +// It is a temporary shortcut; prefer a specific point when one exists. +// The daemon registers its builtin provider for extension dependencies, +// including out-of-process extensions, but does not publish it on the public +// daemon socket. +var Point = extensions.DefineSinglePoint[DaemonAPI]("org.mobyproject.extension.daemon.api.v0") + +// GetRequest requests the daemon API endpoint. +type GetRequest struct{} + +// GetResponse contains the daemon API endpoint. +type GetResponse struct { + // Host is the endpoint in DOCKER_HOST form, such as unix:///path/api.sock. + Host string `pb:"1"` +} diff --git a/extpoints/daemonapi/v0/protogen/daemon_api.pb.go b/extpoints/daemonapi/v0/protogen/daemon_api.pb.go new file mode 100644 index 0000000000..2f0fd1a935 --- /dev/null +++ b/extpoints/daemonapi/v0/protogen/daemon_api.pb.go @@ -0,0 +1,165 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.12 +// protoc (unknown) +// source: extpoints/daemonapi/v0/daemon_api.proto + +package protogen + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetRequest) Reset() { + *x = GetRequest{} + mi := &file_extpoints_daemonapi_v0_daemon_api_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRequest) ProtoMessage() {} + +func (x *GetRequest) ProtoReflect() protoreflect.Message { + mi := &file_extpoints_daemonapi_v0_daemon_api_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetRequest.ProtoReflect.Descriptor instead. +func (*GetRequest) Descriptor() ([]byte, []int) { + return file_extpoints_daemonapi_v0_daemon_api_proto_rawDescGZIP(), []int{0} +} + +type GetResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetResponse) Reset() { + *x = GetResponse{} + mi := &file_extpoints_daemonapi_v0_daemon_api_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetResponse) ProtoMessage() {} + +func (x *GetResponse) ProtoReflect() protoreflect.Message { + mi := &file_extpoints_daemonapi_v0_daemon_api_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetResponse.ProtoReflect.Descriptor instead. +func (*GetResponse) Descriptor() ([]byte, []int) { + return file_extpoints_daemonapi_v0_daemon_api_proto_rawDescGZIP(), []int{1} +} + +func (x *GetResponse) GetHost() string { + if x != nil { + return x.Host + } + return "" +} + +var File_extpoints_daemonapi_v0_daemon_api_proto protoreflect.FileDescriptor + +const file_extpoints_daemonapi_v0_daemon_api_proto_rawDesc = "" + + "\n" + + "'extpoints/daemonapi/v0/daemon_api.proto\x12'org.mobyproject.extension.daemon.api.v0\"\f\n" + + "\n" + + "GetRequest\"!\n" + + "\vGetResponse\x12\x12\n" + + "\x04host\x18\x01 \x01(\tR\x04host2}\n" + + "\tDaemonAPI\x12p\n" + + "\x03Get\x123.org.mobyproject.extension.daemon.api.v0.GetRequest\x1a4.org.mobyproject.extension.daemon.api.v0.GetResponseB9Z7github.com/moby/moby/v2/extpoints/daemonapi/v0/protogenb\x06proto3" + +var ( + file_extpoints_daemonapi_v0_daemon_api_proto_rawDescOnce sync.Once + file_extpoints_daemonapi_v0_daemon_api_proto_rawDescData []byte +) + +func file_extpoints_daemonapi_v0_daemon_api_proto_rawDescGZIP() []byte { + file_extpoints_daemonapi_v0_daemon_api_proto_rawDescOnce.Do(func() { + file_extpoints_daemonapi_v0_daemon_api_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_extpoints_daemonapi_v0_daemon_api_proto_rawDesc), len(file_extpoints_daemonapi_v0_daemon_api_proto_rawDesc))) + }) + return file_extpoints_daemonapi_v0_daemon_api_proto_rawDescData +} + +var file_extpoints_daemonapi_v0_daemon_api_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_extpoints_daemonapi_v0_daemon_api_proto_goTypes = []any{ + (*GetRequest)(nil), // 0: org.mobyproject.extension.daemon.api.v0.GetRequest + (*GetResponse)(nil), // 1: org.mobyproject.extension.daemon.api.v0.GetResponse +} +var file_extpoints_daemonapi_v0_daemon_api_proto_depIdxs = []int32{ + 0, // 0: org.mobyproject.extension.daemon.api.v0.DaemonAPI.Get:input_type -> org.mobyproject.extension.daemon.api.v0.GetRequest + 1, // 1: org.mobyproject.extension.daemon.api.v0.DaemonAPI.Get:output_type -> org.mobyproject.extension.daemon.api.v0.GetResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_extpoints_daemonapi_v0_daemon_api_proto_init() } +func file_extpoints_daemonapi_v0_daemon_api_proto_init() { + if File_extpoints_daemonapi_v0_daemon_api_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeFor[x]().PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_extpoints_daemonapi_v0_daemon_api_proto_rawDesc), len(file_extpoints_daemonapi_v0_daemon_api_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_extpoints_daemonapi_v0_daemon_api_proto_goTypes, + DependencyIndexes: file_extpoints_daemonapi_v0_daemon_api_proto_depIdxs, + MessageInfos: file_extpoints_daemonapi_v0_daemon_api_proto_msgTypes, + }.Build() + File_extpoints_daemonapi_v0_daemon_api_proto = out.File + file_extpoints_daemonapi_v0_daemon_api_proto_goTypes = nil + file_extpoints_daemonapi_v0_daemon_api_proto_depIdxs = nil +} diff --git a/extpoints/daemonapi/v0/protogen/wire.gen.go b/extpoints/daemonapi/v0/protogen/wire.gen.go new file mode 100644 index 0000000000..ddc53c1d2a --- /dev/null +++ b/extpoints/daemonapi/v0/protogen/wire.gen.go @@ -0,0 +1,156 @@ +// Code generated by mobyextgen. DO NOT EDIT. +package protogen + +import ( + context "context" + extensions "github.com/moby/extensions" + clientpoint "github.com/moby/extensions/clientpoint" + serverpoint "github.com/moby/extensions/serverpoint" + mobyextcontract "github.com/moby/moby/v2/extpoints/daemonapi/v0" + grpc "google.golang.org/grpc" +) + +// serviceName is the point's fully-qualified gRPC service name. +const serviceName = "org.mobyproject.extension.daemon.api.v0.DaemonAPI" + +const ( + methodGet = "/" + serviceName + "/Get" +) + +// DaemonAPIServer is the server side of the point's gRPC service. It is the +// proto-level shape of the point, not the point's Go interface: a contract +// method returning a bare error returns an empty response message here. +type DaemonAPIServer interface { + // Get returns the endpoint. + Get(context.Context, *GetRequest) (*GetResponse, error) +} + +// serviceDesc describes the point's gRPC service to a server. HandlerType is +// what a registrar type-checks an implementation against, so registering the +// wrong provider for this point is caught at registration. +var serviceDesc = grpc.ServiceDesc{ + ServiceName: serviceName, + HandlerType: (*DaemonAPIServer)(nil), + Metadata: "extpoints/daemonapi/v0/daemon_api.proto", + Methods: []grpc.MethodDesc{ + {MethodName: "Get", Handler: handleGet}, + }, +} + +func handleGet(srv any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) { + in := new(GetRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonAPIServer).Get(ctx, in) + } + info := &grpc.UnaryServerInfo{Server: srv, FullMethod: methodGet} + return interceptor(ctx, in, info, func(ctx context.Context, req any) (any, error) { + return srv.(DaemonAPIServer).Get(ctx, req.(*GetRequest)) + }) +} + +// DaemonAPIClient calls the point's gRPC service. It is exported so a client +// outside the framework - one calling a service an extension publishes on the +// API socket - can reach it with a plain gRPC client. +type DaemonAPIClient interface { + // Get returns the endpoint. + Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetResponse, error) +} + +// NewDaemonAPIClient returns a client for the point's gRPC service on cc. +func NewDaemonAPIClient(cc grpc.ClientConnInterface) DaemonAPIClient { return &serviceClient{cc: cc} } + +type serviceClient struct{ cc grpc.ClientConnInterface } + +func (c *serviceClient) Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption) (*GetResponse, error) { + out := new(GetResponse) + if err := c.cc.Invoke(ctx, methodGet, in, out, append([]grpc.CallOption{grpc.StaticMethod()}, opts...)...); err != nil { + return nil, err + } + return out, nil +} + +// ServerPoint serves the DaemonAPI point: it registers the point's gRPC service for +// a provider with an SDK server. A binary passes it to (*sdk.Server).Register. +var ServerPoint = serverpoint.Registration{ + Point: mobyextcontract.Point.ID(), + Register: func(r grpc.ServiceRegistrar, impl any) { + r.RegisterService(&serviceDesc, &grpcServer{impl: impl.(mobyextcontract.DaemonAPI)}) + }, +} + +// ClientProvider builds a broker provider for the DaemonAPI point from an +// out-of-process gRPC connection. +func ClientProvider(conn grpc.ClientConnInterface) extensions.Provider { + return mobyextcontract.Point.Provide(NewClient(conn)) +} + +// ClientPoint registers ClientProvider for the DaemonAPI point with a host. +// Single carries the contract's cardinality: the point admits one provider. +var ClientPoint = clientpoint.Registration{Point: mobyextcontract.Point.ID(), Provider: ClientProvider, Single: true} + +// NewClient returns a mobyextcontract.DaemonAPI that calls the DaemonAPI point over conn. +func NewClient(conn grpc.ClientConnInterface) mobyextcontract.DaemonAPI { + return &grpcClient{client: NewDaemonAPIClient(conn)} +} + +// grpcServer serves an implementation of the contract's Go interface. +type grpcServer struct { + impl mobyextcontract.DaemonAPI +} + +func (s *grpcServer) Get(ctx context.Context, req *GetRequest) (*GetResponse, error) { + resp, err := s.impl.Get(ctx, getRequestFromProto(req)) + if err != nil { + return nil, err + } + return getResponseToProto(resp), nil +} + +type grpcClient struct { + client DaemonAPIClient +} + +func (c *grpcClient) Get(ctx context.Context, req *mobyextcontract.GetRequest) (*mobyextcontract.GetResponse, error) { + resp, err := c.client.Get(ctx, getRequestToProto(req)) + if err != nil { + return nil, err + } + return getResponseFromProto(resp), nil +} + +func getRequestToProto(in *mobyextcontract.GetRequest) *GetRequest { + if in == nil { + return nil + } + out := &GetRequest{} + return out +} + +func getRequestFromProto(in *GetRequest) *mobyextcontract.GetRequest { + if in == nil { + return nil + } + out := &mobyextcontract.GetRequest{} + return out +} + +func getResponseToProto(in *mobyextcontract.GetResponse) *GetResponse { + if in == nil { + return nil + } + out := &GetResponse{} + out.Host = in.Host + return out +} + +func getResponseFromProto(in *GetResponse) *mobyextcontract.GetResponse { + if in == nil { + return nil + } + out := &mobyextcontract.GetResponse{} + out.Host = in.GetHost() + return out +} diff --git a/integration/extension/apiclient_test.go b/integration/extension/apiclient_test.go new file mode 100644 index 0000000000..ae5e8eab9d --- /dev/null +++ b/integration/extension/apiclient_test.go @@ -0,0 +1,52 @@ +package extension + +import ( + "runtime" + "testing" + + extensionclient "github.com/moby/extensions/client" + greeterv0 "github.com/moby/extensions/example/greeter/v0" + greeterpb "github.com/moby/extensions/example/greeter/v0/protogen" + "github.com/moby/moby/client" + "github.com/moby/moby/v2/integration/extension/testdata/apiclient" + "github.com/moby/moby/v2/internal/testutil" + "github.com/moby/moby/v2/internal/testutil/daemon" + "gotest.tools/v3/assert" + "gotest.tools/v3/skip" +) + +func TestExtensionDaemonAPIClient(t *testing.T) { + skip.If(t, testEnv.IsRemoteDaemon, "the extension binary must be on the daemon's host") + + ctx := testutil.StartSpan(baseContext, t) + + extDir := buildExtension(ctx, t, apiclient.ID, "./testdata/apiclient/cmd/apiclient") + startArgs := []string{"--extension-dir", extDir} + if testEnv.DaemonInfo.OSType == "linux" { + startArgs = append(startArgs, "--iptables=false", "--ip6tables=false") + } + + d := daemon.New(t) + d.Start(t, startArgs...) + defer func() { + if runtime.GOOS == "windows" { + assert.NilError(t, d.Kill()) + return + } + d.Stop(t) + }() + + engine := d.NewClientT(t) + info, err := engine.Info(ctx, client.InfoOptions{}) + assert.NilError(t, err) + + extensions, err := extensionclient.New(engine, extensionclient.WithGRPCPoint(greeterpb.ClientPoint)) + assert.NilError(t, err) + defer func() { assert.NilError(t, extensions.Close()) }() + + greeter, err := extensionclient.Resolve(extensions, greeterv0.Point) + assert.NilError(t, err) + resp, err := greeter.Greet(ctx, &greeterv0.HelloRequest{}) + assert.NilError(t, err) + assert.Equal(t, resp.Message, info.Info.ID) +} diff --git a/integration/extension/testdata/apiclient/apiclient.go b/integration/extension/testdata/apiclient/apiclient.go new file mode 100644 index 0000000000..69178b3a23 --- /dev/null +++ b/integration/extension/testdata/apiclient/apiclient.go @@ -0,0 +1,66 @@ +// Package apiclient is the daemon API client integration-test fixture. +package apiclient + +import ( + "context" + + "github.com/moby/extensions" + greeterv0 "github.com/moby/extensions/example/greeter/v0" + greeterpb "github.com/moby/extensions/example/greeter/v0/protogen" + servicev0 "github.com/moby/extensions/extpoints/service/v0" + "github.com/moby/moby/client" + daemonapiv0 "github.com/moby/moby/v2/extpoints/daemonapi/v0" + servicegrpcv0 "github.com/moby/moby/v2/extpoints/servicegrpc/v0" + "google.golang.org/grpc" +) + +// ID is the extension id and binary name. +const ID = "org.mobyproject.example.apiclient.v1" + +type extension struct { + api *client.Client +} + +// Greet replies with the ID of the daemon reached through the API client. +func (e *extension) Greet(ctx context.Context, _ *greeterv0.HelloRequest) (*greeterv0.HelloReply, error) { + info, err := e.api.Info(ctx, client.InfoOptions{}) + if err != nil { + return nil, err + } + return &greeterv0.HelloReply{Message: info.Info.ID}, nil +} + +func (e *extension) RegisterServices(r grpc.ServiceRegistrar) { + greeterpb.ServerPoint.Register(r, e) +} + +func (e *extension) Declaration() extensions.Declaration { + return extensions.Declaration{ + ID: ID, + Providers: []extensions.Provider{ + servicegrpcv0.Point.Provide(e), + servicev0.Offer(servicegrpcv0.Point), + }, + Dependencies: []extensions.Dependency{ + daemonapiv0.Point.Dependency(), + }, + Init: func(ctx context.Context, _ extensions.Config, resolver extensions.Resolver) error { + provider, err := daemonapiv0.Point.Single(resolver) + if err != nil { + return err + } + api, err := daemonapiv0.NewClient(ctx, provider) + if err != nil { + return err + } + e.api = api + return nil + }, + Shutdown: func(context.Context) error { + return e.api.Close() + }, + } +} + +// Extension publishes a greeter service backed by the daemon API. +var Extension extensions.Extension = &extension{} diff --git a/integration/extension/testdata/apiclient/cmd/apiclient/main.go b/integration/extension/testdata/apiclient/cmd/apiclient/main.go new file mode 100644 index 0000000000..29e9b23e40 --- /dev/null +++ b/integration/extension/testdata/apiclient/cmd/apiclient/main.go @@ -0,0 +1,31 @@ +// Command apiclient wraps the daemon API client fixture for the integration test. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/moby/extensions/sdk" + daemonapipb "github.com/moby/moby/v2/extpoints/daemonapi/v0/protogen" + servicegrpcv0 "github.com/moby/moby/v2/extpoints/servicegrpc/v0" + "github.com/moby/moby/v2/integration/extension/testdata/apiclient" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + srv := sdk.NewServer() + if err := srv.Register(apiclient.Extension, servicegrpcv0.ServerPoint); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + srv.Depends(daemonapipb.ClientPoint) + if err := srv.Listen(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} From be285dcad899b4feea5419a36343b4450f40d9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Wed, 4 Nov 2026 19:47:14 +0100 Subject: [PATCH 2/2] daemon: Create the extension API socket on first use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every daemon with an extension host opened an extra full-access API socket at startup, even when no extension used the API. The socket is now created when an extension first connects to the API. The daemonapi client looks up the endpoint on its first connection, so NewClient no longer takes a context. The socket is served separately from the configured hosts, returns 503 until the daemon is ready, and closes after extensions shut down. It is accessible only to the daemon user, or to Administrators and LocalSystem on Windows. Signed-off-by: Paweł Gronowski --- daemon/command/daemon.go | 12 +- daemon/daemon.go | 8 + daemon/extensions.go | 41 +++-- daemon/extensions_default.go | 2 +- daemon/internal/apisocket/listen_unix.go | 13 ++ daemon/internal/apisocket/listen_windows.go | 11 ++ daemon/internal/apisocket/manager.go | 161 ++++++++++++++++++ .../internal/apisocket/manager_linux_test.go | 89 ++++++++++ daemon/internal/apisocket/manager_test.go | 79 +++++++++ daemon/internal/extensionapi/api.go | 15 +- extpoints/daemonapi/v0/client.go | 67 +++++++- extpoints/daemonapi/v0/client_test.go | 60 +++++++ extpoints/daemonapi/v0/daemonapi.go | 8 + .../extension/testdata/apiclient/apiclient.go | 4 +- 14 files changed, 527 insertions(+), 43 deletions(-) create mode 100644 daemon/internal/apisocket/listen_unix.go create mode 100644 daemon/internal/apisocket/listen_windows.go create mode 100644 daemon/internal/apisocket/manager.go create mode 100644 daemon/internal/apisocket/manager_linux_test.go create mode 100644 daemon/internal/apisocket/manager_test.go create mode 100644 extpoints/daemonapi/v0/client_test.go diff --git a/daemon/command/daemon.go b/daemon/command/daemon.go index da5bfc7c18..b26bf371b3 100644 --- a/daemon/command/daemon.go +++ b/daemon/command/daemon.go @@ -360,16 +360,10 @@ func (cli *daemonCLI) start(ctx context.Context) (retErr error) { if err != nil { return err } - // Serve the API to extensions on a private socket. - extLs, err := d.ExtensionAPIListener() - if err != nil { - return err - } - if extLs != nil { - lss = append(lss, extLs) - } httpServer.Protocols = &p - httpServer.Handler = newHTTPHandler(ctx, gs, extProxy, apiServer.CreateMux(ctx, routers...)) + handler := newHTTPHandler(ctx, gs, extProxy, apiServer.CreateMux(ctx, routers...)) + httpServer.Handler = handler + d.StartAPISockets(handler) go d.ProcessClusterNotifications(ctx, c.GetWatchStream()) diff --git a/daemon/daemon.go b/daemon/daemon.go index a6a38af6c7..0243c7f489 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -38,6 +38,7 @@ import ( networktypes "github.com/moby/moby/api/types/network" registrytypes "github.com/moby/moby/api/types/registry" "github.com/moby/moby/api/types/swarm" + "github.com/moby/moby/v2/daemon/internal/apisocket" "github.com/moby/moby/v2/daemon/internal/nri" "github.com/moby/sys/user" "github.com/moby/sys/userns" @@ -136,6 +137,7 @@ type Daemon struct { nri *nri.NRI pluginManager *plugin.Manager extensionHost *host.Host + apiSockets *apisocket.Manager linkIndex *linkIndex containerdClient *containerd.Client containerd libcontainerdtypes.Client @@ -933,6 +935,7 @@ func NewDaemon(ctx context.Context, config *config.Config, pluginStore *plugin.S d := &Daemon{ PluginStore: pluginStore, startupDone: make(chan struct{}), + apiSockets: apisocket.NewManager(), } cfgStore := &configStore{ Config: *config, @@ -1556,6 +1559,11 @@ func (daemon *Daemon) Shutdown(ctx context.Context) error { log.G(ctx).WithError(err).Error("failed to shut down extensions") } } + // Close the API sockets only after extensions have shut down, so they can + // still use the API while shutting down. + if err := daemon.apiSockets.Close(); err != nil { + log.G(ctx).WithError(err).Error("failed to close API sockets") + } // Keep mounts and networking running on daemon shutdown if // we are to keep containers running and restore them. diff --git a/daemon/extensions.go b/daemon/extensions.go index c9b5f72567..c91b60ff04 100644 --- a/daemon/extensions.go +++ b/daemon/extensions.go @@ -5,7 +5,7 @@ import ( "errors" "fmt" "io/fs" - "net" + "net/http" "os" "path/filepath" "slices" @@ -31,6 +31,12 @@ func (daemon *Daemon) newExtensionHost(ctx context.Context) (*host.Host, error) if err := os.Chmod(runtimeDir, 0o700); err != nil { return nil, fmt.Errorf("set permissions on extension runtime directory: %w", err) } + // A crashed daemon leaves its API socket behind. Opening the socket + // replaces it, but a daemon whose extensions never use the API would + // otherwise keep the dead socket around. + if err := os.Remove(extensionAPISocket(&cfg)); err != nil && !errors.Is(err, fs.ErrNotExist) { + log.G(ctx).WithError(err).Warn("failed to remove stale extension API socket") + } defaults := slices.Concat(builtinExtensions, daemon.daemonExtensions()) exts, err := selectBuiltinExtensions(cfg.Extensions, defaults, daemon.optionalExtensions()) if err != nil { @@ -55,30 +61,29 @@ func extensionRuntimeDir(cfg *config.Config) string { return filepath.Join(cfg.ExecRoot, "extensions") } -// extensionAPISocket is the path of the API socket served for extensions. +// extensionAPISocket is the path of the API socket for extensions. func extensionAPISocket(cfg *config.Config) string { return filepath.Join(extensionRuntimeDir(cfg), "api.sock") } -// ExtensionAPIListener listens on the API socket that the daemon.api point -// gives to extensions. -// It is separate from the configured hosts, which may be TLS-only TCP or -// socket-activated and so unusable by extensions. -// It returns nil if the daemon has no extension host. -func (daemon *Daemon) ExtensionAPIListener() (net.Listener, error) { - if daemon.extensionHost == nil { - return nil, nil - } +// extensionAPIEndpoint provides the daemon.api point. +// It opens the API socket for extensions when an extension first asks for the +// endpoint, so daemons without such extensions expose no extra API endpoint. +// The socket is separate from the configured hosts, which may be TLS-only TCP +// or socket-activated and so unusable by extensions. +func (daemon *Daemon) extensionAPIEndpoint(ctx context.Context) (string, error) { cfg := daemon.Config() path := extensionAPISocket(&cfg) - if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { - return nil, fmt.Errorf("remove stale extension API socket: %w", err) + if err := daemon.apiSockets.Open(ctx, path); err != nil { + return "", err } - ls, err := net.Listen("unix", path) - if err != nil { - return nil, fmt.Errorf("listen on extension API socket: %w", err) - } - return ls, nil + return "unix://" + path, nil +} + +// StartAPISockets serves h on the API sockets the daemon creates for its own +// consumers, such as extensions. +func (daemon *Daemon) StartAPISockets(h http.Handler) { + daemon.apiSockets.Start(h) } // extensionConfig converts daemon.json's extensions.configs entries to host form. diff --git a/daemon/extensions_default.go b/daemon/extensions_default.go index abf2bd7ee9..0cb5f1fc42 100644 --- a/daemon/extensions_default.go +++ b/daemon/extensions_default.go @@ -43,7 +43,7 @@ func (daemon *Daemon) daemonExtensions() []extensions.Extension { return []extensions.Extension{ extensionconfig.New(&cfg), newMonolithExtension(daemon), - extensionapi.New("unix://" + extensionAPISocket(&cfg)), + extensionapi.New(daemon.extensionAPIEndpoint), } } diff --git a/daemon/internal/apisocket/listen_unix.go b/daemon/internal/apisocket/listen_unix.go new file mode 100644 index 0000000000..9f945b6b05 --- /dev/null +++ b/daemon/internal/apisocket/listen_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package apisocket + +import ( + "net" + + "github.com/docker/go-connections/sockets" +) + +func listen(path string) (net.Listener, error) { + return sockets.NewUnixSocketWithOpts(path, sockets.WithChmod(0o600)) +} diff --git a/daemon/internal/apisocket/listen_windows.go b/daemon/internal/apisocket/listen_windows.go new file mode 100644 index 0000000000..c7bdf945f3 --- /dev/null +++ b/daemon/internal/apisocket/listen_windows.go @@ -0,0 +1,11 @@ +package apisocket + +import ( + "net" + + "github.com/docker/go-connections/sockets" +) + +func listen(path string) (net.Listener, error) { + return sockets.NewUnixSocketWithOpts(path, sockets.WithBasePermissions()) +} diff --git a/daemon/internal/apisocket/manager.go b/daemon/internal/apisocket/manager.go new file mode 100644 index 0000000000..bf747980f2 --- /dev/null +++ b/daemon/internal/apisocket/manager.go @@ -0,0 +1,161 @@ +// Package apisocket serves the Engine API on unix sockets that the daemon +// creates for its own consumers, separately from the configured hosts. +package apisocket + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "sync/atomic" + "time" + + "github.com/containerd/log" +) + +// Manager owns the daemon-created API sockets and serves them all with one +// server. +type Manager struct { + handler atomic.Pointer[http.Handler] + srv *http.Server + + mu sync.Mutex + sockets map[string]*listener + closed bool +} + +// NewManager returns a Manager with no sockets. +func NewManager() *Manager { + m := &Manager{sockets: make(map[string]*listener)} + + var p http.Protocols + p.SetHTTP1(true) + p.SetUnencryptedHTTP2(true) + m.srv = &http.Server{ + Handler: http.HandlerFunc(m.serveHTTP), + Protocols: &p, + ReadHeaderTimeout: 5 * time.Minute, // Same as the configured hosts. + } + return m +} + +// Open creates a unix socket at path and serves the API on it. +// On Unix, the socket is accessible only to its owner. +// Until [Manager.Start], requests fail with 503 Service Unavailable, so a +// socket can be handed out before the API is ready. +// Opening a path that is already open does nothing. +func (m *Manager) Open(ctx context.Context, path string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed { + return errors.New("API socket manager is closed") + } + if _, ok := m.sockets[path]; ok { + return nil + } + + ls, err := listen(path) + if err != nil { + return fmt.Errorf("listen on API socket %q: %w", path, err) + } + ul, ok := ls.(*net.UnixListener) + if !ok { + _ = ls.Close() + return fmt.Errorf("listen on API socket %q: unexpected listener type %T", path, ls) + } + + l := &listener{UnixListener: ul, conns: make(map[*conn]struct{})} + m.sockets[path] = l + + log.G(ctx).Infof("API listen on %s", path) + go func() { + if err := m.srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.G(ctx).WithFields(log.Fields{ + "error": err, + "listener": path, + }).Error("ServeAPI error") + } + }() + return nil +} + +// Start serves h on all sockets, including ones opened later. +func (m *Manager) Start(h http.Handler) { + m.handler.Store(&h) +} + +func (m *Manager) serveHTTP(w http.ResponseWriter, r *http.Request) { + h := m.handler.Load() + if h == nil { + http.Error(w, "the daemon is not ready to serve API requests", http.StatusServiceUnavailable) + return + } + (*h).ServeHTTP(w, r) +} + +// Close stops serving all sockets, closes all their connections, including +// hijacked ones, and removes the socket files. +// Later calls to [Manager.Open] fail. +func (m *Manager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + m.closed = true + err := m.srv.Close() + for _, l := range m.sockets { + // Serve may not track the listener yet; closing it here removes the + // socket file either way. + _ = l.Close() + l.closeConns() + } + clear(m.sockets) + return err +} + +// listener tracks its connections, because the server does not track +// hijacked connections such as attach and exec streams. +type listener struct { + *net.UnixListener + + mu sync.Mutex + conns map[*conn]struct{} +} + +func (l *listener) Accept() (net.Conn, error) { + uc, err := l.AcceptUnix() + if err != nil { + return nil, err + } + c := &conn{UnixConn: uc, l: l} + l.mu.Lock() + l.conns[c] = struct{}{} + l.mu.Unlock() + return c, nil +} + +func (l *listener) closeConns() { + l.mu.Lock() + conns := make([]*conn, 0, len(l.conns)) + for c := range l.conns { + conns = append(conns, c) + } + l.mu.Unlock() + for _, c := range conns { + _ = c.Close() + } +} + +// conn embeds *net.UnixConn so that CloseWrite stays available for +// half-closing hijacked streams. +type conn struct { + *net.UnixConn + l *listener +} + +func (c *conn) Close() error { + c.l.mu.Lock() + delete(c.l.conns, c) + c.l.mu.Unlock() + return c.UnixConn.Close() +} diff --git a/daemon/internal/apisocket/manager_linux_test.go b/daemon/internal/apisocket/manager_linux_test.go new file mode 100644 index 0000000000..59827a56be --- /dev/null +++ b/daemon/internal/apisocket/manager_linux_test.go @@ -0,0 +1,89 @@ +package apisocket + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "syscall" + "testing" + "time" + + "github.com/moby/sys/reexec" + "gotest.tools/v3/assert" + "gotest.tools/v3/skip" +) + +const ( + socketConnectCommand = "apisocket-test-connect" + unprivilegedUID = 65534 +) + +func TestMain(m *testing.M) { + reexec.Register(socketConnectCommand, func() { + if uid := os.Geteuid(); uid != unprivilegedUID { + fmt.Fprintf(os.Stderr, "connection probe has unexpected UID %d\n", uid) + os.Exit(1) + } + + c, err := net.DialTimeout("unix", os.Args[1], 5*time.Second) // #nosec G704 -- reexec dials a test-owned Unix socket. + if c != nil { + defer c.Close() + } + if os.Args[2] == "true" && err == nil { + return + } + if os.Args[2] == "false" && errors.Is(err, os.ErrPermission) { + return + } + fmt.Fprintf(os.Stderr, "unexpected connection result (allow %s): %v\n", os.Args[2], err) + os.Exit(1) + }) + if reexec.Init() { + return + } + os.Exit(m.Run()) +} + +func TestManagerRejectsOtherUsers(t *testing.T) { + t.Parallel() + skip.If(t, os.Geteuid() != 0, "requires root to connect as another user") + + // Keep every parent traversable so only the socket can deny access. + dir, err := os.MkdirTemp("/tmp", "apisocket") + assert.NilError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + assert.NilError(t, os.Chmod(dir, 0o755)) + + path := filepath.Join(dir, "api.sock") + + m := NewManager() + + t.Cleanup(func() { assert.NilError(t, m.Close()) }) + assert.NilError(t, m.Open(t.Context(), path)) + + checkConnection := func(gid uint32, allow bool) { + t.Helper() + + // Keep the creating thread alive for reexec's parent-death signal. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cmd := reexec.Command(socketConnectCommand, path, strconv.FormatBool(allow)) + cmd.SysProcAttr.Credential = &syscall.Credential{Uid: unprivilegedUID, Gid: gid} + out, err := cmd.CombinedOutput() + assert.NilError(t, err, "connection probe with GID %d (allow %t): %s", gid, allow, out) + } + + sameGroup := uint32(os.Getegid()) + checkConnection(sameGroup, false) + checkConnection(sameGroup+1, false) + + // Prove both probes can reach the listener when socket access is allowed. + assert.NilError(t, os.Chmod(path, 0o666)) + checkConnection(sameGroup, true) + checkConnection(sameGroup+1, true) +} diff --git a/daemon/internal/apisocket/manager_test.go b/daemon/internal/apisocket/manager_test.go new file mode 100644 index 0000000000..6d5b64226e --- /dev/null +++ b/daemon/internal/apisocket/manager_test.go @@ -0,0 +1,79 @@ +package apisocket + +import ( + "bufio" + "context" + "errors" + "io" + "io/fs" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestManagerLifecycle(t *testing.T) { + // t.TempDir paths can exceed the unix socket path limit on macOS. + dir, err := os.MkdirTemp("", "apisocket") + assert.NilError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + path := filepath.Join(dir, "api.sock") + ctx := t.Context() + + m := NewManager() + assert.NilError(t, m.Open(ctx, path)) + assert.NilError(t, m.Open(ctx, path), "opening an open socket again must be a no-op") + + c := &http.Client{Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + }, + }} + t.Cleanup(c.CloseIdleConnections) + status := func() int { + t.Helper() + resp, err := c.Get("http://api/_ping") + assert.NilError(t, err) + _ = resp.Body.Close() + return resp.StatusCode + } + + assert.Check(t, is.Equal(status(), http.StatusServiceUnavailable), "requests before Start must fail") + + m.Start(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/hijack" { + w.WriteHeader(http.StatusNoContent) + return + } + // Keep the hijacked connection open, like an attach stream. + hc, _, err := http.NewResponseController(w).Hijack() + if err == nil { + _, _ = hc.Write([]byte("hijacked\n")) + } + })) + assert.Check(t, is.Equal(status(), http.StatusNoContent)) + + hijacked, err := net.Dial("unix", path) + assert.NilError(t, err) + defer hijacked.Close() + _, err = hijacked.Write([]byte("GET /hijack HTTP/1.1\r\nHost: api\r\n\r\n")) + assert.NilError(t, err) + br := bufio.NewReader(hijacked) + line, err := br.ReadString('\n') + assert.NilError(t, err) + assert.Check(t, is.Equal(line, "hijacked\n")) + + assert.NilError(t, m.Close()) + _, err = os.Stat(path) + assert.Check(t, errors.Is(err, fs.ErrNotExist), "Close must remove the socket file") + assert.Check(t, m.Open(ctx, path) != nil, "Open after Close must fail") + + assert.NilError(t, hijacked.SetReadDeadline(time.Now().Add(10*time.Second))) + _, err = br.ReadByte() + assert.Check(t, is.ErrorIs(err, io.EOF), "Close must close hijacked connections") +} diff --git a/daemon/internal/extensionapi/api.go b/daemon/internal/extensionapi/api.go index d5b46d9085..774a1cdbef 100644 --- a/daemon/internal/extensionapi/api.go +++ b/daemon/internal/extensionapi/api.go @@ -13,8 +13,9 @@ import ( daemonapiv0 "github.com/moby/moby/v2/extpoints/daemonapi/v0" ) -// New declares a provider for the API endpoint the daemon serves at host. -func New(host string) extensions.Extension { +// New declares a provider for the API endpoint returned by host. +// host is called each time an extension resolves the endpoint. +func New(host func(context.Context) (string, error)) extensions.Extension { return extensions.New(extensions.Declaration{ ID: "org.mobyproject.daemon.api.v0", Providers: []extensions.Provider{ @@ -24,9 +25,13 @@ func New(host string) extensions.Extension { } type endpoint struct { - host string + host func(context.Context) (string, error) } -func (e endpoint) Get(context.Context, *daemonapiv0.GetRequest) (*daemonapiv0.GetResponse, error) { - return &daemonapiv0.GetResponse{Host: e.host}, nil +func (e endpoint) Get(ctx context.Context, _ *daemonapiv0.GetRequest) (*daemonapiv0.GetResponse, error) { + host, err := e.host(ctx) + if err != nil { + return nil, err + } + return &daemonapiv0.GetResponse{Host: host}, nil } diff --git a/extpoints/daemonapi/v0/client.go b/extpoints/daemonapi/v0/client.go index 91a3839755..1ccd23d0ba 100644 --- a/extpoints/daemonapi/v0/client.go +++ b/extpoints/daemonapi/v0/client.go @@ -3,23 +3,74 @@ package daemonapiv0 import ( "context" "fmt" + "net" + "strings" "github.com/moby/moby/client" ) -// NewClient returns an Engine API client connected to the endpoint api -// reports. +// placeholderHost only selects the plain unix transport; the client dials the +// endpoint the provider reports. +// The client includes its host in connection errors, so the name points at +// where the real endpoint comes from rather than looking like a real path. +const placeholderHost = "unix:///resolved-by-daemonapi-v0.sock" + +// NewClient returns an Engine API client for the endpoint api reports. +// It is a temporary shortcut; prefer a specific extension point when one +// covers what the extension needs. // Resolve api with Point.Single during initialization, after declaring // Point.Dependency(). +// The client asks api for the endpoint when it first connects, so the daemon +// creates the socket only once an extension actually uses the API. // opts are applied after the endpoint, so they may override it. // -// The daemon serves the API only after every extension has initialized, so do -// not make API calls from Init; they would block daemon startup. +// The daemon serves the API only after it finishes initializing, so API calls +// made before then, including from Init, fail. // The caller owns the returned client and must close it. -func NewClient(ctx context.Context, api DaemonAPI, opts ...client.Opt) (*client.Client, error) { - resp, err := api.Get(ctx, &GetRequest{}) +func NewClient(api DaemonAPI, opts ...client.Opt) (*client.Client, error) { + d := &dialer{api: api, lock: make(chan struct{}, 1)} + return client.New(append([]client.Opt{ + client.WithHost(placeholderHost), + client.WithDialContext(d.dial), + }, opts...)...) +} + +type dialer struct { + api DaemonAPI + + // lock is a one-slot semaphore rather than a mutex so that dials waiting + // on a slow Get still honor their own context. + lock chan struct{} + path string +} + +func (d *dialer) dial(ctx context.Context, _, _ string) (net.Conn, error) { + path, err := d.socketPath(ctx) if err != nil { - return nil, fmt.Errorf("get daemon API endpoint: %w", err) + return nil, err } - return client.New(append([]client.Opt{client.WithHost(resp.Host)}, opts...)...) + var nd net.Dialer + return nd.DialContext(ctx, "unix", path) +} + +func (d *dialer) socketPath(ctx context.Context) (string, error) { + select { + case d.lock <- struct{}{}: + case <-ctx.Done(): + return "", ctx.Err() + } + defer func() { <-d.lock }() + if d.path != "" { + return d.path, nil + } + resp, err := d.api.Get(ctx, &GetRequest{}) + if err != nil { + return "", fmt.Errorf("get daemon API endpoint: %w", err) + } + path, ok := strings.CutPrefix(resp.Host, "unix://") + if !ok { + return "", fmt.Errorf("unsupported daemon API endpoint %q", resp.Host) + } + d.path = path + return path, nil } diff --git a/extpoints/daemonapi/v0/client_test.go b/extpoints/daemonapi/v0/client_test.go new file mode 100644 index 0000000000..b3a5997ff7 --- /dev/null +++ b/extpoints/daemonapi/v0/client_test.go @@ -0,0 +1,60 @@ +package daemonapiv0_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "github.com/moby/moby/client" + daemonapiv0 "github.com/moby/moby/v2/extpoints/daemonapi/v0" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +type fakeAPI struct { + host string + calls atomic.Int32 +} + +func (f *fakeAPI) Get(context.Context, *daemonapiv0.GetRequest) (*daemonapiv0.GetResponse, error) { + f.calls.Add(1) + return &daemonapiv0.GetResponse{Host: f.host}, nil +} + +func TestNewClientGetsEndpointOnFirstConnect(t *testing.T) { + t.Parallel() + + // t.TempDir paths can exceed the unix socket path limit on macOS. + dir, err := os.MkdirTemp("", "daemonapi") + assert.NilError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + + sock := filepath.Join(dir, "api.sock") + l, err := net.Listen("unix", sock) + assert.NilError(t, err) + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // Force a new connection per request so the second Ping dials again. + w.Header().Set("Connection", "close") + })) + _ = srv.Listener.Close() + srv.Listener = l + srv.Start() + t.Cleanup(srv.Close) + + api := &fakeAPI{host: "unix://" + sock} + c, err := daemonapiv0.NewClient(api) + assert.NilError(t, err) + t.Cleanup(func() { _ = c.Close() }) + assert.Check(t, is.Equal(api.calls.Load(), int32(0))) + + for range 2 { + _, err = c.Ping(t.Context(), client.PingOptions{}) + assert.NilError(t, err) + } + assert.Check(t, is.Equal(api.calls.Load(), int32(1))) +} diff --git a/extpoints/daemonapi/v0/daemonapi.go b/extpoints/daemonapi/v0/daemonapi.go index 0602cefd41..855acc77f7 100644 --- a/extpoints/daemonapi/v0/daemonapi.go +++ b/extpoints/daemonapi/v0/daemonapi.go @@ -27,6 +27,14 @@ type DaemonAPI interface { // The daemon registers its builtin provider for extension dependencies, // including out-of-process extensions, but does not publish it on the public // daemon socket. +// +// The endpoint is a unix socket that only the daemon user can access; on +// Windows, only Administrators and LocalSystem. +// The daemon creates it on the first Get call, which a [NewClient] client +// makes when it first connects. +// Extensions run as the daemon user, so every extension can reach the socket +// and gets unrestricted Engine API access, whether or not it declares a +// dependency on this point. var Point = extensions.DefineSinglePoint[DaemonAPI]("org.mobyproject.extension.daemon.api.v0") // GetRequest requests the daemon API endpoint. diff --git a/integration/extension/testdata/apiclient/apiclient.go b/integration/extension/testdata/apiclient/apiclient.go index 69178b3a23..06142bf69d 100644 --- a/integration/extension/testdata/apiclient/apiclient.go +++ b/integration/extension/testdata/apiclient/apiclient.go @@ -44,12 +44,12 @@ func (e *extension) Declaration() extensions.Declaration { Dependencies: []extensions.Dependency{ daemonapiv0.Point.Dependency(), }, - Init: func(ctx context.Context, _ extensions.Config, resolver extensions.Resolver) error { + Init: func(_ context.Context, _ extensions.Config, resolver extensions.Resolver) error { provider, err := daemonapiv0.Point.Single(resolver) if err != nil { return err } - api, err := daemonapiv0.NewClient(ctx, provider) + api, err := daemonapiv0.NewClient(provider) if err != nil { return err }