diff --git a/app/controlplane/cmd/main.go b/app/controlplane/cmd/main.go
index bb83fa78f..631c7c488 100644
--- a/app/controlplane/cmd/main.go
+++ b/app/controlplane/cmd/main.go
@@ -41,8 +41,6 @@ import (
"github.com/go-kratos/kratos/v2/config/file"
"github.com/go-kratos/kratos/v2/log"
"github.com/go-kratos/kratos/v2/transport"
- "github.com/go-kratos/kratos/v2/transport/grpc"
- "github.com/go-kratos/kratos/v2/transport/http"
)
var (
@@ -61,11 +59,16 @@ func init() {
flag.StringVar(&flagconf, "conf", "../configs", "config path, eg: -conf config.yaml")
}
-func newApp(logger log.Logger, gs *grpc.Server, hs *http.Server, ms *server.HTTPMetricsServer, profilerSvc *server.HTTPProfilerServer,
+func newApp(logger log.Logger, gs *server.GRPCServers, api *server.APIServer, ms *server.HTTPMetricsServer, profilerSvc *server.HTTPProfilerServer,
expirer *biz.WorkflowRunExpirerUseCase, plugins sdk.AvailablePlugins,
userAccessSyncer *biz.UserAccessSyncerUseCase, casBackendChecker *biz.CASBackendChecker,
apiTokenStaleRevoker *biz.APITokenStaleRevoker, loginHandoffs *biz.LoginHandoffUseCase, cfg *conf.Bootstrap) *app {
- servers := []transport.Server{gs, hs, ms}
+ servers := []transport.Server{api, ms}
+ // APIServer owns the gRPC handler's lifecycle. Only the legacy server
+ // starts a native gRPC listener.
+ if gs.Legacy != nil {
+ servers = append(servers, gs.Legacy)
+ }
if cfg.EnableProfiler {
servers = append(servers, profilerSvc)
}
diff --git a/app/controlplane/cmd/wire_gen.go b/app/controlplane/cmd/wire_gen.go
index afc16d9ae..1775e07b6 100644
--- a/app/controlplane/cmd/wire_gen.go
+++ b/app/controlplane/cmd/wire_gen.go
@@ -373,14 +373,14 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr
Credentials: readerWriter,
Validator: validator,
}
- grpcServer, err := server.NewGRPCServer(opts)
+ grpcServers, err := server.NewGRPCServers(opts)
if err != nil {
cleanup3()
cleanup2()
cleanup()
return nil, nil, err
}
- httpServer, err := server.NewHTTPServer(opts, grpcServer)
+ apiServer, err := server.NewHTTPServer(opts, grpcServers)
if err != nil {
cleanup3()
cleanup2()
@@ -405,7 +405,7 @@ func wireApp(contextContext context.Context, bootstrap *conf.Bootstrap, readerWr
distributedLock := data.NewPostgresLock(dataData, logger)
casBackendChecker := biz.NewCASBackendChecker(logger, casBackendRepo, casBackendUseCase, distributedLock)
apiTokenStaleRevoker := biz.NewAPITokenStaleRevoker(organizationRepo, apiTokenRepo, apiTokenUseCase, logger)
- mainApp := newApp(logger, grpcServer, httpServer, httpMetricsServer, httpProfilerServer, workflowRunExpirerUseCase, availablePlugins, userAccessSyncerUseCase, casBackendChecker, apiTokenStaleRevoker, loginHandoffUseCase, bootstrap)
+ mainApp := newApp(logger, grpcServers, apiServer, httpMetricsServer, httpProfilerServer, workflowRunExpirerUseCase, availablePlugins, userAccessSyncerUseCase, casBackendChecker, apiTokenStaleRevoker, loginHandoffUseCase, bootstrap)
return mainApp, func() {
cleanup3()
cleanup2()
diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go
index 16c083973..43cf11e54 100644
--- a/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go
+++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.pb.go
@@ -627,9 +627,15 @@ type Server struct {
Http *Server_HTTP `protobuf:"bytes,1,opt,name=http,proto3" json:"http,omitempty"`
Grpc *Server_GRPC `protobuf:"bytes,2,opt,name=grpc,proto3" json:"grpc,omitempty"`
// HTTPMetrics defines the HTTP server that exposes prometheus metrics
- HttpMetrics *Server_HTTP `protobuf:"bytes,3,opt,name=http_metrics,json=httpMetrics,proto3" json:"http_metrics,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ HttpMetrics *Server_HTTP `protobuf:"bytes,3,opt,name=http_metrics,json=httpMetrics,proto3" json:"http_metrics,omitempty"`
+ // Legacy gRPC-only listener that serves native gRPC on its own port, next
+ // to the single port that already serves gRPC, gRPC-Web and HTTP. It exists
+ // for the deprecation period of docs/specs/issue-3628-single-endpoint.md.
+ // Defaults to true when unset. Set it to false to listen on the single port
+ // only, plus the internal metrics and profiler ports.
+ LegacyGrpcListener *bool `protobuf:"varint,4,opt,name=legacy_grpc_listener,json=legacyGrpcListener,proto3,oneof" json:"legacy_grpc_listener,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *Server) Reset() {
@@ -683,6 +689,13 @@ func (x *Server) GetHttpMetrics() *Server_HTTP {
return nil
}
+func (x *Server) GetLegacyGrpcListener() bool {
+ if x != nil && x.LegacyGrpcListener != nil {
+ return *x.LegacyGrpcListener
+ }
+ return false
+}
+
type Data struct {
state protoimpl.MessageState `protogen:"open.v1"`
Database *Data_Database `protobuf:"bytes,1,opt,name=database,proto3" json:"database,omitempty"`
@@ -1363,8 +1376,14 @@ type Server_HTTP struct {
Addr string `protobuf:"bytes,2,opt,name=addr,proto3" json:"addr,omitempty"`
// In the form of [scheme]://[host] i.e https://instance.chainloop.dev
// Optional
- ExternalUrl string `protobuf:"bytes,4,opt,name=external_url,json=externalUrl,proto3" json:"external_url,omitempty"`
- Timeout *durationpb.Duration `protobuf:"bytes,5,opt,name=timeout,proto3" json:"timeout,omitempty"`
+ ExternalUrl string `protobuf:"bytes,4,opt,name=external_url,json=externalUrl,proto3" json:"external_url,omitempty"`
+ Timeout *durationpb.Duration `protobuf:"bytes,5,opt,name=timeout,proto3" json:"timeout,omitempty"`
+ // TLS for the single port, which serves gRPC, gRPC-Web and HTTP: the
+ // listener negotiates HTTP/2 and HTTP/1.1 through ALPN. A partial
+ // configuration (only one of certificate/private_key) is a startup
+ // error. Note: server.http_metrics reuses this message type but does
+ // not support TLS.
+ TlsConfig *Server_TLS `protobuf:"bytes,6,opt,name=tls_config,json=tlsConfig,proto3" json:"tls_config,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -1427,6 +1446,13 @@ func (x *Server_HTTP) GetTimeout() *durationpb.Duration {
return nil
}
+func (x *Server_HTTP) GetTlsConfig() *Server_TLS {
+ if x != nil {
+ return x.TlsConfig
+ }
+ return nil
+}
+
type Server_TLS struct {
state protoimpl.MessageState `protogen:"open.v1"`
// path to certificate and private key
@@ -1941,16 +1967,19 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" +
"\rname.dns-1123\x12:must contain only lowercase letters, numbers, and hyphens.\x1a/this.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')R\x04name\x12\x18\n" +
"\adefault\x18\x02 \x01(\bR\adefault\x12\x16\n" +
"\x04host\x18\x03 \x01(\tB\x02\x18\x01R\x04host\x12\x10\n" +
- "\x03url\x18\x04 \x01(\tR\x03url\"\xfe\x04\n" +
+ "\x03url\x18\x04 \x01(\tR\x03url\"\x91\x06\n" +
"\x06Server\x127\n" +
"\x04http\x18\x01 \x01(\v2#.controlplane.config.v1.Server.HTTPR\x04http\x127\n" +
"\x04grpc\x18\x02 \x01(\v2#.controlplane.config.v1.Server.GRPCR\x04grpc\x12F\n" +
- "\fhttp_metrics\x18\x03 \x01(\v2#.controlplane.config.v1.Server.HTTPR\vhttpMetrics\x1a\x8c\x01\n" +
+ "\fhttp_metrics\x18\x03 \x01(\v2#.controlplane.config.v1.Server.HTTPR\vhttpMetrics\x125\n" +
+ "\x14legacy_grpc_listener\x18\x04 \x01(\bH\x00R\x12legacyGrpcListener\x88\x01\x01\x1a\xcf\x01\n" +
"\x04HTTP\x12\x18\n" +
"\anetwork\x18\x01 \x01(\tR\anetwork\x12\x12\n" +
"\x04addr\x18\x02 \x01(\tR\x04addr\x12!\n" +
"\fexternal_url\x18\x04 \x01(\tR\vexternalUrl\x123\n" +
- "\atimeout\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\atimeout\x1aH\n" +
+ "\atimeout\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12A\n" +
+ "\n" +
+ "tls_config\x18\x06 \x01(\v2\".controlplane.config.v1.Server.TLSR\ttlsConfig\x1aH\n" +
"\x03TLS\x12 \n" +
"\vcertificate\x18\x01 \x01(\tR\vcertificate\x12\x1f\n" +
"\vprivate_key\x18\x02 \x01(\tR\n" +
@@ -1961,7 +1990,8 @@ const file_controlplane_config_v1_conf_proto_rawDesc = "" +
"\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\atimeout\x12A\n" +
"\n" +
"tls_config\x18\x04 \x01(\v2\".controlplane.config.v1.Server.TLSR\ttlsConfig\x12)\n" +
- "\x11max_recv_msg_size\x18\x05 \x01(\x05R\x0emaxRecvMsgSize\"\x9a\x02\n" +
+ "\x11max_recv_msg_size\x18\x05 \x01(\x05R\x0emaxRecvMsgSizeB\x17\n" +
+ "\x15_legacy_grpc_listener\"\x9a\x02\n" +
"\x04Data\x12A\n" +
"\bdatabase\x18\x01 \x01(\v2%.controlplane.config.v1.Data.DatabaseR\bdatabase\x1a\xce\x01\n" +
"\bDatabase\x12\x16\n" +
@@ -2085,14 +2115,15 @@ var file_controlplane_config_v1_conf_proto_depIdxs = []int32{
16, // 28: controlplane.config.v1.Bootstrap.Observability.tracing:type_name -> controlplane.config.v1.Bootstrap.Observability.Tracing
19, // 29: controlplane.config.v1.Bootstrap.CASServer.grpc:type_name -> controlplane.config.v1.Server.GRPC
26, // 30: controlplane.config.v1.Server.HTTP.timeout:type_name -> google.protobuf.Duration
- 26, // 31: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration
- 18, // 32: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS
- 26, // 33: controlplane.config.v1.Data.Database.max_conn_idle_time:type_name -> google.protobuf.Duration
- 34, // [34:34] is the sub-list for method output_type
- 34, // [34:34] is the sub-list for method input_type
- 34, // [34:34] is the sub-list for extension type_name
- 34, // [34:34] is the sub-list for extension extendee
- 0, // [0:34] is the sub-list for field type_name
+ 18, // 31: controlplane.config.v1.Server.HTTP.tls_config:type_name -> controlplane.config.v1.Server.TLS
+ 26, // 32: controlplane.config.v1.Server.GRPC.timeout:type_name -> google.protobuf.Duration
+ 18, // 33: controlplane.config.v1.Server.GRPC.tls_config:type_name -> controlplane.config.v1.Server.TLS
+ 26, // 34: controlplane.config.v1.Data.Database.max_conn_idle_time:type_name -> google.protobuf.Duration
+ 35, // [35:35] is the sub-list for method output_type
+ 35, // [35:35] is the sub-list for method input_type
+ 35, // [35:35] is the sub-list for extension type_name
+ 35, // [35:35] is the sub-list for extension extendee
+ 0, // [0:35] is the sub-list for field type_name
}
func init() { file_controlplane_config_v1_conf_proto_init() }
@@ -2101,6 +2132,7 @@ func file_controlplane_config_v1_conf_proto_init() {
return
}
file_controlplane_config_v1_conf_proto_msgTypes[2].OneofWrappers = []any{}
+ file_controlplane_config_v1_conf_proto_msgTypes[6].OneofWrappers = []any{}
file_controlplane_config_v1_conf_proto_msgTypes[8].OneofWrappers = []any{}
file_controlplane_config_v1_conf_proto_msgTypes[10].OneofWrappers = []any{
(*CA_FileCa)(nil),
diff --git a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto
index 36140d8a3..40e36c69e 100644
--- a/app/controlplane/internal/conf/controlplane/config/v1/conf.proto
+++ b/app/controlplane/internal/conf/controlplane/config/v1/conf.proto
@@ -226,6 +226,12 @@ message Server {
// Optional
string external_url = 4;
google.protobuf.Duration timeout = 5;
+ // TLS for the single port, which serves gRPC, gRPC-Web and HTTP: the
+ // listener negotiates HTTP/2 and HTTP/1.1 through ALPN. A partial
+ // configuration (only one of certificate/private_key) is a startup
+ // error. Note: server.http_metrics reuses this message type but does
+ // not support TLS.
+ TLS tls_config = 6;
}
message TLS {
@@ -247,6 +253,12 @@ message Server {
GRPC grpc = 2;
// HTTPMetrics defines the HTTP server that exposes prometheus metrics
HTTP http_metrics = 3;
+ // Legacy gRPC-only listener that serves native gRPC on its own port, next
+ // to the single port that already serves gRPC, gRPC-Web and HTTP. It exists
+ // for the deprecation period of docs/specs/issue-3628-single-endpoint.md.
+ // Defaults to true when unset. Set it to false to listen on the single port
+ // only, plus the internal metrics and profiler ports.
+ optional bool legacy_grpc_listener = 4;
}
message Data {
diff --git a/app/controlplane/internal/server/grpc.go b/app/controlplane/internal/server/grpc.go
index f781d1481..74bc1231a 100644
--- a/app/controlplane/internal/server/grpc.go
+++ b/app/controlplane/internal/server/grpc.go
@@ -19,6 +19,7 @@ import (
"context"
"crypto/tls"
"fmt"
+ "net/url"
"regexp"
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
@@ -109,29 +110,56 @@ var (
allButOrganizationOperationsSkipRegexp = regexp.MustCompile("/controlplane.v1.OrganizationService/Create|/controlplane.v1.UserService/ListMemberships|/controlplane.v1.ContextService/Current|/controlplane.v1.AuthService/DeleteAccount")
)
-// NewGRPCServer new a gRPC server.
-func NewGRPCServer(opts *Opts) (*grpc.Server, error) {
- // Opt-in histogram metrics for the interceptor
- grpc_prometheus.EnableHandlingTimeHistogram()
+// GRPCServers separates the HTTP handler from the optional native listener.
+// Only Legacy must be started as a transport server.
+type GRPCServers struct {
+ Handler *grpc.Server
+ Legacy *grpc.Server
+}
- // NOTE: kratos middlewares will always be run before plain grpc unary interceptors
- // If you want to change the behavior, please refer to wrappers.go and the Prometheus wrapper as example
- var serverOpts = []grpc.ServerOption{
- grpc.Middleware(craftMiddleware(opts)...),
- grpc.UnaryInterceptor(
- protovalidateMiddleware.UnaryServerInterceptor(opts.Validator),
- ),
+// NewGRPCServers assembles the HTTP handler and optional native listener.
+func NewGRPCServers(opts *Opts) (*GRPCServers, error) {
+ servers := &GRPCServers{Handler: newGRPCHandler(opts)}
+ if legacyGRPCListenerEnabled(opts.ServerConfig) {
+ // Ignore unused legacy certificates when this listener is disabled.
+ legacy, err := NewGRPCServer(opts)
+ if err != nil {
+ return nil, err
+ }
+ servers.Legacy = legacy
+ }
+ return servers, nil
+}
+
+// newGRPCHandler builds the handler served on the shared HTTP endpoint.
+func newGRPCHandler(opts *Opts) *grpc.Server {
+ // Kratos' stream interceptor needs a non-nil endpoint even when the gRPC
+ // handler never starts a native listener. Supply it explicitly rather than
+ // calling Endpoint(), which would bind an otherwise unused socket.
+ scheme := "grpc"
+ if opts.ServerConfig.GetHttp().GetTlsConfig().GetCertificate() != "" {
+ scheme = "grpcs"
}
+ return newGRPCServer(opts, grpc.Endpoint(&url.URL{
+ Scheme: scheme,
+ Host: opts.ServerConfig.GetHttp().GetAddr(),
+ }))
+}
+
+func legacyGRPCListenerEnabled(c *conf.Server) bool {
+ return c.LegacyGrpcListener == nil || *c.LegacyGrpcListener
+}
+// NewGRPCServer builds the native gRPC listener with its existing configuration.
+// A partial TLS configuration keeps this listener plaintext, as it always did.
+func NewGRPCServer(opts *Opts) (*grpc.Server, error) {
+ var serverOpts []grpc.ServerOption
if v := opts.ServerConfig.Grpc.Network; v != "" {
serverOpts = append(serverOpts, grpc.Network(v))
}
if v := opts.ServerConfig.Grpc.Addr; v != "" {
serverOpts = append(serverOpts, grpc.Address(v))
}
- if v := opts.ServerConfig.Grpc.Timeout; v != nil {
- serverOpts = append(serverOpts, grpc.Timeout(v.AsDuration()))
- }
if tlsConf := opts.ServerConfig.Grpc.GetTlsConfig(); tlsConf != nil {
cert := tlsConf.GetCertificate()
privKey := tlsConf.GetPrivateKey()
@@ -146,6 +174,26 @@ func NewGRPCServer(opts *Opts) (*grpc.Server, error) {
}))
}
}
+ return newGRPCServer(opts, serverOpts...), nil
+}
+
+// newGRPCServer builds one gRPC server with the shared middleware chain,
+// interceptors and service registrations, bound through the given extra
+// options.
+func newGRPCServer(opts *Opts, extraOpts ...grpc.ServerOption) *grpc.Server {
+ // Opt-in histogram metrics for the interceptor.
+ grpc_prometheus.EnableHandlingTimeHistogram()
+
+ var serverOpts = append([]grpc.ServerOption{
+ grpc.Middleware(craftMiddleware(opts)...),
+ grpc.UnaryInterceptor(
+ protovalidateMiddleware.UnaryServerInterceptor(opts.Validator),
+ ),
+ }, extraOpts...)
+
+ if v := opts.ServerConfig.Grpc.Timeout; v != nil {
+ serverOpts = append(serverOpts, grpc.Timeout(v.AsDuration()))
+ }
// Raw grpc-go server options must be passed in a single grpc.Options call:
// kratos' grpc.Options assigns s.grpcOpts instead of appending, so a second
@@ -182,7 +230,7 @@ func NewGRPCServer(opts *Opts) (*grpc.Server, error) {
// Register Prometheus metrics
grpc_prometheus.Register(srv.Server)
- return srv, nil
+ return srv
}
func craftMiddleware(opts *Opts) []middleware.Middleware {
@@ -288,13 +336,13 @@ func craftMiddleware(opts *Opts) []middleware.Middleware {
// If we should load the user
func requireCurrentUserMatcher() selector.MatchFunc {
- return func(ctx context.Context, operation string) bool {
+ return func(_ context.Context, operation string) bool {
return !currentUserSkipRegexp.MatchString(operation)
}
}
func requireFullyConfiguredOrgMatcher() selector.MatchFunc {
- return func(ctx context.Context, operation string) bool {
+ return func(_ context.Context, operation string) bool {
return !fullyConfiguredOrgSkipRegexp.MatchString(operation)
}
}
@@ -306,20 +354,20 @@ func requireFullyConfiguredCASBackendMatcher() selector.MatchFunc {
}
func allowListEnabled() selector.MatchFunc {
- return func(ctx context.Context, operation string) bool {
+ return func(_ context.Context, operation string) bool {
return !allowListSkipRegexp.MatchString(operation)
}
}
func requireRobotAccountMatcher() selector.MatchFunc {
- return func(ctx context.Context, operation string) bool {
+ return func(_ context.Context, operation string) bool {
return robotAccountRequireRegexp.MatchString(operation)
}
}
// Matches all operations that require to have a current organization
func requireAllButOrganizationOperationsMatcher() selector.MatchFunc {
- return func(ctx context.Context, operation string) bool {
+ return func(_ context.Context, operation string) bool {
return !allButOrganizationOperationsSkipRegexp.MatchString(operation)
}
}
diff --git a/app/controlplane/internal/server/grpc_test.go b/app/controlplane/internal/server/grpc_test.go
index 9c25ffcac..ead63aa42 100644
--- a/app/controlplane/internal/server/grpc_test.go
+++ b/app/controlplane/internal/server/grpc_test.go
@@ -22,13 +22,19 @@ import (
"github.com/stretchr/testify/assert"
)
+// Operations used across the matcher tests.
+const (
+ opWorkflowServiceList = "/controlplane.v1.WorkflowService/List"
+ opWorkflowRunServiceList = "/controlplane.v1.WorkflowRunService/List"
+)
+
func TestRequireCurrentUserMatcher(t *testing.T) {
testCases := []struct {
operation string
matches bool
}{
- {"/controlplane.v1.WorkflowService/List", true},
- {"/controlplane.v1.WorkflowRunService/List", true},
+ {opWorkflowServiceList, true},
+ {opWorkflowRunServiceList, true},
{"/controlplane.v1.StatusService/Infoz", false},
{"/controlplane.v1.StatusService/Statusz", false},
{"/controlplane.v1.AttestationService/Init", false},
@@ -71,11 +77,11 @@ func TestRequireRobotAccountMatcher(t *testing.T) {
operation string
matches bool
}{
- {"/controlplane.v1.WorkflowService/List", false},
+ {opWorkflowServiceList, false},
{"/controlplane.v1.StatusService/Infoz", false},
{"/controlplane.v1.AttestationService/Init", true},
{"/controlplane.v1.AttestationService/Store", true},
- {"/controlplane.v1.WorkflowRunService/List", false},
+ {opWorkflowRunServiceList, false},
}
matchFunc := requireRobotAccountMatcher()
@@ -89,8 +95,8 @@ func TestRequireAllButOrganizationOperationsMatcher(t *testing.T) {
operation string
matches bool
}{
- {"/controlplane.v1.WorkflowService/List", true},
- {"/controlplane.v1.WorkflowRunService/List", true},
+ {opWorkflowServiceList, true},
+ {opWorkflowRunServiceList, true},
{"/controlplane.v1.OrganizationService/Create", false},
{"/controlplane.v1.CASBackendService/List", true},
{"/controlplane.v1.CASBackendService/Add", true},
diff --git a/app/controlplane/internal/server/http.go b/app/controlplane/internal/server/http.go
index 80665f9e5..4ed4234a2 100644
--- a/app/controlplane/internal/server/http.go
+++ b/app/controlplane/internal/server/http.go
@@ -18,6 +18,9 @@ package server
import (
"context"
"errors"
+ "fmt"
+ "strings"
+ "time"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/apitoken"
middlewares_http "github.com/chainloop-dev/chainloop/pkg/middlewares/http"
@@ -26,6 +29,7 @@ import (
"buf.build/go/protovalidate"
v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/service"
+ "github.com/go-kratos/kratos/v2/log"
"github.com/go-kratos/kratos/v2/middleware"
"github.com/go-kratos/kratos/v2/middleware/tracing"
"github.com/improbable-eng/grpc-web/go/grpcweb"
@@ -40,10 +44,43 @@ import (
"github.com/go-kratos/kratos/v2/transport/http"
)
-// NewHTTPServer new an HTTP server.
-func NewHTTPServer(opts *Opts, grpcSrv *grpc.Server) (*http.Server, error) {
- var serverOpts = []http.ServerOption{
+// APIServer owns the public HTTP listener and its gRPC handler. The HTTP
+// server must drain before the handler is stopped, so in-flight RPCs finish.
+type APIServer struct {
+ *http.Server
+ grpcHandler *grpc.Server
+}
+
+func (s *APIServer) Stop(ctx context.Context) error {
+ httpErr := s.Server.Stop(ctx)
+ return errors.Join(httpErr, s.grpcHandler.Stop(ctx))
+}
+
+// NewHTTPServer serves HTTP, gRPC-Web and native gRPC per request on one
+// HTTP/1.1 and HTTP/2 listener, without classifying connections.
+func NewHTTPServer(opts *Opts, grpcServers *GRPCServers) (*APIServer, error) {
+ if opts.ServerConfig.GetHttp().GetAddr() == "" {
+ return nil, errors.New("server.http.addr must be set: the shared endpoint cannot bind an empty address")
+ }
+ srv, err := newHTTPServer(opts, grpcServers.Handler)
+ if err != nil {
+ return nil, err
+ }
+ if legacyGRPCListenerEnabled(opts.ServerConfig) {
+ log.NewHelper(opts.Logger).Warnw("msg", fmt.Sprintf("the gRPC-only listener on %q is deprecated; use the shared HTTP endpoint and set server.legacy_grpc_listener to false when all clients have moved", opts.ServerConfig.GetGrpc().GetAddr()))
+ }
+ return &APIServer{Server: srv, grpcHandler: grpcServers.Handler}, nil
+}
+
+// newHTTPServer builds the shared HTTP listener and its request dispatcher.
+func newHTTPServer(opts *Opts, grpcSrv *grpc.Server) (*http.Server, error) {
+ tlsConfig, err := loadServerTLSConfig(opts.ServerConfig.GetHttp().GetTlsConfig())
+ if err != nil {
+ return nil, err
+ }
+ serverOpts := []http.ServerOption{
http.Middleware(httpMiddlewares(opts)...),
+ http.Address(opts.ServerConfig.GetHttp().GetAddr()),
}
// Prevent unmatched routes from falling through to http.DefaultServeMux,
// which would otherwise expose net/http/pprof's /debug/pprof/* endpoints
@@ -53,14 +90,13 @@ func NewHTTPServer(opts *Opts, grpcSrv *grpc.Server) (*http.Server, error) {
if v := opts.ServerConfig.Http.Network; v != "" {
serverOpts = append(serverOpts, http.Network(v))
}
- if v := opts.ServerConfig.Http.Addr; v != "" {
- serverOpts = append(serverOpts, http.Address(v))
- }
if v := opts.ServerConfig.Http.Timeout; v != nil {
serverOpts = append(serverOpts, http.Timeout(v.AsDuration()))
}
+ if tlsConfig != nil {
+ serverOpts = append(serverOpts, http.TLSConfig(tlsConfig))
+ }
- // initialize the underneath http server
httpSrv := http.NewServer(serverOpts...)
// Relax secure cookie settings in development mode
opts.AuthSvc.SetDevMode(Version == "dev")
@@ -97,8 +133,27 @@ func NewHTTPServer(opts *Opts, grpcSrv *grpc.Server) (*http.Server, error) {
// Include the OpenAPI spec handler
r.GET("/openapi.yaml", statusSvc.HandleOpenAPISpec)
- // Handle grpc-web requests or fallback
+ // The standard HTTP server owns every connection and dispatches each
+ // stream independently, including mixed HTTP/2 upstream proxy pools.
+ wrappedServer.Protocols = new(h.Protocols)
+ wrappedServer.Protocols.SetHTTP1(true)
+ wrappedServer.Protocols.SetHTTP2(true)
+ wrappedServer.Protocols.SetUnencryptedHTTP2(true)
+ // Bound how long a client may take to deliver its request headers over
+ // HTTP/1.x, on either listener (slowloris protection). Response streaming
+ // is not affected. The bound does not protect HTTP/2 header completion:
+ // Go's HTTP/2 path clears the connection deadline, so that case is
+ // tracked separately.
+ wrappedServer.ReadHeaderTimeout = 60 * time.Second
+
wrappedServer.Handler = h.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ // Native gRPC uses grpc-go's HTTP adapter and its existing middleware.
+ // gRPC-Web must reach its wrapper even though its content type shares
+ // the application/grpc prefix. Native gRPC requires HTTP/2.
+ if req.ProtoMajor == 2 && isNativeGRPCContentType(req.Header.Get("Content-Type")) {
+ grpcSrv.ServeHTTP(res, req)
+ return
+ }
if wrappedGrpc.IsGrpcWebRequest(req) || wrappedGrpc.IsAcceptableGrpcCorsRequest(req) {
wrappedGrpc.ServeHTTP(res, req)
return
@@ -109,6 +164,12 @@ func NewHTTPServer(opts *Opts, grpcSrv *grpc.Server) (*http.Server, error) {
return wrappedServer, nil
}
+func isNativeGRPCContentType(contentType string) bool {
+ contentType = strings.ToLower(contentType)
+ return strings.HasPrefix(contentType, "application/grpc") &&
+ !strings.HasPrefix(contentType, "application/grpc-web")
+}
+
// httpMiddlewares returns the middleware chain for the HTTP server.
// Transport-level tracing comes first: the gRPC server is instrumented via the
// otelgrpc stats handler, but transcoded HTTP requests get no server span
diff --git a/app/controlplane/internal/server/http_dispatch_test.go b/app/controlplane/internal/server/http_dispatch_test.go
new file mode 100644
index 000000000..513a85af1
--- /dev/null
+++ b/app/controlplane/internal/server/http_dispatch_test.go
@@ -0,0 +1,243 @@
+// Copyright 2026 The Chainloop Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
+ conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
+ "github.com/go-kratos/kratos/v2/middleware"
+ "github.com/go-kratos/kratos/v2/transport"
+ kratosgrpc "github.com/go-kratos/kratos/v2/transport/grpc"
+ "github.com/stretchr/testify/require"
+ grpcLib "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/peer"
+ reflection "google.golang.org/grpc/reflection/grpc_reflection_v1"
+ "google.golang.org/grpc/status"
+)
+
+// A proxy can send browser traffic after native gRPC on the same upstream
+// connection. Verify real HTTP/2 reuse, rather than separate client sockets.
+func TestSharedHTTP2ConnectionAfterNativeGRPC(t *testing.T) {
+ for _, useTLS := range []bool{false, true} {
+ t.Run(map[bool]string{false: "h2c", true: "TLS"}[useTLS], func(t *testing.T) {
+ testSharedHTTP2Connection(t, useTLS)
+ })
+ }
+}
+
+func testSharedHTTP2Connection(t *testing.T, useTLS bool) {
+ t.Helper()
+ const httpKind, grpcKind, preflightKind = "http", "grpc", "preflight"
+ off := false
+ config := &conf.Server{
+ Grpc: &conf.Server_GRPC{Addr: testAnyAddr},
+ Http: &conf.Server_HTTP{Addr: testAnyAddr},
+ LegacyGrpcListener: &off,
+ }
+ scheme := httpKind
+ if useTLS {
+ cert, key := writeSelfSignedCert(t)
+ config.Http.TlsConfig = &conf.Server_TLS{Certificate: cert, PrivateKey: key}
+ scheme = "https"
+ }
+ addr, _, _ := startSinglePortAppWithConfig(t, config)
+ var connections atomic.Int32
+ tr := &http.Transport{
+ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
+ connections.Add(1)
+ return (&net.Dialer{}).DialContext(ctx, network, addr)
+ },
+ Protocols: new(http.Protocols),
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test certificate
+ }
+ tr.Protocols.SetUnencryptedHTTP2(true)
+ tr.Protocols.SetHTTP2(true)
+ t.Cleanup(tr.CloseIdleConnections)
+ client := &http.Client{Transport: tr, Timeout: 5 * time.Second}
+
+ for _, kind := range []string{grpcKind, httpKind, "web", preflightKind, grpcKind, httpKind} {
+ path, method := "/grpc.health.v1.Health/Check", http.MethodPost
+ switch kind {
+ case httpKind:
+ path, method = "/statusz", http.MethodGet
+ case preflightKind:
+ method = http.MethodOptions
+ }
+ req, err := http.NewRequest(method, scheme+"://"+addr+path, bytes.NewReader([]byte{0, 0, 0, 0, 0}))
+ require.NoError(t, err)
+ switch kind {
+ case grpcKind:
+ req.Header.Set("Content-Type", "application/grpc")
+ req.Header.Set("Te", "trailers")
+ case "web":
+ req.Header.Set("Content-Type", "application/grpc-web+proto")
+ req.Header.Set("X-Grpc-Web", "1")
+ case preflightKind:
+ req.Header.Set("Origin", "https://example.test")
+ req.Header.Set("Access-Control-Request-Method", "POST")
+ req.Header.Set("Access-Control-Request-Headers", "content-type,x-grpc-web")
+ }
+ resp, err := client.Do(req)
+ require.NoError(t, err, kind)
+ _, err = io.Copy(io.Discard, resp.Body)
+ require.NoError(t, resp.Body.Close())
+ require.NoError(t, err)
+ expectedStatus := http.StatusOK
+ if kind == preflightKind {
+ expectedStatus = http.StatusNoContent
+ }
+ require.Equal(t, expectedStatus, resp.StatusCode, kind)
+ require.Equal(t, 2, resp.ProtoMajor)
+ if kind == grpcKind {
+ grpcStatus := resp.Trailer.Get("Grpc-Status")
+ if grpcStatus == "" {
+ grpcStatus = resp.Header.Get("Grpc-Status")
+ }
+ require.Equal(t, "16", grpcStatus)
+ }
+ }
+ require.EqualValues(t, 1, connections.Load(), "all request types must share one HTTP/2 connection")
+}
+
+// This is a focused parity check. The full token and authorization matrix in
+// the spec remains a separate qualification requirement.
+func TestSharedAndLegacyGRPCBehavior(t *testing.T) {
+ addr, legacyPort, _ := startSinglePortApp(t, nil)
+ for _, endpoint := range []string{addr, "127.0.0.1:" + legacyPort} {
+ t.Run(endpoint, func(t *testing.T) {
+ conn, err := grpcLib.NewClient(endpoint, grpcLib.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _, err = v1.NewStatusServiceClient(conn).Statusz(ctx, &v1.StatuszRequest{})
+ require.NoError(t, err)
+ for _, token := range []string{"", "Bearer invalid.jwt.token"} {
+ callCtx := metadata.AppendToOutgoingContext(ctx, "authorization", token)
+ _, err = grpc_health_v1.NewHealthClient(conn).Check(callCtx, &grpc_health_v1.HealthCheckRequest{})
+ require.Equal(t, codes.Unauthenticated, status.Code(err))
+ }
+ // Reflection exercises the Kratos bidirectional-stream interceptor,
+ // including its endpoint metadata on a handler with no native listener.
+ stream, err := reflection.NewServerReflectionClient(conn).ServerReflectionInfo(ctx)
+ require.NoError(t, err)
+ for range 2 {
+ require.NoError(t, stream.Send(&reflection.ServerReflectionRequest{
+ MessageRequest: &reflection.ServerReflectionRequest_ListServices{ListServices: ""},
+ }))
+ resp, err := stream.Recv()
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.GetListServicesResponse().GetService())
+ }
+ require.NoError(t, stream.CloseSend())
+ _, err = stream.Recv()
+ require.ErrorIs(t, err, io.EOF)
+ })
+ }
+}
+
+func TestAPIServerDrainsRPCAndPreservesTLSMetadata(t *testing.T) {
+ off := false
+ cert, key := writeSelfSignedCert(t)
+ opts := testServerOpts(t, &conf.Server{
+ Grpc: &conf.Server_GRPC{Addr: testAnyAddr},
+ Http: &conf.Server_HTTP{Addr: testAnyAddr, TlsConfig: &conf.Server_TLS{Certificate: cert, PrivateKey: key}},
+ LegacyGrpcListener: &off,
+ })
+ release := make(chan struct{})
+ unblock := sync.OnceFunc(func() { close(release) })
+ defer unblock()
+ entered := make(chan context.Context, 1)
+ g := newGRPCServer(opts, kratosgrpc.Endpoint(&url.URL{Scheme: "grpcs", Host: testAnyAddr}),
+ kratosgrpc.Middleware(func(next middleware.Handler) middleware.Handler {
+ return func(ctx context.Context, req any) (any, error) {
+ entered <- ctx
+ select {
+ case <-release:
+ return next(ctx, req)
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+ }
+ }))
+ api, err := NewHTTPServer(opts, &GRPCServers{Handler: g})
+ require.NoError(t, err)
+ endpoint, err := api.Endpoint()
+ require.NoError(t, err)
+ serveErr := make(chan error, 1)
+ go func() { serveErr <- api.Start(context.Background()) }()
+ t.Cleanup(func() {
+ unblock()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ require.NoError(t, api.Stop(ctx))
+ })
+ conn, err := grpcLib.NewClient(endpoint.Host, grpcLib.WithTransportCredentials(credentials.NewTLS(
+ &tls.Config{InsecureSkipVerify: true}))) //nolint:gosec // test certificate
+ require.NoError(t, err)
+ defer conn.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ rpcErr := make(chan error, 1)
+ go func() {
+ callCtx := metadata.AppendToOutgoingContext(ctx, "x-dispatch-test", "preserved")
+ _, err := v1.NewStatusServiceClient(conn).Statusz(callCtx, &v1.StatuszRequest{})
+ rpcErr <- err
+ }()
+ select {
+ case callCtx := <-entered:
+ p, ok := peer.FromContext(callCtx)
+ require.True(t, ok)
+ tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
+ require.True(t, ok, "the HTTP adapter must retain TLS peer information")
+ require.Equal(t, "h2", tlsInfo.State.NegotiatedProtocol)
+ tr, ok := transport.FromServerContext(callCtx)
+ require.True(t, ok)
+ require.Equal(t, "/controlplane.v1.StatusService/Statusz", tr.Operation())
+ require.Equal(t, "preserved", tr.RequestHeader().Get("x-dispatch-test"))
+ case <-ctx.Done():
+ require.FailNow(t, "RPC did not enter the middleware")
+ }
+ shutdownStarted := make(chan struct{})
+ api.RegisterOnShutdown(sync.OnceFunc(func() { close(shutdownStarted) }))
+ stopErr := make(chan error, 1)
+ go func() { stopErr <- api.Stop(ctx) }()
+ select {
+ case <-shutdownStarted:
+ case <-ctx.Done():
+ require.FailNow(t, "HTTP shutdown did not start")
+ }
+ unblock()
+ require.NoError(t, <-rpcErr, "shutdown must allow the in-flight RPC to finish")
+ require.NoError(t, <-stopErr)
+ require.NoError(t, <-serveErr)
+}
diff --git a/app/controlplane/internal/server/http_test.go b/app/controlplane/internal/server/http_test.go
new file mode 100644
index 000000000..f23e92dab
--- /dev/null
+++ b/app/controlplane/internal/server/http_test.go
@@ -0,0 +1,407 @@
+//
+// Copyright 2026 The Chainloop Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "buf.build/go/protovalidate"
+ conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
+ "github.com/chainloop-dev/chainloop/app/controlplane/internal/service"
+ "github.com/go-kratos/kratos/v2/log"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ grpcLib "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/status"
+)
+
+// Shared literals across the server tests.
+const (
+ testAnyAddr = "127.0.0.1:0"
+ testLoginURL = "http://localhost"
+)
+
+// startSinglePortApp starts the real HTTP-owned endpoint and optional legacy
+// native gRPC listener. The HTTP-served gRPC handler never starts a listener.
+func startSinglePortApp(t *testing.T, legacyListener *bool) (string, string, int) {
+ t.Helper()
+ return startSinglePortAppWithConfig(t, &conf.Server{
+ Grpc: &conf.Server_GRPC{Addr: testAnyAddr},
+ Http: &conf.Server_HTTP{Addr: testAnyAddr},
+ LegacyGrpcListener: legacyListener,
+ })
+}
+
+func startSinglePortAppWithConfig(t *testing.T, serverConfig *conf.Server) (string, string, int) {
+ t.Helper()
+ opts := testServerOpts(t, serverConfig)
+ gs, err := NewGRPCServers(opts)
+ require.NoError(t, err)
+ api, err := NewHTTPServer(opts, gs)
+ require.NoError(t, err)
+ endpoint, err := api.Endpoint()
+ require.NoError(t, err)
+ _, port, err := net.SplitHostPort(endpoint.Host)
+ require.NoError(t, err)
+
+ legacyPort := ""
+ serverCount := 1
+ if gs.Legacy != nil {
+ serverCount++
+ }
+ errs := make(chan error, serverCount)
+ if g := gs.Legacy; g != nil {
+ endpoint, err := g.Endpoint()
+ require.NoError(t, err)
+ _, legacyPort, err = net.SplitHostPort(endpoint.Host)
+ require.NoError(t, err)
+ go func() { errs <- g.Start(context.Background()) }()
+ }
+ go func() { errs <- api.Start(context.Background()) }()
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ require.NoError(t, api.Stop(ctx))
+ if g := gs.Legacy; g != nil {
+ require.NoError(t, g.Stop(ctx))
+ }
+ for range serverCount {
+ select {
+ case err := <-errs:
+ require.NoError(t, err)
+ case <-ctx.Done():
+ require.FailNow(t, "API servers did not stop")
+ }
+ }
+ })
+ // Wait for the legacy server to enter Serve before a test can stop it.
+ // Endpoint binds its socket, but Start runs in another goroutine.
+ if legacyPort != "" {
+ legacyCreds := insecure.NewCredentials()
+ if cfg := serverConfig.GetGrpc().GetTlsConfig(); cfg.GetCertificate() != "" && cfg.GetPrivateKey() != "" {
+ legacyCreds = credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec // test certificate
+ }
+ conn, err := grpcLib.NewClient(net.JoinHostPort("127.0.0.1", legacyPort), grpcLib.WithTransportCredentials(legacyCreds))
+ require.NoError(t, err)
+ defer conn.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _, err = grpc_health_v1.NewHealthClient(conn).Check(ctx, &grpc_health_v1.HealthCheckRequest{})
+ require.Equal(t, codes.Unauthenticated, status.Code(err))
+ }
+ return net.JoinHostPort("127.0.0.1", port), legacyPort, serverCount
+}
+
+func testServerOpts(t *testing.T, serverConfig *conf.Server) *Opts {
+ t.Helper()
+ validator, err := protovalidate.New()
+ require.NoError(t, err)
+ return &Opts{
+ ServerConfig: serverConfig,
+ AuthConfig: &conf.Auth{},
+ Logger: log.NewStdLogger(io.Discard),
+ Validator: validator,
+ AuthSvc: &service.AuthService{AuthURLs: &service.AuthURLs{Login: testLoginURL}},
+ }
+}
+
+func TestSinglePortServesAllProtocols(t *testing.T) {
+ addr, _, _ := startSinglePortApp(t, nil)
+
+ t.Run("native gRPC", func(t *testing.T) {
+ conn, err := grpcLib.NewClient(addr, grpcLib.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _, err = grpc_health_v1.NewHealthClient(conn).Check(ctx, &grpc_health_v1.HealthCheckRequest{})
+ // The RPC must reach the HTTP-served gRPC handler, whose middleware chain
+ // rejects unauthenticated calls exactly as it does today on the
+ // dedicated gRPC port (R-002: same behavior as today).
+ require.Error(t, err)
+ assert.Equal(t, codes.Unauthenticated, status.Code(err))
+ })
+
+ t.Run("plain HTTP/1.1", func(t *testing.T) {
+ resp, err := http.Get("http://" + addr + "/statusz")
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ })
+
+ t.Run("plain HTTP/2 without TLS", func(t *testing.T) {
+ tr := &http.Transport{}
+ tr.Protocols = new(http.Protocols)
+ tr.Protocols.SetUnencryptedHTTP2(true)
+ client := &http.Client{Transport: tr}
+ resp, err := client.Get("http://" + addr + "/statusz")
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ })
+
+ t.Run("gRPC-Web", func(t *testing.T) {
+ // An empty HealthCheckRequest serialized as a grpc-web frame:
+ // compression flag 0 + 4 zero length bytes.
+ req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/grpc.health.v1.Health/Check", bytes.NewReader([]byte{0, 0, 0, 0, 0}))
+ require.NoError(t, err)
+ req.Header.Set("content-type", "application/grpc-web+proto")
+ req.Header.Set("x-grpc-web", "1")
+
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ })
+
+ t.Run("native gRPC on an HTTP-owned HTTP/2 connection", func(t *testing.T) {
+ // One HTTP/2 client connection that first speaks plain HTTP and then
+ // a native gRPC call: the spec promises this direction works, via the
+ // HTTP server handing the request to the gRPC server.
+ tr := &http.Transport{}
+ tr.Protocols = new(http.Protocols)
+ tr.Protocols.SetUnencryptedHTTP2(true)
+ client := &http.Client{Transport: tr}
+
+ resp, err := client.Get("http://" + addr + "/statusz")
+ require.NoError(t, err)
+ _, _ = io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ // A native gRPC call (content-type application/grpc) with an empty
+ // HealthCheckRequest frame on the same connection.
+ req, err := http.NewRequest(http.MethodPost, "http://"+addr+"/grpc.health.v1.Health/Check", bytes.NewReader([]byte{0, 0, 0, 0, 0}))
+ require.NoError(t, err)
+ req.Header.Set("content-type", "application/grpc")
+ req.Header.Set("te", "trailers")
+
+ resp, err = client.Do(req)
+ require.NoError(t, err)
+ _, _ = io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ // The gRPC server answered through the same middleware chain as on
+ // the dedicated gRPC port: the unauthenticated call is rejected.
+ assert.Equal(t, "16", resp.Trailer.Get("Grpc-Status"), "expected an Unauthenticated gRPC status")
+ })
+}
+
+func TestLegacyGRPCListenerFlag(t *testing.T) {
+ t.Run("on by default", func(t *testing.T) {
+ _, legacyPort, gsCount := startSinglePortApp(t, nil)
+ require.NotEmpty(t, legacyPort)
+ assert.Equal(t, 2, gsCount)
+
+ // The legacy listener serves native gRPC: the call reaches the server
+ // and is rejected by the same middleware chain as on the single port.
+ conn, err := grpcLib.NewClient("127.0.0.1:"+legacyPort, grpcLib.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ defer conn.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _, err = grpc_health_v1.NewHealthClient(conn).Check(ctx, &grpc_health_v1.HealthCheckRequest{})
+ require.Error(t, err)
+ assert.Equal(t, codes.Unauthenticated, status.Code(err))
+
+ // It does NOT serve the HTTP API: grpc-go rejects non-HTTP/2 requests
+ // outright.
+ _, herr := http.Get("http://127.0.0.1:" + legacyPort + "/statusz")
+ require.Error(t, herr, "the legacy gRPC listener must not serve the HTTP API")
+ })
+
+ t.Run("explicitly on", func(t *testing.T) {
+ on := true
+ _, legacyPort, gsCount := startSinglePortApp(t, &on)
+ assert.Equal(t, 2, gsCount)
+ assert.NotEmpty(t, legacyPort)
+ })
+
+ t.Run("off listens on the single port only", func(t *testing.T) {
+ off := false
+ _, legacyPort, gsCount := startSinglePortApp(t, &off)
+ assert.Equal(t, 1, gsCount)
+ assert.Empty(t, legacyPort)
+ })
+}
+
+// TestSinglePortTLS proves R-004: one TLS configuration on the single port
+// serves gRPC (HTTP/2), gRPC-Web and plain HTTP, negotiated through ALPN.
+// TestPartialTLSConfigRejected proves that a partial tls_config is a hard
+// startup error instead of silently disabling TLS on the shared endpoint.
+func TestPartialTLSConfigRejected(t *testing.T) {
+ certPath, keyPath := writeSelfSignedCert(t)
+
+ serverConfig := &conf.Server{
+ Grpc: &conf.Server_GRPC{
+ Addr: testAnyAddr,
+ },
+ Http: &conf.Server_HTTP{
+ Addr: testAnyAddr,
+ TlsConfig: &conf.Server_TLS{Certificate: certPath}, // private key missing
+ },
+ }
+
+ _, err := newHTTPServer(&Opts{ServerConfig: serverConfig}, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "incomplete TLS configuration")
+
+ serverConfig.Http.TlsConfig = &conf.Server_TLS{PrivateKey: keyPath} // certificate missing
+ _, err = newHTTPServer(&Opts{ServerConfig: serverConfig}, nil)
+ require.Error(t, err)
+}
+
+func TestSinglePortTLS(t *testing.T) {
+ certPath, keyPath := writeSelfSignedCert(t)
+
+ serverConfig := &conf.Server{
+ Grpc: &conf.Server_GRPC{
+ Addr: testAnyAddr,
+ },
+ Http: &conf.Server_HTTP{
+ Addr: testAnyAddr,
+ TlsConfig: &conf.Server_TLS{
+ Certificate: certPath,
+ PrivateKey: keyPath,
+ },
+ },
+ }
+
+ addr, _, _ := startSinglePortAppWithConfig(t, serverConfig)
+
+ // TLS client without ALPN restriction: HTTP/1.1 over TLS must work.
+ client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} //nolint:gosec // test server with a self-signed certificate
+ resp, err := client.Get("https://" + addr + "/statusz")
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ // Native gRPC over TLS uses the gRPC HTTP adapter.
+ gconn, err := grpcLib.NewClient(addr, grpcLib.WithTransportCredentials(credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}))) //nolint:gosec // test server with a self-signed certificate
+ require.NoError(t, err)
+ defer gconn.Close()
+ gctx, gcancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer gcancel()
+ gresp, gerr := grpc_health_v1.NewHealthClient(gconn).Check(gctx, &grpc_health_v1.HealthCheckRequest{})
+ require.Error(t, gerr)
+ assert.Nil(t, gresp)
+ assert.Equal(t, codes.Unauthenticated, status.Code(gerr))
+
+ // gRPC-Web over TLS: served by the HTTP server's wrapper.
+ wreq, werr := http.NewRequest(http.MethodPost, "https://"+addr+"/grpc.health.v1.Health/Check", strings.NewReader("\x00\x00\x00\x00\x00"))
+ require.NoError(t, werr)
+ wreq.Header.Set("content-type", "application/grpc-web+proto")
+ wreq.Header.Set("x-grpc-web", "1")
+ wresp, werr := client.Do(wreq)
+ require.NoError(t, werr)
+ defer wresp.Body.Close()
+ assert.Equal(t, http.StatusOK, wresp.StatusCode)
+
+ // TLS client forcing HTTP/2: ALPN must negotiate h2 against the same
+ // certificate and port.
+ h2Client := &http.Client{Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test server with a self-signed certificate
+ ForceAttemptHTTP2: true,
+ }}
+ resp, err = h2Client.Get("https://" + addr + "/statusz")
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, "HTTP/2.0", resp.Proto)
+}
+
+func TestLegacyTLSIsIndependentFromSharedEndpoint(t *testing.T) {
+ cert, key := writeSelfSignedCert(t)
+ for _, sharedTLS := range []bool{false, true} {
+ t.Run(map[bool]string{false: "plaintext shared port", true: "TLS shared port"}[sharedTLS], func(t *testing.T) {
+ cfg := &conf.Server{
+ Grpc: &conf.Server_GRPC{Addr: testAnyAddr, TlsConfig: &conf.Server_TLS{Certificate: cert, PrivateKey: key}},
+ Http: &conf.Server_HTTP{Addr: testAnyAddr},
+ }
+ sharedCreds := insecure.NewCredentials()
+ if sharedTLS {
+ cfg.Http.TlsConfig = cfg.Grpc.TlsConfig
+ sharedCreds = credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec // test certificate
+ }
+ addr, _, _ := startSinglePortAppWithConfig(t, cfg)
+ // The helper verifies that the legacy listener requires TLS. This
+ // call verifies that its credentials do not affect the HTTP handler.
+ conn, err := grpcLib.NewClient(addr, grpcLib.WithTransportCredentials(sharedCreds))
+ require.NoError(t, err)
+ defer conn.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _, err = grpc_health_v1.NewHealthClient(conn).Check(ctx, &grpc_health_v1.HealthCheckRequest{})
+ require.Equal(t, codes.Unauthenticated, status.Code(err))
+ })
+ }
+}
+
+func writeSelfSignedCert(t *testing.T) (string, string) {
+ t.Helper()
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ tmpl := x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "localhost"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
+ DNSNames: []string{"localhost"},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
+ require.NoError(t, err)
+
+ certPath := filepath.Join(t.TempDir(), "tls.crt")
+ keyPath := filepath.Join(t.TempDir(), "tls.key")
+ require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600))
+ require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0o600))
+ return certPath, keyPath
+}
+
+// TestEmptyHTTPAddrRejected proves the single port never binds an ephemeral
+// address: a missing server.http.addr is a startup error, not a silent :0.
+func TestEmptyHTTPAddrRejected(t *testing.T) {
+ serverConfig := &conf.Server{
+ Grpc: &conf.Server_GRPC{Addr: testAnyAddr},
+ Http: &conf.Server_HTTP{}, // addr omitted
+ }
+ _, err := NewHTTPServer(&Opts{ServerConfig: serverConfig}, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "server.http.addr must be set")
+}
diff --git a/app/controlplane/internal/server/ingress_test.go b/app/controlplane/internal/server/ingress_test.go
new file mode 100644
index 000000000..9324aff34
--- /dev/null
+++ b/app/controlplane/internal/server/ingress_test.go
@@ -0,0 +1,146 @@
+// Copyright 2026 The Chainloop Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ v1 "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
+ "github.com/stretchr/testify/require"
+ grpcLib "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+)
+
+// Run on Linux with Docker and an available image:
+// NGINX_INTEGRATION_IMAGE=nginx:1.28.0-alpine go test ./app/controlplane/internal/server -run TestNGINXSharedIngress -v
+// This checks the grpc_pass path used by the chart, not a Kubernetes controller.
+func TestNGINXSharedIngress(t *testing.T) {
+ image := os.Getenv("NGINX_INTEGRATION_IMAGE")
+ if image == "" {
+ t.Skip("set NGINX_INTEGRATION_IMAGE to run the Docker integration test")
+ }
+ off := false
+ upstream, _, _ := startSinglePortApp(t, &off)
+ cert, key := writeSelfSignedCert(t)
+ dir := t.TempDir()
+ for name, src := range map[string]string{"cert.pem": cert, "key.pem": key} {
+ data, err := os.ReadFile(src)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o600)) //nolint:gosec // fixed filenames inside the test's temporary directory
+ }
+ lis, err := net.Listen("tcp", testAnyAddr)
+ require.NoError(t, err)
+ addr := lis.Addr().String()
+ require.NoError(t, lis.Close())
+ config := fmt.Sprintf(`events { worker_connections 128; }
+http {
+ upstream controlplane { server %s; keepalive 16; }
+ server {
+ listen %s ssl http2;
+ ssl_certificate /probe/cert.pem;
+ ssl_certificate_key /probe/key.pem;
+ location / { grpc_pass grpc://controlplane; }
+ }
+}
+`, upstream, addr)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "nginx.conf"), []byte(config), 0o600))
+ name := fmt.Sprintf("chainloop-shared-ingress-%d-%s", os.Getpid(), strings.ReplaceAll(addr, ":", "-"))
+ ctx, cancel := context.WithCancel(context.Background())
+ cmd := exec.CommandContext(ctx, "docker", "run", "--rm", "--name", name, //nolint:gosec // test-selected image passed as an argument, without a shell
+ "--network", "host", "-v", dir+":/probe:ro", image,
+ "nginx", "-c", "/probe/nginx.conf", "-g", "daemon off;")
+ var logs bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &logs, &logs
+ require.NoError(t, cmd.Start())
+ t.Cleanup(func() {
+ stopCtx, stopCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer stopCancel()
+ _ = exec.CommandContext(stopCtx, "docker", "stop", "--timeout", "1", name).Run()
+ cancel()
+ _ = cmd.Wait()
+ t.Log(logs.String())
+ })
+ require.Eventually(t, func() bool {
+ conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
+ if err != nil {
+ return false
+ }
+ _ = conn.Close()
+ return true
+ }, 10*time.Second, 50*time.Millisecond)
+
+ conn, err := grpcLib.NewClient(addr, grpcLib.WithTransportCredentials(credentials.NewTLS(
+ &tls.Config{InsecureSkipVerify: true}))) //nolint:gosec // test certificate
+ require.NoError(t, err)
+ defer conn.Close()
+ rpcCtx, rpcCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer rpcCancel()
+ _, err = v1.NewStatusServiceClient(conn).Statusz(rpcCtx, &v1.StatuszRequest{})
+ require.NoError(t, err, "native gRPC must succeed before browser requests")
+
+ for _, useHTTP2 := range []bool{false, true} {
+ t.Run(fmt.Sprintf("client_HTTP2_%t", useHTTP2), func(t *testing.T) {
+ tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, ForceAttemptHTTP2: useHTTP2} //nolint:gosec // test certificate
+ defer tr.CloseIdleConnections()
+ client := &http.Client{Transport: tr, Timeout: 5 * time.Second}
+ for range 3 {
+ resp, err := client.Get("https://" + addr + "/statusz")
+ require.NoError(t, err)
+ _, err = io.Copy(io.Discard, resp.Body)
+ require.NoError(t, resp.Body.Close())
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.Equal(t, map[bool]int{false: 1, true: 2}[useHTTP2], resp.ProtoMajor)
+
+ url := "https://" + addr + "/controlplane.v1.StatusService/Statusz"
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader([]byte{0, 0, 0, 0, 0}))
+ require.NoError(t, err)
+ req.Header.Set("Content-Type", "application/grpc-web+proto")
+ req.Header.Set("X-Grpc-Web", "1")
+ resp, err = client.Do(req)
+ require.NoError(t, err)
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, resp.Body.Close())
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.Contains(t, resp.Header.Get("Content-Type"), "application/grpc-web")
+ require.Contains(t, string(body), "grpc-status: 0")
+
+ req, err = http.NewRequest(http.MethodOptions, url, nil)
+ require.NoError(t, err)
+ req.Header.Set("Origin", "https://example.test")
+ req.Header.Set("Access-Control-Request-Method", "POST")
+ req.Header.Set("Access-Control-Request-Headers", "content-type,x-grpc-web")
+ resp, err = client.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+ require.Equal(t, http.StatusNoContent, resp.StatusCode)
+ require.Equal(t, "https://example.test", resp.Header.Get("Access-Control-Allow-Origin"))
+ }
+ })
+ }
+}
diff --git a/app/controlplane/internal/server/otel_test.go b/app/controlplane/internal/server/otel_test.go
index cf0e66fd6..37d6d5feb 100644
--- a/app/controlplane/internal/server/otel_test.go
+++ b/app/controlplane/internal/server/otel_test.go
@@ -24,7 +24,6 @@ import (
"buf.build/go/protovalidate"
conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
- "github.com/chainloop-dev/chainloop/app/controlplane/internal/service"
"github.com/go-kratos/kratos/v2/log"
kratoshttp "github.com/go-kratos/kratos/v2/transport/http"
"github.com/stretchr/testify/assert"
@@ -65,47 +64,27 @@ func spanNames(exporter *tracetest.InMemoryExporter) []string {
func TestGRPCServerSpanWithMaxRecvMsgSize(t *testing.T) {
exporter := withInMemoryTracing(t)
- validator, err := protovalidate.New()
- require.NoError(t, err)
-
- srv, err := NewGRPCServer(&Opts{
- ServerConfig: &conf.Server{
- Grpc: &conf.Server_GRPC{
- Addr: "127.0.0.1:0",
- MaxRecvMsgSize: 8 * 1024 * 1024,
- },
+ addr, legacyPort, _ := startSinglePortAppWithConfig(t, &conf.Server{
+ Grpc: &conf.Server_GRPC{
+ Addr: testAnyAddr,
+ MaxRecvMsgSize: 8 * 1024 * 1024,
},
- AuthConfig: &conf.Auth{},
- Logger: log.NewStdLogger(io.Discard),
- Validator: validator,
- AuthSvc: &service.AuthService{AuthURLs: &service.AuthURLs{Login: "http://localhost"}},
+ Http: &conf.Server_HTTP{Addr: testAnyAddr},
})
- require.NoError(t, err)
-
- // Endpoint() binds the listener so the address is known before Start.
- endpoint, err := srv.Endpoint()
- require.NoError(t, err)
- go func() { _ = srv.Start(context.Background()) }()
- defer func() { _ = srv.Stop(context.Background()) }()
-
- conn, err := grpcLib.NewClient(endpoint.Host, grpcLib.WithTransportCredentials(insecure.NewCredentials()))
- require.NoError(t, err)
- defer conn.Close()
-
- // The request itself is rejected by the auth middleware, but the
- // transport-level stats handler records the server span regardless.
- deadline := time.Now().Add(10 * time.Second)
- for time.Now().Before(deadline) {
- ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ for _, endpoint := range []string{addr, "127.0.0.1:" + legacyPort} {
+ exporter.Reset()
+ conn, err := grpcLib.NewClient(endpoint, grpcLib.WithTransportCredentials(insecure.NewCredentials()))
+ require.NoError(t, err)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, _ = grpc_health_v1.NewHealthClient(conn).Check(ctx, &grpc_health_v1.HealthCheckRequest{})
cancel()
- if names := spanNames(exporter); len(names) > 0 {
- assert.Contains(t, names, "grpc.health.v1.Health/Check")
- return
- }
- time.Sleep(50 * time.Millisecond)
+ require.NoError(t, conn.Close())
+ // The transport records a span even when authentication rejects the RPC.
+ require.Eventually(t, func() bool {
+ return len(spanNames(exporter)) > 0
+ }, time.Second, time.Millisecond)
+ assert.Contains(t, spanNames(exporter), "grpc.health.v1.Health/Check", endpoint)
}
- require.Fail(t, "expected gRPC server span from otelgrpc stats handler")
}
// The HTTP server must produce a transport-level server span so transcoded
@@ -118,7 +97,7 @@ func TestHTTPServerSpanFromTracingMiddleware(t *testing.T) {
require.NoError(t, err)
srv := kratoshttp.NewServer(
- kratoshttp.Address("127.0.0.1:0"),
+ kratoshttp.Address(testAnyAddr),
kratoshttp.Middleware(httpMiddlewares(&Opts{
AuthConfig: &conf.Auth{},
Logger: log.NewStdLogger(io.Discard),
diff --git a/app/controlplane/internal/server/server.go b/app/controlplane/internal/server/server.go
index 07827a3e9..25d0a0865 100644
--- a/app/controlplane/internal/server/server.go
+++ b/app/controlplane/internal/server/server.go
@@ -24,7 +24,7 @@ import (
// ProviderSet is server providers.
var ProviderSet = wire.NewSet(
- NewGRPCServer,
+ NewGRPCServers,
NewHTTPServer,
NewHTTPMetricsServer,
NewHTTPProfilerServer,
diff --git a/app/controlplane/internal/server/tls.go b/app/controlplane/internal/server/tls.go
new file mode 100644
index 000000000..74019baf6
--- /dev/null
+++ b/app/controlplane/internal/server/tls.go
@@ -0,0 +1,49 @@
+//
+// Copyright 2026 The Chainloop Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "crypto/tls"
+ "fmt"
+
+ conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
+)
+
+// loadServerTLSConfig builds a tls.Config from the certificate and private key
+// paths of the server configuration. It returns nil when no TLS is configured.
+// For the single port the config negotiates HTTP/2 and HTTP/1.1 through ALPN.
+func loadServerTLSConfig(tlsConf *conf.Server_TLS) (*tls.Config, error) {
+ cert := tlsConf.GetCertificate()
+ privKey := tlsConf.GetPrivateKey()
+ switch {
+ case cert == "" && privKey == "":
+ return nil, nil
+ case cert == "" || privKey == "":
+ // A partial configuration is a typo: refuse it instead of silently
+ // exposing the endpoint over plaintext.
+ return nil, fmt.Errorf("incomplete TLS configuration: certificate and private key must be set together")
+ }
+ certPair, err := tls.LoadX509KeyPair(cert, privKey)
+ if err != nil {
+ return nil, fmt.Errorf("loading server TLS certificate: %w", err)
+ }
+ return &tls.Config{
+ Certificates: []tls.Certificate{certPair},
+ MinVersion: tls.VersionTLS12,
+ // Serve gRPC (HTTP/2) and HTTP/1.1 from the same certificate.
+ NextProtos: []string{"h2", "http/1.1"},
+ }, nil
+}
diff --git a/deployment/chainloop/.helmignore b/deployment/chainloop/.helmignore
index 0e8a0eb36..bd4da3cac 100644
--- a/deployment/chainloop/.helmignore
+++ b/deployment/chainloop/.helmignore
@@ -21,3 +21,5 @@
.idea/
*.tmproj
.vscode/
+# Go tests run from the source tree and do not belong in the chart package.
+*_test.go
diff --git a/deployment/chainloop/Chart.yaml b/deployment/chainloop/Chart.yaml
index 85b0b28dd..713c92fd9 100644
--- a/deployment/chainloop/Chart.yaml
+++ b/deployment/chainloop/Chart.yaml
@@ -7,7 +7,7 @@ description: Chainloop is an open source software supply chain control plane, a
type: application
# Bump the patch (not minor, not major) version on each change in the Chart Source code
-version: 1.468.0
+version: 1.468.1
# Do not update appVersion, this is handled automatically by the release process
appVersion: v1.125.0
diff --git a/deployment/chainloop/README.md b/deployment/chainloop/README.md
index ce50fd1ed..28d51d8d6 100644
--- a/deployment/chainloop/README.md
+++ b/deployment/chainloop/README.md
@@ -241,7 +241,33 @@ or as shown before, provide them as imperative inputs during Helm Install/Upgrad
### Enable a custom domain with TLS
-Chainloop uses three endpoints so we'll need to enable the ingress resource for each one of them.
+The control plane ingress serves HTTP, gRPC-Web, and native gRPC on the same hostname.
+Its HTTP server routes each request, so these protocols can share an upstream HTTP/2 connection.
+The existing gRPC ingress stays available. Existing clients can keep their current endpoints.
+To test the shared endpoint, point a gRPC client to the control plane HTTP hostname on port 443.
+The Artifact CAS keeps its existing endpoints and native gRPC transport.
+
+The chart configures HTTP/2 upstream traffic for these controllers:
+
+- Community ingress-nginx: `controlplane.ingress.annotations` includes `nginx.ingress.kubernetes.io/backend-protocol: GRPC`.
+ This uses HTTP/2 upstream for all requests, including ordinary HTTP routes.
+ This setting also applies to `extraPaths`. Place other services that require HTTP/1 upstream in a separate Ingress.
+- F5 NGINX Ingress Controller with NGINX or NGINX Plus: the same annotations include `nginx.org/grpc-services` and `nginx.org/http2: "true"`.
+ For controller versions that require it, also enable `http2` in the controller ConfigMap.
+ Native gRPC clients normally use TLS on the ingress. Plaintext client HTTP/2 needs controller configuration too.
+- Traefik: `controlplane.service.annotations` includes `traefik.ingress.kubernetes.io/service.serversscheme: h2c`.
+
+These defaults apply to plaintext traffic between the ingress and the pod. Public ingress TLS still works as before.
+If you enable TLS on the pod listener, configure the controller for encrypted HTTP/2 upstream traffic instead.
+For ingress-nginx, use `GRPCS`. For F5 NGINX, also configure `nginx.org/ssl-services`.
+For Traefik, use the `https` service scheme and configure certificate trust as required.
+Other controllers need their own upstream protocol settings.
+
+The NGINX gRPC module path supports the shared endpoint without path or content-type rules.
+The regression suite checks that path with NGINX Open Source. It does not certify every controller or NGINX Plus release.
+See the [F5 annotation reference](https://docs.nginx.com/nginx-ingress-controller/configuration/ingress-resources/advanced-configuration-with-annotations/) for controller requirements.
+
+Enable the shared control plane ingress and the existing gRPC ingresses as needed.
See below an example of a `values.yaml` override
@@ -276,6 +302,7 @@ controlplane:
ingressClassName: nginx
hostname: cp.chainloop.dev
annotations:
+ nginx.ingress.kubernetes.io/backend-protocol: "GRPC"
# This depends on your configured issuer
cert-manager.io/cluster-issuer: "letsencrypt-prod"
@@ -634,7 +661,7 @@ Once done, you can access with [two predefined users](https://github.com/chainlo
| `controlplane.service.loadBalancerIP` | controlplane service Load Balancer IP | `""` |
| `controlplane.service.loadBalancerSourceRanges` | controlplane service Load Balancer sources | `[]` |
| `controlplane.service.externalTrafficPolicy` | controlplane service external traffic policy | `Cluster` |
-| `controlplane.service.annotations` | Additional custom annotations for controlplane service | `{}` |
+| `controlplane.service.annotations` | Annotations for the shared HTTP and gRPC service. Traefik uses h2c upstream. | See values.yaml |
| `controlplane.service.extraPorts` | Extra ports to expose in controlplane service (normally used with the `sidecars` value) | `[]` |
| `controlplane.service.sessionAffinity` | Control where client requests go, to the same pod or round-robin | `None` |
| `controlplane.service.sessionAffinityConfig` | Additional settings for the sessionAffinity | `{}` |
@@ -651,12 +678,12 @@ Once done, you can access with [two predefined users](https://github.com/chainlo
| `controlplane.serviceAPI.extraPorts` | Extra ports to expose in controlplane service (normally used with the `sidecars` value) | `[]` |
| `controlplane.serviceAPI.sessionAffinity` | Control where client requests go, to the same pod or round-robin | `None` |
| `controlplane.serviceAPI.sessionAffinityConfig` | Additional settings for the sessionAffinity | `{}` |
-| `controlplane.ingress.enabled` | Enable ingress record generation for controlplane | `false` |
+| `controlplane.ingress.enabled` | Enable the shared HTTP, gRPC-Web and native gRPC ingress for controlplane | `false` |
| `controlplane.ingress.pathType` | Ingress path type | `ImplementationSpecific` |
| `controlplane.ingress.hostname` | Default host for the ingress record | `cp.dev.local` |
| `controlplane.ingress.ingressClassName` | IngressClass that will be be used to implement the Ingress (Kubernetes 1.18+) | `""` |
| `controlplane.ingress.path` | Default path for the ingress record | `/` |
-| `controlplane.ingress.annotations` | Additional annotations for the Ingress resource. To enable certificate autogeneration, place here your cert-manager annotations. | `{}` |
+| `controlplane.ingress.annotations` | Annotations for the shared ingress. NGINX uses HTTP/2 upstream. Add cert-manager annotations here. | See values.yaml |
| `controlplane.ingress.tls` | Enable TLS configuration for the host defined at `controlplane.ingress.hostname` parameter | `false` |
| `controlplane.ingress.selfSigned` | Create a TLS secret for this ingress record using self-signed certificates generated by Helm | `false` |
| `controlplane.ingress.extraHosts` | An array with additional hostname(s) to be covered with the ingress record | `[]` |
diff --git a/deployment/chainloop/chart_test.go b/deployment/chainloop/chart_test.go
new file mode 100644
index 000000000..149b81484
--- /dev/null
+++ b/deployment/chainloop/chart_test.go
@@ -0,0 +1,145 @@
+// Copyright 2026 The Chainloop Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package chart_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "os"
+ "os/exec"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+// Verify the rendered resources, including templated F5 service names and
+// overrides. The legacy gRPC and CAS resources keep their own upstreams.
+func TestSharedControlplaneIngress(t *testing.T) {
+ const (
+ setFlag = "--set"
+ setStringFlag = "--set-string"
+ grpcBackend = "GRPC"
+ h2cScheme = "h2c"
+ chartFullname = "test-chainloop"
+ )
+ helm, err := exec.LookPath("helm")
+ if err != nil {
+ t.Skip("helm is required for chart rendering tests")
+ }
+ for _, tc := range []struct {
+ name string
+ args []string
+ fullname string
+ backendProtocol string
+ traefikScheme string
+ }{
+ {name: "defaults", fullname: chartFullname, backendProtocol: grpcBackend, traefikScheme: h2cScheme},
+ {name: "custom name", args: []string{setFlag, "fullnameOverride=shared-api"}, fullname: "shared-api", backendProtocol: grpcBackend, traefikScheme: h2cScheme},
+ {
+ name: "common annotations merge",
+ args: []string{setStringFlag, `commonAnnotations.nginx\.org/http2=false`},
+ fullname: chartFullname, backendProtocol: grpcBackend, traefikScheme: h2cScheme,
+ },
+ {
+ name: "encrypted upstream overrides",
+ args: []string{
+ setStringFlag, `controlplane.ingress.annotations.nginx\.ingress\.kubernetes\.io/backend-protocol=GRPCS`,
+ setStringFlag, `controlplane.service.annotations.traefik\.ingress\.kubernetes\.io/service\.serversscheme=https`,
+ },
+ fullname: chartFullname, backendProtocol: "GRPCS", traefikScheme: "https",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ serviceName := tc.fullname + "-controlplane"
+ args := append([]string{"template", "test", ".", setFlag, "development=true",
+ setFlag, "controlplane.ingress.enabled=true", setFlag, "controlplane.ingress.hostname=shared.example.test",
+ setFlag, "controlplane.ingressAPI.enabled=true", setFlag, "cas.ingressAPI.enabled=true"}, tc.args...)
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, helm, args...)
+ // Local plugins are unrelated to chart rendering and can print
+ // startup warnings on stdout before the YAML document.
+ cmd.Env = append(os.Environ(), "HELM_PLUGINS="+t.TempDir())
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ out, err := cmd.Output()
+ require.NoError(t, err, "%s", stderr.String())
+ docs := yaml.NewDecoder(bytes.NewReader(out))
+ resources := make(map[string]chartResource)
+ for {
+ var resource chartResource
+ err := docs.Decode(&resource)
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ require.NoError(t, err)
+ resources[resource.Kind+"/"+resource.Metadata.Name] = resource
+ }
+ ingress, ok := resources["Ingress/"+serviceName]
+ require.True(t, ok)
+ require.Equal(t, tc.backendProtocol, ingress.Metadata.Annotations["nginx.ingress.kubernetes.io/backend-protocol"])
+ require.Equal(t, serviceName, ingress.Metadata.Annotations["nginx.org/grpc-services"])
+ require.Equal(t, "true", ingress.Metadata.Annotations["nginx.org/http2"])
+ require.Len(t, ingress.Spec.Rules, 1)
+ require.Equal(t, "shared.example.test", ingress.Spec.Rules[0].Host)
+ require.Len(t, ingress.Spec.Rules[0].HTTP.Paths, 1)
+ path := ingress.Spec.Rules[0].HTTP.Paths[0]
+ require.Equal(t, "/", path.Path)
+ require.Equal(t, serviceName, path.Backend.Service.Name)
+ require.Equal(t, "http", path.Backend.Service.Port.Name)
+ svc := resources["Service/"+serviceName]
+ require.Equal(t, tc.traefikScheme, svc.Metadata.Annotations["traefik.ingress.kubernetes.io/service.serversscheme"])
+ legacy := resources["Ingress/"+serviceName+"-api"]
+ require.Equal(t, grpcBackend, legacy.Metadata.Annotations["nginx.ingress.kubernetes.io/backend-protocol"])
+ require.Len(t, legacy.Spec.Rules, 1)
+ require.Equal(t, serviceName+"-api", legacy.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Name)
+ casName := tc.fullname + "-cas-api"
+ cas := resources["Ingress/"+casName]
+ require.Equal(t, grpcBackend, cas.Metadata.Annotations["nginx.ingress.kubernetes.io/backend-protocol"])
+ require.Len(t, cas.Spec.Rules, 1)
+ require.Equal(t, casName, cas.Spec.Rules[0].HTTP.Paths[0].Backend.Service.Name)
+ })
+ }
+}
+
+type chartResource struct {
+ Kind string `yaml:"kind"`
+ Metadata struct {
+ Name string `yaml:"name"`
+ Annotations map[string]string `yaml:"annotations"`
+ } `yaml:"metadata"`
+ Spec struct {
+ Rules []struct {
+ Host string `yaml:"host"`
+ HTTP struct {
+ Paths []struct {
+ Path string `yaml:"path"`
+ Backend struct {
+ Service struct {
+ Name string `yaml:"name"`
+ Port struct {
+ Name string `yaml:"name"`
+ } `yaml:"port"`
+ } `yaml:"service"`
+ } `yaml:"backend"`
+ } `yaml:"paths"`
+ } `yaml:"http"`
+ } `yaml:"rules"`
+ } `yaml:"spec"`
+}
diff --git a/deployment/chainloop/templates/controlplane/ingress.yaml b/deployment/chainloop/templates/controlplane/ingress.yaml
index de35e887b..e5725f5ac 100644
--- a/deployment/chainloop/templates/controlplane/ingress.yaml
+++ b/deployment/chainloop/templates/controlplane/ingress.yaml
@@ -12,13 +12,8 @@ metadata:
namespace: {{ include "common.names.namespace" . | quote }}
labels: {{- include "chainloop.controlplane.labels" . | nindent 4 }}
{{- if or .Values.controlplane.ingress.annotations .Values.commonAnnotations }}
- annotations:
- {{- if .Values.controlplane.ingress.annotations }}
- {{- include "common.tplvalues.render" ( dict "value" .Values.controlplane.ingress.annotations "context" $) | nindent 4 }}
- {{- end }}
- {{- if .Values.commonAnnotations }}
- {{- include "common.tplvalues.render" ( dict "value" .Values.commonAnnotations "context" $ ) | nindent 4 }}
- {{- end }}
+ {{- $annotations := include "common.tplvalues.merge" (dict "values" (list .Values.controlplane.ingress.annotations .Values.commonAnnotations) "context" .) }}
+ annotations: {{- include "common.tplvalues.render" (dict "value" $annotations "context" $) | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.controlplane.ingress.ingressClassName (eq "true" (include "common.ingress.supportsIngressClassname" .)) }}
@@ -62,4 +57,4 @@ spec:
{{- include "common.tplvalues.render" (dict "value" .Values.controlplane.ingress.extraTls "context" $) | nindent 4 }}
{{- end }}
{{- end }}
-{{- end }}
\ No newline at end of file
+{{- end }}
diff --git a/deployment/chainloop/values.yaml b/deployment/chainloop/values.yaml
index 03865dc18..4fb5d843a 100644
--- a/deployment/chainloop/values.yaml
+++ b/deployment/chainloop/values.yaml
@@ -345,9 +345,11 @@ controlplane:
## ref http://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/#preserving-the-client-source-ip
##
externalTrafficPolicy: Cluster
- ## @param controlplane.service.annotations Additional custom annotations for controlplane service
+ ## @extra controlplane.service.annotations Annotations for the shared HTTP and gRPC service. Traefik uses h2c for HTTP/2 upstream traffic.
##
- annotations: {}
+ annotations:
+ ## @skip controlplane.service.annotations.traefik.ingress.kubernetes.io/service.serversscheme
+ traefik.ingress.kubernetes.io/service.serversscheme: h2c
## @param controlplane.service.extraPorts Extra ports to expose in controlplane service (normally used with the `sidecars` value)
##
extraPorts: []
@@ -432,7 +434,7 @@ controlplane:
## ref: http://kubernetes.io/docs/user-guide/ingress/
ingress:
- ## @param controlplane.ingress.enabled Enable ingress record generation for controlplane
+ ## @param controlplane.ingress.enabled Enable the shared HTTP, gRPC-Web and native gRPC ingress for controlplane
##
enabled: false
## @param controlplane.ingress.pathType Ingress path type
@@ -450,7 +452,7 @@ controlplane:
## NOTE: You may need to set this to '/*' in order to use this with ALB ingress controllers
##
path: /
- ## @param controlplane.ingress.annotations Additional annotations for the Ingress resource. To enable certificate autogeneration, place here your cert-manager annotations.
+ ## @extra controlplane.ingress.annotations Annotations for the shared ingress. NGINX uses HTTP/2 upstream for every request. Add cert-manager annotations here.
## Use this parameter to set the required annotations for cert-manager, see
## ref: https://cert-manager.io/docs/usage/ingress/#supported-annotations
## e.g:
@@ -458,7 +460,13 @@ controlplane:
## kubernetes.io/controlplane.ingress.class: nginx
## cert-manager.io/cluster-issuer: cluster-issuer-name
##
- annotations: {}
+ annotations:
+ ## @skip controlplane.ingress.annotations.nginx.ingress.kubernetes.io/backend-protocol
+ nginx.ingress.kubernetes.io/backend-protocol: GRPC
+ ## @skip controlplane.ingress.annotations.nginx.org/grpc-services
+ nginx.org/grpc-services: '{{ include "chainloop.controlplane.fullname" . }}'
+ ## @skip controlplane.ingress.annotations.nginx.org/http2
+ nginx.org/http2: "true"
## @param controlplane.ingress.tls Enable TLS configuration for the host defined at `controlplane.ingress.hostname` parameter
## TLS certificates will be retrieved from a TLS secret with name: `{{- printf "%s-tls" .Values.controlplane.ingress.hostname }}`
## You can:
diff --git a/docs/specs/issue-3628-controlplane-http-dispatch.md b/docs/specs/issue-3628-controlplane-http-dispatch.md
new file mode 100644
index 000000000..c4bd3a9c9
--- /dev/null
+++ b/docs/specs/issue-3628-controlplane-http-dispatch.md
@@ -0,0 +1,168 @@
+---
+owner: migmartri
+ticket: https://github.com/chainloop-dev/chainloop/issues/3628
+prd:
+---
+
+
+
+# Spec issue-3628-controlplane-http-dispatch: One control-plane ingress for HTTP and gRPC
+
+## Summary
+
+This spec replaces the control-plane design in [Spec issue-3628-single-endpoint](issue-3628-single-endpoint.md).
+The HTTP server owns the shared endpoint and routes every request to the existing HTTP, gRPC-Web, or gRPC handlers.
+HTTP and gRPC requests can share one HTTP/2 connection in either order.
+The chart enables HTTP/2 upstream traffic on the existing HTTP ingress, while the legacy gRPC endpoint remains available.
+CAS transport changes remain separate work under the parent ticket.
+
+## Problem
+
+Connection classification assigns an entire connection to one server.
+A proxy can later reuse that connection for a different request type.
+If the first request selects the native gRPC server, later HTTP or gRPC-Web requests fail.
+Path rules cannot separate native gRPC from gRPC-Web because both use the same RPC paths.
+Operators need one control-plane hostname that serves existing browser and native gRPC clients.
+
+## Goals and Non-Goals
+
+- Goal: HTTP, gRPC-Web, and native gRPC work through one control-plane ingress hostname.
+- Goal: the same endpoint works directly on VMs, in Compose, and in Kubernetes.
+- Goal: existing clients keep their endpoints during migration.
+- Non-goal: migrate the service or clients to Connect, or add the Connect protocol.
+- Non-goal: change the CAS listeners, ingress, or ByteStream transport in this change.
+ CAS still requires bounded memory and the performance evidence from the original R-003.
+- Non-goal: retire the legacy gRPC endpoint or change client defaults.
+
+## Requirements
+
+### R-001: Shared control-plane endpoint
+The control plane MUST serve native gRPC, gRPC-Web, HTTP/1.1, and HTTP/2 on its current HTTP endpoint.
+- Done when: each request type works directly with plaintext or TLS, and through the shared NGINX ingress path.
+- Done when: one HTTP/2 connection serves native gRPC followed by HTTP, gRPC-Web, and CORS preflight, without a reconnect.
+ The reverse order also works.
+
+### R-002: Existing behavior
+The shared endpoint MUST retain the existing authentication, authorization, validation, status, tracing, and metrics behavior.
+- Done when: the control-plane cases in the original R-002 matrix pass through the shared endpoint.
+ Native gRPC cases also pass through the legacy gRPC endpoint.
+- CAS stream and download cases remain part of the separate CAS qualification under the parent ticket.
+- Focused protocol tests do not establish full alignment with this requirement.
+
+### R-003: Existing endpoints remain available
+The legacy gRPC endpoint MUST remain enabled by default, with its own TLS settings and the existing disable flag.
+The CAS endpoints MUST retain their current behavior.
+- Done when: an upgrade needs no client endpoint changes, and clients can test the shared control-plane hostname separately.
+
+### R-004: Shared ingress
+The chart MUST support one control-plane ingress path for HTTP, gRPC-Web, and native gRPC, with HTTP/2 upstream traffic.
+- Done when: the NGINX and Traefik protocol settings target the HTTP service, while legacy ingress settings remain independent.
+- Done when: ordinary HTTP clients still receive their existing responses through that ingress.
+
+## Constraints
+
+- Keep the existing generated gRPC services, interceptors, HTTP routes, and gRPC-Web wrapper.
+- Keep metrics and profiler listeners separate from the public endpoint.
+- A proxy must preserve HTTP/2 upstream for native gRPC, even when a browser uses HTTP/1.1 at the edge.
+- Do not claim CAS streaming parity from control-plane tests or a transport microbenchmark.
+
+## Proposal
+
+Operators enable the existing HTTP ingress to expose all control-plane protocols on one hostname.
+Existing gRPC clients can continue to use the legacy hostname.
+A client can test the shared hostname without changing other clients.
+
+The HTTP server handles connection setup, optional TLS, HTTP/2 settings, and stream ownership.
+For every request, it selects the native gRPC handler, the existing gRPC-Web wrapper, or an existing HTTP route.
+A native gRPC request requires HTTP/2 and a native gRPC content type.
+The native gRPC handler uses grpc-go's HTTP adapter and the existing services and interceptors.
+The service contains no connection classifier, frame replay, or injected settings exchange.
+On shutdown, the HTTP server drains requests before the gRPC handler stops.
+
+The ingress sends every request on its root path to the shared HTTP service over HTTP/2.
+NGINX uses its gRPC upstream setting for that path.
+Traefik uses its unencrypted HTTP/2 upstream setting for the service.
+F5 NGINX and NGINX Plus use their gRPC service and HTTP/2 settings.
+Controller versions can require additional configuration, so the chart documentation states those prerequisites.
+TLS between the ingress and the pod requires the controller's encrypted HTTP/2 settings.
+
+```mermaid
+flowchart TD
+ clients["Browsers, HTTP clients, and native gRPC clients"]
+ ingress["Optional shared ingress hostname
One root path, HTTP/2 upstream"]
+ http["HTTP server owns the shared port
HTTP/1.1, HTTP/2, optional TLS
Dispatch every request"]
+ grpc["Native gRPC over HTTP/2
grpc-go HTTP adapter"]
+ web["Existing gRPC-Web wrapper"]
+ routes["Existing HTTP routes"]
+ rpc["Existing RPC services and interceptors"]
+ legacy["Optional legacy gRPC listener
Native transport, enabled by default"]
+ clients --> ingress --> http
+ clients -->|"Direct VM or Compose access"| http
+ http --> grpc --> rpc
+ http --> web --> rpc
+ http --> routes
+ clients -->|"Existing gRPC endpoint"| legacy --> rpc
+```
+
+## Decision Record
+
+### D-001: HTTP owns the control-plane connection
+This replaces the control-plane parts of the original D-014, D-015, and D-016.
+The service routes each request through the HTTP server, including all native gRPC requests on the shared endpoint.
+- Why: proxies can reuse upstream connections without depending on the first request's protocol.
+ The standard HTTP server owns all HTTP/2 framing and settings.
+- Alternatives considered:
+ - cmux with a native gRPC fast path: requires separate upstream pools and retains custom handshake code.
+ - A proxy sidecar: adds a process that this control-plane dispatch does not need.
+ - Full Connect migration: changes generated service interfaces and middleware for a larger scope.
+- Source: [implementation and design discussion](https://github.com/chainloop-dev/chainloop/pull/3647).
+
+### D-002: One ingress path with HTTP/2 upstream
+This replaces the control-plane ingress design in the original D-004.
+The shared ingress sends all requests to the HTTP service, without path or content-type splits.
+The chart keeps the separate legacy gRPC ingress and its hostname.
+- Why: per-request dispatch supports all request types on the same upstream connection.
+- Alternatives considered:
+ - Split RPC paths: native gRPC and gRPC-Web share those paths.
+ - Switch existing clients to the shared hostname: optional client testing does not require a coordinated migration.
+- Source: [ingress reproduction and research](https://github.com/chainloop-dev/chainloop/pull/3647#issuecomment-6272007730).
+
+### D-003: Defer CAS transport selection
+CAS keeps its current native gRPC transport and separate endpoints.
+The original CAS requirements remain open under the parent ticket.
+- Why: a paused-reader probe showed that the HTTP adapter can read ahead beyond the application's receive pace.
+ That result prevents an unqualified claim of bounded memory for ByteStream uploads.
+- Alternatives considered:
+ - Apply the control-plane design to CAS now: lacks the original R-003 benchmark and bounded-memory evidence.
+- Source: [implementation and design discussion](https://github.com/chainloop-dev/chainloop/pull/3647).
+
+## Open Questions
+
+None for this control-plane change. CAS transport selection remains separate work under the parent ticket.
+
+## Milestones
+
+1. **Shared server and ingress.** The control plane dispatches every request and the chart exposes the shared hostname.
+2. **Behavior qualification.** The full control-plane authentication matrix passes, and deployed controller tests check the documented configurations.
+
+## Risks
+
+| Risk | Mitigation |
+|------|------------|
+| The HTTP adapter differs from the native gRPC transport in performance or features. | Retain the legacy endpoint. Measure relevant workloads before client migration. Keep CAS on its current transport. |
+| A controller uses HTTP/1.1 upstream or needs extra HTTP/2 settings. | Supply protocol annotations and document prerequisites. Test deployed controller versions before production use. |
+| Focused tests miss authentication or authorization differences. | Keep all control-plane cases from the original R-002 matrix as a qualification requirement. |
diff --git a/docs/specs/issue-3628-single-endpoint.md b/docs/specs/issue-3628-single-endpoint.md
index e54b24764..b23215c7c 100644
--- a/docs/specs/issue-3628-single-endpoint.md
+++ b/docs/specs/issue-3628-single-endpoint.md
@@ -22,6 +22,12 @@ When your code goes against this spec, say so in your PR, and supersede the entr
# Spec issue-3628: Serve gRPC and HTTP from one endpoint per service
+Control-plane requirements and design superseded by [Spec issue-3628-controlplane-http-dispatch](issue-3628-controlplane-http-dispatch.md).
+For the control plane, the requirements, constraints, proposal, diagram, decision records, milestones, and risks below are historical.
+Use the replacement spec for all control-plane implementation and qualification.
+Only CAS requirements remain in scope here.
+CAS transport selection and qualification remain open under the parent ticket.
+
## Summary
The control plane and the Artifact CAS will each serve gRPC and HTTP on one port. The control plane also serves gRPC-Web on it, as it does today. Today each service has a gRPC port and an HTTP port. An installation thus needs two Services, two ingresses, two hostnames, and two TLS settings per service. The service will accept all connections on its current HTTP port, the one that serves the human-facing hostname today. It will look at each new connection and give it to the gRPC server or to the HTTP server that runs today. The middleware, the authentication, and the native gRPC transport for large CAS streams do not change. The legacy gRPC port stays on by default for a deprecation period, behind a config flag. The Helm chart moves to one Service and one hostname per service.
@@ -42,7 +48,7 @@ The control plane and the Artifact CAS will each serve gRPC and HTTP on one port
- Goal: one port and one hostname per service, for all gRPC, gRPC-Web, and HTTP traffic.
- Goal: existing clients and configuration keep working without change during the deprecation period.
- Goal: large CAS uploads and downloads keep their current streaming behavior and throughput.
-- Non-goal: move the services to another RPC framework (for example Connect). The single port does not need it (see D-001).
+- Non-goal: move the services to another RPC framework (for example Connect). The single port does not need it (see D-013).
- Non-goal: merge the Prometheus metrics port or the profiler port into the single port. These ports are internal and must not be on the public hostname (see D-006).
- Non-goal: add Gateway API resources to the Helm chart. It is a separate chart feature, and the single port works without it (see D-004).
- Non-goal: change the CLI's default endpoints or its configuration format. The CLI holds hostnames on the ingress port, so it needs no change (see R-005).
@@ -104,16 +110,18 @@ After the upgrade, nothing changes for the operator. The HTTP port now also answ
### How the single port works
-Each service opens one listener on its current HTTP address. A small classifier reads the start of each new connection, without consuming it:
+Each service opens one listener on its current HTTP address. The cmux connection multiplexer reads the start of each new connection, without consuming it. A service-owned matcher decides from the first request:
- An HTTP/2 connection whose first request has a content type that starts with `application/grpc` goes to the native gRPC server. This includes `application/grpc+proto` and content types with parameters. gRPC-Web (`application/grpc-web`) is not in this family and stays on the HTTP server. This server is the same one that runs today, with its middleware, interceptors, message size limits, and streaming transport.
- All other connections go to the HTTP server that runs today. These are HTTP/1.1, HTTP/2 without gRPC, and gRPC-Web. The HTTP server keeps its routes and its middleware. In the control plane it also keeps the gRPC-Web wrapper. In the CAS it keeps the CORS rules for browser downloads. The CAS has no gRPC-Web wrapper, and this spec does not add one.
Both servers keep their own lifecycle. They read from the classifier instead of from their own port. When the operator configures TLS, the listener terminates TLS before the classifier, and it offers HTTP/2 and HTTP/1.1 through ALPN. One certificate then covers all protocols.
-The classifier decides once per connection, from its first request. A connection that starts with gRPC carries only gRPC: the native gRPC server cannot serve a later plain HTTP request on it. The other direction works. When a gRPC request arrives on a connection that the HTTP server owns, the HTTP server gives it to the gRPC server. The gRPC-Web wrapper already works in this way. The request gets the right result but takes the slower path.
+The classifier speaks only when a connection stays silent. gRPC clients wait for the server's HTTP/2 settings frame before they send their first request, so after a short grace the classifier sends one. From that moment the connection belongs to the gRPC server: the client's answer to that frame would break the HTTP server's HTTP/2 framing. The classifier closes the connection when its first request is not native gRPC. Clients that send their first request without waiting never see a write.
+
+The classifier decides once per connection, from its first request. A connection that starts with gRPC carries only gRPC: the native gRPC server cannot serve a later plain HTTP request on it. The other direction works. When a gRPC request arrives on a connection that the HTTP server owns, the HTTP server gives it to the gRPC server. The gRPC-Web wrapper already works in this way. The request gets the right result through the HTTP adapter of the gRPC server. That adapter is a separate transport. Its performance and features may differ from the native one (D-013).
-Thus the single port supports clients and proxies that keep gRPC and plain HTTP on separate HTTP/2 connections. These include direct clients such as the CLI, nginx ingress, and Traefik with HTTP/1.1 for the HTTP path. A proxy that sends both on one shared upstream connection is not supported (see D-010 and Open Questions).
+Thus the single port supports clients and proxies that keep gRPC and plain HTTP on separate HTTP/2 connections. These include direct clients such as the CLI, nginx ingress, and Traefik with HTTP/1.1 for the HTTP path. A proxy that sends both on one shared upstream connection is not supported (see D-014).
CAS streams on the single port use the same native gRPC transport as today, so uploads keep their disk spill and their throughput (R-003). Browser downloads stay on HTTP/1.1 through the ingress, so the kernel copy path stays.
@@ -122,11 +130,12 @@ flowchart LR
CLI[CLI / control plane
gRPC] --> L
UI[Web UI
gRPC-Web] --> L
B[Browser
OIDC, status, downloads] --> L
- L["Single port
(optional TLS + ALPN)"] --> C{Classify
new connection}
- C -- "HTTP/2 + application/grpc" --> G[Native gRPC server
today's middleware]
- C -- "everything else" --> H[HTTP server
routes, CORS
gRPC-Web: control plane only]
- H -. "gRPC request on shared connection" .-> G
- LG[Legacy gRPC port
flag, default on] --> G
+ L["Single port
(optional TLS + ALPN)"] --> C{"cmux:
classify new connection
by first request"}
+ C -- "HTTP/2 + application/grpc" --> T["Native gRPC transport
(grpc-go Serve)
today's middleware"]
+ C -- "everything else" --> H["HTTP server
routes, CORS
gRPC-Web: control plane only"]
+ T --> S["gRPC services"]
+ H -. "native gRPC request on an HTTP-owned
connection (grpc-go ServeHTTP)" .-> S
+ LG["Legacy gRPC port
flag, default on"] --> T
```
### Configuration
@@ -146,13 +155,15 @@ The current HTTP address becomes the single port. It gets its own TLS settings,
## Decision Record
-### D-001: How to serve both protocols on one port
-The service splits by connection in front of the current gRPC and HTTP servers.
-- Why: it keeps the whole middleware chain and the native gRPC transport for CAS streams, and it needs the smallest change.
-- Alternatives considered:
- - One HTTP handler that routes each request: gRPC calls then use the HTTP adapter of the gRPC server. The gRPC library documents that path as slower and with fewer features. That puts R-003 at risk.
- - A move to Connect: it replaces every middleware and route, which is a large migration for the same result.
-- Source: drafting
+### ~~D-001: How to serve both protocols on one port~~
+Superseded by D-013.
+
+~~The service splits by connection in front of the current gRPC and HTTP servers.~~
+- ~~Why: it keeps the whole middleware chain and the native gRPC transport for CAS streams, and it needs the smallest change.~~
+- ~~Alternatives considered:~~
+ - ~~One HTTP handler that routes each request: gRPC calls then use the HTTP adapter of the gRPC server. The gRPC library documents that path as slower and with fewer features. That puts R-003 at risk.~~
+ - ~~A move to Connect: it replaces every middleware and route, which is a large migration for the same result.~~
+- ~~Source: drafting~~
### D-003: Deprecation default
The legacy gRPC port is on by default, then off by default, then deleted.
@@ -169,12 +180,14 @@ The chart uses one Service and one hostname per service. Only where the controll
- Add Gateway API in this work: it is a separate feature.
- Source: drafting
-### D-005: Solve in the server or in the chart
-The services solve it in the server.
-- Why: it works with any ingress, any proxy, and outside Kubernetes.
-- Alternatives considered:
- - A chart-level proxy or Gateway API routes (#2642): they need extra infrastructure and leave two ports in the service.
-- Source: drafting
+### ~~D-005: Solve in the server or in the chart~~
+Superseded by D-015.
+
+~~The services solve it in the server.~~
+- ~~Why: it works with any ingress, any proxy, and outside Kubernetes.~~
+- ~~Alternatives considered:~~
+ - ~~A chart-level proxy or Gateway API routes (#2642): they need extra infrastructure and leave two ports in the service.~~
+- ~~Source: drafting~~
### D-006: Metrics and profiler ports
The metrics and profiler ports stay separate.
@@ -202,12 +215,14 @@ The Helm chart change stays in this spec, as milestone 2.
- A separate spec for the chart: the server change alone does not delete the second hostname.
- Source: drafting
-### D-010: Proxies that share one connection for gRPC and HTTP
-The single port routes each connection once. It supports only clients and proxies that keep gRPC and plain HTTP on separate HTTP/2 connections.
-- Why: a connection that the native gRPC server owns cannot pass single requests to the HTTP server. Native gRPC transport for CAS streams (R-003) needs the connection split of D-001.
-- Alternatives considered:
- - Route each request on one HTTP/2 server: it supports every proxy. But all gRPC calls then use the slower HTTP adapter of the gRPC server, which puts R-003 at risk.
-- Source: [cubic review](https://github.com/chainloop-dev/chainloop/pull/3629#discussion_r4286475927)
+### ~~D-010: Proxies that share one connection for gRPC and HTTP~~
+Superseded by D-014.
+
+~~The single port routes each connection once. It supports only clients and proxies that keep gRPC and plain HTTP on separate HTTP/2 connections.~~
+- ~~Why: a connection that the native gRPC server owns cannot pass single requests to the HTTP server. Native gRPC transport for CAS streams (R-003) needs the connection split of D-001.~~
+- ~~Alternatives considered:~~
+ - ~~Route each request on one HTTP/2 server: it supports every proxy. But all gRPC calls then use the slower HTTP adapter of the gRPC server, which puts R-003 at risk.~~
+- ~~Source: [cubic review](https://github.com/chainloop-dev/chainloop/pull/3629#discussion_r4286475927)~~
### D-011: gRPC-Web only on the control plane
The single port serves gRPC-Web on the control plane only. The CAS serves gRPC and HTTP.
@@ -227,9 +242,45 @@ Drops D-002. The current HTTP port becomes the single port, not the gRPC port. T
- A new port: every client changes.
- Source: [PR comment](https://github.com/chainloop-dev/chainloop/pull/3629#issuecomment-6253559302)
+### ~~D-013: How to serve both protocols on one port~~
+Superseded by D-016.
+
+~~Supersedes D-001. The service splits by connection in front of the current gRPC and HTTP servers, with the cmux library as the connection classifier. The library owns the connection buffering and the listener plumbing. The service owns only a small matcher and the HTTP request dispatcher. The matcher sends the server's HTTP/2 settings frame to clients that wait for it. It reads the first request's content type through the standard HTTP/2 library. The matcher closes the connection when its first request is not native gRPC. The dispatcher carries the gRPC-Web wrapper and the native-gRPC fallback.~~
+- ~~Why: it keeps the whole middleware chain and the native gRPC transport for CAS streams. It also takes HTTP/2 parsing out of the code this project maintains. Argo CD and etcd run the same library split in production.~~
+- ~~Why: the gRPC library documents that its HTTP adapter may differ in performance and features from the native transport. It does not document the adapter as slower, and this project has not measured a difference. The native transport stays the normal path for gRPC connections, so R-003 does not rest on the adapter.~~
+- ~~Alternatives considered:~~
+ - ~~The custom connection parser of the first implementation: it works, but the project then maintains its own HTTP/2 frame parsing, replay, and resource bounds.~~
+ - ~~One HTTP handler that routes each request: every gRPC call, on every connection, then uses the HTTP adapter of the gRPC server.~~
+ - ~~A move to Connect: it requires migrating the generated server interfaces and the interceptors. Ordinary HTTP routes can stay, but the migration is larger than the classifier swap for the same single port.~~
+- ~~Source: [PR comment](https://github.com/chainloop-dev/chainloop/pull/3647#issuecomment-6266145446)~~
+
+### D-014: Separate upstream connections, one-way fallback
+Supersedes D-010. The single port routes each connection once. It supports clients and proxies that keep native gRPC and plain HTTP on separate upstream HTTP/2 connections. Supported ingress configurations use separate upstream pools for the two kinds of traffic. The chart documents and tests them for nginx and Traefik. A connection that the HTTP server owns may carry a native gRPC request later. The HTTP server then gives the request to the gRPC server through the documented fallback, on the adapter transport. The reverse is not supported: a connection that the native gRPC server owns cannot carry a plain HTTP request. Arbitrary mixed native-gRPC and plain-HTTP requests on one shared upstream connection are not required.
+- Why: direct clients keep native gRPC and plain HTTP on separate transports, and no measured traffic for this product shows mixed upstream connections. The fallback keeps the HTTP-first direction working, and the separate-pool contract keeps the native transport on the normal gRPC path.
+- Alternatives considered:
+ - Route each request on one HTTP/2 server: it supports every proxy. But every gRPC call then uses the HTTP adapter of the gRPC server, whose performance and features may differ (D-013).
+- Source: [PR comment](https://github.com/chainloop-dev/chainloop/pull/3647#issuecomment-6266145446)
+
+### D-015: Solve in the server, with tested proxy configurations
+Supersedes D-005. The services solve the multiplexing in the server process. The single port runs directly on a VM, in Docker Compose, and in Kubernetes, with no proxy or sidecar. Behind an L7 proxy, the supported configurations keep native gRPC and plain HTTP on separate upstream connections (D-014). The chart tests and documents those configurations for nginx and Traefik.
+- Why: no additional process, configuration, or lifecycle in any deployment. The earlier "any ingress, any proxy" claim was broader than any test covered. The separate-connection contract is the tested and documented support boundary.
+- Alternatives considered:
+ - A chart-level proxy or Gateway API routes (#2642): they need extra infrastructure and leave two ports in the service.
+ - A mandatory sidecar or embedded proxy: it works behind more proxy configurations, but it adds a process and its lifecycle to every deployment.
+- Source: [PR comment](https://github.com/chainloop-dev/chainloop/pull/3647#issuecomment-6266145446)
+
+### D-016: Complete the classifier's SETTINGS exchange before HTTP handoff
+Supersedes D-013. The service keeps cmux connection classification, the native gRPC server, and the HTTP request dispatcher with its gRPC-Web wrapper and native-gRPC fallback. Both native gRPC and ordinary HTTP/2 clients may wait for server SETTINGS before their first request. Silence does not identify the protocol of that request.
+- The matcher uses the standard HTTP/2 framer and HPACK decoder. It limits incoming frames to the initial 16 KiB maximum before reading their payloads, as well as limiting classification bytes, time, and concurrency.
+- When the matcher sends an empty SETTINGS frame and the first request is HTTP, it waits within the classification bounds for that SETTINGS acknowledgement. It records the validated ACK's exact offset in cmux's replay. The HTTP listener removes only that nine-byte frame before the HTTP server reads the connection. The HTTP server owns all subsequent SETTINGS exchanges. An incomplete injected handshake is closed.
+- Native gRPC connections still go to the existing native server with their original replay. HTTP connections that did not need an injected SETTINGS frame also retain their original replay. HTTP requests and later streams are not parsed again by the handoff adapter.
+- Why: rejecting every non-gRPC request after the matcher sends SETTINGS also rejects valid HTTP-only clients. Forwarding the extra ACK unchanged makes net/http reject the connection after the client acknowledges the HTTP server's own SETTINGS. Removing the one ACK preserves its ownership without adding an ongoing frame parser.
+- Evidence: focused tests cover HTTP and gRPC-Web content types over plaintext and TLS, ACKs before and after the first request, subsequent requests on the same connection, incomplete handshakes, and an oversized frame header without its payload. These tests do not establish full R-002 parity or R-003 CAS performance.
+- Source: [implementation review](https://github.com/chainloop-dev/chainloop/pull/3647#pullrequestreview-5573391966).
+
## Open Questions
-- [ ] **Must the single port support proxies that share one upstream connection for gRPC and plain HTTP?** Examples: Envoy-based proxies, service meshes. Proposed: not in this spec. Until the legacy gRPC port is gone, those setups can route gRPC to it and HTTP to the single port. Revisit before the release that deletes it. Source: [cubic review](https://github.com/chainloop-dev/chainloop/pull/3629#discussion_r4286475927).
+- [x] **Must the single port support proxies that share one upstream connection for gRPC and plain HTTP?** Examples: Envoy-based proxies, service meshes. Answered by D-014: not required. Setups that share one upstream pool can route gRPC to the legacy gRPC port during the deprecation period. The chart documents the supported nginx and Traefik configurations, which keep the two kinds of traffic on separate upstream connections. Source: [cubic review](https://github.com/chainloop-dev/chainloop/pull/3629#discussion_r4286475927).
D-007 and D-008 record the answers to the questions from drafting.
@@ -243,8 +294,8 @@ D-007 and D-008 record the answers to the questions from drafting.
| Risk | Mitigation |
|------|------------|
-| A proxy shares one upstream connection between gRPC and HTTP requests, for example an Envoy-based proxy or a service mesh. A plain HTTP request then lands on a connection that the classifier gave to gRPC, and fails. | The chart configures nginx and Traefik to use separate upstream connections. The documentation lists the supported setups. The legacy gRPC port stays available during the deprecation period. The open question on mixing proxies gets an answer before the legacy gRPC port is deleted. |
-| A gRPC client waits for the server before it sends anything, so the classifier cannot read the first request. | The classifier sends the server's HTTP/2 settings frame first when it sees an HTTP/2 preface, then reads the first request. |
-| A slow or idle client holds a connection before the classifier decides. | Put a short read deadline on classification only, and clear it when the classifier hands over the connection. Long CAS streams are not affected. |
+| A proxy shares one upstream connection between gRPC and HTTP requests, for example an Envoy-based proxy or a service mesh. A plain HTTP request then lands on a connection that the classifier gave to gRPC, and fails. | The chart configures nginx and Traefik to use separate upstream connections. The documentation lists the supported setups. The legacy gRPC port stays available during the deprecation period. |
+| A gRPC client waits for the server's HTTP/2 settings frame before it sends its first request, so the matcher cannot read it. | After a short silence the matcher sends an empty settings frame. The connection then stays on the gRPC side, because the client's answer to that frame would break the HTTP server's HTTP/2 framing. The matcher closes the connection when its first request is not native gRPC. Clients that send their first request without waiting never see a write. |
+| A slow or idle client holds a connection before the classifier decides. | Put a short read deadline on classification only, and clear it when the classifier hands over the connection. The matcher reads at most a small header budget per connection. The service caps how many connections may be in classification. Long CAS streams are not affected. |
| A middleware behaves differently on the single port, for example an authentication step that does not run. | The server-level tests in R-002 merge first and record the current behavior. The same tests then run against the single port. |
| CAS throughput drops on the single port. | The benchmark in R-003 runs before release. gRPC streams use the native transport, so the expected result is no change. |