Skip to content

Commit f93f4fb

Browse files
committed
dynamic TLS CA rotation for sidecar and query service
Add dynamic TLS support so that peer organization CA certificates are automatically updated when config blocks add or remove organizations. - DynamicTLSManager in utils/connection/ watches config block updates and rebuilds the trusted CA pool on the fly - Sidecar updates TLS immediately during config block processing - Query service polls the DB periodically for config changes - Integration test covers add/remove/restore of peer orgs with mTLS - Infrastructure plumbing for QueryTLSRefreshInterval and PeerOrganizationCount in runner config Signed-off-by: Senthilnathan <cendhu@gmail.com>
1 parent 9c09b0b commit f93f4fb

File tree

19 files changed

+801
-24
lines changed

19 files changed

+801
-24
lines changed

cmd/committer/start_cmd.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/hyperledger/fabric-x-committer/service/sidecar"
2020
"github.com/hyperledger/fabric-x-committer/service/vc"
2121
"github.com/hyperledger/fabric-x-committer/service/verifier"
22+
"github.com/hyperledger/fabric-x-committer/utils/connection"
2223
"github.com/hyperledger/fabric-x-committer/utils/grpcservice"
2324
)
2425

@@ -58,7 +59,11 @@ func startService(ctx context.Context, name, configPath string) error {
5859

5960
switch c := conf.(type) {
6061
case *sidecar.Config:
61-
service, err := sidecar.New(c)
62+
tlsUpdater, err := setTLSConfigProviderAndGetUpdater(c.Server)
63+
if err != nil {
64+
return err
65+
}
66+
service, err := sidecar.New(c, tlsUpdater)
6267
if err != nil {
6368
return errors.Wrap(err, "failed to create sidecar service")
6469
}
@@ -80,9 +85,27 @@ func startService(ctx context.Context, name, configPath string) error {
8085
return grpcservice.StartAndServe(ctx, verifier.New(c), c.Server)
8186

8287
case *query.Config:
83-
return grpcservice.StartAndServe(ctx, query.NewQueryService(c), c.Server)
88+
tlsUpdater, err := setTLSConfigProviderAndGetUpdater(c.Server)
89+
if err != nil {
90+
return err
91+
}
92+
return grpcservice.StartAndServe(ctx, query.NewQueryService(c, tlsUpdater), c.Server)
8493

8594
default:
8695
return errors.Newf("unknown config type: %T", conf)
8796
}
8897
}
98+
99+
func setTLSConfigProviderAndGetUpdater(serverConfig *connection.ServerConfig) (connection.TLSCertUpdater, error) {
100+
if serverConfig == nil || serverConfig.TLS.Mode != connection.MutualTLSMode {
101+
return nil, nil
102+
}
103+
104+
dynamicTLS, err := connection.NewDynamicTLSFromConfig(serverConfig.TLS)
105+
if err != nil {
106+
return nil, errors.Wrap(err, "failed to create dynamic TLS config")
107+
}
108+
serverConfig.SetTLSConfigProvider(dynamicTLS)
109+
110+
return dynamicTLS, nil
111+
}

cmd/config/create_config_file.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,18 @@ type (
4141
DB DatabaseConfig
4242

4343
// Per service configurations.
44-
BlockSize uint64 // orderer, loadgen
45-
BlockTimeout time.Duration // orderer
46-
LedgerPath string // sidecar
47-
Policy *workload.PolicyProfile // loadgen
48-
LoadGenBlockLimit uint64 // loadgen
49-
LoadGenTXLimit uint64 // loadgen
50-
LoadGenWorkers uint64 // loadgen
51-
Logging flogging.Config // for all
52-
RateLimit *connection.RateLimitConfig // query, sidecar
53-
MaxRequestKeys int // query
54-
MaxConcurrentStreams int // sidecar
44+
BlockSize uint64 // orderer, loadgen
45+
BlockTimeout time.Duration // orderer
46+
LedgerPath string // sidecar
47+
Policy *workload.PolicyProfile // loadgen
48+
LoadGenBlockLimit uint64 // loadgen
49+
LoadGenTXLimit uint64 // loadgen
50+
LoadGenWorkers uint64 // loadgen
51+
Logging flogging.Config // for all
52+
RateLimit *connection.RateLimitConfig // query, sidecar
53+
MaxRequestKeys int // query
54+
QueryTLSRefreshInterval time.Duration // query
55+
MaxConcurrentStreams int // sidecar
5556

5657
// VC service batching configuration (for testing).
5758
VCMinTransactionBatchSize int // vc

cmd/config/templates/query.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ database:
5454
max-elapsed-time: 1h
5555

5656
max-request-keys: {{ .MaxRequestKeys }}
57+
tls-refresh-interval: {{ .QueryTLSRefreshInterval }}
5758

5859
logging:
5960
logSpec: {{ .Logging.LogSpec }}

integration/runner/runtime.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,14 @@ type (
102102
VerifierBatchTimeCutoff time.Duration
103103
// VerifierBatchSizeCutoff configures the batch size cutoff for verifier service.
104104
VerifierBatchSizeCutoff int
105+
106+
// QueryTLSRefreshInterval configures how often the query service polls the DB
107+
// for config block updates to refresh dynamic TLS CA certificates.
108+
QueryTLSRefreshInterval time.Duration
109+
110+
// PeerOrganizationCount is the number of peer organizations in the genesis config block.
111+
// Defaults to 2 if not set.
112+
PeerOrganizationCount uint32
105113
}
106114
)
107115

@@ -163,7 +171,7 @@ func NewRuntime(t *testing.T, conf *Config) *CommitterRuntime {
163171
ordererEnv := mock.NewOrdererTestEnv(t, &mock.OrdererTestParameters{
164172
NumIDs: 3,
165173
ServerPerID: 2,
166-
PeerOrganizationCount: 2,
174+
PeerOrganizationCount: max(2, conf.PeerOrganizationCount),
167175
ChanID: TestChannelName,
168176
ClientTLSConfig: clientTLS,
169177
ServerTLSConfig: ordererServiceTLS,
@@ -205,6 +213,7 @@ func NewRuntime(t *testing.T, conf *Config) *CommitterRuntime {
205213
VCTimeoutForMinTransactionBatchSize: conf.VCTimeoutForMinTransactionBatchSize,
206214
VerifierBatchTimeCutoff: conf.VerifierBatchTimeCutoff,
207215
VerifierBatchSizeCutoff: conf.VerifierBatchSizeCutoff,
216+
QueryTLSRefreshInterval: conf.QueryTLSRefreshInterval,
208217
},
209218
CommittedBlock: make(chan *common.Block, 100),
210219
SeedForCryptoGen: rand.New(rand.NewSource(10)),
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package test
8+
9+
import (
10+
"context"
11+
"testing"
12+
"time"
13+
14+
"github.com/hyperledger/fabric-x-common/api/committerpb"
15+
"github.com/onsi/gomega"
16+
"github.com/stretchr/testify/require"
17+
"google.golang.org/grpc"
18+
19+
"github.com/hyperledger/fabric-x-committer/integration/runner"
20+
"github.com/hyperledger/fabric-x-committer/utils/connection"
21+
"github.com/hyperledger/fabric-x-committer/utils/grpcerror"
22+
"github.com/hyperledger/fabric-x-committer/utils/test"
23+
"github.com/hyperledger/fabric-x-committer/utils/testcrypto"
24+
)
25+
26+
// TestDynamicTLS verifies that the sidecar and query service dynamically update
27+
// their trusted TLS CA pool when config blocks add or remove peer organizations,
28+
// while preserving static (YAML-configured) root CAs.
29+
func TestDynamicTLS(t *testing.T) {
30+
t.Parallel()
31+
gomega.RegisterTestingT(t)
32+
33+
c := runner.NewRuntime(t, &runner.Config{
34+
TLSMode: connection.MutualTLSMode,
35+
PeerOrganizationCount: 3,
36+
BlockTimeout: 2 * time.Second,
37+
QueryTLSRefreshInterval: 5 * time.Second,
38+
CrashTest: true,
39+
})
40+
// Start orderer servers in-process so SubmitConfigBlock can write directly
41+
// to the orderer's block channel (separate-process orderers don't share memory).
42+
c.OrdererEnv.StartServers(t)
43+
c.Start(t, runner.CommitterTxPath|runner.QueryService)
44+
45+
serverCACertPaths := c.SystemConfig.ClientTLS.CACertPaths
46+
sidecarEndpoint := c.SystemConfig.Services.Sidecar.GrpcEndpoint
47+
queryEndpoint := c.SystemConfig.Services.Query.GrpcEndpoint
48+
49+
// Per-org mTLS configs. Crypto artifacts don't change across config block updates,
50+
// so these remain valid throughout the test.
51+
orgTLS := [3]connection.TLSConfig{}
52+
for i := range orgTLS {
53+
orgTLS[i] = test.OrgClientTLSConfig(c.OrdererEnv.ArtifactsPath, i, serverCACertPaths)
54+
}
55+
56+
// Step 1: Assert all three peer orgs can connect to sidecar and query service.
57+
// The sidecar updates dynamic TLS immediately when processing the genesis config block.
58+
// The query service polls the DB periodically, so we use Eventually for it.
59+
t.Log("Step 1: Initial connection - all three orgs should connect")
60+
for orgIdx, tlsCfg := range orgTLS {
61+
assertRPCSucceeds(t, sidecarEndpoint, tlsCfg, "sidecar", orgIdx)
62+
eventuallyRPCSucceeds(t, queryEndpoint, tlsCfg, "query", orgIdx)
63+
}
64+
65+
// Step 2: Submit config block removing peer-org-2 (keep only peer-org-0 and peer-org-1).
66+
// CreateOrExtendConfigBlockWithCrypto retains existing crypto on disk, so peer-org-2's
67+
// certs remain available for reconnection in Step 4.
68+
t.Log("Step 2: Dynamic removal - submit config with 2 peer orgs")
69+
c.OrdererEnv.SubmitConfigBlock(t, &testcrypto.ConfigBlock{
70+
OrdererEndpoints: c.OrdererEnv.AllEndpoints,
71+
PeerOrganizationCount: 2,
72+
})
73+
c.ValidateExpectedResultsInCommittedBlock(t, &runner.ExpectedStatusInBlock{
74+
Statuses: []committerpb.Status{committerpb.Status_COMMITTED},
75+
})
76+
77+
// Step 3: Verify peer-org-2 is rejected and peer-org-0 remains trusted.
78+
t.Log("Step 3: Negative assertion - peer-org-2 rejected, peer-org-0 accepted")
79+
80+
// Sidecar updates immediately from config block processing.
81+
assertRPCSucceeds(t, sidecarEndpoint, orgTLS[0], "sidecar", 0)
82+
assertRPCFails(t, sidecarEndpoint, orgTLS[2], "sidecar", 2)
83+
84+
// Query service polls the DB; wait for the TLS refresh (up to 15s).
85+
assertRPCSucceeds(t, queryEndpoint, orgTLS[0], "query", 0)
86+
gomega.Eventually(func() error {
87+
return tryRPC(queryEndpoint, orgTLS[2])
88+
}, 15*time.Second, time.Second).Should(gomega.HaveOccurred(),
89+
"query service should reject peer-org-2 after TLS refresh")
90+
91+
// Step 4: Restore peer-org-2.
92+
t.Log("Step 4: Restoration - add peer-org-2 back")
93+
c.OrdererEnv.SubmitConfigBlock(t, &testcrypto.ConfigBlock{
94+
OrdererEndpoints: c.OrdererEnv.AllEndpoints,
95+
PeerOrganizationCount: 3,
96+
})
97+
c.ValidateExpectedResultsInCommittedBlock(t, &runner.ExpectedStatusInBlock{
98+
Statuses: []committerpb.Status{committerpb.Status_COMMITTED},
99+
})
100+
101+
// Sidecar: peer-org-2 connects immediately.
102+
assertRPCSucceeds(t, sidecarEndpoint, orgTLS[2], "sidecar", 2)
103+
104+
// Query service: wait for TLS refresh.
105+
eventuallyRPCSucceeds(t, queryEndpoint, orgTLS[2], "query", 2)
106+
107+
// Step 5: Static persistence - CredentialsFactory client still works.
108+
t.Log("Step 5: Static persistence - static TLS client still trusted")
109+
assertRPCSucceeds(t, sidecarEndpoint, c.SystemConfig.ClientTLS, "sidecar (static)", -1)
110+
assertRPCSucceeds(t, queryEndpoint, c.SystemConfig.ClientTLS, "query (static)", -1)
111+
}
112+
113+
func assertRPCSucceeds( //nolint:revive // test helper, readability over argument count
114+
t *testing.T, endpoint connection.WithAddress,
115+
tlsConfig connection.TLSConfig, service string, orgIdx int,
116+
) {
117+
t.Helper()
118+
err := tryRPC(endpoint, tlsConfig)
119+
require.NoErrorf(t, err, "%s: peer-org-%d should connect successfully", service, orgIdx)
120+
}
121+
122+
func assertRPCFails( //nolint:revive // test helper, readability over argument count
123+
t *testing.T, endpoint connection.WithAddress,
124+
tlsConfig connection.TLSConfig, service string, orgIdx int,
125+
) {
126+
t.Helper()
127+
err := tryRPC(endpoint, tlsConfig)
128+
require.Errorf(t, err, "%s: peer-org-%d should be rejected", service, orgIdx)
129+
}
130+
131+
func eventuallyRPCSucceeds( //nolint:revive // test helper, readability over argument count
132+
t *testing.T, endpoint connection.WithAddress,
133+
tlsConfig connection.TLSConfig, service string, orgIdx int,
134+
) {
135+
t.Helper()
136+
gomega.Eventually(func() error {
137+
return tryRPC(endpoint, tlsConfig)
138+
}, 15*time.Second, time.Second).ShouldNot(gomega.HaveOccurred(),
139+
"%s: peer-org-%d should connect successfully", service, orgIdx)
140+
}
141+
142+
// tryRPC attempts a lightweight gRPC call and returns an error only if the TLS
143+
// handshake fails. Application-level gRPC errors (InvalidArgument, Unimplemented,
144+
// etc.) indicate a successful TLS connection and are treated as success.
145+
func tryRPC(endpoint connection.WithAddress, tlsConfig connection.TLSConfig) error {
146+
creds, err := tlsConfig.ClientCredentials()
147+
if err != nil {
148+
return err
149+
}
150+
conn, err := grpc.NewClient(endpoint.Address(), grpc.WithTransportCredentials(creds))
151+
if err != nil {
152+
return err
153+
}
154+
defer conn.Close() //nolint:errcheck
155+
156+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
157+
defer cancel()
158+
159+
client := committerpb.NewQueryServiceClient(conn)
160+
_, err = client.GetTransactionStatus(ctx, &committerpb.TxStatusQuery{})
161+
// FilterUnavailableErrorCode returns nil for transient connectivity errors
162+
// (Unavailable, DeadlineExceeded) and passes through application-level errors.
163+
// An application-level error means TLS succeeded, so we invert: if the filter
164+
// passes through an error, the connection worked.
165+
if grpcerror.FilterUnavailableErrorCode(err) != nil {
166+
return nil
167+
}
168+
return err
169+
}

loadgen/client_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ func TestLoadGenForSidecar(t *testing.T) {
237237
},
238238
Orderer: e.OrdererConnConfig,
239239
}
240-
service, err := sidecar.New(sidecarConf)
240+
service, err := sidecar.New(sidecarConf, nil)
241241
require.NoError(t, err)
242242
t.Cleanup(service.Close)
243243
test.RunServiceAndGrpcForTest(t.Context(), t, service, sidecarConf.Server)
@@ -279,7 +279,7 @@ func TestLoadGenForOrderer(t *testing.T) {
279279
}
280280

281281
// Start sidecar.
282-
service, err := sidecar.New(sidecarConf)
282+
service, err := sidecar.New(sidecarConf, nil)
283283
require.NoError(t, err)
284284
t.Cleanup(service.Close)
285285
test.RunServiceAndGrpcForTest(t.Context(), t, service, sidecarConf.Server)

service/query/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,7 @@ type Config struct {
4848
// GetTransactionStatus (number of transaction IDs).
4949
// Set to 0 to disable the limit.
5050
MaxRequestKeys int `mapstructure:"max-request-keys"`
51+
// TLSRefreshInterval is the interval at which the query service polls the database
52+
// for config block updates to refresh TLS CA certificates. Defaults to 1 minute.
53+
TLSRefreshInterval time.Duration `mapstructure:"tls-refresh-interval"`
5154
}

0 commit comments

Comments
 (0)