From ed28baf6f242444ad4059125510c4d8bc80e552c Mon Sep 17 00:00:00 2001 From: William Chong Date: Wed, 9 Oct 2024 09:27:38 +0400 Subject: [PATCH] Support providing an x.509 certificate for user authentication --- .github/workflows/pull-request.yml | 15 +++ .github/workflows/test-dispatch.yml | 10 ++ .github/workflows/test-plugins.yml | 89 +++++++++++++++ .github/workflows/tests.yml | 10 +- .gitignore | 2 +- Makefile | 2 +- README.md | 4 +- cluster-docker-compose.yml | 8 +- docker-compose.yml | 2 + esdb/client.go | 4 + esdb/client_certificates_test.go | 167 ++++++++++++++++++++++++++++ esdb/client_test.go | 20 ++++ esdb/configuration.go | 38 ++++++- esdb/containers_test.go | 15 +-- esdb/impl.go | 49 +++++--- go.mod | 16 +-- go.sum | 17 +++ samples/userCertificates.go | 23 ++++ shared.env | 3 +- 19 files changed, 447 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/test-plugins.yml create mode 100644 esdb/client_certificates_test.go create mode 100644 samples/userCertificates.go diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 263efb83..d9bbbce3 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -40,6 +40,21 @@ jobs: esdb_version: ${{ matrix.version }} go_version: ${{ needs.go-version.outputs.go_version }} + plugins-tests: + needs: build + name: Plugins Tests + + strategy: + fail-fast: false + matrix: + version: [24.2.0-jammy] + + uses: ./.github/workflows/test-plugins.yml + with: + esdb_version: ${{ matrix.version }} + go_version: ${{ needs.go-version.outputs.go_version }} + esdb_repository: "docker.eventstore.com/eventstore-ee/eventstoredb-commercial" + secrets: inherit linting: needs: tests diff --git a/.github/workflows/test-dispatch.yml b/.github/workflows/test-dispatch.yml index c65ee2a8..4ebc0701 100644 --- a/.github/workflows/test-dispatch.yml +++ b/.github/workflows/test-dispatch.yml @@ -19,3 +19,13 @@ jobs: with: esdb_version: ${{ inputs.version }} go_version: ${{ needs.go-version.outputs.go_version }} + + plugins-tests: + needs: go-version + name: Plugins Tests + uses: ./.github/workflows/plugins-tests.yml + with: + esdb_version: ${{ inputs.version }} + go_version: ${{ needs.go-version.outputs.go_version }} + esdb_repository: "docker.eventstore.com/eventstore-ee/eventstoredb-commercial" + secrets: inherit diff --git a/.github/workflows/test-plugins.yml b/.github/workflows/test-plugins.yml new file mode 100644 index 00000000..6d73272b --- /dev/null +++ b/.github/workflows/test-plugins.yml @@ -0,0 +1,89 @@ +name: enterprise plugins tests workflow + +on: + workflow_call: + inputs: + esdb_repository: + required: true + type: string + + esdb_version: + required: true + type: string + + go_version: + required: true + type: string + +jobs: + secure: + name: Secure + + strategy: + fail-fast: false + matrix: + test: [Plugins] + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go_version }} + + - name: Login to Cloudsmith + uses: docker/login-action@v3 + with: + registry: docker.eventstore.com + username: ${{ secrets.CLOUDSMITH_CICD_USER }} + password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }} + + - name: Generate certificates + run: docker compose --file docker-compose.yml up + + - name: Run Go Tests + run: make ci CI_TARGET=Test${{ matrix.test }} + + env: + EVENTSTORE_DOCKER_REPOSITORY: ${{ inputs.esdb_repository }} + EVENTSTORE_DOCKER_TAG: ${{ inputs.esdb_version }} + EVENTSTORE_INSECURE: false + + cluster: + name: Cluster + + strategy: + fail-fast: false + matrix: + test: [Plugins] + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go_version }} + + - name: Login to Cloudsmith + uses: docker/login-action@v3 + with: + registry: docker.eventstore.com + username: ${{ secrets.CLOUDSMITH_CICD_USER }} + password: ${{ secrets.CLOUDSMITH_CICD_TOKEN }} + + - name: Set up cluster with Docker Compose + run: | + docker compose -f cluster-docker-compose.yml up -d + env: + EVENTSTORE_DOCKER_REPOSITORY: ${{ inputs.esdb_repository }} + EVENTSTORE_DOCKER_TAG: ${{ inputs.esdb_version }} + + - name: Run Go Tests + run: make ci CI_TARGET=Test${{ matrix.test }} + env: + EVENTSTORE_INSECURE: false + CLUSTER: true + + - name: Shutdown cluster + run: docker compose -f cluster-docker-compose.yml down + if: always() diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a51aa3a4..38417d51 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,12 +28,12 @@ jobs: go-version: ${{ inputs.go_version }} - name: Generate certificates - run: docker-compose --file docker-compose.yml up + run: docker compose --file docker-compose.yml up - name: Run Go Tests run: make ci CI_TARGET=Test${{ matrix.test }} env: - EVENTSTORE_DOCKER_TAG_ENV: ${{ inputs.esdb_version }} + EVENTSTORE_DOCKER_TAG: ${{ inputs.esdb_version }} EVENTSTORE_INSECURE: true secure: @@ -52,13 +52,13 @@ jobs: go-version: ${{ inputs.go_version }} - name: Generate certificates - run: docker-compose --file docker-compose.yml up + run: docker compose --file docker-compose.yml up - name: Run Go Tests run: make ci CI_TARGET=Test${{ matrix.test }} env: - EVENTSTORE_DOCKER_TAG_ENV: ${{ inputs.esdb_version }} + EVENTSTORE_DOCKER_TAG: ${{ inputs.esdb_version }} EVENTSTORE_INSECURE: false cluster: @@ -80,7 +80,7 @@ jobs: run: | docker compose -f cluster-docker-compose.yml up -d env: - EVENTSTORE_DOCKER_TAG_ENV: ${{ inputs.esdb_version }} + EVENTSTORE_DOCKER_TAG: ${{ inputs.esdb_version }} - name: Run Go Tests run: make ci CI_TARGET=Test${{ matrix.test }} diff --git a/.gitignore b/.gitignore index ff0cb535..02dfce72 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,4 @@ _testmain.go .idea certs/ -tools/ \ No newline at end of file +tools/ diff --git a/Makefile b/Makefile index 406eafff..84ea4a2d 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ else $(MAKE) build GENERATE_PROTOS_FLAG=-generateProtos endif -DOCKER_COMPOSE_CMD := $(shell command -v docker-compose 2> /dev/null) +DOCKER_COMPOSE_CMD := $(shell command -v docker compose 2> /dev/null) ifeq ($(DOCKER_COMPOSE_CMD),) DOCKER_COMPOSE_CMD := docker compose endif diff --git a/README.md b/README.md index 4dea7b34..88524c43 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ make generate-protos-and-build make test ``` -By default the tests use `docker.eventstore.com/eventstore-ce:ci`. To override this, set the `EVENTSTORE_DOCKER_TAG_ENV` environment variable to the tag you wish to use: +By default the tests use `docker.eventstore.com/eventstore-ce:ci`. To override this, set the `EVENTSTORE_DOCKER_TAG` environment variable to the tag you wish to use: ```shell -export EVENTSTORE_DOCKER_TAG_ENV="21.10.0-focal" +export EVENTSTORE_DOCKER_TAG="21.10.0-focal" make test ``` diff --git a/cluster-docker-compose.yml b/cluster-docker-compose.yml index bba68629..ece8c90d 100644 --- a/cluster-docker-compose.yml +++ b/cluster-docker-compose.yml @@ -19,6 +19,8 @@ services: && es-gencert-cli create-node -out ./node1 -ip-addresses 127.0.0.1,172.30.240.11 -dns-names localhost && es-gencert-cli create-node -out ./node2 -ip-addresses 127.0.0.1,172.30.240.12 -dns-names localhost && es-gencert-cli create-node -out ./node3 -ip-addresses 127.0.0.1,172.30.240.13 -dns-names localhost + && es-gencert-cli create-user -username admin + && es-gencert-cli create-user -username invalid && find . -type f -print0 | xargs -0 chmod 666" volumes: - "./certs:/certs" @@ -26,7 +28,7 @@ services: - volumes-provisioner esdb-node1: - image: docker.eventstore.com/eventstore-ce/eventstoredb-ce:${EVENTSTORE_DOCKER_TAG_ENV:-latest} + image: ${EVENTSTORE_DOCKER_REPOSITORY:-docker.eventstore.com/eventstore-ce/eventstoredb-ce}:${EVENTSTORE_DOCKER_TAG:-latest} env_file: - shared.env environment: @@ -47,7 +49,7 @@ services: - cert-gen esdb-node2: - image: docker.eventstore.com/eventstore-ce/eventstoredb-ce:${EVENTSTORE_DOCKER_TAG_ENV:-latest} + image: ${EVENTSTORE_DOCKER_REPOSITORY:-docker.eventstore.com/eventstore-ce/eventstoredb-ce}:${EVENTSTORE_DOCKER_TAG:-latest} env_file: - shared.env environment: @@ -68,7 +70,7 @@ services: - cert-gen esdb-node3: - image: docker.eventstore.com/eventstore-ce/eventstoredb-ce:${EVENTSTORE_DOCKER_TAG_ENV:-latest} + image: ${EVENTSTORE_DOCKER_REPOSITORY:-docker.eventstore.com/eventstore-ce/eventstoredb-ce}:${EVENTSTORE_DOCKER_TAG:-latest} env_file: - shared.env environment: diff --git a/docker-compose.yml b/docker-compose.yml index 38a5265c..185667e8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,8 @@ services: && es-gencert-cli create-ca && es-gencert-cli create-node -out ./node --dns-names localhost && es-gencert-cli create-ca -out ./untrusted-ca + && es-gencert-cli create-user -username admin + && es-gencert-cli create-user -username invalid && find . -type f -print0 | xargs -0 chmod 666" container_name: setup volumes: diff --git a/esdb/client.go b/esdb/client.go index 1b7aaaef..a7ce648a 100644 --- a/esdb/client.go +++ b/esdb/client.go @@ -25,6 +25,10 @@ type Client struct { // NewClient Creates a gRPC client to an EventStoreDB database. func NewClient(configuration *Configuration) (*Client, error) { + if err := configuration.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + grpcClient := newGrpcClient(*configuration) return &Client{ grpcClient: grpcClient, diff --git a/esdb/client_certificates_test.go b/esdb/client_certificates_test.go new file mode 100644 index 00000000..e4151bdc --- /dev/null +++ b/esdb/client_certificates_test.go @@ -0,0 +1,167 @@ +package esdb_test + +import ( + "context" + "errors" + "fmt" + "github.com/EventStore/EventStore-Client-Go/v4/esdb" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "io" + "path/filepath" + "testing" +) + +func ClientCertificatesSingleNodeTests(t *testing.T, emptyDBContainer *Container) { + t.Run("AuthenticationTests", func(t *testing.T) { + t.Run("ValidClientCertificatesTlsRelativePath", func(t *testing.T) { + testValidClientCertificatesTlsWithRelativePath(t, emptyDBContainer.Endpoint) + }) + t.Run("ValidClientCertificatesTlsWithAbsolutePath", func(t *testing.T) { + testValidClientCertificatesTlsWithAbsolutePath(t, emptyDBContainer.Endpoint) + }) + t.Run("InvalidUserCertificates", func(t *testing.T) { + testInvalidUserCertificates(t, emptyDBContainer.Endpoint) + }) + t.Run("MissingCertificateFile", func(t *testing.T) { + testMissingCertificateFile(t, emptyDBContainer.Endpoint) + }) + }) +} + +func ClientCertificatesClusterNodesTests(t *testing.T) { + t.Run("AuthenticationTests", func(t *testing.T) { + var endpoint = "localhost:2111,localhost:2112,localhost:2113" + + t.Run("ValidClientCertificatesTlsRelativePath", func(t *testing.T) { + testValidClientCertificatesTlsWithRelativePath(t, endpoint) + }) + t.Run("ValidClientCertificatesTlsWithAbsolutePath", func(t *testing.T) { + testValidClientCertificatesTlsWithAbsolutePath(t, endpoint) + }) + t.Run("InvalidUserCertificates", func(t *testing.T) { + testInvalidUserCertificates(t, endpoint) + }) + t.Run("MissingCertificateFile", func(t *testing.T) { + testMissingCertificateFile(t, endpoint) + }) + }) +} + +func testValidClientCertificatesTlsWithRelativePath(t *testing.T, endpoint string) { + tlsCaFile := "../certs/ca/ca.crt" + userCertFile := "../certs/user-admin/user-admin.crt" + userKeyFile := "../certs/user-admin/user-admin.key" + + config, err := esdb.ParseConnectionString(fmt.Sprintf("esdb://admin:changeit@%s?tls=true&tlscafile=%s&userCertFile=%s&userKeyFile=%s", endpoint, tlsCaFile, userCertFile, userKeyFile)) + if err != nil { + t.Fatalf("Unexpected configuration error: %s", err.Error()) + } + + c, err := esdb.NewClient(config) + if err != nil { + t.Fatalf("Unexpected error: %s", err.Error()) + } + defer c.Close() + + numberOfEventsToRead := 1 + numberOfEvents := uint64(numberOfEventsToRead) + opts := esdb.ReadAllOptions{ + From: esdb.Start{}, + Direction: esdb.Backwards, + ResolveLinkTos: true, + } + + stream, err := c.ReadAll(context.Background(), opts, numberOfEvents) + require.NoError(t, err) + defer stream.Close() + evt, err := stream.Recv() + require.Nil(t, evt) + require.True(t, errors.Is(err, io.EOF)) +} + +func testValidClientCertificatesTlsWithAbsolutePath(t *testing.T, endpoint string) { + userCertFile, err := filepath.Abs("../certs/user-admin/user-admin.crt") + if err != nil { + t.Fatalf("Unexpected error: %s", err.Error()) + } + + userKeyFile, err := filepath.Abs("../certs/user-admin/user-admin.key") + if err != nil { + t.Fatalf("Unexpected error: %s", err.Error()) + } + + tlsCaFile, err := filepath.Abs("../certs/ca/ca.crt") + if err != nil { + t.Fatalf("Unexpected error: %s", err.Error()) + } + + config, err := esdb.ParseConnectionString(fmt.Sprintf("esdb://admin:changeit@%s?tls=true&tlscafile=%s&usercertfile=%s&userkeyfile=%s", endpoint, tlsCaFile, userCertFile, userKeyFile)) + if err != nil { + t.Fatalf("Unexpected configuration error: %s", err.Error()) + } + + c, err := esdb.NewClient(config) + if err != nil { + t.Fatalf("Unexpected error: %s", err.Error()) + } + defer c.Close() + + numberOfEventsToRead := 1 + numberOfEvents := uint64(numberOfEventsToRead) + opts := esdb.ReadAllOptions{ + From: esdb.Start{}, + Direction: esdb.Backwards, + ResolveLinkTos: true, + } + + stream, err := c.ReadAll(context.Background(), opts, numberOfEvents) + require.NoError(t, err) + defer stream.Close() + evt, err := stream.Recv() + require.Nil(t, evt) + require.True(t, errors.Is(err, io.EOF)) +} + +func testInvalidUserCertificates(t *testing.T, endpoint string) { + tlsCaFile := "../certs/ca/ca.crt" + userCertFile := "../certs/user-invalid/user-invalid.crt" + userKeyFile := "../certs/user-invalid/user-invalid.key" + + config, err := esdb.ParseConnectionString(fmt.Sprintf("esdb://admin:changeit@%s?tls=true&tlscafile=%s&usercertfile=%s&userkeyfile=%s", endpoint, tlsCaFile, userCertFile, userKeyFile)) + if err != nil { + t.Fatalf("Unexpected configuration error: %s", err.Error()) + } + + c, err := esdb.NewClient(config) + if err != nil { + t.Fatalf("Unexpected error: %s", err.Error()) + } + defer c.Close() + + testEvent := createTestEvent() + + streamID := uuid.NewString() + opts := esdb.AppendToStreamOptions{ + ExpectedRevision: esdb.Any{}, + } + + result, err := c.AppendToStream(context.Background(), streamID, opts, testEvent) + require.Nil(t, result) + require.Error(t, err) + require.Contains(t, err.Error(), "Unauthenticated") +} + +func testMissingCertificateFile(t *testing.T, endpoint string) { + tlsCaFile := "../certs/ca/ca.crt" + userCertFile := "../certs/user-admin/user-admin.crt" + + config, err := esdb.ParseConnectionString(fmt.Sprintf("esdb://admin:changeit@%s?tls=true&tlscafile=%s&usercertfile=%s", endpoint, tlsCaFile, userCertFile)) + + _, err = esdb.NewClient(config) + esdbErr, ok := esdb.FromError(err) + require.False(t, ok) + require.NotNil(t, esdbErr) + assert.Contains(t, esdbErr.Error(), "both userCertFile and userKeyFile must be provided") +} diff --git a/esdb/client_test.go b/esdb/client_test.go index a98f333f..8a2e3a09 100644 --- a/esdb/client_test.go +++ b/esdb/client_test.go @@ -87,6 +87,26 @@ func TestProjections(t *testing.T) { ProjectionTests(t, emptyClient) } +func TestPlugins(t *testing.T) { + isCluster := GetEnvOrDefault("CLUSTER", "false") == "true" + + if !isCluster { + emptyContainer, emptyClient := CreateEmptyDatabase(t) + + if emptyContainer != nil { + defer emptyContainer.Close() + } + + if emptyClient != nil { + defer emptyClient.Close() + } + + ClientCertificatesSingleNodeTests(t, emptyContainer) + } else { + ClientCertificatesClusterNodesTests(t) + } +} + func TestExpectations(t *testing.T) { populatedContainer, populatedClient := CreatePopulatedDatabase(t) diff --git a/esdb/configuration.go b/esdb/configuration.go index 72730e1b..dc59303d 100644 --- a/esdb/configuration.go +++ b/esdb/configuration.go @@ -3,8 +3,9 @@ package esdb import ( "crypto/x509" "fmt" - "io/ioutil" url2 "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -15,6 +16,13 @@ const ( schemeNameWithDiscover = "esdb+discover" ) +var basepath string + +func init() { + cwd, _ := os.Getwd() + basepath = cwd +} + // Configuration describes how to connect to an instance of EventStoreDB. type Configuration struct { // The URI of the EventStoreDB. Use this when connecting to a single node. @@ -41,6 +49,12 @@ type Configuration struct { // If RootCAs is nil, TLS uses the host's root CA set. RootCAs *x509.CertPool // Defaults to nil. + // The path to the file containing the X.509 user certificate in PEM format. + UserCertFile string + + // The path to the file containing the user certificate’s matching private key in PEM format + UserKeyFile string + // Allows to skip certificate validation. SkipCertificateVerification bool // Defaults to false. @@ -77,6 +91,14 @@ func (conf *Configuration) applyLogger(level LogLevel, format string, args ...in } } +func (conf *Configuration) Validate() error { + if (conf.UserCertFile == "") != (conf.UserKeyFile == "") { + return fmt.Errorf("both userCertFile and userKeyFile must be provided") + } + + return nil +} + func initConfiguration() *Configuration { return &Configuration{ DiscoveryInterval: 100, @@ -292,6 +314,10 @@ func parseSetting(k, v string, config *Configuration) error { if err != nil { return err } + case "usercertfile": + config.UserCertFile = resolvePath(v) + case "userkeyfile": + config.UserKeyFile = resolvePath(v) case "tlsverifycert": err := parseBoolSetting(k, v, &config.SkipCertificateVerification, true) if err != nil { @@ -311,7 +337,7 @@ func parseSetting(k, v string, config *Configuration) error { } func parseCertificateFile(certFile string, config *Configuration) error { - b, err := ioutil.ReadFile(certFile) + b, err := os.ReadFile(certFile) if err != nil { return err } @@ -391,6 +417,14 @@ func parseDurationAsMs(k, v string, d *time.Duration) error { return nil } +func resolvePath(rel string) string { + if filepath.IsAbs(rel) { + return rel + } + + return filepath.Join(basepath, rel) +} + // NodePreference indicates which order of preferred nodes for connecting to. type NodePreference string diff --git a/esdb/containers_test.go b/esdb/containers_test.go index 4e67e4aa..0e5f8054 100644 --- a/esdb/containers_test.go +++ b/esdb/containers_test.go @@ -19,9 +19,9 @@ import ( ) const ( - EVENTSTORE_DOCKER_REPOSITORY_ENV = "EVENTSTORE_DOCKER_REPOSITORY" - EVENTSTORE_DOCKER_TAG_ENV = "EVENTSTORE_DOCKER_TAG_ENV" - EVENTSTORE_DOCKER_PORT_ENV = "EVENTSTORE_DOCKER_PORT" + EVENTSTORE_DOCKER_REPOSITORY = "EVENTSTORE_DOCKER_REPOSITORY" + EVENTSTORE_DOCKER_TAG = "EVENTSTORE_DOCKER_TAG" + EVENTSTORE_DOCKER_PORT_ENV = "EVENTSTORE_DOCKER_PORT" ) var ( @@ -61,8 +61,8 @@ func GetEnvOrDefault(key, defaultValue string) string { } func readEnvironmentVariables(config EventStoreDockerConfig) EventStoreDockerConfig { - config.Repository = GetEnvOrDefault(EVENTSTORE_DOCKER_REPOSITORY_ENV, config.Repository) - config.Tag = GetEnvOrDefault(EVENTSTORE_DOCKER_TAG_ENV, config.Tag) + config.Repository = GetEnvOrDefault(EVENTSTORE_DOCKER_REPOSITORY, config.Repository) + config.Tag = GetEnvOrDefault(EVENTSTORE_DOCKER_TAG, config.Tag) config.Port = GetEnvOrDefault(EVENTSTORE_DOCKER_PORT_ENV, config.Port) fmt.Println(spew.Sdump(config)) @@ -78,7 +78,7 @@ type ESDBVersion struct { type VersionPredicateFn = func(ESDBVersion) bool func IsESDB_Version(predicate VersionPredicateFn) bool { - value, exists := os.LookupEnv(EVENTSTORE_DOCKER_TAG_ENV) + value, exists := os.LookupEnv(EVENTSTORE_DOCKER_TAG) if !exists || value == "ci" { return false } @@ -149,6 +149,7 @@ func getContainerRequest() (*EventStoreDockerConfig, *testcontainers.ContainerRe env["EVENTSTORE_RUN_PROJECTIONS"] = "all" env["EVENTSTORE_START_STANDARD_PROJECTIONS"] = "true" env["EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP"] = "true" + env["EventStore__Plugins__UserCertificates__Enabled"] = "true" return &config, &testcontainers.ContainerRequest{ Image: fmt.Sprintf("%s:%s", config.Repository, config.Tag), @@ -217,7 +218,7 @@ func verifyCertificatesExist() error { for _, f := range certs { if _, err := os.Stat(path.Join(certsDir, f)); os.IsNotExist(err) { - return fmt.Errorf("could not locate the certificates needed to run EventStoreDB and the tests. Please run 'docker-compose up' for generating the certificates") + return fmt.Errorf("could not locate the certificates needed to run EventStoreDB and the tests. Please run 'docker compose up' for generating the certificates") } } return nil diff --git a/esdb/impl.go b/esdb/impl.go index 953d3741..6a500c2a 100644 --- a/esdb/impl.go +++ b/esdb/impl.go @@ -300,11 +300,20 @@ func createGrpcConnection(conf *Configuration, address string) (*grpc.ClientConn if conf.DisableTLS { transport = insecure.NewCredentials() } else { - transport = credentials.NewTLS( - &tls.Config{ - InsecureSkipVerify: conf.SkipCertificateVerification, - RootCAs: conf.RootCAs, - }) + tlsConfig := &tls.Config{ + InsecureSkipVerify: conf.SkipCertificateVerification, + RootCAs: conf.RootCAs, + } + + if conf.UserCertFile != "" && conf.UserKeyFile != "" { + cert, err := tls.LoadX509KeyPair(conf.UserCertFile, conf.UserKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load user certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + transport = credentials.NewTLS(tlsConfig) } opts = append(opts, grpc.WithTransportCredentials(transport)) @@ -317,7 +326,7 @@ func createGrpcConnection(conf *Configuration, address string) (*grpc.ClientConn })) } - conn, err := grpc.Dial(address, opts...) + conn, err := grpc.NewClient(address, opts...) if err != nil { return nil, fmt.Errorf("failed to initialize connection to %s. Reason: %w", address, err) } @@ -443,9 +452,8 @@ func allowedNodeState() []gossipApi.MemberInfo_VNodeState { } func discoverNode(conf Configuration, logger *logger) (*grpc.ClientConn, *serverInfo, error) { - var connection *grpc.ClientConn = nil var serverInfo *serverInfo = nil - var err error + var lastErr error var candidates []string attempt := 0 @@ -474,9 +482,10 @@ func discoverNode(conf Configuration, logger *logger) (*grpc.ClientConn, *server logger.info("discovery attempt %v/%v", attempt, conf.MaxDiscoverAttempts) for _, candidate := range candidates { logger.debug("trying candidate '%s'...", candidate) - connection, err = createGrpcConnection(&conf, candidate) + connection, err := createGrpcConnection(&conf, candidate) if err != nil { logger.warn("error when creating a grpc connection for candidate %s: %v", candidate, err) + lastErr = err continue } @@ -488,7 +497,10 @@ func discoverNode(conf Configuration, logger *logger) (*grpc.ClientConn, *server s, ok := status.FromError(err) if !ok || (s != nil && s.Code() != codes.OK) { logger.warn("error when reading gossip from candidate %s: %v", candidate, err) + lastErr = err cancel() + _ = connection.Close() + connection = nil continue } @@ -498,6 +510,9 @@ func discoverNode(conf Configuration, logger *logger) (*grpc.ClientConn, *server if err != nil { logger.warn("error when picking best candidate out of %s gossip response: %v", candidate, err) + lastErr = err + _ = connection.Close() + connection = nil continue } @@ -510,6 +525,9 @@ func discoverNode(conf Configuration, logger *logger) (*grpc.ClientConn, *server if err != nil { logger.warn("error when creating gRPC connection for the selected candidate '%s': %v", selectedAddress, err) + lastErr = err + _ = connection.Close() + connection = nil continue } } @@ -518,7 +536,8 @@ func discoverNode(conf Configuration, logger *logger) (*grpc.ClientConn, *server logger.debug("attempting node supported features retrieval on '%s'...", candidate) serverInfo, err = getSupportedMethods(context.Background(), &conf, connection) if err != nil { - logger.warn("error when creating reading server features from the best candidate '%s': %v", candidate, err) + logger.warn("error when reading server features from the best candidate '%s': %v", candidate, err) + lastErr = err _ = connection.Close() connection = nil continue @@ -536,14 +555,10 @@ func discoverNode(conf Configuration, logger *logger) (*grpc.ClientConn, *server time.Sleep(time.Duration(conf.DiscoveryInterval) * time.Millisecond) } - if connection == nil { - return nil, nil, &Error{ - code: errToCode(err), - err: fmt.Errorf("maximum discovery attempt count reached: %v. Last Error: %w", conf.MaxDiscoverAttempts, err), - } + return nil, nil, &Error{ + code: errToCode(lastErr), + err: fmt.Errorf("maximum discovery attempt count reached: %v. Last Error: %w", conf.MaxDiscoverAttempts, lastErr), } - - return connection, serverInfo, nil } // In that case, `err` is always != nil diff --git a/go.mod b/go.mod index e960f9b8..eaeee533 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,9 @@ require ( github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e github.com/stretchr/testify v1.9.0 github.com/testcontainers/testcontainers-go v0.30.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda - google.golang.org/grpc v1.63.2 - google.golang.org/protobuf v1.33.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 + google.golang.org/grpc v1.67.1 + google.golang.org/protobuf v1.35.1 ) require ( @@ -58,10 +58,10 @@ require ( go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect - golang.org/x/mod v0.16.0 // indirect - golang.org/x/net v0.21.0 // indirect - golang.org/x/sys v0.17.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.13.0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ee748383..a5f0c6f8 100644 --- a/go.sum +++ b/go.sum @@ -137,12 +137,15 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -160,10 +163,14 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -172,19 +179,29 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 h1:Z0hjGZePRE0ZBWotvtrwxFNrNE9CUAGtplaDK5NNI/g= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= +google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 h1:QCqS/PdaHTSWGvupk2F/ehwHtGc0/GYkT+3GAcR1CCc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/samples/userCertificates.go b/samples/userCertificates.go new file mode 100644 index 00000000..8d27e1e0 --- /dev/null +++ b/samples/userCertificates.go @@ -0,0 +1,23 @@ +package samples + +import ( + "github.com/EventStore/EventStore-Client-Go/v4/esdb" +) + +func UserCertificates() { + // region client-with-user-certificates + settings, err := esdb.ParseConnectionString("esdb://admin:changeit@{endpoint}?tls=true&userCertFile={pathToCaFile}&userKeyFile={pathToKeyFile}") + + if err != nil { + panic(err) + } + + db, err := esdb.NewClient(settings) + // endregion client-with-user-certificates + + if err != nil { + panic(err) + } + + db.Close() +} diff --git a/shared.env b/shared.env index bc45d715..adf08293 100644 --- a/shared.env +++ b/shared.env @@ -5,4 +5,5 @@ EVENTSTORE_HTTP_PORT=2113 EVENTSTORE_TRUSTED_ROOT_CERTIFICATES_PATH=/etc/eventstore/certs/ca EVENTSTORE_DISCOVER_VIA_DNS=false EVENTSTORE_ENABLE_ATOM_PUB_OVER_HTTP=true -EVENTSTORE_ADVERTISE_HOST_TO_CLIENT_AS=localhost \ No newline at end of file +EVENTSTORE_ADVERTISE_HOST_TO_CLIENT_AS=localhost +EventStore__Plugins__UserCertificates__Enabled=true \ No newline at end of file