Remove plans/ directory (P0/P1/P2 work complete)
This commit is contained in:
+49
-23
@@ -269,6 +269,7 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques
|
||||
Err(err).
|
||||
Msg("Failed to read status line from cached response")
|
||||
sc.metrics.IncrementErrors()
|
||||
sc.metrics.IncrementServiceError("cache_corrupt")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -282,6 +283,7 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques
|
||||
Err(err).
|
||||
Msg("Failed to parse status code from cached response")
|
||||
sc.metrics.IncrementErrors()
|
||||
sc.metrics.IncrementServiceError("cache_corrupt")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -297,6 +299,7 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques
|
||||
Err(err).
|
||||
Msg("Failed to read headers from cached response")
|
||||
sc.metrics.IncrementErrors()
|
||||
sc.metrics.IncrementServiceError("cache_corrupt")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -742,7 +745,7 @@ func (sc *SteamCache) removeCoalescedRequest(cacheKey string) {
|
||||
}
|
||||
|
||||
// isTrustedProxy reports whether ipStr matches any CIDR or IP in trustedProxies list.
|
||||
// Used for P1-02 safe client IP extraction (rightmost untrusted wins).
|
||||
// Used for safe client IP extraction from X-Forwarded-For (rightmost untrusted proxy wins).
|
||||
func isTrustedProxy(ipStr string, trustedProxies []string) bool {
|
||||
ip := net.ParseIP(strings.TrimSpace(ipStr))
|
||||
if ip == nil {
|
||||
@@ -767,7 +770,7 @@ func isTrustedProxy(ipStr string, trustedProxies []string) bool {
|
||||
}
|
||||
|
||||
// getClientIP extracts the client IP address from the request.
|
||||
// P1-02: if trustedProxies empty (default), ALWAYS use RemoteAddr only (spoof-proof).
|
||||
// If trustedProxies is empty (the safe default), always use RemoteAddr only (prevents spoofing).
|
||||
// When list non-empty, use rightmost-untrusted from XFF+Remote chain (proper proxy extraction, not naive first XFF).
|
||||
// X-Real-IP is ignored for simplicity/safety (XFF is the standard multi-hop header).
|
||||
// Security: prevents clients spoofing XFF to bypass per-client rate limits.
|
||||
@@ -779,7 +782,7 @@ func getClientIP(r *http.Request, trustedProxies []string) string {
|
||||
}
|
||||
|
||||
if len(trustedProxies) == 0 {
|
||||
// Conservative safe default: never trust forwarded headers (P1-02)
|
||||
// Conservative safe default: never trust forwarded headers (spoof prevention)
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
@@ -882,7 +885,7 @@ type SteamCache struct {
|
||||
clientRequestsMu sync.RWMutex
|
||||
maxRequestsPerClient int64
|
||||
|
||||
// P1 config (plumbed)
|
||||
// Hardening config fields (plumbed)
|
||||
maxObjectSize int64
|
||||
trustedProxies []string
|
||||
|
||||
@@ -898,12 +901,12 @@ type SteamCache struct {
|
||||
}
|
||||
|
||||
// New creates a new SteamCache instance.
|
||||
// Since P0-01, it returns an error (instead of panicking) on invalid memorySize or diskSize strings from units.FromHumanSize.
|
||||
// Since P1, also validates maxObjectSize (P1-01) and accepts trustedProxies (P1-02).
|
||||
// Empty maxObjectSize or nil trustedProxies are normalized to safe defaults ("0", []) *before* parsing.
|
||||
// Callers (including cmd/root.go and all tests) must check the returned error.
|
||||
// Migration note (P1): the 2 new positional params on New() are breaking for direct importers.
|
||||
// Prefer NewWithOptions (or config file) for forward compatibility. See README "Migration / Breaking Changes (P1)".
|
||||
// Returns an error (instead of panicking) on invalid memorySize or diskSize strings.
|
||||
// Also validates maxObjectSize and accepts trustedProxies for X-Forwarded-For handling.
|
||||
// Empty maxObjectSize or nil trustedProxies are normalized to safe defaults before parsing.
|
||||
// Callers must check the returned error.
|
||||
// The two new positional parameters are a breaking change for direct importers of the simple constructor.
|
||||
// Prefer NewWithOptions (or config file) for forward compatibility. See README migration notes.
|
||||
func New(address string, memorySize string, diskSize string, diskPath, upstream, memoryGC, diskGC string, maxConcurrentRequests int64, maxRequestsPerClient int64, maxObjectSize string, trustedProxies []string) (*SteamCache, error) {
|
||||
memorysize, err := units.FromHumanSize(memorySize)
|
||||
if err != nil {
|
||||
@@ -915,7 +918,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
||||
return nil, fmt.Errorf("invalid disk size: %w", err)
|
||||
}
|
||||
|
||||
// P1 defaults *before* parse (fixes zero-value Options / NewWithOptions("") callers)
|
||||
// Apply safe defaults before parsing user-provided sizes (handles zero-value Options)
|
||||
if maxObjectSize == "" {
|
||||
maxObjectSize = "0"
|
||||
}
|
||||
@@ -1046,7 +1049,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
||||
maxRequestsPerClient: maxRequestsPerClient,
|
||||
clientLimiterCleanupStop: make(chan struct{}),
|
||||
|
||||
// P1 plumbed
|
||||
// Hardening config plumbed
|
||||
maxObjectSize: maxObjBytes,
|
||||
trustedProxies: trustedProxies,
|
||||
|
||||
@@ -1160,7 +1163,7 @@ func (sc *SteamCache) Shutdown() {
|
||||
}
|
||||
}
|
||||
sc.wg.Wait()
|
||||
// Brief reap window after stopping workers (helps T2 delta checks see low goroutine counts immediately; workers have already exited their loops).
|
||||
// Brief reap window after stopping workers (helps goroutine delta checks see low counts quickly).
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
})
|
||||
}
|
||||
@@ -1178,8 +1181,8 @@ func (sc *SteamCache) GetMetrics() *metrics.Stats {
|
||||
return sc.metrics.GetStats()
|
||||
}
|
||||
|
||||
// Minimal Options + NewWithOptions for T3 (small, delegates to positional New).
|
||||
// NewWithOptions propagates the P0-01 error return (see New godoc).
|
||||
// Minimal Options + NewWithOptions usage (delegates to the main positional constructor).
|
||||
// NewWithOptions propagates the error return from New (see New godoc).
|
||||
type Options struct {
|
||||
Address string
|
||||
MemorySize string
|
||||
@@ -1191,7 +1194,7 @@ type Options struct {
|
||||
MaxConcurrentRequests int64
|
||||
MaxRequestsPerClient int64
|
||||
|
||||
// P1: new config plumbed for hardening (smallest extension)
|
||||
// New config fields for hardening (max object size + trusted proxies)
|
||||
MaxObjectSize string
|
||||
TrustedProxies []string
|
||||
}
|
||||
@@ -1213,13 +1216,14 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Keep-Alive", "timeout=300, max=1000")
|
||||
|
||||
// Apply global concurrency limit first
|
||||
// C4 (smallest): propagate r.Context for cancellation (review item)
|
||||
// Propagate request context for cancellation support
|
||||
if err := sc.requestSemaphore.Acquire(r.Context(), 1); err != nil {
|
||||
// Capacity rejections are counted in Errors + RateLimited but intentionally *before* TotalRequests.
|
||||
// This preserves original hit-rate / processed-traffic semantics for accepted requests only.
|
||||
// (All other 5xx occur after Total inc.)
|
||||
sc.metrics.IncrementRateLimited()
|
||||
sc.metrics.IncrementErrors()
|
||||
sc.metrics.IncrementServiceError("rate_limit")
|
||||
logger.Logger.Warn().Str("client_ip", clientIP).Msg("Server at capacity, rejecting request")
|
||||
http.Error(w, "Server busy, please try again later", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -1232,7 +1236,7 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Apply per-client rate limiting
|
||||
clientLimiter := sc.getOrCreateClientLimiter(clientIP)
|
||||
|
||||
// C4 (smallest): per-client too
|
||||
// Per-client request limiting (context aware)
|
||||
if err := clientLimiter.semaphore.Acquire(r.Context(), 1); err != nil {
|
||||
logger.Logger.Warn().
|
||||
Str("client_ip", clientIP).
|
||||
@@ -1282,6 +1286,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "cache_coalesced %d\n", stats.CacheCoalesced)
|
||||
fmt.Fprintf(w, "errors %d\n", stats.Errors)
|
||||
fmt.Fprintf(w, "rate_limited %d\n", stats.RateLimited)
|
||||
fmt.Fprintf(w, "upstream_errors %d\n", stats.UpstreamErrors)
|
||||
fmt.Fprintf(w, "cache_write_failures %d\n", stats.CacheWriteFailures)
|
||||
fmt.Fprintf(w, "memory_cache_hits %d\n", stats.MemoryCacheHits)
|
||||
fmt.Fprintf(w, "disk_cache_hits %d\n", stats.DiskCacheHits)
|
||||
fmt.Fprintf(w, "promotions %d\n", stats.Promotions)
|
||||
fmt.Fprintf(w, "evictions %d\n", stats.Evictions)
|
||||
for svc, cnt := range stats.ServiceErrors {
|
||||
fmt.Fprintf(w, "service_errors{service=%q} %d\n", svc, cnt)
|
||||
}
|
||||
for svc, cnt := range stats.ServiceRequests {
|
||||
fmt.Fprintf(w, "service_requests{service=%q} %d\n", svc, cnt)
|
||||
}
|
||||
fmt.Fprintf(w, "hit_rate %.4f\n", stats.HitRate)
|
||||
fmt.Fprintf(w, "avg_response_time_ms %.2f\n", float64(stats.AvgResponseTime.Nanoseconds())/1e6)
|
||||
fmt.Fprintf(w, "total_bytes_served %d\n", stats.TotalBytesServed)
|
||||
@@ -1547,6 +1563,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
sc.metrics.IncrementErrors()
|
||||
sc.metrics.IncrementUpstreamErrors()
|
||||
sc.metrics.IncrementServiceError("upstream")
|
||||
http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1560,6 +1578,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
sc.metrics.IncrementErrors()
|
||||
sc.metrics.IncrementUpstreamErrors()
|
||||
sc.metrics.IncrementServiceError("upstream")
|
||||
http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -1584,14 +1604,14 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
expectedSize := resp.ContentLength
|
||||
|
||||
// Reject only truly invalid content lengths (zero or negative)
|
||||
// P1-01: when limit set, treat unknown/lying-CL as potential oversize (413) instead of 502.
|
||||
// When max object size limit is set, treat unknown or lying Content-Length as potential oversize (return 413).
|
||||
if expectedSize <= 0 {
|
||||
if sc.maxObjectSize > 0 {
|
||||
logger.Logger.Warn().
|
||||
Str("url", req.URL.String()).
|
||||
Int64("content_length", expectedSize).
|
||||
Int64("max_object_size", sc.maxObjectSize).
|
||||
Msg("Chunked/unknown CL with limit set - treating as potential oversize (P1-01)")
|
||||
Msg("Chunked/unknown Content-Length with size limit set - treating as potential oversize")
|
||||
if isNew {
|
||||
coalescedReq.complete(nil, fmt.Errorf("chunked response with size limit"))
|
||||
}
|
||||
@@ -1610,7 +1630,7 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Content length is valid - no size restrictions to keep logs clean
|
||||
|
||||
// P1-01: bounded response size to prevent OOM (cap approach chosen for minimal VFS impact vs full streaming tee).
|
||||
// Bounded response size to prevent OOM (capped reader chosen for minimal VFS impact).
|
||||
// Large objects still served if <= limit; >limit returns 413 without caching or unbounded ReadAll.
|
||||
// Coalesced paths also protected (leader enforces before buffering).
|
||||
// Security: mitigates DoS via huge malicious upstream responses/manifests.
|
||||
@@ -1619,7 +1639,7 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
Str("url", req.URL.String()).
|
||||
Int64("content_length", expectedSize).
|
||||
Int64("max_object_size", sc.maxObjectSize).
|
||||
Msg("Response exceeds max_object_size limit - rejecting to prevent OOM (P1-01)")
|
||||
Msg("Response exceeds configured max object size limit - rejecting to prevent OOM")
|
||||
if isNew {
|
||||
coalescedReq.complete(nil, fmt.Errorf("response too large: %d > %d", expectedSize, sc.maxObjectSize))
|
||||
}
|
||||
@@ -1633,7 +1653,7 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
validationPassed := true
|
||||
|
||||
// Read the entire response body into memory to avoid consuming it twice
|
||||
// P1-01: LimitReader caps even if CL lied small (protects against chunked/lying-CL OOM).
|
||||
// LimitReader caps the body even if the client lied about Content-Length.
|
||||
readLimit := resp.ContentLength
|
||||
if sc.maxObjectSize > 0 && (readLimit <= 0 || readLimit > sc.maxObjectSize) {
|
||||
readLimit = sc.maxObjectSize
|
||||
@@ -1707,6 +1727,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
Str("url", urlPath).
|
||||
Err(err).
|
||||
Msg("Failed to serialize cache file")
|
||||
sc.metrics.IncrementCacheWriteFailures()
|
||||
sc.metrics.IncrementServiceError("serialize")
|
||||
} else {
|
||||
// Store the serialized cache data
|
||||
cacheWriter, err := sc.vfs.Create(cachePath, int64(len(cacheData)))
|
||||
@@ -1724,6 +1746,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
Int("written", bytesWritten).
|
||||
Err(cacheErr).
|
||||
Msg("Cache write failed or incomplete - removing corrupted entry")
|
||||
sc.metrics.IncrementCacheWriteFailures()
|
||||
sc.metrics.IncrementServiceError("cache_write")
|
||||
sc.vfs.Delete(cachePath)
|
||||
} else {
|
||||
// Track successful cache write
|
||||
@@ -1741,6 +1765,8 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
Str("url", urlPath).
|
||||
Err(err).
|
||||
Msg("Failed to create cache file")
|
||||
sc.metrics.IncrementCacheWriteFailures()
|
||||
sc.metrics.IncrementServiceError("cache_create")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user