aboutsummaryrefslogtreecommitdiff
path: root/internal/node
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
parent4b20ef0c1732bcef633c0ed7104501898aa84e2c (diff)
implement primary and secondary role, for replicationv0.5.0
Diffstat (limited to 'internal/node')
-rw-r--r--internal/node/handler/handler.go91
-rw-r--r--internal/node/handler/handler_test.go113
-rw-r--r--internal/node/handler/metric.go19
-rw-r--r--internal/node/primary/endpoint_external.go147
-rw-r--r--internal/node/primary/endpoint_external_test.go626
-rw-r--r--internal/node/primary/endpoint_internal.go30
-rw-r--r--internal/node/primary/endpoint_internal_test.go82
-rw-r--r--internal/node/primary/primary.go74
-rw-r--r--internal/node/primary/primary_test.go75
-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
13 files changed, 1662 insertions, 0 deletions
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.
+ }()
+ }
+}