diff options
Diffstat (limited to 'internal/node')
| -rw-r--r-- | internal/node/handler/handler.go | 91 | ||||
| -rw-r--r-- | internal/node/handler/handler_test.go | 113 | ||||
| -rw-r--r-- | internal/node/handler/metric.go | 19 | ||||
| -rw-r--r-- | internal/node/primary/endpoint_external.go | 147 | ||||
| -rw-r--r-- | internal/node/primary/endpoint_external_test.go | 626 | ||||
| -rw-r--r-- | internal/node/primary/endpoint_internal.go | 30 | ||||
| -rw-r--r-- | internal/node/primary/endpoint_internal_test.go | 82 | ||||
| -rw-r--r-- | internal/node/primary/primary.go | 74 | ||||
| -rw-r--r-- | internal/node/primary/primary_test.go | 75 | ||||
| -rw-r--r-- | internal/node/secondary/endpoint_internal.go | 44 | ||||
| -rw-r--r-- | internal/node/secondary/endpoint_internal_test.go | 111 | ||||
| -rw-r--r-- | internal/node/secondary/secondary.go | 112 | ||||
| -rw-r--r-- | internal/node/secondary/secondary_test.go | 138 | 
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. +		}() +	} +} | 
