843772e9f7
- Updated .golangci.yml to enable default linters and refine suppression rules, enhancing code quality visibility. - Improved error handling in cmd/root.go by explicitly discarding low-value error messages during fatal exits for consistency with errcheck posture. - Added best-effort error handling in various locations across the codebase, ensuring that non-critical errors are logged without affecting overall functionality. - Introduced a new writeMetricsText function to streamline metrics output, improving code clarity and maintainability.
1851 lines
59 KiB
Go
1851 lines
59 KiB
Go
// steamcache/steamcache.go
|
|
package steamcache
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"s1d3sw1ped/steamcache2/steamcache/logger"
|
|
"s1d3sw1ped/steamcache2/steamcache/metrics"
|
|
"s1d3sw1ped/steamcache2/vfs"
|
|
"s1d3sw1ped/steamcache2/vfs/cache"
|
|
"s1d3sw1ped/steamcache2/vfs/disk"
|
|
"s1d3sw1ped/steamcache2/vfs/gc"
|
|
"s1d3sw1ped/steamcache2/vfs/memory"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/docker/go-units"
|
|
"golang.org/x/sync/semaphore"
|
|
)
|
|
|
|
// ServiceConfig defines configuration for a cacheable service
|
|
type ServiceConfig struct {
|
|
Name string `json:"name"` // Service name (e.g., "steam", "epic", "origin")
|
|
Prefix string `json:"prefix"` // Cache key prefix (e.g., "steam", "epic")
|
|
UserAgents []string `json:"user_agents"` // User-Agent patterns to match
|
|
compiled []*regexp.Regexp // Compiled regex patterns (internal use)
|
|
}
|
|
|
|
// ServiceManager manages service configurations
|
|
type ServiceManager struct {
|
|
services map[string]*ServiceConfig
|
|
mutex sync.RWMutex
|
|
}
|
|
|
|
// NewServiceManager creates a new service manager with default Steam configuration
|
|
func NewServiceManager() *ServiceManager {
|
|
sm := &ServiceManager{
|
|
services: make(map[string]*ServiceConfig),
|
|
}
|
|
|
|
// Add default Steam service configuration
|
|
steamConfig := &ServiceConfig{
|
|
Name: "steam",
|
|
Prefix: "steam",
|
|
UserAgents: []string{
|
|
`Valve/Steam HTTP Client 1\.0`,
|
|
`SteamClient`,
|
|
`Steam`,
|
|
},
|
|
}
|
|
_ = sm.AddService(steamConfig) // error impossible: hardcoded patterns are valid regexes (user-provided services validated in AddService)
|
|
|
|
return sm
|
|
}
|
|
|
|
// AddService adds or updates a service configuration
|
|
func (sm *ServiceManager) AddService(config *ServiceConfig) error {
|
|
sm.mutex.Lock()
|
|
defer sm.mutex.Unlock()
|
|
|
|
// Compile regex patterns
|
|
compiled := make([]*regexp.Regexp, 0, len(config.UserAgents))
|
|
for _, pattern := range config.UserAgents {
|
|
regex, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid regex pattern %q for service %s: %w", pattern, config.Name, err)
|
|
}
|
|
compiled = append(compiled, regex)
|
|
}
|
|
|
|
config.compiled = compiled
|
|
sm.services[config.Name] = config
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetService returns a service configuration by name
|
|
func (sm *ServiceManager) GetService(name string) (*ServiceConfig, bool) {
|
|
sm.mutex.RLock()
|
|
defer sm.mutex.RUnlock()
|
|
|
|
service, exists := sm.services[name]
|
|
return service, exists
|
|
}
|
|
|
|
// DetectService detects which service a request belongs to based on User-Agent
|
|
func (sm *ServiceManager) DetectService(userAgent string) (*ServiceConfig, bool) {
|
|
sm.mutex.RLock()
|
|
defer sm.mutex.RUnlock()
|
|
|
|
for _, service := range sm.services {
|
|
for _, regex := range service.compiled {
|
|
if regex.MatchString(userAgent) {
|
|
return service, true
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// ListServices returns all configured services
|
|
func (sm *ServiceManager) ListServices() []*ServiceConfig {
|
|
sm.mutex.RLock()
|
|
defer sm.mutex.RUnlock()
|
|
|
|
services := make([]*ServiceConfig, 0, len(sm.services))
|
|
for _, service := range sm.services {
|
|
services = append(services, service)
|
|
}
|
|
|
|
return services
|
|
}
|
|
|
|
// Cache file format structures
|
|
const (
|
|
CacheFileMagic = "SC2C" // SteamCache2 Cache
|
|
)
|
|
|
|
// CacheFileFormat represents the complete cache file structure
|
|
type CacheFileFormat struct {
|
|
ContentHash string // SHA256 hash of the response body (internal)
|
|
ResponseSize int64 // Size of the entire HTTP response
|
|
Response []byte // The entire HTTP response as raw bytes
|
|
}
|
|
|
|
// serializeRawResponse serializes a raw HTTP response into our text-based cache format
|
|
// upstreamHash and upstreamAlgo are used for verification during download but not stored
|
|
func serializeRawResponse(rawResponse []byte) ([]byte, error) {
|
|
// Extract body from raw response for hash calculation
|
|
bodyStart := bytes.Index(rawResponse, []byte("\r\n\r\n"))
|
|
if bodyStart == -1 {
|
|
return nil, fmt.Errorf("invalid HTTP response format: no body separator found")
|
|
}
|
|
bodyStart += 4 // Skip the \r\n\r\n
|
|
bodyData := rawResponse[bodyStart:]
|
|
|
|
// Always calculate our internal SHA256 hash
|
|
contentHash := calculateSHA256(bodyData)
|
|
|
|
// Create text-based cache file
|
|
var buf bytes.Buffer
|
|
|
|
// First line: magic number, content hash, response size
|
|
headerLine := fmt.Sprintf("%s %s %d\n", CacheFileMagic, contentHash, len(rawResponse))
|
|
buf.WriteString(headerLine)
|
|
|
|
// Rest of the file: raw HTTP response
|
|
buf.Write(rawResponse)
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// deserializeCacheFile deserializes our text-based cache format and returns both metadata and raw response
|
|
func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
|
if len(data) < 4 {
|
|
return nil, fmt.Errorf("cache file too short")
|
|
}
|
|
|
|
// Find the first newline to separate header from content
|
|
newlineIndex := bytes.IndexByte(data, '\n')
|
|
if newlineIndex == -1 {
|
|
return nil, fmt.Errorf("invalid cache file format: no header line found")
|
|
}
|
|
|
|
// Parse header line: "SC2C <hash> <size>"
|
|
headerLine := string(data[:newlineIndex])
|
|
parts := strings.Fields(headerLine)
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("invalid header format: expected 3 fields, got %d", len(parts))
|
|
}
|
|
|
|
// Check magic number
|
|
if parts[0] != CacheFileMagic {
|
|
return nil, fmt.Errorf("invalid cache file magic number: %s", parts[0])
|
|
}
|
|
|
|
// Parse content hash
|
|
contentHash := parts[1]
|
|
if len(contentHash) != 64 {
|
|
return nil, fmt.Errorf("invalid content hash length: expected 64, got %d", len(contentHash))
|
|
}
|
|
|
|
// Parse response size
|
|
responseSize, err := strconv.ParseInt(parts[2], 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid response size: %w", err)
|
|
}
|
|
|
|
// Extract raw response (everything after the header line)
|
|
rawResponse := data[newlineIndex+1:]
|
|
|
|
// Verify response size
|
|
if int64(len(rawResponse)) != responseSize {
|
|
return nil, fmt.Errorf("response size mismatch: expected %d, got %d",
|
|
responseSize, len(rawResponse))
|
|
}
|
|
|
|
// Extract body from response for hash verification
|
|
bodyStart := bytes.Index(rawResponse, []byte("\r\n\r\n"))
|
|
if bodyStart == -1 {
|
|
return nil, fmt.Errorf("invalid HTTP response format: no body separator found")
|
|
}
|
|
bodyStart += 4 // Skip the \r\n\r\n
|
|
bodyData := rawResponse[bodyStart:]
|
|
|
|
// Verify our internal SHA256 hash
|
|
calculatedSHA256 := calculateSHA256(bodyData)
|
|
if calculatedSHA256 != contentHash {
|
|
return nil, fmt.Errorf("content hash mismatch: expected %s, got %s",
|
|
contentHash, calculatedSHA256)
|
|
}
|
|
|
|
// Create cache file structure
|
|
cacheFile := &CacheFileFormat{
|
|
ContentHash: contentHash,
|
|
ResponseSize: responseSize,
|
|
Response: rawResponse,
|
|
}
|
|
|
|
return cacheFile, nil
|
|
}
|
|
|
|
// reconstructRawResponse reconstructs the exact HTTP response as received from upstream
|
|
func (sc *SteamCache) reconstructRawResponse(resp *http.Response, bodyData []byte) []byte {
|
|
var responseBuffer bytes.Buffer
|
|
|
|
// Write status line exactly as it would appear from upstream
|
|
responseBuffer.WriteString(fmt.Sprintf("HTTP/1.1 %d %s\r\n", resp.StatusCode, http.StatusText(resp.StatusCode)))
|
|
|
|
// Write headers in the exact order and format as received
|
|
for k, vv := range resp.Header {
|
|
for _, v := range vv {
|
|
responseBuffer.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
|
|
}
|
|
}
|
|
responseBuffer.WriteString("\r\n") // End of headers
|
|
|
|
// Write body
|
|
responseBuffer.Write(bodyData)
|
|
|
|
return responseBuffer.Bytes()
|
|
}
|
|
|
|
// streamCachedResponse streams the raw HTTP response bytes directly to the client
|
|
// Supports Range requests by serving partial content from the cached full file
|
|
func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Request, cacheFile *CacheFileFormat, cacheKey, clientIP string, tstart time.Time) {
|
|
// Parse the HTTP response to extract headers for our own headers
|
|
responseReader := bytes.NewReader(cacheFile.Response)
|
|
|
|
// Read the status line
|
|
statusLine, err := readLine(responseReader)
|
|
if err != nil {
|
|
logger.Logger.Error().
|
|
Str("key", cacheKey).
|
|
Str("url", r.URL.String()).
|
|
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
|
|
}
|
|
|
|
// Parse status code from status line
|
|
var statusCode int
|
|
if _, err := fmt.Sscanf(statusLine, "HTTP/1.1 %d", &statusCode); err != nil {
|
|
logger.Logger.Error().
|
|
Str("key", cacheKey).
|
|
Str("url", r.URL.String()).
|
|
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
|
|
}
|
|
|
|
// Read headers
|
|
headers := make(map[string][]string)
|
|
for {
|
|
line, err := readLine(responseReader)
|
|
if err != nil {
|
|
logger.Logger.Error().
|
|
Str("key", cacheKey).
|
|
Str("url", r.URL.String()).
|
|
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
|
|
}
|
|
|
|
// Empty line indicates end of headers
|
|
if line == "" {
|
|
break
|
|
}
|
|
|
|
// Parse header line
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) == 2 {
|
|
key := strings.TrimSpace(parts[0])
|
|
value := strings.TrimSpace(parts[1])
|
|
headers[key] = append(headers[key], value)
|
|
}
|
|
}
|
|
|
|
// Get the body data (everything after headers)
|
|
bodyStart := responseReader.Size() - int64(responseReader.Len())
|
|
bodyData := cacheFile.Response[bodyStart:]
|
|
|
|
// Handle Range requests
|
|
rangeHeader := r.Header.Get("Range")
|
|
if rangeHeader != "" {
|
|
// Parse the range request
|
|
start, end, totalSize, valid := parseRangeHeader(rangeHeader, int64(len(bodyData)))
|
|
if !valid {
|
|
// Invalid range - return 416 Range Not Satisfiable
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", len(bodyData)))
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
|
|
// Extract the requested range from the body
|
|
rangeData := bodyData[start : end+1]
|
|
|
|
// Set appropriate headers for partial content
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(rangeData)))
|
|
w.Header().Set("Accept-Ranges", "bytes")
|
|
|
|
// Copy other headers (excluding Content-Length which we set above)
|
|
for k, vv := range headers {
|
|
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
|
|
continue
|
|
}
|
|
if strings.ToLower(k) == "content-length" {
|
|
continue // We set this above for the range
|
|
}
|
|
for _, v := range vv {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
|
|
// Add our own headers
|
|
w.Header().Set("X-LanCache-Status", "HIT")
|
|
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
|
|
|
// Write 206 Partial Content status
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
|
|
// Send the range data
|
|
_, _ = w.Write(rangeData) // client write error ignored (disconnect during range body send is not actionable)
|
|
|
|
logger.Logger.Info().
|
|
Str("cache_key", cacheKey).
|
|
Str("url", r.URL.String()).
|
|
Str("host", r.Host).
|
|
Str("client_ip", clientIP).
|
|
Str("cache_status", "HIT").
|
|
Str("range", fmt.Sprintf("%d-%d/%d", start, end, totalSize)).
|
|
Int64("range_size", end-start+1).
|
|
Dur("response_time", time.Since(tstart)).
|
|
Msg("cache request")
|
|
|
|
return
|
|
}
|
|
|
|
// No range request - serve the full file
|
|
// Set response headers (excluding hop-by-hop headers)
|
|
for k, vv := range headers {
|
|
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
|
|
continue
|
|
}
|
|
for _, v := range vv {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
|
|
// Add our own headers
|
|
w.Header().Set("X-LanCache-Status", "HIT")
|
|
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
|
|
|
// Write status code
|
|
w.WriteHeader(statusCode)
|
|
|
|
// Stream the full response body
|
|
_, _ = w.Write(bodyData) // client write error ignored (disconnect during full cached body send is not actionable)
|
|
|
|
logger.Logger.Info().
|
|
Str("cache_key", cacheKey).
|
|
Str("url", r.URL.String()).
|
|
Str("host", r.Host).
|
|
Str("client_ip", clientIP).
|
|
Str("cache_status", "HIT").
|
|
Int64("file_size", int64(len(bodyData))).
|
|
Dur("response_time", time.Since(tstart)).
|
|
Msg("cache request")
|
|
}
|
|
|
|
// readLine reads a line from the reader, removing \r\n
|
|
func readLine(reader *bytes.Reader) (string, error) {
|
|
var line []byte
|
|
for {
|
|
b, err := reader.ReadByte()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if b == '\n' {
|
|
// Remove \r if present
|
|
if len(line) > 0 && line[len(line)-1] == '\r' {
|
|
line = line[:len(line)-1]
|
|
}
|
|
return string(line), nil
|
|
}
|
|
line = append(line, b)
|
|
}
|
|
}
|
|
|
|
// parseRangeHeader parses a Range header and returns start, end, totalSize, and validity
|
|
// Supports formats like "bytes=0-1023", "bytes=1024-", "bytes=-500"
|
|
func parseRangeHeader(rangeHeader string, totalSize int64) (start, end, total int64, valid bool) {
|
|
// Remove "bytes=" prefix
|
|
if !strings.HasPrefix(strings.ToLower(rangeHeader), "bytes=") {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
|
|
rangeSpec := strings.TrimSpace(rangeHeader[6:]) // Remove "bytes="
|
|
|
|
// Handle single range (we don't support multiple ranges)
|
|
if strings.Contains(rangeSpec, ",") {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
|
|
// Parse the range
|
|
if strings.Contains(rangeSpec, "-") {
|
|
parts := strings.Split(rangeSpec, "-")
|
|
if len(parts) != 2 {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
|
|
startStr := strings.TrimSpace(parts[0])
|
|
endStr := strings.TrimSpace(parts[1])
|
|
|
|
var start, end int64
|
|
var err error
|
|
|
|
if startStr == "" {
|
|
// Suffix range: "-500" means last 500 bytes
|
|
if endStr == "" {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
suffix, err := strconv.ParseInt(endStr, 10, 64)
|
|
if err != nil || suffix <= 0 {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
start = totalSize - suffix
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end = totalSize - 1
|
|
} else if endStr == "" {
|
|
// Open range: "1024-" means from 1024 to end
|
|
start, err = strconv.ParseInt(startStr, 10, 64)
|
|
if err != nil || start < 0 {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
end = totalSize - 1
|
|
} else {
|
|
// Closed range: "0-1023"
|
|
start, err = strconv.ParseInt(startStr, 10, 64)
|
|
if err != nil || start < 0 {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
end, err = strconv.ParseInt(endStr, 10, 64)
|
|
if err != nil || end < start {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
}
|
|
|
|
// Validate bounds
|
|
if start >= totalSize || end >= totalSize || start > end {
|
|
return 0, 0, totalSize, false
|
|
}
|
|
|
|
return start, end, totalSize, true
|
|
}
|
|
|
|
return 0, 0, totalSize, false
|
|
}
|
|
|
|
// generateURLHash creates a SHA256 hash of the entire URL path for cache key
|
|
func generateURLHash(urlPath string) (string, error) {
|
|
// Validate input to prevent cache key pollution
|
|
if urlPath == "" {
|
|
return "", fmt.Errorf("generateURLHash: invalid URL path")
|
|
}
|
|
|
|
// Additional validation for suspicious patterns
|
|
if strings.Contains(urlPath, "..") || strings.Contains(urlPath, "//") {
|
|
return "", fmt.Errorf("generateURLHash: invalid URL path")
|
|
}
|
|
|
|
hash := sha256.Sum256([]byte(urlPath))
|
|
return hex.EncodeToString(hash[:]), nil
|
|
}
|
|
|
|
// calculateSHA256 calculates SHA256 hash of the given data
|
|
func calculateSHA256(data []byte) string {
|
|
hasher := sha256.New()
|
|
hasher.Write(data)
|
|
return hex.EncodeToString(hasher.Sum(nil))
|
|
}
|
|
|
|
// validateURLPath validates URL path for security concerns
|
|
func validateURLPath(urlPath string) error {
|
|
if urlPath == "" {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
|
|
// Check for directory traversal attempts
|
|
if strings.Contains(urlPath, "..") {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
|
|
// Check for double slashes (potential path manipulation)
|
|
if strings.Contains(urlPath, "//") {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
|
|
// Check for suspicious characters
|
|
if strings.ContainsAny(urlPath, "<>\"'&") {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
|
|
// Check for reasonable length (prevent DoS)
|
|
if len(urlPath) > 2048 {
|
|
return fmt.Errorf("validateURLPath: invalid URL path")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// verifyCompleteFile verifies that we received the complete file by checking Content-Length
|
|
// Returns true if the file is complete, false if it's incomplete (allowing retry)
|
|
func (sc *SteamCache) verifyCompleteFile(bodyData []byte, resp *http.Response, urlPath string, cacheKey string) bool {
|
|
// Check if we have a Content-Length header to verify against
|
|
if resp.ContentLength > 0 {
|
|
receivedBytes := int64(len(bodyData))
|
|
if receivedBytes != resp.ContentLength {
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Int64("received_bytes", receivedBytes).
|
|
Int64("expected_bytes", resp.ContentLength).
|
|
Msg("File size mismatch - incomplete download detected")
|
|
return false
|
|
}
|
|
|
|
logger.Logger.Debug().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Int64("file_size", receivedBytes).
|
|
Msg("File completeness verified")
|
|
} else {
|
|
// No Content-Length header - we can't verify completeness
|
|
// This is common with chunked transfer encoding
|
|
// We don't cache chunked content to avoid risk of incomplete data
|
|
logger.Logger.Info().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Int("received_bytes", len(bodyData)).
|
|
Msg("No Content-Length header - passing through without caching")
|
|
return false // Don't cache chunked content
|
|
}
|
|
|
|
// Basic check: ensure we got some content
|
|
if len(bodyData) == 0 {
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Msg("Empty file received")
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// detectService detects which service a request belongs to based on User-Agent
|
|
func (sc *SteamCache) detectService(r *http.Request) (*ServiceConfig, bool) {
|
|
userAgent := r.Header.Get("User-Agent")
|
|
if userAgent == "" {
|
|
return nil, false
|
|
}
|
|
|
|
return sc.serviceManager.DetectService(userAgent)
|
|
}
|
|
|
|
// generateServiceCacheKey creates a cache key from the URL path using SHA256
|
|
// The prefix indicates which service the request came from (detected via User-Agent)
|
|
// Input: /depot/1684171/chunk/0016cfc5019b8baa6026aa1cce93e685d6e06c6e, "steam"
|
|
// Output: steam/a1b2c3d4e5f678901234567890123456789012345678901234567890
|
|
func generateServiceCacheKey(urlPath string, servicePrefix string) (string, error) {
|
|
// Validate service prefix
|
|
if servicePrefix == "" {
|
|
return "", fmt.Errorf("generateServiceCacheKey: unsupported service")
|
|
}
|
|
|
|
// Generate hash for URL path
|
|
hash, err := generateURLHash(urlPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Create a SHA256 hash of the entire path for all service client requests
|
|
return servicePrefix + "/" + hash, nil
|
|
}
|
|
|
|
var hopByHopHeaders = map[string]struct{}{
|
|
"Connection": {},
|
|
"Keep-Alive": {},
|
|
"Proxy-Authenticate": {},
|
|
"Proxy-Authorization": {},
|
|
"TE": {},
|
|
"Trailer": {},
|
|
"Transfer-Encoding": {},
|
|
"Upgrade": {},
|
|
"Date": {},
|
|
"Server": {},
|
|
}
|
|
|
|
type clientLimiter struct {
|
|
semaphore *semaphore.Weighted
|
|
lastSeen time.Time
|
|
}
|
|
|
|
type coalescedRequest struct {
|
|
responseChan chan *http.Response
|
|
errorChan chan error
|
|
waitingCount atomic.Int32
|
|
done bool
|
|
mu sync.Mutex
|
|
// Buffered response data for coalesced clients
|
|
responseData []byte
|
|
responseHeaders http.Header
|
|
statusCode int
|
|
status string
|
|
// Broadcast signal for all waiters (closed by leader in complete)
|
|
doneCh chan struct{}
|
|
completionErr error
|
|
}
|
|
|
|
func newCoalescedRequest() *coalescedRequest {
|
|
cr := &coalescedRequest{
|
|
responseChan: make(chan *http.Response, 1),
|
|
errorChan: make(chan error, 1),
|
|
done: false,
|
|
responseHeaders: make(http.Header),
|
|
doneCh: make(chan struct{}),
|
|
}
|
|
cr.waitingCount.Store(1)
|
|
return cr
|
|
}
|
|
|
|
func (cr *coalescedRequest) addWaiter() {
|
|
cr.waitingCount.Add(1)
|
|
}
|
|
|
|
func (cr *coalescedRequest) complete(resp *http.Response, err error) {
|
|
cr.mu.Lock()
|
|
defer cr.mu.Unlock()
|
|
if cr.done {
|
|
return
|
|
}
|
|
cr.done = true
|
|
|
|
if err != nil {
|
|
cr.completionErr = err
|
|
select {
|
|
case cr.errorChan <- err:
|
|
default:
|
|
}
|
|
} else {
|
|
// Store response data for coalesced clients
|
|
if resp != nil {
|
|
cr.statusCode = resp.StatusCode
|
|
cr.status = resp.Status
|
|
// Copy headers (excluding hop-by-hop headers)
|
|
for k, vv := range resp.Header {
|
|
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; !skip {
|
|
cr.responseHeaders[k] = vv
|
|
}
|
|
}
|
|
}
|
|
select {
|
|
case cr.responseChan <- resp:
|
|
default:
|
|
}
|
|
}
|
|
// Broadcast to *all* waiters (thundering herd fix). Close is safe here because of the done guard above.
|
|
close(cr.doneCh)
|
|
}
|
|
|
|
// setResponseData stores the buffered response data for coalesced clients
|
|
func (cr *coalescedRequest) setResponseData(data []byte) {
|
|
cr.mu.Lock()
|
|
defer cr.mu.Unlock()
|
|
cr.responseData = make([]byte, len(data))
|
|
copy(cr.responseData, data)
|
|
}
|
|
|
|
// getOrCreateCoalescedRequest gets an existing coalesced request or creates a new one
|
|
func (sc *SteamCache) getOrCreateCoalescedRequest(cacheKey string) (*coalescedRequest, bool) {
|
|
sc.coalescedRequestsMu.Lock()
|
|
defer sc.coalescedRequestsMu.Unlock()
|
|
|
|
if cr, exists := sc.coalescedRequests[cacheKey]; exists {
|
|
cr.addWaiter()
|
|
return cr, false
|
|
}
|
|
|
|
cr := newCoalescedRequest()
|
|
sc.coalescedRequests[cacheKey] = cr
|
|
return cr, true
|
|
}
|
|
|
|
// removeCoalescedRequest removes a completed coalesced request
|
|
func (sc *SteamCache) removeCoalescedRequest(cacheKey string) {
|
|
sc.coalescedRequestsMu.Lock()
|
|
defer sc.coalescedRequestsMu.Unlock()
|
|
delete(sc.coalescedRequests, cacheKey)
|
|
}
|
|
|
|
// isTrustedProxy reports whether ipStr matches any CIDR or IP in trustedProxies list.
|
|
// 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 {
|
|
return false
|
|
}
|
|
for _, c := range trustedProxies {
|
|
c = strings.TrimSpace(c)
|
|
if c == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(c, "/") {
|
|
if p := net.ParseIP(c); p != nil && p.Equal(ip) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
if _, n, err := net.ParseCIDR(c); err == nil && n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getClientIP extracts the client IP address from the request.
|
|
// 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.
|
|
func getClientIP(r *http.Request, trustedProxies []string) string {
|
|
// Normalize remote
|
|
remoteIP := r.RemoteAddr
|
|
if host, _, err := net.SplitHostPort(remoteIP); err == nil {
|
|
remoteIP = host
|
|
}
|
|
|
|
if len(trustedProxies) == 0 {
|
|
// Conservative safe default: never trust forwarded headers (spoof prevention)
|
|
return remoteIP
|
|
}
|
|
|
|
// Build trust chain: XFF parts (left=original client) + direct remote (right=closest)
|
|
chain := []string{}
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
for _, p := range strings.Split(xff, ",") {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
chain = append(chain, t)
|
|
}
|
|
}
|
|
}
|
|
chain = append(chain, remoteIP)
|
|
|
|
// Walk from right (closest to server) to left; return first (rightmost) non-trusted = real client
|
|
for i := len(chain) - 1; i >= 0; i-- {
|
|
cand := chain[i]
|
|
if !isTrustedProxy(cand, trustedProxies) {
|
|
return cand
|
|
}
|
|
}
|
|
return remoteIP
|
|
}
|
|
|
|
// getOrCreateClientLimiter gets or creates a rate limiter for a client IP
|
|
func (sc *SteamCache) getOrCreateClientLimiter(clientIP string) *clientLimiter {
|
|
sc.clientRequestsMu.Lock()
|
|
defer sc.clientRequestsMu.Unlock()
|
|
|
|
limiter, exists := sc.clientRequests[clientIP]
|
|
if !exists || time.Since(limiter.lastSeen) > 5*time.Minute {
|
|
// Create new limiter or refresh existing one
|
|
limiter = &clientLimiter{
|
|
semaphore: semaphore.NewWeighted(sc.maxRequestsPerClient),
|
|
lastSeen: time.Now(),
|
|
}
|
|
sc.clientRequests[clientIP] = limiter
|
|
} else {
|
|
limiter.lastSeen = time.Now()
|
|
}
|
|
|
|
return limiter
|
|
}
|
|
|
|
// cleanupOldClientLimiters removes old client limiters to prevent memory leaks.
|
|
// Respects clientLimiterCleanupStop to allow graceful shutdown (prevents wg hang).
|
|
func (sc *SteamCache) cleanupOldClientLimiters() {
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-sc.clientLimiterCleanupStop:
|
|
return
|
|
case <-ticker.C:
|
|
sc.clientRequestsMu.Lock()
|
|
now := time.Now()
|
|
for ip, limiter := range sc.clientRequests {
|
|
if now.Sub(limiter.lastSeen) > 30*time.Minute {
|
|
delete(sc.clientRequests, ip)
|
|
}
|
|
}
|
|
sc.clientRequestsMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
type SteamCache struct {
|
|
address string
|
|
upstream string
|
|
|
|
vfs vfs.VFS
|
|
|
|
memory *memory.MemoryFS
|
|
disk *disk.DiskFS
|
|
|
|
memorygc *gc.AsyncGCFS
|
|
diskgc *gc.AsyncGCFS
|
|
|
|
server *http.Server
|
|
client *http.Client
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
|
|
// Shutdown safety (Once hardening per existing patterns)
|
|
shutdownOnce sync.Once
|
|
|
|
// Stop signal for the client limiter cleanup goroutine (fixes shutdown hang/leak; wg.Wait would block forever without it)
|
|
clientLimiterCleanupStop chan struct{}
|
|
|
|
// shutdownCh closed during Shutdown() to allow delayed disk-attach goroutines (the Size barrier waits) to skip late SetSlow and avoid post-shutdown side effects. Also enables wg tracking for hygiene (reaps on wg.Wait).
|
|
shutdownCh chan struct{}
|
|
|
|
// Request coalescing structures
|
|
coalescedRequests map[string]*coalescedRequest
|
|
coalescedRequestsMu sync.RWMutex
|
|
|
|
// Concurrency control
|
|
maxConcurrentRequests int64
|
|
requestSemaphore *semaphore.Weighted
|
|
|
|
// Per-client rate limiting
|
|
clientRequests map[string]*clientLimiter
|
|
clientRequestsMu sync.RWMutex
|
|
maxRequestsPerClient int64
|
|
|
|
// Hardening config fields (plumbed)
|
|
maxObjectSize int64
|
|
trustedProxies []string
|
|
|
|
// Service management
|
|
serviceManager *ServiceManager
|
|
|
|
// Metrics
|
|
metrics *metrics.Metrics
|
|
}
|
|
|
|
// New creates a new SteamCache instance.
|
|
// 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 {
|
|
return nil, fmt.Errorf("invalid memory size: %w", err)
|
|
}
|
|
|
|
disksize, err := units.FromHumanSize(diskSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid disk size: %w", err)
|
|
}
|
|
|
|
// Apply safe defaults before parsing user-provided sizes (handles zero-value Options)
|
|
if maxObjectSize == "" {
|
|
maxObjectSize = "0"
|
|
}
|
|
if trustedProxies == nil {
|
|
trustedProxies = []string{}
|
|
}
|
|
|
|
maxObjBytes, err := units.FromHumanSize(maxObjectSize)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid max object size: %w", err)
|
|
}
|
|
|
|
c := cache.New()
|
|
|
|
var m *memory.MemoryFS
|
|
var mgc *gc.AsyncGCFS
|
|
if memorysize > 0 {
|
|
var err error
|
|
m, err = memory.New(memorysize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
memoryGCAlgo := gc.GCAlgorithm(memoryGC)
|
|
if memoryGCAlgo == "" {
|
|
memoryGCAlgo = gc.LRU // default to LRU
|
|
}
|
|
// Use hybrid async GC with thresholds: 80% async, 95% sync, 100% hard limit
|
|
mgc = gc.NewAsync(m, memoryGCAlgo, true, 0.8, 0.95, 1.0)
|
|
}
|
|
|
|
var d *disk.DiskFS
|
|
var dgc *gc.AsyncGCFS // for disk cases, created inside delayed attach goroutine (just before SetSlow) so preemptive ticker does not run during proxy/init window (addresses Issue 7 / Plan #1)
|
|
if disksize > 0 {
|
|
diskGCAlgo := gc.GCAlgorithm(diskGC)
|
|
if diskGCAlgo == "" {
|
|
diskGCAlgo = gc.LRU // default to LRU
|
|
}
|
|
evictFn := gc.GetGCAlgorithm(diskGCAlgo)
|
|
var err error
|
|
d, err = disk.New(diskPath, disksize, evictFn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Use hybrid async GC with thresholds: 80% async, 95% sync, 100% hard limit
|
|
dgc = gc.NewAsync(d, diskGCAlgo, true, 0.8, 0.95, 1.0)
|
|
}
|
|
|
|
// NOTE: cache tier attach (SetFast/SetSlow + delayed disk attach gos) moved *after* sc construction (see below).
|
|
// This enables wg tracking on sc.wg + shutdownCh signaling for attach goroutines (goroutine hygiene + robust Shutdown during pending init).
|
|
// (Early return for invalid no-mem/no-disk still needed before heavy setup.)
|
|
|
|
if disksize == 0 && memorysize == 0 {
|
|
return nil, fmt.Errorf("no memory or disk cache configured")
|
|
}
|
|
|
|
transport := &http.Transport{
|
|
// Connection pooling optimizations
|
|
MaxIdleConns: 500, // Increased for high concurrency
|
|
MaxIdleConnsPerHost: 100, // Increased for better connection reuse
|
|
MaxConnsPerHost: 200, // Limit connections per host
|
|
IdleConnTimeout: 300 * time.Second, // Longer idle timeout for better reuse
|
|
|
|
// Dial optimizations
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 10 * time.Second, // Faster connection timeout
|
|
KeepAlive: 60 * time.Second, // Longer keep-alive
|
|
DualStack: true, // Enable dual-stack (IPv4/IPv6)
|
|
}).DialContext,
|
|
|
|
// Timeout optimizations
|
|
TLSHandshakeTimeout: 5 * time.Second, // Faster TLS handshake
|
|
ResponseHeaderTimeout: 15 * time.Second, // Faster header timeout
|
|
ExpectContinueTimeout: 1 * time.Second, // Faster expect-continue
|
|
|
|
// Performance optimizations
|
|
DisableCompression: true, // Steam doesn't use compression
|
|
ForceAttemptHTTP2: true, // Enable HTTP/2 if available
|
|
DisableKeepAlives: false, // Enable keep-alives
|
|
|
|
// Buffer optimizations
|
|
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
|
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
|
|
|
// Connection reuse optimizations
|
|
MaxResponseHeaderBytes: 1 << 20, // 1MB max header size
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
Timeout: 60 * time.Second, // Optimized timeout for better responsiveness
|
|
// Add redirect policy for better performance
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
// Limit redirects to prevent infinite loops
|
|
if len(via) >= 10 {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
sc := &SteamCache{
|
|
upstream: upstream,
|
|
address: address,
|
|
vfs: c,
|
|
memory: m,
|
|
disk: d,
|
|
memorygc: mgc,
|
|
diskgc: dgc,
|
|
client: client,
|
|
server: &http.Server{
|
|
Addr: address,
|
|
ReadTimeout: 15 * time.Second, // Optimized for faster response
|
|
WriteTimeout: 30 * time.Second, // Optimized for faster response
|
|
IdleTimeout: 300 * time.Second, // Longer idle timeout for better connection reuse
|
|
ReadHeaderTimeout: 5 * time.Second, // Faster header timeout
|
|
MaxHeaderBytes: 1 << 20, // 1MB max header size
|
|
// Connection optimizations will be handled in ServeHTTP method
|
|
},
|
|
|
|
// Initialize concurrency control fields
|
|
coalescedRequests: make(map[string]*coalescedRequest),
|
|
maxConcurrentRequests: maxConcurrentRequests,
|
|
requestSemaphore: semaphore.NewWeighted(maxConcurrentRequests),
|
|
clientRequests: make(map[string]*clientLimiter),
|
|
maxRequestsPerClient: maxRequestsPerClient,
|
|
clientLimiterCleanupStop: make(chan struct{}),
|
|
shutdownCh: make(chan struct{}),
|
|
|
|
// Hardening config plumbed
|
|
maxObjectSize: maxObjBytes,
|
|
trustedProxies: trustedProxies,
|
|
|
|
// Initialize service management
|
|
serviceManager: NewServiceManager(),
|
|
|
|
// Initialize metrics
|
|
metrics: metrics.NewMetrics(),
|
|
}
|
|
|
|
// configure the cache to match the specified mode (memory only, disk only, or memory and disk) based on the provided sizes.
|
|
// Disk tier SetSlow delayed until after disk init+eviction (evict func passed at disk.New for race-free guard; via Size barrier); mem immediate.
|
|
// Attach goroutines now tracked on sc.wg (Add before go + Done), guarded by shutdownCh (skip SetSlow if Shutdown racing) for goroutine hygiene. (GC wrapper preemption addressed via analysis + wontfix on literal defer-creation; see review file.)
|
|
|
|
if disksize == 0 && memorysize != 0 {
|
|
// memory only mode - no disk
|
|
c.SetSlow(mgc)
|
|
} else if disksize != 0 && memorysize == 0 {
|
|
// disk only mode: delay attach until disk ready (pure-proxy during scan; Create returns ErrNotFound until slow tier Set)
|
|
sc.wg.Add(1)
|
|
go func() {
|
|
defer sc.wg.Done()
|
|
_ = d.Size() // block on barrier per design (all Size callers during window do this; documented)
|
|
select {
|
|
case <-sc.shutdownCh:
|
|
return // Shutdown raced; do not attach or SetSlow after stop
|
|
default:
|
|
c.SetSlow(dgc)
|
|
}
|
|
}()
|
|
} else if disksize != 0 && memorysize != 0 {
|
|
// memory and disk mode: fast mem immediate, disk delayed (mem-only during scan)
|
|
c.SetFast(mgc)
|
|
sc.wg.Add(1)
|
|
go func() {
|
|
defer sc.wg.Done()
|
|
_ = d.Size()
|
|
select {
|
|
case <-sc.shutdownCh:
|
|
return
|
|
default:
|
|
c.SetSlow(dgc)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Log GC algorithm configuration
|
|
if m != nil {
|
|
logger.Logger.Info().Str("memory_gc", memoryGC).Msg("Memory cache GC algorithm configured")
|
|
}
|
|
if d != nil {
|
|
logger.Logger.Info().Str("disk_gc", diskGC).Msg("Disk cache GC algorithm configured")
|
|
}
|
|
|
|
return sc, nil
|
|
}
|
|
|
|
func (sc *SteamCache) Run() error {
|
|
if sc.upstream != "" {
|
|
resp, err := sc.client.Get(sc.upstream)
|
|
if err != nil {
|
|
if resp != nil {
|
|
_ = resp.Body.Close() // best-effort body close on connectivity error path; primary err returned
|
|
}
|
|
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed upstream connectivity check")
|
|
return err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
_ = resp.Body.Close() // best-effort body close on non-OK status; primary error returned
|
|
logger.Logger.Error().Int("status_code", resp.StatusCode).Str("upstream", sc.upstream).Msg("Upstream connectivity check returned non-OK status")
|
|
return fmt.Errorf("upstream returned status %d", resp.StatusCode)
|
|
}
|
|
_ = resp.Body.Close() // best-effort; success path close before ListenAndServe
|
|
}
|
|
|
|
sc.server.Handler = sc
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
sc.cancel = cancel
|
|
|
|
// Start cleanup goroutine for old client limiters
|
|
sc.wg.Add(1)
|
|
go func() {
|
|
defer sc.wg.Done()
|
|
sc.cleanupOldClientLimiters()
|
|
}()
|
|
|
|
sc.wg.Add(1)
|
|
go func() {
|
|
defer sc.wg.Done()
|
|
err := sc.server.ListenAndServe()
|
|
if err != nil && err != http.ErrServerClosed {
|
|
logger.Logger.Error().Err(err).Msg("Failed to start SteamCache2")
|
|
return // goroutine cannot return err to caller; logged; shutdown path handles
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
_ = sc.server.Shutdown(ctx) // shutdown error secondary (ListenAndServe already terminated or context done); logged elsewhere if fatal
|
|
sc.wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
func (sc *SteamCache) Shutdown() {
|
|
if sc == nil {
|
|
return
|
|
}
|
|
sc.shutdownOnce.Do(func() {
|
|
if sc.cancel != nil {
|
|
sc.cancel()
|
|
}
|
|
// Stop all background managers started in New() (critical for test hygiene + no leaks when using httptest direct ServeHTTP paths that never call Run()).
|
|
if sc.memorygc != nil {
|
|
sc.memorygc.Stop()
|
|
}
|
|
if sc.diskgc != nil {
|
|
sc.diskgc.Stop()
|
|
}
|
|
// Signal cleanup goroutine to exit so wg.Wait below does not hang indefinitely.
|
|
if sc.clientLimiterCleanupStop != nil {
|
|
select {
|
|
case <-sc.clientLimiterCleanupStop:
|
|
default:
|
|
close(sc.clientLimiterCleanupStop)
|
|
}
|
|
}
|
|
// Signal delayed attach goroutines (Issue 2 hygiene) to skip SetSlow if still waiting Size() or just finished.
|
|
if sc.shutdownCh != nil {
|
|
select {
|
|
case <-sc.shutdownCh:
|
|
default:
|
|
close(sc.shutdownCh)
|
|
}
|
|
}
|
|
sc.wg.Wait()
|
|
// Brief reap window after stopping workers (helps goroutine delta checks see low counts quickly).
|
|
time.Sleep(10 * time.Millisecond)
|
|
})
|
|
}
|
|
|
|
// GetMetrics returns current metrics
|
|
func (sc *SteamCache) GetMetrics() *metrics.Stats {
|
|
// Update cache sizes
|
|
if sc.memory != nil {
|
|
sc.metrics.SetMemoryCacheSize(sc.memory.Size())
|
|
}
|
|
if sc.disk != nil {
|
|
// Note: blocks on initDone (post-eviction state) for accurate post-attach size during long disk init window.
|
|
sc.metrics.SetDiskCacheSize(sc.disk.Size())
|
|
}
|
|
|
|
return sc.metrics.GetStats()
|
|
}
|
|
|
|
// 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
|
|
DiskSize string
|
|
DiskPath string
|
|
Upstream string
|
|
MemoryGC string
|
|
DiskGC string
|
|
MaxConcurrentRequests int64
|
|
MaxRequestsPerClient int64
|
|
|
|
// New config fields for hardening (max object size + trusted proxies)
|
|
MaxObjectSize string
|
|
TrustedProxies []string
|
|
}
|
|
|
|
func NewWithOptions(o Options) (*SteamCache, error) {
|
|
return New(o.Address, o.MemorySize, o.DiskSize, o.DiskPath, o.Upstream, o.MemoryGC, o.DiskGC, o.MaxConcurrentRequests, o.MaxRequestsPerClient, o.MaxObjectSize, o.TrustedProxies)
|
|
}
|
|
|
|
// ResetMetrics resets all metrics to zero
|
|
func (sc *SteamCache) ResetMetrics() {
|
|
sc.metrics.Reset()
|
|
}
|
|
|
|
// writeMetricsText emits the Prometheus-style text metrics to the ResponseWriter.
|
|
// All fmt.Fprintf errors are intentionally discarded via _ = : this is a best-effort
|
|
// read-only debug endpoint; client disconnects or write errors during metrics dump
|
|
// are not actionable (do not affect cache correctness or require retries).
|
|
func writeMetricsText(w http.ResponseWriter, stats *metrics.Stats) {
|
|
_, _ = fmt.Fprintf(w, "# SteamCache2 Metrics\n")
|
|
_, _ = fmt.Fprintf(w, "total_requests %d\n", stats.TotalRequests)
|
|
_, _ = fmt.Fprintf(w, "cache_hits %d\n", stats.CacheHits)
|
|
_, _ = fmt.Fprintf(w, "cache_misses %d\n", stats.CacheMisses)
|
|
_, _ = 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)
|
|
_, _ = fmt.Fprintf(w, "total_bytes_cached %d\n", stats.TotalBytesCached)
|
|
_, _ = fmt.Fprintf(w, "memory_cache_size %d\n", stats.MemoryCacheSize)
|
|
_, _ = fmt.Fprintf(w, "disk_cache_size %d\n", stats.DiskCacheSize)
|
|
_, _ = fmt.Fprintf(w, "uptime_seconds %.2f\n", stats.Uptime.Seconds())
|
|
}
|
|
|
|
func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
clientIP := getClientIP(r, sc.trustedProxies)
|
|
|
|
// Set keep-alive headers for better performance
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("Keep-Alive", "timeout=300, max=1000")
|
|
|
|
// Apply global concurrency limit first
|
|
// 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
|
|
}
|
|
defer sc.requestSemaphore.Release(1)
|
|
|
|
// Track total requests
|
|
sc.metrics.IncrementTotalRequests()
|
|
|
|
// Apply per-client rate limiting
|
|
clientLimiter := sc.getOrCreateClientLimiter(clientIP)
|
|
|
|
// Per-client request limiting (context aware)
|
|
if err := clientLimiter.semaphore.Acquire(r.Context(), 1); err != nil {
|
|
logger.Logger.Warn().
|
|
Str("client_ip", clientIP).
|
|
Int("max_per_client", int(sc.maxRequestsPerClient)).
|
|
Msg("Client exceeded concurrent request limit")
|
|
http.Error(w, "Too many concurrent requests from this client", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
defer clientLimiter.semaphore.Release(1)
|
|
|
|
if r.Method != http.MethodGet {
|
|
logger.Logger.Warn().
|
|
Str("method", r.Method).
|
|
Str("client_ip", clientIP).
|
|
Msg("Only GET method is supported")
|
|
http.Error(w, "Only GET method is supported", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if r.URL.Path == "/" {
|
|
logger.Logger.Debug().
|
|
Str("client_ip", clientIP).
|
|
Msg("Health check request")
|
|
w.WriteHeader(http.StatusOK) // this is used by steamcache2's upstream verification at startup
|
|
return
|
|
}
|
|
|
|
if r.URL.String() == "/lancache-heartbeat" {
|
|
logger.Logger.Debug().
|
|
Str("client_ip", clientIP).
|
|
Msg("LanCache heartbeat request")
|
|
w.Header().Add("X-LanCache-Processed-By", "SteamCache2")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
_, _ = w.Write(nil) // client write error ignored (heartbeat path; nil write is no-op)
|
|
return
|
|
}
|
|
|
|
if r.URL.String() == "/metrics" {
|
|
// Return metrics in a simple text format
|
|
stats := sc.GetMetrics()
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
w.WriteHeader(http.StatusOK)
|
|
writeMetricsText(w, stats)
|
|
return
|
|
}
|
|
|
|
// Check if this is a request from a supported service
|
|
if service, isSupported := sc.detectService(r); isSupported {
|
|
// trim the query parameters from the URL path
|
|
// this is necessary because the cache key should not include query parameters
|
|
urlPath, _, _ := strings.Cut(r.URL.String(), "?")
|
|
|
|
// Validate URL path for security
|
|
if err := validateURLPath(urlPath); err != nil {
|
|
logger.Logger.Warn().
|
|
Err(err).
|
|
Str("url", urlPath).
|
|
Str("client_ip", clientIP).
|
|
Msg("Invalid URL path detected")
|
|
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tstart := time.Now()
|
|
|
|
// Generate service cache key: {service}/{hash} (prefix indicates service via User-Agent)
|
|
cacheKey, err := generateServiceCacheKey(urlPath, service.Prefix)
|
|
if err != nil {
|
|
logger.Logger.Warn().
|
|
Err(err).
|
|
Str("url", urlPath).
|
|
Str("service", service.Name).
|
|
Str("client_ip", clientIP).
|
|
Msg("Failed to generate cache key")
|
|
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
w.Header().Add("X-LanCache-Processed-By", "SteamCache2") // SteamPrefill uses this header to determine if the request was processed by the cache maybe steam uses it too
|
|
|
|
cachePath := cacheKey // You may want to add a .http or .cache extension for clarity
|
|
|
|
logger.Logger.Debug().
|
|
Str("url", urlPath).
|
|
Str("key", cacheKey).
|
|
Str("client_ip", clientIP).
|
|
Msg("Generated cache key")
|
|
|
|
// Try to serve from cache
|
|
file, err := sc.vfs.Open(cachePath)
|
|
if err == nil {
|
|
defer func() { _ = file.Close() }() // best-effort close of cache file reader; error secondary (data already read or connection issue)
|
|
|
|
// Read the entire cached file
|
|
cachedData, err := io.ReadAll(file)
|
|
if err != nil {
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Err(err).
|
|
Msg("Failed to read cached file - removing corrupted entry")
|
|
_ = sc.vfs.Delete(cachePath) // best-effort cleanup of corrupt entry; failure non-fatal (logged)
|
|
} else {
|
|
// Deserialize using new format
|
|
cacheFile, err := deserializeCacheFile(cachedData)
|
|
if err != nil {
|
|
// Cache file is corrupted or invalid format
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Err(err).
|
|
Msg("Failed to deserialize cache file - removing corrupted entry")
|
|
_ = sc.vfs.Delete(cachePath) // best-effort cleanup of corrupt entry; failure non-fatal (logged)
|
|
} else {
|
|
// Track cache hit metrics
|
|
sc.metrics.IncrementCacheHits()
|
|
sc.metrics.AddResponseTime(time.Since(tstart))
|
|
sc.metrics.AddBytesServed(int64(len(cachedData)))
|
|
sc.metrics.IncrementServiceRequests(service.Name)
|
|
|
|
logger.Logger.Debug().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Str("content_hash", cacheFile.ContentHash).
|
|
Msg("Successfully loaded from cache")
|
|
|
|
// Stream the raw HTTP response directly
|
|
sc.streamCachedResponse(w, r, cacheFile, cacheKey, clientIP, tstart)
|
|
return
|
|
}
|
|
}
|
|
// If we reach here, cache validation failed and we need to fetch from upstream
|
|
}
|
|
|
|
// Check for coalesced request (another client already downloading this)
|
|
coalescedReq, isNew := sc.getOrCreateCoalescedRequest(cacheKey)
|
|
if !isNew {
|
|
// Wait for the existing download to complete
|
|
logger.Logger.Debug().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Str("client_ip", clientIP).
|
|
Int("waiting_clients", int(coalescedReq.waitingCount.Load())).
|
|
Msg("Joining coalesced request")
|
|
|
|
// Wait on the broadcast doneCh (closed once by leader). All N waiters wake.
|
|
select {
|
|
case <-coalescedReq.doneCh:
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
|
|
coalescedReq.mu.Lock()
|
|
if coalescedReq.completionErr != nil {
|
|
err := coalescedReq.completionErr
|
|
coalescedReq.mu.Unlock()
|
|
logger.Logger.Error().
|
|
Err(err).
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Str("client_ip", clientIP).
|
|
Msg("Coalesced request failed")
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Upstream request failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if coalescedReq.responseData == nil {
|
|
coalescedReq.mu.Unlock()
|
|
logger.Logger.Error().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Str("client_ip", clientIP).
|
|
Msg("No response data available for coalesced client")
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "No response data available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Copy the buffered response data
|
|
responseData := make([]byte, len(coalescedReq.responseData))
|
|
copy(responseData, coalescedReq.responseData)
|
|
coalescedReq.mu.Unlock()
|
|
|
|
// Serve the buffered response
|
|
for k, vv := range coalescedReq.responseHeaders {
|
|
for _, v := range vv {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
w.Header().Set("X-LanCache-Status", "HIT-COALESCED")
|
|
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
|
w.WriteHeader(coalescedReq.statusCode)
|
|
_, _ = w.Write(responseData) // client write error ignored (disconnect during coalesced response send is not actionable)
|
|
|
|
// Track coalesced cache hit metrics
|
|
sc.metrics.IncrementCacheCoalesced()
|
|
sc.metrics.AddResponseTime(time.Since(tstart))
|
|
sc.metrics.AddBytesServed(int64(len(responseData)))
|
|
sc.metrics.IncrementServiceRequests(service.Name)
|
|
|
|
logger.Logger.Info().
|
|
Str("cache_key", cacheKey).
|
|
Str("url", urlPath).
|
|
Str("host", r.Host).
|
|
Str("client_ip", clientIP).
|
|
Str("cache_status", "HIT-COALESCED").
|
|
Int("waiting_clients", int(coalescedReq.waitingCount.Load())).
|
|
Int64("file_size", int64(len(responseData))).
|
|
Dur("response_time", time.Since(tstart)).
|
|
Msg("cache request")
|
|
|
|
return
|
|
}
|
|
|
|
// Remove coalesced request when done
|
|
defer sc.removeCoalescedRequest(cacheKey)
|
|
|
|
var req *http.Request
|
|
if sc.upstream != "" { // if an upstream server is configured, proxy the request to the upstream server
|
|
ur, err := url.JoinPath(sc.upstream, urlPath)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to join URL path")
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
req, err = http.NewRequestWithContext(r.Context(), http.MethodGet, ur, nil)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Str("upstream", sc.upstream).Msg("Failed to create request")
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.Host = r.Host
|
|
} else { // if no upstream server is configured, proxy the request to the host specified in the request
|
|
host := r.Host
|
|
if r.Header.Get("X-Sls-Https") == "enable" {
|
|
host = "https://" + host
|
|
} else {
|
|
host = "http://" + host
|
|
}
|
|
|
|
ur, err := url.JoinPath(host, urlPath)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to join URL path")
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Failed to join URL path", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
req, err = http.NewRequestWithContext(r.Context(), http.MethodGet, ur, nil)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Str("host", host).Msg("Failed to create request")
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Copy headers from the original request to the new request
|
|
// BUT exclude Range headers - we always want to cache the full file
|
|
for key, values := range r.Header {
|
|
// Skip Range headers to ensure we always cache the complete file
|
|
if strings.ToLower(key) == "range" {
|
|
logger.Logger.Debug().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Str("range_header", values[0]).
|
|
Msg("Skipping Range header to cache full file")
|
|
continue
|
|
}
|
|
for _, value := range values {
|
|
req.Header.Add(key, value)
|
|
}
|
|
}
|
|
|
|
// Retry logic
|
|
backoffSchedule := []time.Duration{1 * time.Second, 3 * time.Second, 10 * time.Second}
|
|
var resp *http.Response
|
|
for i, backoff := range backoffSchedule {
|
|
resp, err = sc.client.Do(req)
|
|
if err == nil && resp.StatusCode == http.StatusOK {
|
|
break
|
|
}
|
|
if i < len(backoffSchedule)-1 {
|
|
time.Sleep(backoff)
|
|
}
|
|
}
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Str("url", req.URL.String()).Msg("Failed to fetch the requested URL")
|
|
|
|
if resp != nil {
|
|
_ = resp.Body.Close() // best-effort close on upstream fetch error; primary error logged/returned
|
|
}
|
|
// Complete coalesced request with error
|
|
if isNew {
|
|
coalescedReq.complete(nil, err)
|
|
}
|
|
|
|
sc.metrics.IncrementErrors()
|
|
sc.metrics.IncrementUpstreamErrors()
|
|
sc.metrics.IncrementServiceError("upstream")
|
|
http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
logger.Logger.Error().Int("status_code", resp.StatusCode).Str("url", req.URL.String()).Msg("Failed to fetch the requested URL (non-OK status after retries)")
|
|
|
|
_ = resp.Body.Close() // best-effort close on non-OK upstream; primary error path
|
|
// Complete coalesced request with error
|
|
if isNew {
|
|
coalescedReq.complete(nil, fmt.Errorf("upstream returned status %d", resp.StatusCode))
|
|
}
|
|
|
|
sc.metrics.IncrementErrors()
|
|
sc.metrics.IncrementUpstreamErrors()
|
|
sc.metrics.IncrementServiceError("upstream")
|
|
http.Error(w, "Failed to fetch the requested URL", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer func() { _ = resp.Body.Close() }() // best-effort close for success upstream response body (standard handler cleanup)
|
|
|
|
// Fast path: Flexible lightweight validation for all files
|
|
// Multiple validation layers ensure data integrity without blocking legitimate Steam content
|
|
|
|
// Method 2: Content-Type Validation (Steam files can be various types)
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if contentType != "" {
|
|
// Log the content type for monitoring, but don't restrict based on it
|
|
// Steam serves different content types: chunks, manifests, patches, etc.
|
|
logger.Logger.Debug().
|
|
Str("url", req.URL.String()).
|
|
Str("content_type", contentType).
|
|
Str("service", service.Name).
|
|
Msg("Content type from upstream")
|
|
}
|
|
|
|
// Method 3: Content-Length Validation
|
|
expectedSize := resp.ContentLength
|
|
|
|
// Reject only truly invalid content lengths (zero or negative)
|
|
// 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 Content-Length with size limit set - treating as potential oversize")
|
|
if isNew {
|
|
coalescedReq.complete(nil, fmt.Errorf("chunked response with size limit"))
|
|
}
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Response too large (chunked)", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
logger.Logger.Error().
|
|
Str("url", req.URL.String()).
|
|
Int64("content_length", expectedSize).
|
|
Msg("Invalid content length, rejecting file")
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Invalid content length", http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
// Content length is valid - no size restrictions to keep logs clean
|
|
|
|
// 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.
|
|
if sc.maxObjectSize > 0 && expectedSize > sc.maxObjectSize {
|
|
logger.Logger.Warn().
|
|
Str("url", req.URL.String()).
|
|
Int64("content_length", expectedSize).
|
|
Int64("max_object_size", sc.maxObjectSize).
|
|
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))
|
|
}
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Response too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
|
|
// Lightweight validation passed - trust the Content-Length and HTTP status
|
|
// This provides good integrity with minimal performance overhead
|
|
validationPassed := true
|
|
|
|
// Read the entire response body into memory to avoid consuming it twice
|
|
// 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
|
|
}
|
|
bodyData, err := io.ReadAll(io.LimitReader(resp.Body, readLimit+1))
|
|
if err != nil {
|
|
logger.Logger.Error().
|
|
Err(err).
|
|
Str("url", req.URL.String()).
|
|
Msg("Failed to read response body")
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Failed to read response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// Detect truncation from LimitReader (lying CL or chunked > limit)
|
|
if sc.maxObjectSize > 0 && int64(len(bodyData)) > sc.maxObjectSize {
|
|
if isNew {
|
|
coalescedReq.complete(nil, fmt.Errorf("response body exceeded limit"))
|
|
}
|
|
sc.metrics.IncrementErrors()
|
|
http.Error(w, "Response too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
// Body closed by defer resp.Body.Close() at entry to success path
|
|
|
|
// Reconstruct the exact HTTP response as received from upstream
|
|
rawResponse := sc.reconstructRawResponse(resp, bodyData)
|
|
|
|
// Write to response
|
|
// Remove hop-by-hop and server-specific headers
|
|
for k, vv := range resp.Header {
|
|
if _, skip := hopByHopHeaders[http.CanonicalHeaderKey(k)]; skip {
|
|
continue
|
|
}
|
|
for _, v := range vv {
|
|
w.Header().Add(k, v)
|
|
}
|
|
}
|
|
// Add our own headers
|
|
w.Header().Set("X-LanCache-Status", "MISS")
|
|
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
|
|
|
// Stream the response body to client
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = w.Write(bodyData) // client write error ignored (disconnect during MISS body send is not actionable)
|
|
|
|
// Track cache miss metrics
|
|
sc.metrics.IncrementCacheMisses()
|
|
sc.metrics.AddResponseTime(time.Since(tstart))
|
|
sc.metrics.AddBytesServed(int64(len(bodyData)))
|
|
sc.metrics.IncrementServiceRequests(service.Name)
|
|
|
|
// Cache the file if validation passed
|
|
if validationPassed {
|
|
// Verify we received the complete file by checking Content-Length
|
|
if !sc.verifyCompleteFile(bodyData, resp, urlPath, cacheKey) {
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Int("received_bytes", len(bodyData)).
|
|
Int64("expected_bytes", resp.ContentLength).
|
|
Msg("Incomplete file received - not caching to allow retry")
|
|
return
|
|
}
|
|
|
|
// Serialize the raw response using our new cache format
|
|
cacheData, err := serializeRawResponse(rawResponse)
|
|
if err != nil {
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
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)))
|
|
if err == nil {
|
|
defer func() { _ = cacheWriter.Close() }() // best-effort close of cache writer; errors on close (e.g. final sync) logged via prior write checks or non-fatal
|
|
|
|
// Write the serialized cache data
|
|
bytesWritten, cacheErr := cacheWriter.Write(cacheData)
|
|
|
|
if cacheErr != nil || bytesWritten != len(cacheData) {
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Int("expected", len(cacheData)).
|
|
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) // best-effort removal of partial corrupt cache entry on write failure; non-fatal. Deferred cacheWriter.Close() (from earlier in block) runs after this on error unwind path (harmless per DiskFS design)
|
|
} else {
|
|
// Track successful cache write
|
|
sc.metrics.AddBytesCached(int64(len(cacheData)))
|
|
logger.Logger.Debug().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Str("service", service.Name).
|
|
Int("size", bytesWritten).
|
|
Msg("Successfully cached response")
|
|
}
|
|
} else {
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Err(err).
|
|
Msg("Failed to create cache file")
|
|
sc.metrics.IncrementCacheWriteFailures()
|
|
sc.metrics.IncrementServiceError("cache_create")
|
|
}
|
|
}
|
|
|
|
// Complete coalesced request with the original response
|
|
if isNew {
|
|
coalescedResp := &http.Response{
|
|
StatusCode: resp.StatusCode,
|
|
Status: resp.Status,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(bytes.NewReader(bodyData)), // Buffered body for coalesced clients
|
|
}
|
|
for k, vv := range resp.Header {
|
|
coalescedResp.Header[k] = vv
|
|
}
|
|
coalescedReq.setResponseData(bodyData)
|
|
coalescedReq.complete(coalescedResp, nil)
|
|
}
|
|
} else {
|
|
logger.Logger.Warn().
|
|
Str("key", cacheKey).
|
|
Str("url", urlPath).
|
|
Err(err).
|
|
Msg("Failed to create cache file")
|
|
|
|
// Complete coalesced request with buffered body even if cache creation failed
|
|
if isNew {
|
|
coalescedResp := &http.Response{
|
|
StatusCode: resp.StatusCode,
|
|
Status: resp.Status,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(bytes.NewReader(bodyData)), // Use buffered body
|
|
}
|
|
for k, vv := range resp.Header {
|
|
coalescedResp.Header[k] = vv
|
|
}
|
|
coalescedReq.complete(coalescedResp, nil)
|
|
}
|
|
}
|
|
|
|
logger.Logger.Info().
|
|
Str("cache_key", cacheKey).
|
|
Str("url", urlPath).
|
|
Str("host", r.Host).
|
|
Str("client_ip", clientIP).
|
|
Str("service", service.Name).
|
|
Str("cache_status", "MISS").
|
|
Int64("file_size", int64(len(bodyData))).
|
|
Dur("response_time", time.Since(tstart)).
|
|
Msg("cache request")
|
|
|
|
return
|
|
}
|
|
|
|
http.Error(w, "Not found", http.StatusNotFound)
|
|
}
|