DChain single-node blockchain + React Native messenger client. Core: - PBFT consensus with multi-sig validator admission + equivocation slashing - BadgerDB + schema migration scaffold (CurrentSchemaVersion=0) - libp2p gossipsub (tx/v1, blocks/v1, relay/v1, version/v1) - Native Go contracts (username_registry) alongside WASM (wazero) - WebSocket gateway with topic-based fanout + Ed25519-nonce auth - Relay mailbox with NaCl envelope encryption (X25519 + Ed25519) - Prometheus /metrics, per-IP rate limit, body-size cap Deployment: - Single-node compose (deploy/single/) with Caddy TLS + optional Prometheus - 3-node dev compose (docker-compose.yml) with mocked internet topology - 3-validator prod compose (deploy/prod/) for federation - Auto-update from Gitea via /api/update-check + systemd timer - Build-time version injection (ldflags → node --version) - UI / Swagger toggle flags (DCHAIN_DISABLE_UI, DCHAIN_DISABLE_SWAGGER) Client (client-app/): - Expo / React Native / NativeWind - E2E NaCl encryption, typing indicator, contact requests - Auto-discovery of canonical contracts, chain_id aware, WS reconnect on node switch Documentation: - README.md, CHANGELOG.md, CONTEXT.md - deploy/single/README.md with 6 operator scenarios - deploy/UPDATE_STRATEGY.md with 4-layer forward-compat design - docs/contracts/*.md per contract
300 lines
10 KiB
Go
300 lines
10 KiB
Go
// Package node — HTTP-level guards: body-size limits, timestamp windows, and
|
|
// a tiny per-IP token-bucket rate limiter.
|
|
//
|
|
// These are intentionally lightweight, dependency-free, and fail open if
|
|
// misconfigured. They do not replace proper production fronting (reverse
|
|
// proxy with rate-limit module), but they close the most obvious abuse
|
|
// vectors when the node is exposed directly.
|
|
package node
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go-blockchain/blockchain"
|
|
)
|
|
|
|
// ─── Limits ──────────────────────────────────────────────────────────────────
|
|
|
|
// MaxTxRequestBytes caps the size of a single POST /api/tx body. A signed
|
|
// transaction with a modest payload sits well under 16 KiB; we allow 64 KiB
|
|
// to accommodate small WASM deploys submitted by trusted signers.
|
|
//
|
|
// Note: DEPLOY_CONTRACT with a large WASM binary uses the same endpoint and
|
|
// therefore the same cap. If you deploy bigger contracts, raise this cap on
|
|
// the nodes that accept deploys, or (better) add a dedicated upload route.
|
|
const MaxTxRequestBytes int64 = 64 * 1024
|
|
|
|
// TxTimestampSkew is the maximum accepted deviation between a transaction's
|
|
// declared timestamp and the node's current wall clock. Transactions outside
|
|
// the window are rejected at the API layer to harden against replay + clock
|
|
// skew attacks (we do not retain old txIDs forever, so a very old but
|
|
// otherwise valid tx could otherwise slip in after its dedup entry rotates).
|
|
const TxTimestampSkew = 1 * time.Hour
|
|
|
|
// ValidateTxTimestamp returns an error if tx.Timestamp is further than
|
|
// TxTimestampSkew from now. The zero-time is also rejected.
|
|
//
|
|
// Exported so the WS gateway (and other in-process callers) can reuse the
|
|
// same validation as the HTTP /api/tx path without importing the file-local
|
|
// helper.
|
|
func ValidateTxTimestamp(tx *blockchain.Transaction) error {
|
|
if tx.Timestamp.IsZero() {
|
|
return fmt.Errorf("timestamp is required")
|
|
}
|
|
now := time.Now().UTC()
|
|
delta := tx.Timestamp.Sub(now)
|
|
if delta < 0 {
|
|
delta = -delta
|
|
}
|
|
if delta > TxTimestampSkew {
|
|
return fmt.Errorf("timestamp %s is outside ±%s window of current time %s",
|
|
tx.Timestamp.Format(time.RFC3339), TxTimestampSkew, now.Format(time.RFC3339))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ─── Rate limiter ────────────────────────────────────────────────────────────
|
|
|
|
// ipRateLimiter is a best-effort token bucket keyed by source IP. Buckets are
|
|
// created lazily and garbage-collected by a background sweep.
|
|
type ipRateLimiter struct {
|
|
rate float64 // tokens added per second
|
|
burst float64 // bucket capacity
|
|
sweepEvery time.Duration // how often to GC inactive buckets
|
|
inactiveTTL time.Duration // drop buckets idle longer than this
|
|
|
|
mu sync.Mutex
|
|
buckets map[string]*bucket
|
|
stopCh chan struct{}
|
|
}
|
|
|
|
type bucket struct {
|
|
tokens float64
|
|
lastSeen time.Time
|
|
}
|
|
|
|
// newIPRateLimiter constructs a limiter: each IP gets `burst` tokens that refill
|
|
// at `rate` per second.
|
|
func newIPRateLimiter(rate, burst float64) *ipRateLimiter {
|
|
l := &ipRateLimiter{
|
|
rate: rate,
|
|
burst: burst,
|
|
sweepEvery: 2 * time.Minute,
|
|
inactiveTTL: 10 * time.Minute,
|
|
buckets: make(map[string]*bucket),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
go l.sweepLoop()
|
|
return l
|
|
}
|
|
|
|
// Allow deducts one token for the given IP; returns false if the bucket is empty.
|
|
func (l *ipRateLimiter) Allow(ip string) bool {
|
|
now := time.Now()
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
b, ok := l.buckets[ip]
|
|
if !ok {
|
|
b = &bucket{tokens: l.burst, lastSeen: now}
|
|
l.buckets[ip] = b
|
|
}
|
|
elapsed := now.Sub(b.lastSeen).Seconds()
|
|
b.tokens += elapsed * l.rate
|
|
if b.tokens > l.burst {
|
|
b.tokens = l.burst
|
|
}
|
|
b.lastSeen = now
|
|
if b.tokens < 1 {
|
|
return false
|
|
}
|
|
b.tokens--
|
|
return true
|
|
}
|
|
|
|
func (l *ipRateLimiter) sweepLoop() {
|
|
tk := time.NewTicker(l.sweepEvery)
|
|
defer tk.Stop()
|
|
for {
|
|
select {
|
|
case <-l.stopCh:
|
|
return
|
|
case now := <-tk.C:
|
|
l.mu.Lock()
|
|
for ip, b := range l.buckets {
|
|
if now.Sub(b.lastSeen) > l.inactiveTTL {
|
|
delete(l.buckets, ip)
|
|
}
|
|
}
|
|
l.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// clientIP extracts the best-effort originating IP. X-Forwarded-For is
|
|
// respected but only the leftmost entry is trusted; do not rely on this
|
|
// behind untrusted proxies (configure a real reverse proxy for production).
|
|
func clientIP(r *http.Request) string {
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if i := strings.IndexByte(xff, ','); i >= 0 {
|
|
return strings.TrimSpace(xff[:i])
|
|
}
|
|
return strings.TrimSpace(xff)
|
|
}
|
|
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
|
return strings.TrimSpace(xr)
|
|
}
|
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return ip
|
|
}
|
|
|
|
// Package-level singletons (best-effort; DoS hardening is a defense-in-depth
|
|
// measure, not a primary security boundary).
|
|
var (
|
|
// Allow up to 10 tx submissions/s per IP with a burst of 20.
|
|
submitTxLimiter = newIPRateLimiter(10, 20)
|
|
// Inbox / contacts polls: allow 20/s per IP with a burst of 40.
|
|
readLimiter = newIPRateLimiter(20, 40)
|
|
)
|
|
|
|
// withSubmitTxGuards composes size + rate-limit protection around apiSubmitTx.
|
|
// It rejects oversize bodies and flooding IPs before any JSON decoding happens,
|
|
// so an attacker cannot consume CPU decoding 10 MB of nothing.
|
|
func withSubmitTxGuards(inner http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost {
|
|
ip := clientIP(r)
|
|
if !submitTxLimiter.Allow(ip) {
|
|
w.Header().Set("Retry-After", "2")
|
|
jsonErr(w, fmt.Errorf("rate limit exceeded"), http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, MaxTxRequestBytes)
|
|
}
|
|
inner.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
// withReadLimit is the equivalent for inbox / contacts read endpoints. It only
|
|
// applies rate limiting (no body size cap needed for GETs).
|
|
func withReadLimit(inner http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !readLimiter.Allow(clientIP(r)) {
|
|
w.Header().Set("Retry-After", "1")
|
|
jsonErr(w, fmt.Errorf("rate limit exceeded"), http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
inner.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
// ── Access-token gating ──────────────────────────────────────────────────────
|
|
//
|
|
// Single-node operators often want their node "private" — only they (and
|
|
// apps they explicitly configure) can submit transactions or read state
|
|
// through its HTTP API. Two flavours:
|
|
//
|
|
// 1. Semi-public: anyone can GET chain state (netstats, blocks, txs),
|
|
// but only clients with a valid Bearer token can POST /api/tx or
|
|
// send WS `submit_tx`. Default when `--api-token` is set.
|
|
//
|
|
// 2. Fully private: EVERY endpoint requires the token. Use `--api-private`
|
|
// together with `--api-token`. Good for a personal node whose data
|
|
// the operator considers sensitive (e.g. who they're chatting with).
|
|
//
|
|
// Both modes gate via a shared secret (HTTP `Authorization: Bearer <token>`
|
|
// or WS hello-time check). There's no multi-user access control here —
|
|
// operators wanting role-based auth should front the node with their
|
|
// usual reverse-proxy auth (mTLS, OAuth, basicauth). This is the
|
|
// "just keep randos off my box" level of access control.
|
|
|
|
var (
|
|
// accessToken is the shared secret required by gated endpoints.
|
|
// Empty = gating disabled.
|
|
accessToken string
|
|
// accessPrivate gates READ endpoints too, not just write. Only
|
|
// applies when accessToken is non-empty.
|
|
accessPrivate bool
|
|
)
|
|
|
|
// SetAPIAccess configures the token + private-mode flags. Called once at
|
|
// node startup. Pass empty token to disable gating entirely (public node).
|
|
func SetAPIAccess(token string, private bool) {
|
|
accessToken = strings.TrimSpace(token)
|
|
accessPrivate = private
|
|
}
|
|
|
|
// checkAccessToken returns nil if the request carries the configured
|
|
// Bearer token, or an error describing the problem. When accessToken
|
|
// is empty (public node) it always returns nil.
|
|
//
|
|
// Accepts `Authorization: Bearer <token>` or the `?token=<token>` query
|
|
// parameter — the latter lets operators open `https://node.example.com/
|
|
// api/netstats?token=...` directly in a browser while testing a private
|
|
// node without needing custom headers.
|
|
func checkAccessToken(r *http.Request) error {
|
|
if accessToken == "" {
|
|
return nil
|
|
}
|
|
// Header takes precedence.
|
|
auth := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(auth, "Bearer ") {
|
|
if strings.TrimPrefix(auth, "Bearer ") == accessToken {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("invalid bearer token")
|
|
}
|
|
if q := r.URL.Query().Get("token"); q != "" {
|
|
if q == accessToken {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("invalid ?token= query param")
|
|
}
|
|
return fmt.Errorf("missing bearer token; pass Authorization: Bearer <token> or ?token=...")
|
|
}
|
|
|
|
// withWriteTokenGuard wraps a write handler (submit tx, etc.) so it
|
|
// requires the access token whenever one is configured. Independent of
|
|
// accessPrivate — writes are ALWAYS gated when a token is set.
|
|
func withWriteTokenGuard(inner http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := checkAccessToken(r); err != nil {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`)
|
|
jsonErr(w, err, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
inner.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
// withReadTokenGuard gates a read endpoint. Only enforced when
|
|
// accessPrivate is true; otherwise falls through to the inner handler
|
|
// so public nodes keep working as before.
|
|
func withReadTokenGuard(inner http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if accessToken != "" && accessPrivate {
|
|
if err := checkAccessToken(r); err != nil {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="dchain"`)
|
|
jsonErr(w, err, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
inner.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
// AccessTokenForWS returns the current token + private-mode flag so the
|
|
// WS hub can apply the same policy to websocket submit_tx ops and, when
|
|
// private, to connection upgrades themselves.
|
|
func AccessTokenForWS() (token string, private bool) {
|
|
return accessToken, accessPrivate
|
|
}
|