chore: initial commit for v0.0.1
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
This commit is contained in:
299
node/api_guards.go
Normal file
299
node/api_guards.go
Normal file
@@ -0,0 +1,299 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user