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
8 changes: 8 additions & 0 deletions cmd/ion-node/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<cid> — 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)"},
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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/<cid>
logger.Info("fast-sync content gateway enabled", "endpoint", "/fastsync/cas/<cid>")
}
srv := api.NewServer(cCtx.String("http-addr"), handler.Mux())

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
Expand Down
76 changes: 74 additions & 2 deletions internal/api/resolution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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/<cid>
log *slog.Logger
}

Expand All @@ -89,18 +105,74 @@ 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/<cid>, 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)
mux.HandleFunc("/health", h.handleHealth)
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"})
Expand Down
66 changes: 66 additions & 0 deletions internal/api/resolution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 }
Expand Down
Loading