From 559bccccd40d028e412d9f11709ded0250ba6dcd Mon Sep 17 00:00:00 2001 From: Linus Nordberg Date: Tue, 24 May 2022 23:33:38 +0200 Subject: implement primary and secondary role, for replication --- internal/node/secondary/endpoint_internal.go | 44 +++++++ internal/node/secondary/endpoint_internal_test.go | 111 +++++++++++++++++ internal/node/secondary/secondary.go | 112 ++++++++++++++++++ internal/node/secondary/secondary_test.go | 138 ++++++++++++++++++++++ 4 files changed, 405 insertions(+) create mode 100644 internal/node/secondary/endpoint_internal.go create mode 100644 internal/node/secondary/endpoint_internal_test.go create mode 100644 internal/node/secondary/secondary.go create mode 100644 internal/node/secondary/secondary_test.go (limited to 'internal/node/secondary') diff --git a/internal/node/secondary/endpoint_internal.go b/internal/node/secondary/endpoint_internal.go new file mode 100644 index 0000000..f60d6d8 --- /dev/null +++ b/internal/node/secondary/endpoint_internal.go @@ -0,0 +1,44 @@ +package secondary + +// This file implements internal HTTP handler callbacks for secondary nodes. + +import ( + "context" + "crypto/ed25519" + "fmt" + "net/http" + + "git.sigsum.org/log-go/internal/node/handler" + "git.sigsum.org/sigsum-go/pkg/log" + "git.sigsum.org/sigsum-go/pkg/merkle" + "git.sigsum.org/sigsum-go/pkg/types" +) + +func getTreeHeadToCosign(ctx context.Context, c handler.Config, w http.ResponseWriter, _ *http.Request) (int, error) { + s := c.(Secondary) + log.Debug("handling get-tree-head-to-cosign request") + + signedTreeHead := func() (*types.SignedTreeHead, error) { + tctx, cancel := context.WithTimeout(ctx, s.Config.Deadline) + defer cancel() + th, err := treeHeadFromTrillian(tctx, s.TrillianClient) + if err != nil { + return nil, fmt.Errorf("getting tree head: %w", err) + } + namespace := merkle.HashFn(s.Signer.Public().(ed25519.PublicKey)) + sth, err := th.Sign(s.Signer, namespace) + if err != nil { + return nil, fmt.Errorf("signing tree head: %w", err) + } + return sth, nil + } + + sth, err := signedTreeHead() + if err != nil { + return http.StatusInternalServerError, err + } + if err := sth.ToASCII(w); err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} diff --git a/internal/node/secondary/endpoint_internal_test.go b/internal/node/secondary/endpoint_internal_test.go new file mode 100644 index 0000000..9637e29 --- /dev/null +++ b/internal/node/secondary/endpoint_internal_test.go @@ -0,0 +1,111 @@ +package secondary + +import ( + "crypto" + "crypto/ed25519" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + mocksDB "git.sigsum.org/log-go/internal/mocks/db" + "git.sigsum.org/log-go/internal/node/handler" + "git.sigsum.org/sigsum-go/pkg/merkle" + "git.sigsum.org/sigsum-go/pkg/types" + "github.com/golang/mock/gomock" +) + +// TestSigner implements the signer interface. It can be used to mock +// an Ed25519 signer that always return the same public key, +// signature, and error. +// NOTE: Code duplication with internal/state/single_test.go +type TestSigner struct { + PublicKey [ed25519.PublicKeySize]byte + Signature [ed25519.SignatureSize]byte + Error error +} + +func (ts *TestSigner) Public() crypto.PublicKey { + return ed25519.PublicKey(ts.PublicKey[:]) +} + +func (ts *TestSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + return ts.Signature[:], ts.Error +} + +var ( + testTH = types.TreeHead{ + Timestamp: 0, + TreeSize: 0, + RootHash: *merkle.HashFn([]byte("root hash")), + } + testSignerFailing = TestSigner{types.PublicKey{}, types.Signature{}, fmt.Errorf("mocked error")} + testSignerSucceeding = TestSigner{types.PublicKey{}, types.Signature{}, nil} +) + +func TestGetTreeHeadToCosign(t *testing.T) { + for _, tbl := range []struct { + desc string + trillianTHErr error + trillianTHRet *types.TreeHead + signer crypto.Signer + httpStatus int + }{ + { + desc: "trillian GetTreeHead error", + trillianTHErr: fmt.Errorf("mocked error"), + httpStatus: http.StatusInternalServerError, + }, + { + desc: "signer error", + trillianTHRet: &testTH, + signer: &testSignerFailing, + httpStatus: http.StatusInternalServerError, + }, + { + desc: "success", + trillianTHRet: &testTH, + signer: &testSignerSucceeding, + httpStatus: http.StatusOK, + }, + } { + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + trillianClient := mocksDB.NewMockClient(ctrl) + trillianClient.EXPECT().GetTreeHead(gomock.Any()).Return(tbl.trillianTHRet, tbl.trillianTHErr) + + node := Secondary{ + Config: testConfig, + TrillianClient: trillianClient, + Signer: tbl.signer, + } + + // Create HTTP request + url := types.EndpointAddLeaf.Path("http://example.com", node.Prefix()) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + t.Fatalf("must create http request: %v", err) + } + + // Run HTTP request + w := httptest.NewRecorder() + mustHandleInternal(t, node, types.EndpointGetTreeHeadToCosign).ServeHTTP(w, req) + if got, want := w.Code, tbl.httpStatus; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, tbl.desc) + } + }() + } +} + +func mustHandleInternal(t *testing.T, s Secondary, e types.Endpoint) handler.Handler { + for _, h := range s.InternalHTTPHandlers() { + if h.Endpoint == e { + return h + } + } + t.Fatalf("must handle endpoint: %v", e) + return handler.Handler{} +} diff --git a/internal/node/secondary/secondary.go b/internal/node/secondary/secondary.go new file mode 100644 index 0000000..c181420 --- /dev/null +++ b/internal/node/secondary/secondary.go @@ -0,0 +1,112 @@ +package secondary + +import ( + "context" + "crypto" + "fmt" + "net/http" + "time" + + "git.sigsum.org/log-go/internal/db" + "git.sigsum.org/log-go/internal/node/handler" + "git.sigsum.org/sigsum-go/pkg/client" + "git.sigsum.org/sigsum-go/pkg/log" + "git.sigsum.org/sigsum-go/pkg/requests" + "git.sigsum.org/sigsum-go/pkg/types" +) + +// Config is a collection of log parameters +type Config struct { + LogID string // H(public key), then hex-encoded + TreeID int64 // Merkle tree identifier used by Trillian + Prefix string // The portion between base URL and st/v0 (may be "") + Deadline time.Duration // Deadline used for gRPC requests + Interval time.Duration // Signing frequency +} + +// Secondary is an instance of a secondary node +type Secondary struct { + Config + PublicHTTPMux *http.ServeMux + InternalHTTPMux *http.ServeMux + TrillianClient db.Client // provides access to the Trillian backend + Signer crypto.Signer // provides access to Ed25519 private key + Primary client.Client +} + +// Implementing handler.Config +func (s Secondary) Prefix() string { + return s.Config.Prefix +} +func (s Secondary) LogID() string { + return s.Config.LogID +} +func (s Secondary) Deadline() time.Duration { + return s.Config.Deadline +} + +func (s Secondary) Run(ctx context.Context) { + ticker := time.NewTicker(s.Interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + s.fetchLeavesFromPrimary(ctx) + case <-ctx.Done(): + return + } + } +} + +// TODO: nit-pick: the internal endpoint is used by primaries to figure out how much can be signed; not cosigned - update name? + +func (s Secondary) InternalHTTPHandlers() []handler.Handler { + return []handler.Handler{ + handler.Handler{s, getTreeHeadToCosign, types.EndpointGetTreeHeadToCosign, http.MethodGet}, + } +} + +func (s Secondary) fetchLeavesFromPrimary(ctx context.Context) { + sctx, cancel := context.WithTimeout(ctx, time.Second*10) // FIXME: parameterize 10 + defer cancel() + + prim, err := s.Primary.GetUnsignedTreeHead(sctx) + if err != nil { + log.Warning("unable to get tree head from primary: %v", err) + return + } + log.Debug("got tree head from primary, size %d", prim.TreeSize) + + curTH, err := treeHeadFromTrillian(sctx, s.TrillianClient) + if err != nil { + log.Warning("unable to get tree head from trillian: %v", err) + return + } + var leaves types.Leaves + for index := int64(curTH.TreeSize); index < int64(prim.TreeSize); index += int64(len(leaves)) { + req := requests.Leaves{ + StartSize: uint64(index), + EndSize: prim.TreeSize - 1, + } + leaves, err = s.Primary.GetLeaves(sctx, req) + if err != nil { + log.Warning("error fetching leaves [%d..%d] from primary: %v", req.StartSize, req.EndSize, err) + return + } + log.Debug("got %d leaves from primary when asking for [%d..%d]", len(leaves), req.StartSize, req.EndSize) + if err := s.TrillianClient.AddSequencedLeaves(ctx, leaves, index); err != nil { + log.Error("AddSequencedLeaves: %v", err) + return + } + } +} + +func treeHeadFromTrillian(ctx context.Context, trillianClient db.Client) (*types.TreeHead, error) { + th, err := trillianClient.GetTreeHead(ctx) + if err != nil { + return nil, fmt.Errorf("fetching tree head from trillian: %v", err) + } + log.Debug("got tree head from trillian, size %d", th.TreeSize) + return th, nil +} diff --git a/internal/node/secondary/secondary_test.go b/internal/node/secondary/secondary_test.go new file mode 100644 index 0000000..164bdf6 --- /dev/null +++ b/internal/node/secondary/secondary_test.go @@ -0,0 +1,138 @@ +package secondary + +import ( + "context" + "fmt" + "testing" + + mocksClient "git.sigsum.org/log-go/internal/mocks/client" + mocksDB "git.sigsum.org/log-go/internal/mocks/db" + "git.sigsum.org/sigsum-go/pkg/merkle" + "git.sigsum.org/sigsum-go/pkg/types" + "github.com/golang/mock/gomock" +) + +var ( + testConfig = Config{ + LogID: fmt.Sprintf("%x", merkle.HashFn([]byte("logid"))[:]), + TreeID: 0, + Prefix: "testonly", + Deadline: 10, + } +) + +// TestHandlers checks that the expected internal handlers are configured +func TestIntHandlers(t *testing.T) { + endpoints := map[types.Endpoint]bool{ + types.EndpointGetTreeHeadToCosign: false, + } + node := Secondary{ + Config: testConfig, + } + for _, handler := range node.InternalHTTPHandlers() { + if _, ok := endpoints[handler.Endpoint]; !ok { + t.Errorf("got unexpected endpoint: %s", handler.Endpoint) + } + endpoints[handler.Endpoint] = true + } + for endpoint, ok := range endpoints { + if !ok { + t.Errorf("endpoint %s is not configured", endpoint) + } + } +} + +func TestFetchLeavesFromPrimary(t *testing.T) { + for _, tbl := range []struct { + desc string + // client.GetUnsignedTreeHead() + primaryTHRet types.TreeHead + primaryTHErr error + // db.GetTreeHead() + trillianTHRet *types.TreeHead + trillianTHErr error + // client.GetLeaves() + primaryGetLeavesRet types.Leaves + primaryGetLeavesErr error + // db.AddSequencedLeaves() + trillianAddLeavesExp bool + trillianAddLeavesErr error + }{ + { + desc: "no tree head from primary", + primaryTHErr: fmt.Errorf("mocked error"), + }, + { + desc: "no tree head from trillian", + primaryTHRet: types.TreeHead{}, + trillianTHErr: fmt.Errorf("mocked error"), + }, + { + desc: "error fetching leaves", + primaryTHRet: types.TreeHead{TreeSize: 6}, + trillianTHRet: &types.TreeHead{TreeSize: 5}, // 6-5 => 1 expected GetLeaves + primaryGetLeavesErr: fmt.Errorf("mocked error"), + }, + { + desc: "error adding leaves", + primaryTHRet: types.TreeHead{TreeSize: 6}, + trillianTHRet: &types.TreeHead{TreeSize: 5}, // 6-5 => 1 expected GetLeaves + primaryGetLeavesRet: types.Leaves{ + types.Leaf{}, + }, + trillianAddLeavesErr: fmt.Errorf("mocked error"), + }, + { + desc: "success", + primaryTHRet: types.TreeHead{TreeSize: 10}, + trillianTHRet: &types.TreeHead{TreeSize: 5}, + primaryGetLeavesRet: types.Leaves{ + types.Leaf{}, + }, + trillianAddLeavesExp: true, + }, + } { + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + primaryClient := mocksClient.NewMockClient(ctrl) + primaryClient.EXPECT().GetUnsignedTreeHead(gomock.Any()).Return(tbl.primaryTHRet, tbl.primaryTHErr) + + trillianClient := mocksDB.NewMockClient(ctrl) + if tbl.trillianTHErr != nil || tbl.trillianTHRet != nil { + trillianClient.EXPECT().GetTreeHead(gomock.Any()).Return(tbl.trillianTHRet, tbl.trillianTHErr) + } + + if tbl.primaryGetLeavesErr != nil || tbl.primaryGetLeavesRet != nil { + primaryClient.EXPECT().GetLeaves(gomock.Any(), gomock.Any()).Return(tbl.primaryGetLeavesRet, tbl.primaryGetLeavesErr) + if tbl.trillianAddLeavesExp { + for i := tbl.trillianTHRet.TreeSize; i < tbl.primaryTHRet.TreeSize-1; i++ { + primaryClient.EXPECT().GetLeaves(gomock.Any(), gomock.Any()).Return(tbl.primaryGetLeavesRet, tbl.primaryGetLeavesErr) + } + } + } + + if tbl.trillianAddLeavesErr != nil || tbl.trillianAddLeavesExp { + trillianClient.EXPECT().AddSequencedLeaves(gomock.Any(), gomock.Any(), gomock.Any()).Return(tbl.trillianAddLeavesErr) + if tbl.trillianAddLeavesExp { + for i := tbl.trillianTHRet.TreeSize; i < tbl.primaryTHRet.TreeSize-1; i++ { + trillianClient.EXPECT().AddSequencedLeaves(gomock.Any(), gomock.Any(), gomock.Any()).Return(tbl.trillianAddLeavesErr) + } + } + } + + node := Secondary{ + Config: testConfig, + Primary: primaryClient, + TrillianClient: trillianClient, + } + + node.fetchLeavesFromPrimary(context.Background()) + + // NOTE: We are not verifying that + // AddSequencedLeaves() is being called with + // the right data. + }() + } +} -- cgit v1.2.3