- BLAKE3 keyed proof-of-work on object storage and auth challenges, with frozen vectors cross-checked against an independent Python reference implementing the single-block hash it needs. - Sparse Merkle trie over object IDs: order-independent roots, inclusion and absence proofs (internal/smt). - Signed checkpoint chain per relay: transport key amendment to INV-1, /v1/checkpoint/* and inclusion/absence proof endpoints, restart-safe epoch continuity (internal/checkpoint). - Head gossip with TOFU pinning and equivocation detection; light node (cmd/lightnode) that stores no history: quorum of pinned relays, every served object proven against the agreed root, LRU disk cache. - WebSocket streaming on relay and light node (coder/websocket): scoped channels mirroring REST, raw envelopes verified client-side; light node marks streamed objects unproven until checkpoint coverage. - Protocol v1 additions: DelegationClaim tag 0x07 with deterministic chain resolution in verify.Graph, KeyRotationRequest/Confirm tags 0x08/0x09 with hash-bound two-sided consent and Policy.RotationMaxAge; spec sections, frozen vectors appended byte-identically, Python reference extended. - Optional permissioned BFT finality over gossip (internal/bft): prevote/precommit with quorum certificates verifiable offline. - Quick wins: Policy.TrustedIssuers, per-type stored metrics, batch fetch, lexicographic lists with stable cursor pagination. - Security review of the network layer (docs/SECURITY-REVIEW.md) with findings F-01..F-09; hub send/close race and unstable pagination fixed under review. 12 packages green, vet/gofmt clean, protocol fuzzing stable.
94 lines
2.6 KiB
Go
94 lines
2.6 KiB
Go
// Command lightnode runs a verifying mirror of the relay API that stores no
|
|
// history. It follows signed checkpoint heads from its peers, requires a
|
|
// quorum of relay keys to agree on one root, and serves objects fetched on
|
|
// demand — each one proven against the agreed set before it is cached or
|
|
// returned.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/internal/lightnode"
|
|
)
|
|
|
|
func main() {
|
|
addr := flag.String("addr", ":8090", "listen address")
|
|
peers := flag.String("peers", "", "comma-separated relay base URLs (required)")
|
|
pinKeys := flag.String("pin-keys", "", "comma-separated hex relay public keys; empty = trust-on-first-use")
|
|
quorum := flag.Int("quorum", 0, "relay keys required to agree on one root (0 = all peers)")
|
|
cacheDir := flag.String("cache-dir", "", "directory for fetched objects (empty = memory only)")
|
|
cacheMaxMB := flag.Int64("cache-max-mb", 256, "cache size cap in MiB")
|
|
interval := flag.Duration("refresh-interval", 10*time.Second, "checkpoint polling interval")
|
|
stream := flag.Bool("stream", false, "mirror upstream WebSocket streams to local clients (GET /v1/ws)")
|
|
flag.Parse()
|
|
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
if *peers == "" {
|
|
logger.Error("-peers is required")
|
|
os.Exit(2)
|
|
}
|
|
|
|
node := lightnode.New(lightnode.Config{
|
|
Peers: strings.Split(*peers, ","),
|
|
PinKeys: splitNonEmpty(*pinKeys),
|
|
Quorum: *quorum,
|
|
CacheDir: *cacheDir,
|
|
CacheMaxBytes: *cacheMaxMB << 20,
|
|
})
|
|
|
|
ctx, stop := context.WithCancel(context.Background())
|
|
defer stop()
|
|
go node.Run(ctx, *interval)
|
|
if *stream {
|
|
wsn, err := node.WS()
|
|
if err != nil {
|
|
logger.Error("websocket subsystem unavailable", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
go wsn.RunWS(ctx)
|
|
}
|
|
|
|
h := &http.Server{
|
|
Addr: *addr,
|
|
Handler: node.Handler(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
go func() {
|
|
logger.Info("light node listening", "addr", *addr, "peers", *peers)
|
|
if err := h.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Error("listen", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
<-sig
|
|
stop()
|
|
|
|
ctx2, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = h.Shutdown(ctx2)
|
|
logger.Info("stopped")
|
|
}
|
|
|
|
func splitNonEmpty(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, part := range strings.Split(s, ",") {
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|