Skip to content

Commit 428f0a6

Browse files
committed
pollinate: add cactus-pollinate, a mirror-repair service
cactus-pollinate follows the Chrome MTC cosigners list, watches every issuer's logs and every mirror's copy of them, and pushes missing entries (via the c2sp.org/tlog-mirror write API) to any mirror that has been lagging the log head for longer than a configured delay. CAs are expected to push on their own; pollinate is the backstop for mirrors they are failing to reach — including bootstrapping brand-new mirrors. Discovery probes both layouts seen in the wild: a single log at the bare CA base URL and mtc-tlog-profile logs at <CA prefix>/<n>. A log's identity is the origin its checkpoint declares, including non-oid/ origins. Reads go through tlog.TileHashReader, so every hash and entry is authenticated against the source checkpoint root without keeping a local replica (Cloudflare's bootstrap log has 229M entries); sources are picked freshest-first with random tie-breaks to spread read load, and verified tiles are shared through a bounded cache. Lag is measured against history, not the current head: the state file (pollinate/state.json) records when the head was first seen at each size, and a mirror is only pushed entries the head already had a full delay window ago. Mirrors that answer "unknown origin" on the submission API are recorded as not carrying the log and rechecked on an interval or when the cosigners list version changes. Issuer checkpoints are signature-verified when the key is ML-DSA-44; mirror cosignatures on push responses are always verified, with keys matched from the cosigners PEM bundle by key_sha256 (the SHA-256 of the SPKI DER). Prometheus metrics (cactus_pollinate_*) and slog JSON output follow the cactus server's conventions, including loopback-gated pprof. Tested end-to-end against a stub mirror that verifies every pushed subtree consistency proof, and live against a real Sunlight witness/mirror (562-entry bootstrap push, cosignature verified, mirror copy recomputes to the CA's signed root).
1 parent b53b362 commit 428f0a6

20 files changed

Lines changed: 3296 additions & 6 deletions

CLAUDE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ toolchain). The `Makefile` defaults `GO ?= gotip`; override with `make GO=go` on
1919
## Commands
2020

2121
```sh
22-
make build # builds bin/cactus, bin/cactus-cli, bin/cactus-keygen
22+
make build # builds bin/cactus, bin/cactus-cli, bin/cactus-keygen, bin/cactus-pollinate
2323
make test # gotip test ./...
2424
make test-race # gotip test -race ./...
2525
make vet # gotip vet ./...
@@ -86,6 +86,16 @@ abstraction. **`landmark/`** allocates §6.4 landmark sequences and serves `/lan
8686
processes — single-writer is enforced by documentation, not by code. See
8787
[docs/threat-model.md](docs/threat-model.md), [docs/disk-layout.md](docs/disk-layout.md).
8888

89+
**`cactus-pollinate`** (`pollinate/` + `cmd/cactus-pollinate`) is a separate service, not
90+
part of the cactus server: it follows the Chrome MTC cosigners list
91+
(`specs/cosigners_schema.json`; live data at
92+
https://www.gstatic.com/mtcs/cosigners/v1/{cosigners.json,cosigners.pem}), polls every
93+
issuer's logs and every mirror's copy, and pushes missing entries to mirrors that stay
94+
behind the log head longer than a configured delay — reusing `mirrorpush` for the write
95+
side and `tlog.TileHashReader`-authenticated remote tile reads (no local log replica) for
96+
the read side. See the README's "Keeping mirrors in sync" section and
97+
`config-example-pollinate.json`.
98+
8999
## IDs are derived, not independent
90100

91101
The **CA ID** (`ca_cosigner.id`, e.g. `44363.47.1.99`, the arcs *below* the `1.3.6.1.4.1` enterprise base) is load-bearing: draft

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ build:
1111
$(GO) build -o $(BIN_DIR)/cactus ./cmd/cactus
1212
$(GO) build -o $(BIN_DIR)/cactus-cli ./cmd/cactus-cli
1313
$(GO) build -o $(BIN_DIR)/cactus-keygen ./cmd/cactus-keygen
14+
$(GO) build -o $(BIN_DIR)/cactus-pollinate ./cmd/cactus-pollinate
1415

1516
test:
1617
$(GO) test ./...

README.md

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ operate it, and where to look in the code.
2828
6. [Issuing your first cert](#issuing-your-first-cert)
2929
7. [Verifying certs with the CLI](#verifying-certs-with-the-cli)
3030
8. [Configuration reference](#configuration-reference)
31-
9. [Observability](#observability)
32-
10. [Layout of the codebase](#layout-of-the-codebase)
33-
11. [Tests](#tests)
34-
12. [Status](#status)
31+
9. [Keeping mirrors in sync: cactus-pollinate](#keeping-mirrors-in-sync-cactus-pollinate)
32+
10. [Observability](#observability)
33+
11. [Layout of the codebase](#layout-of-the-codebase)
34+
12. [Tests](#tests)
35+
13. [Status](#status)
3536

3637
---
3738

@@ -406,6 +407,66 @@ With no targets configured the subsystem is entirely inert.
406407

407408
---
408409

410+
## Keeping mirrors in sync: cactus-pollinate
411+
412+
`cactus-pollinate` is a standalone service that repairs desynchronised
413+
mirrors across a whole MTC ecosystem. Point it at the Chrome MTC
414+
cosigners list and it follows every issuer's logs and every mirror's
415+
copy of them; when a mirror has been lagging a log head for longer than
416+
a configurable delay, it reads the missing entries from a healthy
417+
source (the CA's log or another mirror, load-balanced) and pushes them
418+
over the c2sp.org/tlog-mirror write API — the same `add-checkpoint` /
419+
`add-entries` flow cactus's own `mirror_push` uses.
420+
421+
```sh
422+
./bin/cactus-pollinate -config config-example-pollinate.json
423+
```
424+
425+
Design points:
426+
427+
- **Inputs.** `cosigners.list`/`cosigners.keys` accept URLs or file
428+
paths; the defaults are Chrome's published
429+
[`cosigners.json`](https://www.gstatic.com/mtcs/cosigners/v1/cosigners.json)
430+
and [`cosigners.pem`](https://www.gstatic.com/mtcs/cosigners/v1/cosigners.pem)
431+
(schema in `specs/cosigners_schema.json`). Signers whose current state
432+
is `REMOVED` are skipped; per-mirror overrides in `mirrors[]` can set
433+
a distinct `submission_prefix` (default: the mirror's monitoring
434+
`base_url`, which tlog-mirror permits) or `disable` a mirror.
435+
- **Log discovery.** Each issuer's `base_url` is probed at
436+
`<base_url>/checkpoint` and `<base_url>/<n>/checkpoint` for
437+
`n ≤ discovery.max_log_number`, since both single-log CAs and
438+
mtc-tlog-profile CAs exist in the wild. A log's identity is the origin
439+
its checkpoint declares — including non-`oid/` origins.
440+
- **The delay window (`push_delay_ms`).** CAs are expected to push on
441+
their own, so pollinate records a history of log-head sizes and only
442+
pushes to a mirror that is missing entries the head already had a full
443+
delay window ago. After a restart it waits out one window before its
444+
first push.
445+
- **Trust.** All reads go through `tlog.TileHashReader`, so every hash
446+
and entry is authenticated against the source's checkpoint root before
447+
being forwarded; checkpoints are additionally verified against the
448+
issuer's key when it is ML-DSA-44. Nothing needs to *trust* pollinate
449+
either — the receiving mirror re-verifies the log signature and every
450+
subtree consistency proof.
451+
- **Coverage tracking.** Not every mirror carries every log: a mirror
452+
whose submission API answers "unknown origin" is recorded as not
453+
carrying that log and left alone until `not_carried_recheck_ms`
454+
elapses or the cosigners list version changes.
455+
- **State.** Everything learned (logs, head history, per-mirror sizes
456+
and verdicts) is a JSON file at `<data_dir>/pollinate/state.json`;
457+
resumable upload positions live next to it under
458+
`<data_dir>/mirrorpush/`.
459+
460+
Metrics on `metrics.listen` (default `127.0.0.1:14091`), all prefixed
461+
`cactus_pollinate_`: log head and per-mirror sizes and lag
462+
(`log_head_size`, `mirror_size`, `mirror_lag_entries`,
463+
`mirror_carries`), push outcomes (`pushes_total{mirror,result}`,
464+
`pushed_entries_total`), read distribution (`source_reads_total`),
465+
sweep and error counters, and cosigners-list freshness. Logs are the
466+
same slog JSON as cactus.
467+
468+
---
469+
409470
## Observability
410471

411472
cactus emits structured JSON logs (slog) on stdout. Every line carries
@@ -437,12 +498,15 @@ cactus/
437498
├── cmd/
438499
│ ├── cactus/ main server binary
439500
│ ├── cactus-cli/ debugging client (tree show, entry, cert verify, prove)
440-
│ └── cactus-keygen/ cosigner seed generator (-pub / -vkey / -from-vkey)
501+
│ ├── cactus-keygen/ cosigner seed generator (-pub / -vkey / -from-vkey)
502+
│ └── cactus-pollinate/ mirror-repair service (see section above)
441503
├── acme/ RFC 8555 ACME server with §9 extensions
442504
├── ca/ Issuer (CSR → X.509 cert via id-alg-mtcProof)
443505
├── cert/ TBSCertificateLogEntry, MTCProof, CosignedMessage,
444506
│ CertificatePropertyList, multi-mirror sign-subtree client
445507
├── mirrorpush/ c2sp.org/tlog-mirror push client (add-checkpoint, add-entries)
508+
├── pollinate/ mirror-repair service: follows the Chrome cosigners list,
509+
│ detects lagging mirrors, replays missing entries to them
446510
├── landmark/ §6.4 landmark sequence allocator + /landmarks handler
447511
├── log/ issuance log (single-writer, signed checkpoints + subtrees)
448512
├── signer/ cosigner abstraction (ML-DSA-44/65/87, Go 1.27+)

cmd/cactus-pollinate/main.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// cactus-pollinate keeps MTC mirrors in sync with the issuance logs
2+
// they carry.
3+
//
4+
// Usage:
5+
//
6+
// cactus-pollinate -config /path/to/pollinate.json
7+
//
8+
// It follows the Chrome MTC cosigners list, polls every issuer's logs
9+
// and every mirror's copy of them, and pushes missing entries (via the
10+
// c2sp.org/tlog-mirror write API) to any mirror that has been lagging
11+
// the log head for longer than the configured delay. CAs are expected
12+
// to push on their own; pollinate is the backstop for mirrors they are
13+
// failing to reach.
14+
package main
15+
16+
import (
17+
"context"
18+
"errors"
19+
"flag"
20+
"fmt"
21+
"log/slog"
22+
"net"
23+
"net/http"
24+
"net/http/pprof"
25+
"os"
26+
"os/signal"
27+
"syscall"
28+
"time"
29+
30+
"github.com/letsencrypt/cactus/logging"
31+
"github.com/letsencrypt/cactus/pollinate"
32+
)
33+
34+
var version = "dev"
35+
36+
func main() {
37+
configPath := flag.String("config", "pollinate.json", "path to JSON config file")
38+
showVersion := flag.Bool("version", false, "print version and exit")
39+
flag.Parse()
40+
41+
if *showVersion {
42+
fmt.Println("cactus-pollinate", version)
43+
return
44+
}
45+
46+
cfg, err := pollinate.LoadConfig(*configPath)
47+
if err != nil {
48+
fmt.Fprintf(os.Stderr, "cactus-pollinate: load config: %v\n", err)
49+
os.Exit(1)
50+
}
51+
52+
logger := logging.New(os.Stdout, cfg.LogLevel)
53+
slog.SetDefault(logger)
54+
logger.Info("starting", "version", version, "config", *configPath,
55+
"data_dir", cfg.DataDir, "cosigners", cfg.Cosigners.List,
56+
"push_delay", cfg.PushDelay())
57+
58+
if err := run(cfg, logger); err != nil {
59+
logger.Error("fatal", "err", err)
60+
os.Exit(1)
61+
}
62+
}
63+
64+
func run(cfg pollinate.Config, logger *slog.Logger) error {
65+
ctx, cancel := context.WithCancel(context.Background())
66+
defer cancel()
67+
68+
m := pollinate.NewMetrics()
69+
svc, err := pollinate.New(cfg, logger, m)
70+
if err != nil {
71+
return err
72+
}
73+
74+
// Metrics + pprof, gated to loopback listeners exactly as in cactus:
75+
// profiles can leak memory contents and burn CPU on demand.
76+
metricsMux := http.NewServeMux()
77+
metricsMux.Handle("/metrics", m.Handler())
78+
if listenIsLoopback(cfg.Metrics.Listen) {
79+
metricsMux.HandleFunc("/debug/pprof/", pprof.Index)
80+
metricsMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
81+
metricsMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
82+
metricsMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
83+
metricsMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
84+
} else {
85+
logger.Warn("metrics listener is not loopback; /debug/pprof disabled",
86+
"listen", cfg.Metrics.Listen)
87+
}
88+
metricsHTTP := &http.Server{
89+
Addr: cfg.Metrics.Listen,
90+
Handler: metricsMux,
91+
ReadHeaderTimeout: 5 * time.Second,
92+
ReadTimeout: 30 * time.Second,
93+
WriteTimeout: 5 * time.Minute, // pprof profiles stream for a while
94+
IdleTimeout: 120 * time.Second,
95+
MaxHeaderBytes: 16 * 1024,
96+
}
97+
srvErr := make(chan error, 1)
98+
go func() {
99+
logger.Info("listening", "name", "metrics", "addr", metricsHTTP.Addr)
100+
if err := metricsHTTP.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
101+
srvErr <- fmt.Errorf("metrics listener: %w", err)
102+
}
103+
}()
104+
105+
go svc.Run(ctx)
106+
107+
sigCh := make(chan os.Signal, 1)
108+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
109+
var runErr error
110+
select {
111+
case sig := <-sigCh:
112+
logger.Info("shutting down", "signal", sig.String())
113+
case err := <-srvErr:
114+
logger.Error("listener failed, shutting down", "err", err)
115+
runErr = err
116+
}
117+
cancel()
118+
119+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
120+
defer shutdownCancel()
121+
_ = metricsHTTP.Shutdown(shutdownCtx)
122+
return runErr
123+
}
124+
125+
// listenIsLoopback reports whether addr is bound to a loopback host.
126+
// Bare ":<port>" and "0.0.0.0:<port>" are NOT loopback.
127+
func listenIsLoopback(addr string) bool {
128+
host, _, err := net.SplitHostPort(addr)
129+
if err != nil || host == "" {
130+
return false
131+
}
132+
if host == "localhost" {
133+
return true
134+
}
135+
if ip := net.ParseIP(host); ip != nil {
136+
return ip.IsLoopback()
137+
}
138+
return false
139+
}

config-example-pollinate.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"data_dir": "/tmp/cactus-pollinate-data",
3+
"cosigners": {
4+
"list": "https://www.gstatic.com/mtcs/cosigners/v1/cosigners.json",
5+
"keys": "https://www.gstatic.com/mtcs/cosigners/v1/cosigners.pem",
6+
"refresh_ms": 900000
7+
},
8+
"poll_interval_ms": 60000,
9+
"push_delay_ms": 600000,
10+
"discovery": {
11+
"max_log_number": 8,
12+
"interval_ms": 900000
13+
},
14+
"not_carried_recheck_ms": 21600000,
15+
"request_timeout_ms": 30000,
16+
"push_timeout_ms": 300000,
17+
"max_concurrent_pushes": 4,
18+
"mirrors": [],
19+
"metrics": {
20+
"listen": "127.0.0.1:14091"
21+
},
22+
"log_level": "info"
23+
}

0 commit comments

Comments
 (0)