Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 5 additions & 118 deletions cmd/mtc/mirror/internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@
package handler

import (
"bufio"
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
Expand All @@ -27,7 +24,7 @@ import (
"strings"

"github.com/transparency-dev/tessera"
"github.com/transparency-dev/tessera/internal/witness"
"github.com/transparency-dev/witness/witness"
)

const (
Expand All @@ -38,125 +35,15 @@ const (
maxTicketSize = 1<<16 - 1
)

// Mirror is the interface that the handler uses to interact with the mirror's state.
type Mirror interface {
AddCheckpoint(ctx context.Context, origin string, oldSize uint64, proof [][]byte, cp []byte) ([]byte, uint64, error)
AddEntries(ctx context.Context, origin string, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error)
}

// New returns a new http.Handler for the mirror service.
func New(m Mirror) http.Handler {
// New returns a new http.Handler for the tlog-mirror service, based on the provided mux and witness.
func New(m *MirrorMux, w *witness.Witness) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /add-checkpoint", addCheckpoint(m))
mux.HandleFunc("POST /add-checkpoint", witness.NewHTTPHandler(w).AddCheckpoint)
mux.HandleFunc("POST /add-entries", addEntries(m))
return mux
}

func addCheckpoint(m Mirror) http.HandlerFunc {
const maxRequestBodyBytes = 64 << 10

return func(w http.ResponseWriter, r *http.Request) {
origin, oldSize, proof, cp, err := parseBody(http.MaxBytesReader(w, r.Body, maxRequestBodyBytes))
if err != nil {
slog.InfoContext(r.Context(), "Invalid witness request", slog.Any("error", err.Error()))
w.WriteHeader(http.StatusBadRequest)
return
}

sc, body, contentType, err := handleCheckpointUpdate(r.Context(), m, origin, oldSize, cp, proof)
if err != nil {
slog.InfoContext(r.Context(), "Witness update failed", slog.Any("error", err.Error()))
w.WriteHeader(http.StatusInternalServerError)
return
}

if contentType != "" {
w.Header().Add("Content-Type", contentType)
}
w.WriteHeader(sc)
if len(body) > 0 {
if _, err := w.Write(body); err != nil {
slog.InfoContext(r.Context(), "Witness failed to write response", slog.Any("error", err.Error()))
}
}
}
}

// handleCheckpointUpdate submits the provided checkpoint to the witness and interprets any errors which may result.
//
// Returns an appropriate HTTP status code, response body, and Content Type representing the outcome.
func handleCheckpointUpdate(ctx context.Context, m Mirror, origin string, oldSize uint64, cp []byte, proof [][]byte) (int, []byte, string, error) {
sigs, trustedSize, updateErr := m.AddCheckpoint(ctx, origin, oldSize, proof, cp)
// Finally, handle any "soft" error from the update:
if updateErr != nil {
switch {
case errors.Is(updateErr, witness.ErrCheckpointStale):
return http.StatusConflict, fmt.Appendf(nil, "%d\n", trustedSize), "text/x.tlog.size", nil
case errors.Is(updateErr, witness.ErrUnknownLog):
return http.StatusNotFound, nil, "", nil
case errors.Is(updateErr, witness.ErrNoValidSignature):
return http.StatusForbidden, nil, "", nil
case errors.Is(updateErr, witness.ErrOldSizeInvalid):
return http.StatusBadRequest, nil, "", nil
case errors.Is(updateErr, witness.ErrInvalidProof):
return http.StatusUnprocessableEntity, nil, "", nil
case errors.Is(updateErr, witness.ErrRootMismatch):
return http.StatusConflict, nil, "", nil
default:
return http.StatusInternalServerError, nil, "", updateErr
}
}

return http.StatusOK, sigs, "", nil
}

// parseBody reads the incoming request and parses into constituent parts.
//
// The request body MUST be a sequence of
// - a previous size line,
// - zero or more consistency proof lines,
// - and an empty line,
// - followed by a checkpoint.
func parseBody(r io.Reader) (string, uint64, [][]byte, []byte, error) {
b := bufio.NewReader(r)
sizeLine, err := b.ReadString('\n')
if err != nil {
return "", 0, nil, nil, err
}
var size uint64
if n, err := fmt.Sscanf(strings.TrimSuffix(sizeLine, "\n"), "old %d", &size); err != nil || n != 1 {
return "", 0, nil, nil, err
}
proof := [][]byte{}
for {
l, err := b.ReadString('\n')
if err != nil {
return "", 0, nil, nil, err
}
l = strings.TrimSuffix(l, "\n")
if len(l) == 0 {
break
}
hash, err := base64.StdEncoding.DecodeString(l)
if err != nil {
return "", 0, nil, nil, err
}
proof = append(proof, hash)
}
cp, err := io.ReadAll(b)
if err != nil {
return "", 0, nil, nil, err
}
s := strings.SplitN(string(cp), "\n", 2)
if len(s) != 2 {
return "", 0, nil, nil, errors.New("invalid checkpoint")
}

origin := s[0]
return origin, size, proof, cp, nil
}

func addEntries(m Mirror) http.HandlerFunc {
func addEntries(m *MirrorMux) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// SPEC: The request body MUST have Content-Type of application/octet-stream ...
if t := strings.ToLower(r.Header.Get("Content-Type")); t != "application/octet-stream" {
Expand Down
73 changes: 18 additions & 55 deletions cmd/mtc/mirror/internal/handler/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ package handler
import (
"bytes"
"context"
"encoding/base64"
"encoding/binary"
"fmt"
"net/http"
Expand All @@ -27,54 +26,6 @@ import (
"github.com/transparency-dev/tessera"
)

type mockMirror struct {
addCheckpointFunc func(ctx context.Context, logOrigin string, oldSize uint64, proof [][]byte, cp []byte) ([]byte, uint64, error)
addEntriesFunc func(ctx context.Context, logOrigin string, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error)
}

func (m *mockMirror) AddCheckpoint(ctx context.Context, logOrigin string, oldSize uint64, proof [][]byte, cp []byte) ([]byte, uint64, error) {
if m.addCheckpointFunc != nil {
return m.addCheckpointFunc(ctx, logOrigin, oldSize, proof, cp)
}
return nil, 0, nil
}

func (m *mockMirror) AddEntries(ctx context.Context, logOrigin string, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error) {
if m.addEntriesFunc != nil {
return m.addEntriesFunc(ctx, logOrigin, uploadStart, uploadEnd, ticket, next)
}
return []byte("— dummy-cosig\n"), nil
}

func TestAddCheckpoint(t *testing.T) {
const cpOld = 100

mock := &mockMirror{
addCheckpointFunc: func(ctx context.Context, logOrigin string, oldSize uint64, proof [][]byte, cp []byte) ([]byte, uint64, error) {
if oldSize != cpOld {
return nil, cpOld, fmt.Errorf("want oldSize %d, got %d", cpOld, oldSize)
}
if len(proof) != 1 {
return nil, cpOld, fmt.Errorf("want 1 proof hash, got %d", len(proof))
}
return nil, 0, nil
},
}
h := New(mock)

cp := "example-log\n123\nSGVsbG8sIHdvcmxkIQ==\n\n"
proofHash := base64.StdEncoding.EncodeToString(make([]byte, 32))
body := fmt.Sprintf("old %d\n%s\n\n%s", cpOld, proofHash, cp)

req := httptest.NewRequest(http.MethodPost, "/add-checkpoint", bytes.NewBufferString(body))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Errorf("want status 200, got %d: %s", w.Code, w.Body.String())
}
}

func TestAddEntries(t *testing.T) {
const (
testOrigin = "example-log"
Expand All @@ -83,11 +34,8 @@ func TestAddEntries(t *testing.T) {
testUploadEnd = 110
)

mock := &mockMirror{
addEntriesFunc: func(ctx context.Context, logOrigin string, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error) {
if logOrigin != testOrigin {
return nil, fmt.Errorf("want logOrigin %s, got %s", testOrigin, logOrigin)
}
mock := &mockTarget{
addEntriesFunc: func(ctx context.Context, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error) {
if uploadStart != testUploadStart || uploadEnd != testUploadEnd {
return nil, fmt.Errorf("want range %d-%d, got %d-%d", testUploadStart, testUploadEnd, uploadStart, uploadEnd)
}
Expand All @@ -110,7 +58,11 @@ func TestAddEntries(t *testing.T) {
return []byte("— test-cosig\n"), nil
},
}
h := New(mock)
mux := NewMirrorMux()
if err := mux.AddTarget(testOrigin, mock); err != nil {
t.Fatalf("AddTarget() failed: %v", err)
}
h := New(mux, nil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing the nil here would cause a panic if any test tries to call /add-checkpoint endpoint later.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, I'll update this as the implementation of add-checkpoint progresses.


var body bytes.Buffer

Expand Down Expand Up @@ -143,3 +95,14 @@ func TestAddEntries(t *testing.T) {
t.Errorf("response does not contain expected cosignature: %s", w.Body.String())
}
}

type mockTarget struct {
addEntriesFunc func(ctx context.Context, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error)
}

func (m *mockTarget) AddEntries(ctx context.Context, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error) {
if m.addEntriesFunc != nil {
return m.addEntriesFunc(ctx, uploadStart, uploadEnd, ticket, next)
}
return []byte("— dummy-cosig\n"), nil
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
// See the License for the specific language governing permissions and
// limitations under the License.

package mirror
package handler

import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"sync"

"github.com/transparency-dev/tessera"
Expand All @@ -29,33 +29,33 @@ var (
ErrUnknownLog = errors.New("unknown log origin")
)

// New creates a new Mirror from the provided map of origins to mirror target.
func New(targets map[string]Target) *Mirror {
m := &Mirror{
targets: maps.Clone(targets),
// NewMirrorMux creates a new MirrorMux from the provided map of origins to mirror targets.
func NewMirrorMux() *MirrorMux {
return &MirrorMux{
targets: make(map[string]MirrorTarget),
}
return m
}

// Mirror is the backend for the tlog-mirror HTTP service.
//
// Mirror is mostly a multiplexer over the various log targets. It knows about
// the configured logs and routes requests to the appropriate target.
type Mirror struct {
lock sync.RWMutex
targets map[string]Target
// AddTarget adds a new mirror target for the given origin.
// It is an error to add a target for an origin that already has been added.
func (m *MirrorMux) AddTarget(origin string, t MirrorTarget) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.targets[origin]; ok {
return fmt.Errorf("origin %q already added", origin)
}
m.targets[origin] = t
return nil
}

func (m *Mirror) AddCheckpoint(ctx context.Context, origin string, oldSize uint64, proof [][]byte, cpRaw []byte) ([]byte, uint64, error) {
t, err := m.target(origin)
if err != nil {
return nil, 0, err
}
slog.InfoContext(ctx, "AddCheckpoint", slog.String("origin", origin), slog.Uint64("old_size", oldSize), slog.Int("proof_len", len(proof)), slog.String("cp", string(cpRaw)))
return t.AddCheckpoint(ctx, oldSize, proof, cpRaw)
// MirrorMux is a backend for the tlog-mirror HTTP service that multiplexes incoming requests
// over a set of target mirrors based on the log origin.
type MirrorMux struct {
mu sync.RWMutex
targets map[string]MirrorTarget // keyed by log origin.
}

func (m *Mirror) AddEntries(ctx context.Context, origin string, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error) {
func (m *MirrorMux) AddEntries(ctx context.Context, origin string, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error) {
t, err := m.target(origin)
if err != nil {
return nil, err
Expand All @@ -65,20 +65,18 @@ func (m *Mirror) AddEntries(ctx context.Context, origin string, uploadStart, upl
}

// target returns the target for the given origin, or ErrUnknownLog if it doesn't exist.
func (m *Mirror) target(origin string) (Target, error) {
m.lock.RLock()
defer m.lock.RUnlock()
func (m *MirrorMux) target(origin string) (MirrorTarget, error) {
m.mu.RLock()
defer m.mu.RUnlock()
r, ok := m.targets[origin]
if !ok {
return nil, ErrUnknownLog
}
return r, nil
}

// Target describes the contract that a mirror target must satisfy.
type Target interface {
// AddCheckpoint is a tlog-witness.
AddCheckpoint(ctx context.Context, oldSize uint64, proof [][]byte, cpRaw []byte) ([]byte, uint64, error)
// MirrorTarget describes the contract that a mirror target must satisfy.
type MirrorTarget interface {
// AddEntries adds verified consistent entries to the mirror.
AddEntries(ctx context.Context, uploadStart, uploadEnd uint64, ticket []byte, next func() (*tessera.MirrorPackage, error)) ([]byte, error)
}
}
Loading
Loading