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/handler/handler.go | 91 ++++ internal/node/handler/handler_test.go | 113 ++++ internal/node/handler/metric.go | 19 + internal/node/primary/endpoint_external.go | 147 +++++ internal/node/primary/endpoint_external_test.go | 626 ++++++++++++++++++++++ internal/node/primary/endpoint_internal.go | 30 ++ internal/node/primary/endpoint_internal_test.go | 82 +++ internal/node/primary/primary.go | 74 +++ internal/node/primary/primary_test.go | 75 +++ 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 +++++ 13 files changed, 1662 insertions(+) create mode 100644 internal/node/handler/handler.go create mode 100644 internal/node/handler/handler_test.go create mode 100644 internal/node/handler/metric.go create mode 100644 internal/node/primary/endpoint_external.go create mode 100644 internal/node/primary/endpoint_external_test.go create mode 100644 internal/node/primary/endpoint_internal.go create mode 100644 internal/node/primary/endpoint_internal_test.go create mode 100644 internal/node/primary/primary.go create mode 100644 internal/node/primary/primary_test.go 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') diff --git a/internal/node/handler/handler.go b/internal/node/handler/handler.go new file mode 100644 index 0000000..2871c5d --- /dev/null +++ b/internal/node/handler/handler.go @@ -0,0 +1,91 @@ +package handler + +import ( + "context" + "fmt" + "net/http" + "time" + + "git.sigsum.org/sigsum-go/pkg/log" + "git.sigsum.org/sigsum-go/pkg/types" +) + +type Config interface { + Prefix() string + LogID() string + Deadline() time.Duration +} + +// Handler implements the http.Handler interface +type Handler struct { + Config + Fun func(context.Context, Config, http.ResponseWriter, *http.Request) (int, error) + Endpoint types.Endpoint + Method string +} + +// Path returns a path that should be configured for this handler +func (h Handler) Path() string { + if len(h.Prefix()) == 0 { + return h.Endpoint.Path("", "sigsum", "v0") + } + return h.Endpoint.Path("", h.Prefix(), "sigsum", "v0") +} + +// ServeHTTP is part of the http.Handler interface +func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + code := 0 + defer func() { + end := time.Now().Sub(start).Seconds() + sc := fmt.Sprintf("%d", code) + + rspcnt.Inc(h.LogID(), string(h.Endpoint), sc) + latency.Observe(end, h.LogID(), string(h.Endpoint), sc) + }() + reqcnt.Inc(h.LogID(), string(h.Endpoint)) + + code = h.verifyMethod(w, r) + if code != 0 { + return + } + h.handle(w, r) +} + +// verifyMethod checks that an appropriate HTTP method is used and +// returns 0 if so, or an HTTP status code if not. Error handling is +// based on RFC 7231, see Sections 6.5.5 (Status 405) and 6.5.1 +// (Status 400). +func (h Handler) verifyMethod(w http.ResponseWriter, r *http.Request) int { + checkHTTPMethod := func(m string) bool { + return m == http.MethodGet || m == http.MethodPost + } + + if h.Method == r.Method { + return 0 + } + + code := http.StatusBadRequest + if ok := checkHTTPMethod(r.Method); ok { + w.Header().Set("Allow", h.Method) + code = http.StatusMethodNotAllowed + } + + http.Error(w, fmt.Sprintf("error=%s", http.StatusText(code)), code) + return code +} + +// handle handles an HTTP request for which the HTTP method is already verified +func (h Handler) handle(w http.ResponseWriter, r *http.Request) { + deadline := time.Now().Add(h.Deadline()) + ctx, cancel := context.WithDeadline(r.Context(), deadline) + defer cancel() + + code, err := h.Fun(ctx, h.Config, w, r) + if err != nil { + log.Debug("%s/%s: %v", h.Prefix(), h.Endpoint, err) + http.Error(w, fmt.Sprintf("error=%s", err.Error()), code) + } else if code != 200 { + w.WriteHeader(code) + } +} diff --git a/internal/node/handler/handler_test.go b/internal/node/handler/handler_test.go new file mode 100644 index 0000000..dfd27bd --- /dev/null +++ b/internal/node/handler/handler_test.go @@ -0,0 +1,113 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.sigsum.org/sigsum-go/pkg/types" +) + +type dummyConfig struct { + prefix string +} + +func (c dummyConfig) Prefix() string { return c.prefix } +func (c dummyConfig) LogID() string { return "dummyLogID" } +func (c dummyConfig) Deadline() time.Duration { return time.Nanosecond } + +// TestPath checks that Path works for an endpoint (add-leaf) +func TestPath(t *testing.T) { + testFun := func(_ context.Context, _ Config, _ http.ResponseWriter, _ *http.Request) (int, error) { + return 0, nil + } + for _, table := range []struct { + description string + prefix string + want string + }{ + { + description: "no prefix", + want: "/sigsum/v0/add-leaf", + }, + { + description: "a prefix", + prefix: "test-prefix", + want: "/test-prefix/sigsum/v0/add-leaf", + }, + } { + testConfig := dummyConfig{ + prefix: table.prefix, + } + h := Handler{testConfig, testFun, types.EndpointAddLeaf, http.MethodPost} + if got, want := h.Path(), table.want; got != want { + t.Errorf("got path %v but wanted %v", got, want) + } + } +} + +// func TestServeHTTP(t *testing.T) { +// h.ServeHTTP(w http.ResponseWriter, r *http.Request) +// } + +func TestVerifyMethod(t *testing.T) { + badMethod := http.MethodHead + for _, h := range []Handler{ + { + Endpoint: types.EndpointAddLeaf, + Method: http.MethodPost, + }, + { + Endpoint: types.EndpointGetTreeHeadToCosign, + Method: http.MethodGet, + }, + } { + for _, method := range []string{ + http.MethodGet, + http.MethodPost, + badMethod, + } { + url := h.Endpoint.Path("http://log.example.com", "fixme") + req, err := http.NewRequest(method, url, nil) + if err != nil { + t.Fatalf("must create HTTP request: %v", err) + } + + w := httptest.NewRecorder() + code := h.verifyMethod(w, req) + if got, want := code == 0, h.Method == method; got != want { + t.Errorf("%s %s: got %v but wanted %v: %v", method, url, got, want, err) + continue + } + if code == 0 { + continue + } + + if method == badMethod { + if got, want := code, http.StatusBadRequest; got != want { + t.Errorf("%s %s: got status %d, wanted %d", method, url, got, want) + } + if _, ok := w.Header()["Allow"]; ok { + t.Errorf("%s %s: got Allow header, wanted none", method, url) + } + continue + } + + if got, want := code, http.StatusMethodNotAllowed; got != want { + t.Errorf("%s %s: got status %d, wanted %d", method, url, got, want) + } else if methods, ok := w.Header()["Allow"]; !ok { + t.Errorf("%s %s: got no allow header, expected one", method, url) + } else if got, want := len(methods), 1; got != want { + t.Errorf("%s %s: got %d allowed method(s), wanted %d", method, url, got, want) + } else if got, want := methods[0], h.Method; got != want { + t.Errorf("%s %s: got allowed method %s, wanted %s", method, url, got, want) + } + } + } +} + +// func TestHandle(t *testing.T) { +// h.handle(w http.ResponseWriter, r *http.Request) +// } diff --git a/internal/node/handler/metric.go b/internal/node/handler/metric.go new file mode 100644 index 0000000..ced0096 --- /dev/null +++ b/internal/node/handler/metric.go @@ -0,0 +1,19 @@ +package handler + +import ( + "github.com/google/trillian/monitoring" + "github.com/google/trillian/monitoring/prometheus" +) + +var ( + reqcnt monitoring.Counter // number of incoming http requests + rspcnt monitoring.Counter // number of valid http responses + latency monitoring.Histogram // request-response latency +) + +func init() { + mf := prometheus.MetricFactory{} + reqcnt = mf.NewCounter("http_req", "number of http requests", "logid", "endpoint") + rspcnt = mf.NewCounter("http_rsp", "number of http requests", "logid", "endpoint", "status") + latency = mf.NewHistogram("http_latency", "http request-response latency", "logid", "endpoint", "status") +} diff --git a/internal/node/primary/endpoint_external.go b/internal/node/primary/endpoint_external.go new file mode 100644 index 0000000..42e8fcb --- /dev/null +++ b/internal/node/primary/endpoint_external.go @@ -0,0 +1,147 @@ +package primary + +// This file implements external HTTP handler callbacks for primary nodes. + +import ( + "context" + "fmt" + "net/http" + + "git.sigsum.org/log-go/internal/node/handler" + "git.sigsum.org/log-go/internal/requests" + "git.sigsum.org/sigsum-go/pkg/log" +) + +func addLeaf(ctx context.Context, c handler.Config, w http.ResponseWriter, r *http.Request) (int, error) { + p := c.(Primary) + log.Debug("handling add-leaf request") + req, err := requests.LeafRequestFromHTTP(r, p.Config.ShardStart, ctx, p.DNS) + if err != nil { + return http.StatusBadRequest, err + } + + sth := p.Stateman.ToCosignTreeHead() + sequenced, err := p.TrillianClient.AddLeaf(ctx, req, sth.TreeSize) + if err != nil { + return http.StatusInternalServerError, err + } + if sequenced { + return http.StatusOK, nil + } else { + return http.StatusAccepted, nil + } +} + +func addCosignature(ctx context.Context, c handler.Config, w http.ResponseWriter, r *http.Request) (int, error) { + p := c.(Primary) + log.Debug("handling add-cosignature request") + req, err := requests.CosignatureRequestFromHTTP(r, p.Witnesses) + if err != nil { + return http.StatusBadRequest, err + } + vk := p.Witnesses[req.KeyHash] + if err := p.Stateman.AddCosignature(ctx, &vk, &req.Cosignature); err != nil { + return http.StatusBadRequest, err + } + return http.StatusOK, nil +} + +func getTreeHeadToCosign(ctx context.Context, c handler.Config, w http.ResponseWriter, _ *http.Request) (int, error) { + p := c.(Primary) + log.Debug("handling get-tree-head-to-cosign request") + sth := p.Stateman.ToCosignTreeHead() + if err := sth.ToASCII(w); err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func getTreeHeadCosigned(ctx context.Context, c handler.Config, w http.ResponseWriter, _ *http.Request) (int, error) { + p := c.(Primary) + log.Debug("handling get-tree-head-cosigned request") + cth, err := p.Stateman.CosignedTreeHead(ctx) + if err != nil { + return http.StatusInternalServerError, err + } + if err := cth.ToASCII(w); err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func getConsistencyProof(ctx context.Context, c handler.Config, w http.ResponseWriter, r *http.Request) (int, error) { + p := c.(Primary) + log.Debug("handling get-consistency-proof request") + req, err := requests.ConsistencyProofRequestFromHTTP(r) + if err != nil { + return http.StatusBadRequest, err + } + + curTree := p.Stateman.ToCosignTreeHead() + if req.NewSize > curTree.TreeHead.TreeSize { + return http.StatusBadRequest, fmt.Errorf("new_size outside of current tree") + } + + proof, err := p.TrillianClient.GetConsistencyProof(ctx, req) + if err != nil { + return http.StatusInternalServerError, err + } + if err := proof.ToASCII(w); err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func getInclusionProof(ctx context.Context, c handler.Config, w http.ResponseWriter, r *http.Request) (int, error) { + p := c.(Primary) + log.Debug("handling get-inclusion-proof request") + req, err := requests.InclusionProofRequestFromHTTP(r) + if err != nil { + return http.StatusBadRequest, err + } + + curTree := p.Stateman.ToCosignTreeHead() + if req.TreeSize > curTree.TreeHead.TreeSize { + return http.StatusBadRequest, fmt.Errorf("tree_size outside of current tree") + } + + proof, err := p.TrillianClient.GetInclusionProof(ctx, req) + if err != nil { + return http.StatusInternalServerError, err + } + if err := proof.ToASCII(w); err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func getLeavesGeneral(ctx context.Context, c handler.Config, w http.ResponseWriter, r *http.Request, doLimitToCurrentTree bool) (int, error) { + p := c.(Primary) + log.Debug("handling get-leaves request") + req, err := requests.LeavesRequestFromHTTP(r, uint64(p.MaxRange)) + if err != nil { + return http.StatusBadRequest, err + } + + if doLimitToCurrentTree { + curTree := p.Stateman.ToCosignTreeHead() + if req.EndSize >= curTree.TreeHead.TreeSize { + return http.StatusBadRequest, fmt.Errorf("end_size outside of current tree") + } + } + + leaves, err := p.TrillianClient.GetLeaves(ctx, req) + if err != nil { + return http.StatusInternalServerError, err + } + for _, leaf := range *leaves { + if err := leaf.ToASCII(w); err != nil { + return http.StatusInternalServerError, err + } + } + return http.StatusOK, nil +} + +func getLeavesExternal(ctx context.Context, c handler.Config, w http.ResponseWriter, r *http.Request) (int, error) { + return getLeavesGeneral(ctx, c, w, r, true) +} diff --git a/internal/node/primary/endpoint_external_test.go b/internal/node/primary/endpoint_external_test.go new file mode 100644 index 0000000..7ee161b --- /dev/null +++ b/internal/node/primary/endpoint_external_test.go @@ -0,0 +1,626 @@ +package primary + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + mocksDB "git.sigsum.org/log-go/internal/mocks/db" + mocksDNS "git.sigsum.org/log-go/internal/mocks/dns" + mocksState "git.sigsum.org/log-go/internal/mocks/state" + "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" +) + +var ( + testSTH = &types.SignedTreeHead{ + TreeHead: *testTH, + Signature: types.Signature{}, + } + testCTH = &types.CosignedTreeHead{ + SignedTreeHead: *testSTH, + Cosignature: []types.Signature{types.Signature{}}, + KeyHash: []merkle.Hash{merkle.Hash{}}, + } + sth1 = types.SignedTreeHead{TreeHead: types.TreeHead{TreeSize: 1}} + sth2 = types.SignedTreeHead{TreeHead: types.TreeHead{TreeSize: 2}} // 2 < testConfig.MaxRange + sth5 = types.SignedTreeHead{TreeHead: types.TreeHead{TreeSize: 5}} // 5 >= testConfig.MaxRange+1 +) + +// TODO: remove tests that are now located in internal/requests instead + +func TestAddLeaf(t *testing.T) { + for _, table := range []struct { + description string + ascii io.Reader // buffer used to populate HTTP request + expectTrillian bool // expect Trillian client code path + errTrillian error // error from Trillian client + expectDNS bool // expect DNS verifier code path + errDNS error // error from DNS verifier + wantCode int // HTTP status ok + expectStateman bool + sequenced bool // return value from db.AddLeaf() + sthStateman *types.SignedTreeHead + }{ + { + description: "invalid: bad request (parser error)", + ascii: bytes.NewBufferString("key=value\n"), + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (signature error)", + ascii: mustLeafBuffer(t, 10, merkle.Hash{}, false), + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (shard hint is before shard start)", + ascii: mustLeafBuffer(t, 9, merkle.Hash{}, true), + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (shard hint is after shard end)", + ascii: mustLeafBuffer(t, uint64(time.Now().Unix())+1024, merkle.Hash{}, true), + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: failed verifying domain hint", + ascii: mustLeafBuffer(t, 10, merkle.Hash{}, true), + expectDNS: true, + errDNS: fmt.Errorf("something went wrong"), + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: backend failure", + ascii: mustLeafBuffer(t, 10, merkle.Hash{}, true), + expectDNS: true, + expectStateman: true, + sthStateman: testSTH, + expectTrillian: true, + errTrillian: fmt.Errorf("something went wrong"), + wantCode: http.StatusInternalServerError, + }, + { + description: "valid: 202", + ascii: mustLeafBuffer(t, 10, merkle.Hash{}, true), + expectDNS: true, + expectStateman: true, + sthStateman: testSTH, + expectTrillian: true, + wantCode: http.StatusAccepted, + }, + { + description: "valid: 200", + ascii: mustLeafBuffer(t, 10, merkle.Hash{}, true), + expectDNS: true, + expectStateman: true, + sthStateman: testSTH, + expectTrillian: true, + sequenced: true, + wantCode: http.StatusOK, + }, + } { + // Run deferred functions at the end of each iteration + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + dns := mocksDNS.NewMockVerifier(ctrl) + if table.expectDNS { + dns.EXPECT().Verify(gomock.Any(), gomock.Any(), gomock.Any()).Return(table.errDNS) + } + client := mocksDB.NewMockClient(ctrl) + if table.expectTrillian { + client.EXPECT().AddLeaf(gomock.Any(), gomock.Any(), gomock.Any()).Return(table.sequenced, table.errTrillian) + } + stateman := mocksState.NewMockStateManager(ctrl) + if table.expectStateman { + stateman.EXPECT().ToCosignTreeHead().Return(table.sthStateman) + } + node := Primary{ + Config: testConfig, + TrillianClient: client, + Stateman: stateman, + DNS: dns, + } + + // Create HTTP request + url := types.EndpointAddLeaf.Path("http://example.com", node.Prefix()) + req, err := http.NewRequest("POST", url, table.ascii) + if err != nil { + t.Fatalf("must create http request: %v", err) + } + + // Run HTTP request + w := httptest.NewRecorder() + mustHandlePublic(t, node, types.EndpointAddLeaf).ServeHTTP(w, req) + if got, want := w.Code, table.wantCode; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, table.description) + } + }() + } +} + +func TestAddCosignature(t *testing.T) { + buf := func() io.Reader { + return bytes.NewBufferString(fmt.Sprintf("%s=%x\n%s=%x\n", + "cosignature", types.Signature{}, + "key_hash", *merkle.HashFn(testWitVK[:]), + )) + } + for _, table := range []struct { + description string + ascii io.Reader // buffer used to populate HTTP request + expect bool // set if a mock answer is expected + err error // error from Trillian client + wantCode int // HTTP status ok + }{ + { + description: "invalid: bad request (parser error)", + ascii: bytes.NewBufferString("key=value\n"), + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (unknown witness)", + ascii: bytes.NewBufferString(fmt.Sprintf("%s=%x\n%s=%x\n", + "cosignature", types.Signature{}, + "key_hash", *merkle.HashFn(testWitVK[1:]), + )), + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: backend failure", + ascii: buf(), + expect: true, + err: fmt.Errorf("something went wrong"), + wantCode: http.StatusBadRequest, + }, + { + description: "valid", + ascii: buf(), + expect: true, + wantCode: http.StatusOK, + }, + } { + // Run deferred functions at the end of each iteration + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stateman := mocksState.NewMockStateManager(ctrl) + if table.expect { + stateman.EXPECT().AddCosignature(gomock.Any(), gomock.Any(), gomock.Any()).Return(table.err) + } + node := Primary{ + Config: testConfig, + Stateman: stateman, + } + + // Create HTTP request + url := types.EndpointAddCosignature.Path("http://example.com", node.Prefix()) + req, err := http.NewRequest("POST", url, table.ascii) + if err != nil { + t.Fatalf("must create http request: %v", err) + } + + // Run HTTP request + w := httptest.NewRecorder() + mustHandlePublic(t, node, types.EndpointAddCosignature).ServeHTTP(w, req) + if got, want := w.Code, table.wantCode; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, table.description) + } + }() + } +} + +func TestGetTreeToCosign(t *testing.T) { + for _, table := range []struct { + description string + expect bool // set if a mock answer is expected + rsp *types.SignedTreeHead // signed tree head from Trillian client + err error // error from Trillian client + wantCode int // HTTP status ok + }{ + { + description: "valid", + expect: true, + rsp: testSTH, + wantCode: http.StatusOK, + }, + } { + // Run deferred functions at the end of each iteration + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stateman := mocksState.NewMockStateManager(ctrl) + if table.expect { + stateman.EXPECT().ToCosignTreeHead().Return(table.rsp) + } + node := Primary{ + Config: testConfig, + Stateman: stateman, + } + + // Create HTTP request + url := types.EndpointGetTreeHeadToCosign.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() + mustHandlePublic(t, node, types.EndpointGetTreeHeadToCosign).ServeHTTP(w, req) + if got, want := w.Code, table.wantCode; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, table.description) + } + }() + } +} + +func TestGetTreeCosigned(t *testing.T) { + for _, table := range []struct { + description string + expect bool // set if a mock answer is expected + rsp *types.CosignedTreeHead // cosigned tree head from Trillian client + err error // error from Trillian client + wantCode int // HTTP status ok + }{ + { + description: "invalid: no cosigned STH", + expect: true, + err: fmt.Errorf("something went wrong"), + wantCode: http.StatusInternalServerError, + }, + { + description: "valid", + expect: true, + rsp: testCTH, + wantCode: http.StatusOK, + }, + } { + // Run deferred functions at the end of each iteration + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + stateman := mocksState.NewMockStateManager(ctrl) + if table.expect { + stateman.EXPECT().CosignedTreeHead(gomock.Any()).Return(table.rsp, table.err) + } + node := Primary{ + Config: testConfig, + Stateman: stateman, + } + + // Create HTTP request + url := types.EndpointGetTreeHeadCosigned.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() + mustHandlePublic(t, node, types.EndpointGetTreeHeadCosigned).ServeHTTP(w, req) + if got, want := w.Code, table.wantCode; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, table.description) + } + }() + } +} + +func TestGetConsistencyProof(t *testing.T) { + for _, table := range []struct { + description string + params string // params is the query's url params + sth *types.SignedTreeHead + expect bool // set if a mock answer is expected + rsp *types.ConsistencyProof // consistency proof from Trillian client + err error // error from Trillian client + wantCode int // HTTP status ok + }{ + { + description: "invalid: bad request (parser error)", + params: "a/1", + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (OldSize is zero)", + params: "0/1", + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (OldSize > NewSize)", + params: "2/1", + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (NewSize > tree size)", + params: "1/2", + sth: &sth1, + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: backend failure", + params: "1/2", + sth: &sth2, + expect: true, + err: fmt.Errorf("something went wrong"), + wantCode: http.StatusInternalServerError, + }, + { + description: "valid", + params: "1/2", + sth: &sth2, + expect: true, + rsp: &types.ConsistencyProof{ + OldSize: 1, + NewSize: 2, + Path: []merkle.Hash{ + *merkle.HashFn([]byte{}), + }, + }, + wantCode: http.StatusOK, + }, + } { + // Run deferred functions at the end of each iteration + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := mocksDB.NewMockClient(ctrl) + if table.expect { + client.EXPECT().GetConsistencyProof(gomock.Any(), gomock.Any()).Return(table.rsp, table.err) + } + stateman := mocksState.NewMockStateManager(ctrl) + if table.sth != nil { + stateman.EXPECT().ToCosignTreeHead().Return(table.sth) + } + node := Primary{ + Config: testConfig, + TrillianClient: client, + Stateman: stateman, + } + + // Create HTTP request + url := types.EndpointGetConsistencyProof.Path("http://example.com", node.Prefix()) + req, err := http.NewRequest(http.MethodGet, url+table.params, nil) + if err != nil { + t.Fatalf("must create http request: %v", err) + } + + // Run HTTP request + w := httptest.NewRecorder() + mustHandlePublic(t, node, types.EndpointGetConsistencyProof).ServeHTTP(w, req) + if got, want := w.Code, table.wantCode; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, table.description) + } + }() + } +} + +func TestGetInclusionProof(t *testing.T) { + for _, table := range []struct { + description string + params string // params is the query's url params + sth *types.SignedTreeHead + expect bool // set if a mock answer is expected + rsp *types.InclusionProof // inclusion proof from Trillian client + err error // error from Trillian client + wantCode int // HTTP status ok + }{ + { + description: "invalid: bad request (parser error)", + params: "a/0000000000000000000000000000000000000000000000000000000000000000", + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (no proof available for tree size 1)", + params: "1/0000000000000000000000000000000000000000000000000000000000000000", + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (request outside current tree size)", + params: "2/0000000000000000000000000000000000000000000000000000000000000000", + sth: &sth1, + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: backend failure", + params: "2/0000000000000000000000000000000000000000000000000000000000000000", + sth: &sth2, + expect: true, + err: fmt.Errorf("something went wrong"), + wantCode: http.StatusInternalServerError, + }, + { + description: "valid", + params: "2/0000000000000000000000000000000000000000000000000000000000000000", + sth: &sth2, + expect: true, + rsp: &types.InclusionProof{ + TreeSize: 2, + LeafIndex: 0, + Path: []merkle.Hash{ + *merkle.HashFn([]byte{}), + }, + }, + wantCode: http.StatusOK, + }, + } { + // Run deferred functions at the end of each iteration + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := mocksDB.NewMockClient(ctrl) + if table.expect { + client.EXPECT().GetInclusionProof(gomock.Any(), gomock.Any()).Return(table.rsp, table.err) + } + stateman := mocksState.NewMockStateManager(ctrl) + if table.sth != nil { + stateman.EXPECT().ToCosignTreeHead().Return(table.sth) + } + node := Primary{ + Config: testConfig, + TrillianClient: client, + Stateman: stateman, + } + + // Create HTTP request + url := types.EndpointGetInclusionProof.Path("http://example.com", node.Prefix()) + req, err := http.NewRequest(http.MethodGet, url+table.params, nil) + if err != nil { + t.Fatalf("must create http request: %v", err) + } + + // Run HTTP request + w := httptest.NewRecorder() + mustHandlePublic(t, node, types.EndpointGetInclusionProof).ServeHTTP(w, req) + if got, want := w.Code, table.wantCode; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, table.description) + } + }() + } +} + +func TestGetLeaves(t *testing.T) { + for _, table := range []struct { + description string + params string // params is the query's url params + sth *types.SignedTreeHead + expect bool // set if a mock answer is expected + rsp *types.Leaves // list of leaves from Trillian client + err error // error from Trillian client + wantCode int // HTTP status ok + }{ + { + description: "invalid: bad request (parser error)", + params: "a/1", + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (StartSize > EndSize)", + params: "1/0", + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: bad request (EndSize >= current tree size)", + params: "0/2", + sth: &sth2, + wantCode: http.StatusBadRequest, + }, + { + description: "invalid: backend failure", + params: "0/0", + sth: &sth2, + expect: true, + err: fmt.Errorf("something went wrong"), + wantCode: http.StatusInternalServerError, + }, + { + description: "valid: one more entry than the configured MaxRange", + params: fmt.Sprintf("%d/%d", 0, testConfig.MaxRange), // query will be pruned + sth: &sth5, + expect: true, + rsp: func() *types.Leaves { + var list types.Leaves + for i := int64(0); i < testConfig.MaxRange; i++ { + list = append(list[:], types.Leaf{ + Statement: types.Statement{ + ShardHint: 0, + Checksum: merkle.Hash{}, + }, + Signature: types.Signature{}, + KeyHash: merkle.Hash{}, + }) + } + return &list + }(), + wantCode: http.StatusOK, + }, + } { + // Run deferred functions at the end of each iteration + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + client := mocksDB.NewMockClient(ctrl) + if table.expect { + client.EXPECT().GetLeaves(gomock.Any(), gomock.Any()).Return(table.rsp, table.err) + } + stateman := mocksState.NewMockStateManager(ctrl) + if table.sth != nil { + stateman.EXPECT().ToCosignTreeHead().Return(table.sth) + } + node := Primary{ + Config: testConfig, + TrillianClient: client, + Stateman: stateman, + } + + // Create HTTP request + url := types.EndpointGetLeaves.Path("http://example.com", node.Prefix()) + req, err := http.NewRequest(http.MethodGet, url+table.params, nil) + if err != nil { + t.Fatalf("must create http request: %v", err) + } + + // Run HTTP request + w := httptest.NewRecorder() + mustHandlePublic(t, node, types.EndpointGetLeaves).ServeHTTP(w, req) + if got, want := w.Code, table.wantCode; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, table.description) + } + if w.Code != http.StatusOK { + return + } + + list := types.Leaves{} + if err := list.FromASCII(w.Body); err != nil { + t.Fatalf("must unmarshal leaf list: %v", err) + } + if got, want := &list, table.rsp; !reflect.DeepEqual(got, want) { + t.Errorf("got leaf list\n\t%v\nbut wanted\n\t%v\nin test %q", got, want, table.description) + } + }() + } +} + +func mustHandlePublic(t *testing.T, p Primary, e types.Endpoint) handler.Handler { + for _, handler := range p.PublicHTTPHandlers() { + if handler.Endpoint == e { + return handler + } + } + t.Fatalf("must handle endpoint: %v", e) + return handler.Handler{} +} + +func mustLeafBuffer(t *testing.T, shardHint uint64, message merkle.Hash, wantSig bool) io.Reader { + t.Helper() + + vk, sk, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("must generate ed25519 keys: %v", err) + } + msg := types.Statement{ + ShardHint: shardHint, + Checksum: *merkle.HashFn(message[:]), + } + sig := ed25519.Sign(sk, msg.ToBinary()) + if !wantSig { + sig[0] += 1 + } + return bytes.NewBufferString(fmt.Sprintf( + "%s=%d\n"+"%s=%x\n"+"%s=%x\n"+"%s=%x\n"+"%s=%s\n", + "shard_hint", shardHint, + "message", message[:], + "signature", sig, + "public_key", vk, + "domain_hint", "example.com", + )) +} diff --git a/internal/node/primary/endpoint_internal.go b/internal/node/primary/endpoint_internal.go new file mode 100644 index 0000000..f7a684f --- /dev/null +++ b/internal/node/primary/endpoint_internal.go @@ -0,0 +1,30 @@ +package primary + +// This file implements internal HTTP handler callbacks for primary nodes. + +import ( + "context" + "fmt" + "net/http" + + "git.sigsum.org/log-go/internal/node/handler" + "git.sigsum.org/sigsum-go/pkg/log" + "git.sigsum.org/sigsum-go/pkg/types" +) + +func getTreeHeadUnsigned(ctx context.Context, c handler.Config, w http.ResponseWriter, _ *http.Request) (int, error) { + log.Debug("handling %s request", types.EndpointGetTreeHeadUnsigned) + p := c.(Primary) + th, err := p.TrillianClient.GetTreeHead(ctx) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed getting tree head: %v", err) + } + if err := th.ToASCII(w); err != nil { + return http.StatusInternalServerError, err + } + return http.StatusOK, nil +} + +func getLeavesInternal(ctx context.Context, c handler.Config, w http.ResponseWriter, r *http.Request) (int, error) { + return getLeavesGeneral(ctx, c, w, r, false) +} diff --git a/internal/node/primary/endpoint_internal_test.go b/internal/node/primary/endpoint_internal_test.go new file mode 100644 index 0000000..6c76c33 --- /dev/null +++ b/internal/node/primary/endpoint_internal_test.go @@ -0,0 +1,82 @@ +package primary + +import ( + "fmt" + "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" +) + +var ( + testTH = &types.TreeHead{ + Timestamp: 0, + TreeSize: 0, + RootHash: *merkle.HashFn([]byte("root hash")), + } +) + +func TestGetTreeHeadUnsigned(t *testing.T) { + for _, table := range []struct { + description string + expect bool // set if a mock answer is expected + rsp *types.TreeHead // tree head from Trillian client + err error // error from Trillian client + wantCode int // HTTP status ok + }{ + { + description: "invalid: backend failure", + expect: true, + err: fmt.Errorf("something went wrong"), + wantCode: http.StatusInternalServerError, + }, + { + description: "valid", + expect: true, + rsp: testTH, + wantCode: http.StatusOK, + }, + } { + // Run deferred functions at the end of each iteration + func() { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + trillianClient := mocksDB.NewMockClient(ctrl) + trillianClient.EXPECT().GetTreeHead(gomock.Any()).Return(table.rsp, table.err) + + node := Primary{ + Config: testConfig, + TrillianClient: trillianClient, + } + + // Create HTTP request + url := types.EndpointGetTreeHeadUnsigned.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.EndpointGetTreeHeadUnsigned).ServeHTTP(w, req) + if got, want := w.Code, table.wantCode; got != want { + t.Errorf("got HTTP status code %v but wanted %v in test %q", got, want, table.description) + } + }() + } +} + +func mustHandleInternal(t *testing.T, p Primary, e types.Endpoint) handler.Handler { + for _, handler := range p.InternalHTTPHandlers() { + if handler.Endpoint == e { + return handler + } + } + t.Fatalf("must handle endpoint: %v", e) + return handler.Handler{} +} diff --git a/internal/node/primary/primary.go b/internal/node/primary/primary.go new file mode 100644 index 0000000..6128f49 --- /dev/null +++ b/internal/node/primary/primary.go @@ -0,0 +1,74 @@ +package primary + +import ( + "crypto" + "net/http" + "time" + + "git.sigsum.org/log-go/internal/db" + "git.sigsum.org/log-go/internal/node/handler" + "git.sigsum.org/log-go/internal/state" + "git.sigsum.org/sigsum-go/pkg/client" + "git.sigsum.org/sigsum-go/pkg/dns" + "git.sigsum.org/sigsum-go/pkg/merkle" + "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 "") + MaxRange int64 // Maximum number of leaves per get-leaves request + Deadline time.Duration // Deadline used for gRPC requests + Interval time.Duration // Cosigning frequency + ShardStart uint64 // Shard interval start (num seconds since UNIX epoch) + + // Witnesses map trusted witness identifiers to public keys + Witnesses map[merkle.Hash]types.PublicKey +} + +// Primary is an instance of the log's primary node +type Primary 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 + Stateman state.StateManager // coordinates access to (co)signed tree heads + DNS dns.Verifier // checks if domain name knows a public key + Secondary client.Client +} + +// Implementing handler.Config +func (p Primary) Prefix() string { + return p.Config.Prefix +} +func (p Primary) LogID() string { + return p.Config.LogID +} +func (p Primary) Deadline() time.Duration { + return p.Config.Deadline +} + +// PublicHTTPHandlers returns all external handlers +func (p Primary) PublicHTTPHandlers() []handler.Handler { + return []handler.Handler{ + handler.Handler{p, addLeaf, types.EndpointAddLeaf, http.MethodPost}, + handler.Handler{p, addCosignature, types.EndpointAddCosignature, http.MethodPost}, + handler.Handler{p, getTreeHeadToCosign, types.EndpointGetTreeHeadToCosign, http.MethodGet}, + handler.Handler{p, getTreeHeadCosigned, types.EndpointGetTreeHeadCosigned, http.MethodGet}, + handler.Handler{p, getConsistencyProof, types.EndpointGetConsistencyProof, http.MethodGet}, + handler.Handler{p, getInclusionProof, types.EndpointGetInclusionProof, http.MethodGet}, + handler.Handler{p, getLeavesExternal, types.EndpointGetLeaves, http.MethodGet}, + } +} + +// InternalHTTPHandlers() returns all internal handlers +func (p Primary) InternalHTTPHandlers() []handler.Handler { + return []handler.Handler{ + handler.Handler{p, getTreeHeadUnsigned, types.EndpointGetTreeHeadUnsigned, http.MethodGet}, + handler.Handler{p, getConsistencyProof, types.EndpointGetConsistencyProof, http.MethodGet}, + handler.Handler{p, getLeavesInternal, types.EndpointGetLeaves, http.MethodGet}, + } +} diff --git a/internal/node/primary/primary_test.go b/internal/node/primary/primary_test.go new file mode 100644 index 0000000..5062955 --- /dev/null +++ b/internal/node/primary/primary_test.go @@ -0,0 +1,75 @@ +package primary + +import ( + "fmt" + "testing" + + "git.sigsum.org/sigsum-go/pkg/merkle" + "git.sigsum.org/sigsum-go/pkg/types" +) + +var ( + testWitVK = types.PublicKey{} + testConfig = Config{ + LogID: fmt.Sprintf("%x", merkle.HashFn([]byte("logid"))[:]), + TreeID: 0, + Prefix: "testonly", + MaxRange: 3, + Deadline: 10, + Interval: 10, + ShardStart: 10, + Witnesses: map[merkle.Hash]types.PublicKey{ + *merkle.HashFn(testWitVK[:]): testWitVK, + }, + } +) + +// TestPublicHandlers checks that the expected external handlers are configured +func TestPublicHandlers(t *testing.T) { + endpoints := map[types.Endpoint]bool{ + types.EndpointAddLeaf: false, + types.EndpointAddCosignature: false, + types.EndpointGetTreeHeadToCosign: false, + types.EndpointGetTreeHeadCosigned: false, + types.EndpointGetConsistencyProof: false, + types.EndpointGetInclusionProof: false, + types.EndpointGetLeaves: false, + } + node := Primary{ + Config: testConfig, + } + for _, handler := range node.PublicHTTPHandlers() { + 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) + } + } +} + +// TestIntHandlers checks that the expected internal handlers are configured +func TestIntHandlers(t *testing.T) { + endpoints := map[types.Endpoint]bool{ + types.EndpointGetTreeHeadUnsigned: false, + types.EndpointGetConsistencyProof: false, + types.EndpointGetLeaves: false, + } + node := Primary{ + 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) + } + } +} 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