Files
dchain/node/api_guards.go
vsecoder 7e7393e4f8 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
2026-04-17 14:16:44 +03:00

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
}