diff options
Diffstat (limited to 'internal/node/secondary')
| -rw-r--r-- | internal/node/secondary/endpoint_internal.go | 44 | ||||
| -rw-r--r-- | internal/node/secondary/endpoint_internal_test.go | 111 | ||||
| -rw-r--r-- | internal/node/secondary/secondary.go | 112 | ||||
| -rw-r--r-- | internal/node/secondary/secondary_test.go | 138 | 
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. +		}() +	} +} | 
