aboutsummaryrefslogtreecommitdiff
path: root/internal/node/secondary
diff options
context:
space:
mode:
authorLinus Nordberg <linus@nordberg.se>2022-05-24 23:33:38 +0200
committerRasmus Dahlberg <rasmus@mullvad.net>2022-06-23 11:33:17 +0200
commit559bccccd40d028e412d9f11709ded0250ba6dcd (patch)
tree50f3193dbe70fec21357963c11e5f663013f4b4c /internal/node/secondary
parent4b20ef0c1732bcef633c0ed7104501898aa84e2c (diff)
implement primary and secondary role, for replicationv0.5.0
Diffstat (limited to 'internal/node/secondary')
-rw-r--r--internal/node/secondary/endpoint_internal.go44
-rw-r--r--internal/node/secondary/endpoint_internal_test.go111
-rw-r--r--internal/node/secondary/secondary.go112
-rw-r--r--internal/node/secondary/secondary_test.go138
4 files changed, 405 insertions, 0 deletions
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.
+ }()
+ }
+}