- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation. - Changed default base URL for Playwright tests and updated security headers to reflect the new branding. - Enhanced README and API documentation to include new authentication endpoints and product access details. This refactor aligns the project branding and improves clarity in the API documentation.
216 lines
4.8 KiB
Go
216 lines
4.8 KiB
Go
package gateway
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
httperrors "github.com/explorer/backend/libs/go-http-errors"
|
|
httpmiddleware "github.com/explorer/backend/libs/go-http-middleware"
|
|
)
|
|
|
|
// Gateway represents the API gateway
|
|
type Gateway struct {
|
|
apiURL *url.URL
|
|
rateLimiter *RateLimiter
|
|
auth *AuthMiddleware
|
|
}
|
|
|
|
// NewGateway creates a new API gateway
|
|
func NewGateway(apiURL string) (*Gateway, error) {
|
|
parsedURL, err := url.Parse(apiURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid API URL: %w", err)
|
|
}
|
|
|
|
return &Gateway{
|
|
apiURL: parsedURL,
|
|
rateLimiter: NewRateLimiter(),
|
|
auth: NewAuthMiddleware(),
|
|
}, nil
|
|
}
|
|
|
|
// Start starts the gateway server
|
|
func (g *Gateway) Start(port int) error {
|
|
mux := http.NewServeMux()
|
|
|
|
// Proxy to API server
|
|
proxy := httputil.NewSingleHostReverseProxy(g.apiURL)
|
|
|
|
mux.HandleFunc("/", g.handleRequest(proxy))
|
|
|
|
addr := fmt.Sprintf(":%d", port)
|
|
log.Printf("Starting API Gateway on %s", addr)
|
|
return http.ListenAndServe(addr, mux)
|
|
}
|
|
|
|
// handleRequest handles incoming requests with middleware
|
|
func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Add security headers
|
|
g.addSecurityHeaders(w)
|
|
|
|
// Authentication
|
|
if !g.auth.Authenticate(r) {
|
|
httperrors.WriteJSON(w, http.StatusUnauthorized, "UNAUTHORIZED", "Unauthorized")
|
|
return
|
|
}
|
|
|
|
// Rate limiting
|
|
if !g.rateLimiter.Allow(r) {
|
|
httperrors.WriteJSON(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded")
|
|
return
|
|
}
|
|
|
|
// Add headers
|
|
if clientIP := httpmiddleware.ClientIP(r); clientIP != "" {
|
|
r.Header.Set("X-Forwarded-For", clientIP)
|
|
}
|
|
if apiKey := g.auth.GetAPIKey(r); apiKey != "" {
|
|
r.Header.Set("X-API-Key", apiKey)
|
|
}
|
|
|
|
// Add branding header
|
|
w.Header().Set("X-Explorer-Name", "SolaceScan")
|
|
w.Header().Set("X-Explorer-Version", "1.0.0")
|
|
|
|
// Proxy request
|
|
proxy.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
// addSecurityHeaders adds security headers to responses
|
|
func (g *Gateway) addSecurityHeaders(w http.ResponseWriter) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
|
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
// CSP will be set per route if needed
|
|
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
|
}
|
|
|
|
// RateLimiter handles rate limiting
|
|
type RateLimiter struct {
|
|
// Simple in-memory rate limiter (should use Redis in production)
|
|
mu sync.Mutex
|
|
limits map[string]*limitEntry
|
|
}
|
|
|
|
type limitEntry struct {
|
|
count int
|
|
resetAt time.Time
|
|
}
|
|
|
|
const gatewayRequestsPerMinute = 120
|
|
|
|
func NewRateLimiter() *RateLimiter {
|
|
return &RateLimiter{
|
|
limits: make(map[string]*limitEntry),
|
|
}
|
|
}
|
|
|
|
func (rl *RateLimiter) Allow(r *http.Request) bool {
|
|
clientIP := httpmiddleware.ClientIP(r)
|
|
if clientIP == "" {
|
|
clientIP = r.RemoteAddr
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
entry, ok := rl.limits[clientIP]
|
|
if !ok || now.After(entry.resetAt) {
|
|
rl.limits[clientIP] = &limitEntry{
|
|
count: 1,
|
|
resetAt: now.Add(time.Minute),
|
|
}
|
|
return true
|
|
}
|
|
|
|
if entry.count >= gatewayRequestsPerMinute {
|
|
return false
|
|
}
|
|
|
|
entry.count++
|
|
return true
|
|
}
|
|
|
|
// AuthMiddleware handles authentication
|
|
type AuthMiddleware struct {
|
|
allowAnonymous bool
|
|
apiKeys []string
|
|
}
|
|
|
|
func NewAuthMiddleware() *AuthMiddleware {
|
|
return &AuthMiddleware{
|
|
allowAnonymous: parseBoolEnv("GATEWAY_ALLOW_ANONYMOUS"),
|
|
apiKeys: splitNonEmptyEnv("GATEWAY_API_KEYS"),
|
|
}
|
|
}
|
|
|
|
func (am *AuthMiddleware) Authenticate(r *http.Request) bool {
|
|
apiKey := am.GetAPIKey(r)
|
|
if apiKey == "" {
|
|
return am.allowAnonymous
|
|
}
|
|
if len(am.apiKeys) == 0 {
|
|
return am.allowAnonymous
|
|
}
|
|
|
|
for _, allowedKey := range am.apiKeys {
|
|
if subtle.ConstantTimeCompare([]byte(apiKey), []byte(allowedKey)) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (am *AuthMiddleware) GetAPIKey(r *http.Request) string {
|
|
// Check header first
|
|
if key := r.Header.Get("X-API-Key"); key != "" {
|
|
return key
|
|
}
|
|
// Check query parameter
|
|
if key := r.URL.Query().Get("api_key"); key != "" {
|
|
return key
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseBoolEnv(key string) bool {
|
|
value := strings.TrimSpace(os.Getenv(key))
|
|
return strings.EqualFold(value, "1") ||
|
|
strings.EqualFold(value, "true") ||
|
|
strings.EqualFold(value, "yes") ||
|
|
strings.EqualFold(value, "on")
|
|
}
|
|
|
|
func splitNonEmptyEnv(key string) []string {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
|
|
parts := strings.Split(raw, ",")
|
|
values := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
trimmed := strings.TrimSpace(part)
|
|
if trimmed != "" {
|
|
values = append(values, trimmed)
|
|
}
|
|
}
|
|
|
|
return values
|
|
}
|