diff --git a/cmd/ion-node/main.go b/cmd/ion-node/main.go index 6a27553..21fe851 100644 --- a/cmd/ion-node/main.go +++ b/cmd/ion-node/main.go @@ -95,6 +95,7 @@ func globalFlags() []cli.Flag { &cli.StringSliceFlag{Name: "peer", Usage: "explicit peer host:port (repeatable; overrides DNS discovery)"}, &cli.StringFlag{Name: "anchor-peer", Usage: "reserved trusted/anchor peer host:port"}, &cli.StringSliceFlag{Name: "ion-peer", Usage: "ION overlay seed peer host:port for fast-sync bootstrap (repeatable; no public seeds yet, so operator-supplied)"}, + &cli.BoolFlag{Name: "serve-fast-sync", Usage: "expose the fast-sync HTTP gateway on `serve` (GET /fastsync/cas/ — an IPFS-free, self-verifying, CORS-open content endpoint for web wallets)"}, &cli.IntFlag{Name: "outbound", Value: -1, Usage: "target outbound connection count (default 8)"}, &cli.IntFlag{Name: "concurrency", Value: -1, Usage: "block-download fan-out (default 16)"}, &cli.StringFlag{Name: "datadir", Usage: "directory for the anchor-index store (default per profile)"}, @@ -136,6 +137,9 @@ func buildConfig(cCtx *cli.Context) (*config.Config, error) { if v := cCtx.StringSlice("ion-peer"); len(v) > 0 { opts = append(opts, config.WithIONPeers(v...)) } + if cCtx.Bool("serve-fast-sync") { + opts = append(opts, config.WithServeFastSync(true)) + } if v := cCtx.Int("outbound"); v >= 0 { opts = append(opts, config.WithTargetOutbound(v)) } @@ -729,6 +733,10 @@ func runServe(cCtx *cli.Context) error { resolver := resolve.New(st, casClient, cfg.Method) handler := api.NewHandler(resolver, logger).WithStats(st) // GET /stats: dids, pendingRetryable, heights + if cfg.ServeFastSync { + handler = handler.WithCASGateway(casClient) // GET /fastsync/cas/ + logger.Info("fast-sync content gateway enabled", "endpoint", "/fastsync/cas/") + } srv := api.NewServer(cCtx.String("http-addr"), handler.Mux()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/internal/api/resolution.go b/internal/api/resolution.go index 8c163e2..149af65 100644 --- a/internal/api/resolution.go +++ b/internal/api/resolution.go @@ -8,12 +8,14 @@ package api import ( "encoding/json" + "errors" "log/slog" "net/http" "net/url" "strings" "time" + "github.com/13x-tech/ion-node/internal/cas" "github.com/13x-tech/ion-node/internal/resolve" ) @@ -66,10 +68,24 @@ type StatsProvider interface { ProcessedWatermark() (height int32, ok bool, err error) } +// CASGateway fetches content-addressed blobs by CID (the CAS layer satisfies it). +// It backs the fast-sync content gateway (#115): an IPFS-free, self-verifying +// content endpoint usable by web/browser wallets that cannot speak Bitcoin P2P. +type CASGateway interface { + Get(cid string, maxSizeInBytes int) ([]byte, error) +} + +// maxGatewayBlobBytes bounds a single content-gateway response. Sidetree's largest +// file types are well under this; it guards against an unexpectedly huge blob. +const maxGatewayBlobBytes = 1 << 22 // 4 MiB + +const fastSyncCASPrefix = "/fastsync/cas/" + // Handler serves the DID resolution API. type Handler struct { resolver DIDResolver stats StatsProvider // optional; when set, enables GET /stats + cas CASGateway // optional; when set, enables GET /fastsync/cas/ log *slog.Logger } @@ -89,8 +105,16 @@ func (h *Handler) WithStats(s StatsProvider) *Handler { return h } -// Mux returns the routed HTTP handler: the resolution endpoint, /health, and (when a -// stats provider is configured) /stats. +// WithCASGateway enables GET /fastsync/cas/, an IPFS-free content gateway backed +// by g (the node's CAS). The content is self-verifying (CID == content hash) and +// CORS-open so browser/web wallets — which cannot speak Bitcoin P2P — can fetch it. +func (h *Handler) WithCASGateway(g CASGateway) *Handler { + h.cas = g + return h +} + +// Mux returns the routed HTTP handler: the resolution endpoint, /health, and (when +// configured) /stats and the /fastsync/cas/ content gateway. func (h *Handler) Mux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc(identifiersPrefix, h.handleResolve) @@ -98,9 +122,57 @@ func (h *Handler) Mux() *http.ServeMux { if h.stats != nil { mux.HandleFunc("/stats", h.handleStats) } + if h.cas != nil { + mux.HandleFunc(fastSyncCASPrefix, h.handleFastSyncCAS) + } return mux } +// setGatewayCORS opens the fast-sync gateway to browser clients. The served data is +// public and self-verifying (anchors prove inclusion against Bitcoin; CAS blobs are +// content-addressed), so there is nothing to protect with a same-origin policy. +func setGatewayCORS(w http.ResponseWriter) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + h.Set("Access-Control-Allow-Headers", "*") +} + +// handleFastSyncCAS serves a content-addressed blob by CID. The response is +// self-verifying — a client MUST check sha256/multihash(body) == the CID — so this +// is a trustless IPFS replacement, not a source that has to be trusted. +func (h *Handler) handleFastSyncCAS(w http.ResponseWriter, r *http.Request) { + setGatewayCORS(w) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "methodNotAllowed"}) + return + } + cid := strings.TrimPrefix(r.URL.Path, fastSyncCASPrefix) + if cid == "" || strings.Contains(cid, "/") { + writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalidCid"}) + return + } + blob, err := h.cas.Get(cid, maxGatewayBlobBytes) + if err != nil { + if errors.Is(err, cas.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "notFound", "cid": cid}) + return + } + h.log.Warn("fastsync cas gateway", "cid", cid, "err", err) + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unavailable", "cid": cid}) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + // Content-addressed ⇒ immutable; let clients/proxies cache aggressively. + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(blob) +} + func (h *Handler) handleStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "methodNotAllowed"}) diff --git a/internal/api/resolution_test.go b/internal/api/resolution_test.go index 9c09d43..0b7890a 100644 --- a/internal/api/resolution_test.go +++ b/internal/api/resolution_test.go @@ -3,11 +3,13 @@ package api import ( "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "testing" "time" + "github.com/13x-tech/ion-node/internal/cas" "github.com/13x-tech/ion-node/internal/resolve" "github.com/13x-tech/ion-sdk-go/pkg/did" ) @@ -293,3 +295,67 @@ func TestStatsEndpoint(t *testing.T) { t.Errorf("GET /stats without a provider = %d, want 404", r2.StatusCode) } } + +// fakeCASGateway serves canned blobs by CID, ErrNotFound for the rest. +type fakeCASGateway map[string][]byte + +func (f fakeCASGateway) Get(cid string, _ int) ([]byte, error) { + if b, ok := f[cid]; ok { + return b, nil + } + return nil, cas.ErrNotFound +} + +// TestFastSyncCASGateway verifies the #115 content gateway: a present CID returns the +// (CORS-open) blob, a missing one is 404, OPTIONS preflight works, non-GET is 405, and +// the route is absent unless a gateway is configured. +func TestFastSyncCASGateway(t *testing.T) { + gw := fakeCASGateway{"QmGood": []byte("hello sidetree")} + srv := httptest.NewServer(NewHandler(fakeResolver{}, nil).WithCASGateway(gw).Mux()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/fastsync/cas/QmGood") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET present cid = %d, want 200", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "*" { + t.Error("missing CORS header (web wallets need it)") + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != "hello sidetree" { + t.Errorf("body = %q, want the blob", body) + } + + miss, _ := http.Get(srv.URL + "/fastsync/cas/QmMissing") + miss.Body.Close() + if miss.StatusCode != http.StatusNotFound { + t.Errorf("missing cid = %d, want 404", miss.StatusCode) + } + + preReq, _ := http.NewRequest(http.MethodOptions, srv.URL+"/fastsync/cas/QmGood", nil) + pre, _ := http.DefaultClient.Do(preReq) + pre.Body.Close() + if pre.StatusCode != http.StatusNoContent || pre.Header.Get("Access-Control-Allow-Origin") != "*" { + t.Errorf("OPTIONS preflight = %d (CORS %q), want 204 + *", pre.StatusCode, pre.Header.Get("Access-Control-Allow-Origin")) + } + + postReq, _ := http.NewRequest(http.MethodPost, srv.URL+"/fastsync/cas/QmGood", nil) + post, _ := http.DefaultClient.Do(postReq) + post.Body.Close() + if post.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("POST = %d, want 405", post.StatusCode) + } + + // No gateway configured → the route is not registered (404). + plain := httptest.NewServer(NewHandler(fakeResolver{}, nil).Mux()) + defer plain.Close() + none, _ := http.Get(plain.URL + "/fastsync/cas/QmGood") + none.Body.Close() + if none.StatusCode != http.StatusNotFound { + t.Errorf("no-gateway route = %d, want 404", none.StatusCode) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 736b002..ccadba0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,6 +58,7 @@ type Config struct { Peers []string // explicit peer "host:port" addresses; overrides DNS discovery when set AnchorPeer string // reserved trusted/anchor peer "host:port"; "" = none IONPeers []string // ION overlay seed peers "host:port" for fast-sync (#111); "" = none yet + ServeFastSync bool // expose the fast-sync HTTP gateway (#115: /fastsync/cas, web-wallet content) TargetOutbound int // desired number of outbound connections // Local resources. @@ -238,6 +239,11 @@ func WithIONPeers(peers ...string) Option { return func(c *Config) error { c.IONPeers = peers; return nil } } +// WithServeFastSync enables the fast-sync HTTP gateway on the serve API (#115). +func WithServeFastSync(v bool) Option { + return func(c *Config) error { c.ServeFastSync = v; return nil } +} + // WithTargetOutbound sets the desired outbound connection count. func WithTargetOutbound(n int) Option { return func(c *Config) error { c.TargetOutbound = n; return nil }