1918 lines
56 KiB
Go
1918 lines
56 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/redis/go-redis/v9"
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
//go:embed install.sh
|
|
var installScript []byte
|
|
|
|
// Package-level rand source — avoids global mutex contention under concurrent load.
|
|
// Go 1.20+ auto-seeds the global rand, but a local source is faster at high QPS.
|
|
var rng = rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
|
|
const (
|
|
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
PROFILE = "606624d44113"
|
|
LOCALE = "en-US"
|
|
ORG_ID = "y6jn8c31"
|
|
CUSTOMER_ID = "560dc9f3-1aa5-4a2f-b63c-9e18f8d0e175"
|
|
PORT = ":3002"
|
|
|
|
latestCLIVersion = "0.3.6"
|
|
)
|
|
|
|
// --- Session cache (short-lived, used to chain /skuinfo → /proxy) ---
|
|
|
|
// simpleCookieJar stores cookies without domain scoping. The standard
|
|
// net/http/cookiejar rejects cookies whose Domain (e.g. .microsoft.com) doesn't
|
|
// match the request URL (our CF Worker domain). This jar stores all cookies and
|
|
// replays them on every request so they flow through the Worker to Microsoft.
|
|
type simpleCookieJar struct {
|
|
mu sync.Mutex
|
|
cookies []*http.Cookie
|
|
}
|
|
|
|
func (j *simpleCookieJar) SetCookies(_ *url.URL, cookies []*http.Cookie) {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
for _, c := range cookies {
|
|
found := false
|
|
for i, existing := range j.cookies {
|
|
if existing.Name == c.Name {
|
|
j.cookies[i] = c
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
j.cookies = append(j.cookies, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (j *simpleCookieJar) Cookies(_ *url.URL) []*http.Cookie {
|
|
j.mu.Lock()
|
|
defer j.mu.Unlock()
|
|
out := make([]*http.Cookie, len(j.cookies))
|
|
copy(out, j.cookies)
|
|
return out
|
|
}
|
|
|
|
type SessionEntry struct {
|
|
SessionID string
|
|
CreatedAt time.Time
|
|
Jar *simpleCookieJar
|
|
}
|
|
|
|
var (
|
|
sessionCache = make(map[string]SessionEntry)
|
|
cacheMutex sync.RWMutex
|
|
SESSION_TTL = 15 * time.Minute
|
|
workerSecret = os.Getenv("CF_WORKER_SECRET")
|
|
validSkuID = regexp.MustCompile(`^[a-zA-Z0-9_\-]+$`)
|
|
)
|
|
|
|
// --- SKU info cache (7-day TTL — language lists are stable) ---
|
|
|
|
type skuCacheEntry struct {
|
|
RawJSON []byte
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
var (
|
|
skuCache = make(map[string]skuCacheEntry)
|
|
skuCacheMu sync.RWMutex
|
|
skuCacheTTL = 7 * 24 * time.Hour
|
|
)
|
|
|
|
// --- Download link cache (dynamic TTL from URL expiry) ---
|
|
|
|
type linkCacheEntry struct {
|
|
RawJSON []byte
|
|
ExpiresAt time.Time // soft expiry: stop serving without stale fallback
|
|
FetchedAt time.Time
|
|
}
|
|
|
|
var (
|
|
linkCache = make(map[string]linkCacheEntry)
|
|
linkCacheMu sync.RWMutex
|
|
)
|
|
|
|
// truncateBody caps a response body string for log output.
|
|
func truncateBody(s string) string {
|
|
const max = 120
|
|
if len(s) > max {
|
|
return s[:max] + "..."
|
|
}
|
|
return s
|
|
}
|
|
|
|
// --- Negative cache (60s normal / 90min Sentinel lockdown) ---
|
|
|
|
type negCacheEntry struct {
|
|
Message string
|
|
HTTPCode int
|
|
ExpiresAt time.Time
|
|
IsSentinel bool // Sentinel WAF block: use lockdownTTL and suppress background refresh
|
|
}
|
|
|
|
var (
|
|
negCache = make(map[string]negCacheEntry)
|
|
negCacheMu sync.RWMutex
|
|
negCacheTTL = 60 * time.Second
|
|
lockdownTTL = 90 * time.Minute
|
|
)
|
|
|
|
// --- Singleflight (one in-flight Microsoft fetch per unique key) ---
|
|
|
|
var sfGroup singleflight.Group
|
|
|
|
// --- Redis L2 cache client ---
|
|
|
|
var rdb *redis.Client
|
|
|
|
// --- Metrics counters (atomic — no mutex needed) ---
|
|
|
|
var (
|
|
// /skuinfo
|
|
mSkuRequests int64
|
|
mSkuCacheHits int64
|
|
mSkuNegHits int64
|
|
mSkuFetches int64 // actual Microsoft calls
|
|
|
|
// /proxy
|
|
mLinkRequests int64
|
|
mLinkCacheHits int64
|
|
mLinkNegHits int64
|
|
mLinkFetches int64
|
|
mLinkStale int64 // stale-on-failure serves
|
|
|
|
// /evallinks
|
|
mEvalRequests int64
|
|
mEvalCacheHits int64
|
|
mEvalStale int64
|
|
)
|
|
|
|
// --- Evalcenter (Enterprise / Server eval ISOs) ---
|
|
|
|
type EvalProduct struct {
|
|
Name string
|
|
EvalURL string
|
|
}
|
|
|
|
type EvalLink struct {
|
|
Arch string `json:"arch"`
|
|
Lang string `json:"lang"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type EvalLinksResponse struct {
|
|
Product string `json:"product"`
|
|
Name string `json:"name"`
|
|
Links []EvalLink `json:"links"`
|
|
}
|
|
|
|
type evalCacheEntry struct {
|
|
Links []EvalLink
|
|
CachedAt time.Time
|
|
}
|
|
|
|
var (
|
|
evalProductMap = map[string]EvalProduct{
|
|
"server-2025": {Name: "Windows Server 2025", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2025"},
|
|
"server-2022": {Name: "Windows Server 2022", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2022"},
|
|
"server-2019": {Name: "Windows Server 2019", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2019"},
|
|
"server-2016": {Name: "Windows Server 2016", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-server-2016"},
|
|
"win11-ent": {Name: "Windows 11 Enterprise", EvalURL: "https://www.microsoft.com/en-us/evalcenter/download-windows-11-enterprise"},
|
|
}
|
|
evalCache = make(map[string]evalCacheEntry)
|
|
evalCacheMu sync.RWMutex
|
|
evalCacheTTL = 24 * time.Hour
|
|
fwlinkRe = regexp.MustCompile(`https://go\.microsoft\.com/fwlink/[^"'\s<>]+`)
|
|
isoLangRe = regexp.MustCompile(`_([a-z]{2}-[a-z]{2})\.iso$`)
|
|
)
|
|
|
|
// --- Helpers ---
|
|
|
|
// jitter adds a random ±maxJ offset to base duration.
|
|
// Prevents synchronised mass-expiry spikes across cache entries.
|
|
func jitter(base, maxJ time.Duration) time.Duration {
|
|
offset := time.Duration(rng.Int63n(int64(maxJ*2))) - maxJ
|
|
return base + offset
|
|
}
|
|
|
|
// parseLinkExpiry reads the expiration parameter (se or P1) from the first
|
|
// download URL in a Microsoft GetProductDownloadLinksBySku response and returns
|
|
// a cache expiry time of (expiration - now) - 30min, with ±5min jitter.
|
|
// Falls back to 22h on any parse failure.
|
|
func parseLinkExpiry(rawJSON []byte) time.Time {
|
|
fallback := func() time.Time {
|
|
return time.Now().Add(jitter(22*time.Hour, 5*time.Minute))
|
|
}
|
|
expStr := extractRawExpiry(rawJSON)
|
|
if expStr == "" {
|
|
return fallback()
|
|
}
|
|
t, err := time.Parse(time.RFC3339, expStr)
|
|
if err != nil {
|
|
return fallback()
|
|
}
|
|
ttl := time.Until(t) - 30*time.Minute
|
|
if ttl < time.Hour {
|
|
ttl = time.Hour // safety floor — never cache for less than 1h
|
|
}
|
|
return time.Now().Add(jitter(ttl, 5*time.Minute))
|
|
}
|
|
|
|
// extractRawExpiry returns the raw expiration timestamp from the first URL
|
|
// in a GetProductDownloadLinksBySku response (RFC3339 string).
|
|
// It supports `se` (RFC3339) and `P1` (Unix timestamp) query parameters.
|
|
// Returns "" if absent or unparseable.
|
|
func extractRawExpiry(rawJSON []byte) string {
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal(rawJSON, &data); err != nil {
|
|
return ""
|
|
}
|
|
opts, ok := data["ProductDownloadOptions"].([]interface{})
|
|
if !ok || len(opts) == 0 {
|
|
return ""
|
|
}
|
|
first, ok := opts[0].(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
uri, ok := first["Uri"].(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(uri)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// 1. Try "se" query parameter (used on Server/Eval ISOs)
|
|
if se := u.Query().Get("se"); se != "" {
|
|
if _, err := time.Parse(time.RFC3339, se); err == nil {
|
|
return se
|
|
}
|
|
}
|
|
|
|
// 2. Try "P1" query parameter (Unix timestamp, used on Consumer ISOs)
|
|
if p1 := u.Query().Get("P1"); p1 != "" {
|
|
if p1Int, err := strconv.ParseInt(p1, 10, 64); err == nil {
|
|
return time.Unix(p1Int, 0).Format(time.RFC3339)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// rateLimitError marks a Microsoft rate-limit response so callers can choose
|
|
// to serve stale data instead of propagating the error.
|
|
type rateLimitError struct{ message string }
|
|
|
|
func (e *rateLimitError) Error() string { return e.message }
|
|
|
|
// --- Redis L2 cache helpers ---
|
|
|
|
func redisKey(productID, skuID string) string {
|
|
return "msdl:link:" + productID + ":" + skuID
|
|
}
|
|
|
|
func initRedis() {
|
|
redisURL := os.Getenv("REDIS_URL")
|
|
if redisURL == "" {
|
|
log.Println("redis: REDIS_URL not set — memory-only mode")
|
|
return
|
|
}
|
|
opts, err := redis.ParseURL(redisURL)
|
|
if err != nil {
|
|
log.Printf("redis: invalid REDIS_URL: %v — memory-only mode\n", err)
|
|
return
|
|
}
|
|
client := redis.NewClient(opts)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
if err := client.Ping(ctx).Err(); err != nil {
|
|
log.Printf("redis: ping failed: %v — memory-only mode\n", err)
|
|
return
|
|
}
|
|
rdb = client
|
|
log.Println("redis: connected")
|
|
seedMetricsFromRedis()
|
|
}
|
|
|
|
func redisWriteLink(productID, skuID string, raw []byte, expiresAt time.Time) {
|
|
if rdb == nil {
|
|
return
|
|
}
|
|
ttl := time.Until(expiresAt)
|
|
if ttl <= 0 {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if err := rdb.Set(ctx, redisKey(productID, skuID), raw, ttl).Err(); err != nil {
|
|
log.Printf("redis: write failed for %s:%s: %v\n", productID, skuID, err)
|
|
}
|
|
}
|
|
|
|
func redisSeedLinkCache() {
|
|
if rdb == nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
var cursor uint64
|
|
var seeded int
|
|
for {
|
|
keys, nextCursor, err := rdb.Scan(ctx, cursor, "msdl:link:*", 100).Result()
|
|
if err != nil {
|
|
log.Printf("redis: seed scan error: %v\n", err)
|
|
return
|
|
}
|
|
for _, key := range keys {
|
|
raw, err := rdb.Get(ctx, key).Bytes()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ttl, err := rdb.TTL(ctx, key).Result()
|
|
if err != nil || ttl <= 0 {
|
|
continue
|
|
}
|
|
// Key format: "msdl:link:<productID>:<skuID>"
|
|
parts := strings.SplitN(strings.TrimPrefix(key, "msdl:link:"), ":", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
cacheKey := parts[0] + ":" + parts[1]
|
|
expiresAt := time.Now().Add(ttl)
|
|
linkCacheMu.Lock()
|
|
linkCache[cacheKey] = linkCacheEntry{RawJSON: raw, ExpiresAt: expiresAt, FetchedAt: time.Now()}
|
|
linkCacheMu.Unlock()
|
|
seeded++
|
|
}
|
|
cursor = nextCursor
|
|
if cursor == 0 {
|
|
break
|
|
}
|
|
}
|
|
if seeded > 0 {
|
|
log.Printf("redis: seeded %d link cache entries from Redis\n", seeded)
|
|
}
|
|
}
|
|
|
|
// seedMetricsFromRedis loads persisted metric counters from Redis into the
|
|
// in-memory atomic vars. Called once on startup after Redis connects.
|
|
func seedMetricsFromRedis() {
|
|
if rdb == nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
loadHash := func(key string) map[string]string {
|
|
vals, err := rdb.HGetAll(ctx, key).Result()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return vals
|
|
}
|
|
parseI64 := func(m map[string]string, field string) int64 {
|
|
if m == nil {
|
|
return 0
|
|
}
|
|
v, _ := strconv.ParseInt(m[field], 10, 64)
|
|
return v
|
|
}
|
|
|
|
sku := loadHash("msdl:metrics:sku")
|
|
atomic.StoreInt64(&mSkuRequests, parseI64(sku, "requests"))
|
|
atomic.StoreInt64(&mSkuCacheHits, parseI64(sku, "cache_hits"))
|
|
atomic.StoreInt64(&mSkuFetches, parseI64(sku, "ms_fetches"))
|
|
atomic.StoreInt64(&mSkuNegHits, parseI64(sku, "neg_hits"))
|
|
|
|
link := loadHash("msdl:metrics:link")
|
|
atomic.StoreInt64(&mLinkRequests, parseI64(link, "requests"))
|
|
atomic.StoreInt64(&mLinkCacheHits, parseI64(link, "cache_hits"))
|
|
atomic.StoreInt64(&mLinkFetches, parseI64(link, "ms_fetches"))
|
|
atomic.StoreInt64(&mLinkNegHits, parseI64(link, "neg_hits"))
|
|
atomic.StoreInt64(&mLinkStale, parseI64(link, "stale"))
|
|
|
|
eval := loadHash("msdl:metrics:eval")
|
|
atomic.StoreInt64(&mEvalRequests, parseI64(eval, "requests"))
|
|
atomic.StoreInt64(&mEvalCacheHits, parseI64(eval, "cache_hits"))
|
|
atomic.StoreInt64(&mEvalStale, parseI64(eval, "stale"))
|
|
|
|
log.Println("redis: seeded in-memory metrics from Redis")
|
|
}
|
|
|
|
// flushMetricsToRedis writes current in-memory metric counters to Redis.
|
|
// Called periodically (every 5 min) and on graceful shutdown.
|
|
func flushMetricsToRedis() {
|
|
if rdb == nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
rdb.HSet(ctx, "msdl:metrics:sku",
|
|
"requests", atomic.LoadInt64(&mSkuRequests),
|
|
"cache_hits", atomic.LoadInt64(&mSkuCacheHits),
|
|
"ms_fetches", atomic.LoadInt64(&mSkuFetches),
|
|
"neg_hits", atomic.LoadInt64(&mSkuNegHits),
|
|
)
|
|
rdb.HSet(ctx, "msdl:metrics:link",
|
|
"requests", atomic.LoadInt64(&mLinkRequests),
|
|
"cache_hits", atomic.LoadInt64(&mLinkCacheHits),
|
|
"ms_fetches", atomic.LoadInt64(&mLinkFetches),
|
|
"neg_hits", atomic.LoadInt64(&mLinkNegHits),
|
|
"stale", atomic.LoadInt64(&mLinkStale),
|
|
)
|
|
rdb.HSet(ctx, "msdl:metrics:eval",
|
|
"requests", atomic.LoadInt64(&mEvalRequests),
|
|
"cache_hits", atomic.LoadInt64(&mEvalCacheHits),
|
|
"stale", atomic.LoadInt64(&mEvalStale),
|
|
)
|
|
log.Println("redis: flushed in-memory metrics to Redis")
|
|
}
|
|
|
|
// startMetricsFlusher flushes in-memory metrics to Redis every 5 minutes.
|
|
func startMetricsFlusher() {
|
|
ticker := time.NewTicker(5 * time.Minute)
|
|
for range ticker.C {
|
|
flushMetricsToRedis()
|
|
}
|
|
}
|
|
|
|
// --- /contribute validation helpers ---
|
|
|
|
// validContributeProducts is the canonical product ID allow-list for POST /contribute.
|
|
var validContributeProducts = map[string]bool{
|
|
"52": true, "2378": true, "2618": true,
|
|
"3113": true, "3114": true, "3115": true,
|
|
"3131": true, "3132": true, "3133": true,
|
|
"3262": true, "3263": true, "3264": true,
|
|
"3265": true, "3266": true, "3267": true,
|
|
"3321": true, "3322": true, "3323": true,
|
|
"3324": true, "3325": true, "3326": true,
|
|
}
|
|
|
|
// allowedCDNSuffixes is the CDN host allow-list for contributed download URLs.
|
|
// Worst-case abuse: attacker submits a valid Microsoft-signed link — harmless.
|
|
var allowedCDNSuffixes = []string{
|
|
".download.prss.microsoft.com",
|
|
".dl.delivery.mp.microsoft.com",
|
|
".delivery.mp.microsoft.com",
|
|
}
|
|
|
|
func isAllowedCDNHost(rawURL string) bool {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
host := strings.ToLower(u.Hostname())
|
|
for _, suffix := range allowedCDNSuffixes {
|
|
if strings.HasSuffix(host, suffix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// --- Per-IP rate limiter (token bucket, used by /contribute) ---
|
|
|
|
type tokenBucket struct {
|
|
tokens float64
|
|
maxTokens float64
|
|
refillRate float64 // tokens per second
|
|
lastRefill time.Time
|
|
}
|
|
|
|
func (b *tokenBucket) allow() bool {
|
|
now := time.Now()
|
|
elapsed := now.Sub(b.lastRefill).Seconds()
|
|
b.tokens += elapsed * b.refillRate
|
|
if b.tokens > b.maxTokens {
|
|
b.tokens = b.maxTokens
|
|
}
|
|
b.lastRefill = now
|
|
if b.tokens >= 1 {
|
|
b.tokens--
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
type ipRateLimiter struct {
|
|
mu sync.Mutex
|
|
buckets map[string]*tokenBucket
|
|
rate float64
|
|
burst float64
|
|
}
|
|
|
|
func newIPRateLimiter(ratePerMin, burst float64) *ipRateLimiter {
|
|
rl := &ipRateLimiter{
|
|
buckets: make(map[string]*tokenBucket),
|
|
rate: ratePerMin / 60.0,
|
|
burst: burst,
|
|
}
|
|
go func() {
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
for range ticker.C {
|
|
rl.mu.Lock()
|
|
for ip, b := range rl.buckets {
|
|
if b.tokens >= b.maxTokens {
|
|
delete(rl.buckets, ip)
|
|
}
|
|
}
|
|
rl.mu.Unlock()
|
|
}
|
|
}()
|
|
return rl
|
|
}
|
|
|
|
func (rl *ipRateLimiter) allow(ip string) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
b, ok := rl.buckets[ip]
|
|
if !ok {
|
|
b = &tokenBucket{
|
|
tokens: rl.burst,
|
|
maxTokens: rl.burst,
|
|
refillRate: rl.rate,
|
|
lastRefill: time.Now(),
|
|
}
|
|
rl.buckets[ip] = b
|
|
}
|
|
return b.allow()
|
|
}
|
|
|
|
var contributeRL = newIPRateLimiter(5, 5) // 5 requests/min, burst of 5
|
|
|
|
var telemetryRL = newIPRateLimiter(10, 10) // 10 requests/min, burst 10
|
|
|
|
type telemetryPayload struct {
|
|
Action string `json:"action"`
|
|
ProductID string `json:"product_id"`
|
|
EvalSlug string `json:"eval_slug"`
|
|
Platform string `json:"platform"`
|
|
Version string `json:"version"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// --- Evalcenter helpers ---
|
|
|
|
func detectArch(rawURL string) string {
|
|
lower := strings.ToLower(rawURL)
|
|
if strings.Contains(lower, "arm64") {
|
|
return "ARM64"
|
|
}
|
|
if strings.Contains(lower, "x64") {
|
|
return "x64"
|
|
}
|
|
if strings.Contains(lower, "x86") {
|
|
return "x86"
|
|
}
|
|
return "ISO"
|
|
}
|
|
|
|
func detectLang(rawURL string) string {
|
|
m := isoLangRe.FindStringSubmatch(strings.ToLower(rawURL))
|
|
if len(m) > 1 {
|
|
return m[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fetchEvalLinks(evalURL string) ([]EvalLink, error) {
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
|
|
req, _ := http.NewRequest("GET", evalURL, nil)
|
|
req.Header.Set("User-Agent", UA)
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching evalcenter page: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
|
|
rawMatches := fwlinkRe.FindAllString(string(bodyBytes), -1)
|
|
|
|
seen := map[string]bool{}
|
|
var fwlinks []string
|
|
for _, raw := range rawMatches {
|
|
fwlink := html.UnescapeString(raw)
|
|
if !seen[fwlink] {
|
|
seen[fwlink] = true
|
|
fwlinks = append(fwlinks, fwlink)
|
|
}
|
|
}
|
|
|
|
type result struct {
|
|
link EvalLink
|
|
ok bool
|
|
}
|
|
results := make([]result, len(fwlinks))
|
|
var wg sync.WaitGroup
|
|
for i, fwlink := range fwlinks {
|
|
wg.Add(1)
|
|
go func(idx int, fw string) {
|
|
defer wg.Done()
|
|
fwReq, _ := http.NewRequest("GET", fw, nil)
|
|
fwReq.Header.Set("User-Agent", UA)
|
|
fwResp, err := client.Do(fwReq)
|
|
if err != nil {
|
|
return
|
|
}
|
|
fwResp.Body.Close()
|
|
finalURL := fwResp.Request.URL.String()
|
|
if !strings.Contains(strings.ToLower(finalURL), ".iso") {
|
|
return
|
|
}
|
|
results[idx] = result{
|
|
link: EvalLink{Arch: detectArch(finalURL), Lang: detectLang(finalURL), URL: finalURL},
|
|
ok: true,
|
|
}
|
|
}(i, fwlink)
|
|
}
|
|
wg.Wait()
|
|
|
|
var links []EvalLink
|
|
for _, r := range results {
|
|
if r.ok {
|
|
links = append(links, r.link)
|
|
}
|
|
}
|
|
|
|
sort.Slice(links, func(i, j int) bool {
|
|
if links[i].Lang == "en-us" {
|
|
return true
|
|
}
|
|
if links[j].Lang == "en-us" {
|
|
return false
|
|
}
|
|
return links[i].Lang < links[j].Lang
|
|
})
|
|
|
|
return links, nil
|
|
}
|
|
|
|
// --- CF Worker / session helpers ---
|
|
|
|
func setWorkerSecret(req *http.Request) {
|
|
if workerSecret != "" {
|
|
req.Header.Set("X-Worker-Secret", workerSecret)
|
|
}
|
|
}
|
|
|
|
func proxyURL(msURL string) string {
|
|
workerBase := os.Getenv("CF_WORKER_URL")
|
|
if workerBase == "" {
|
|
return msURL
|
|
}
|
|
parsed, err := url.Parse(msURL)
|
|
if err != nil {
|
|
return msURL
|
|
}
|
|
workerParsed, err := url.Parse(workerBase)
|
|
if err != nil {
|
|
return msURL
|
|
}
|
|
q := parsed.Query()
|
|
q.Set("host", parsed.Hostname())
|
|
workerParsed.Path = parsed.Path
|
|
workerParsed.RawQuery = q.Encode()
|
|
return workerParsed.String()
|
|
}
|
|
|
|
func getReferer(productId string) string {
|
|
id, err := strconv.Atoi(productId)
|
|
if err != nil {
|
|
return "https://www.microsoft.com/en-us/software-download/windows8ISO"
|
|
}
|
|
if id >= 2935 {
|
|
return "https://www.microsoft.com/en-us/software-download/windows11"
|
|
}
|
|
if id >= 2618 {
|
|
return "https://www.microsoft.com/en-us/software-download/windows10ISO"
|
|
}
|
|
return "https://www.microsoft.com/en-us/software-download/windows8ISO"
|
|
}
|
|
|
|
func setupSession() (string, *simpleCookieJar, error) {
|
|
sessionID := uuid.New().String()
|
|
jar := &simpleCookieJar{}
|
|
client := &http.Client{Timeout: 10 * time.Second, Jar: jar}
|
|
|
|
q1 := url.Values{}
|
|
q1.Set("org_id", ORG_ID)
|
|
q1.Set("session_id", sessionID)
|
|
req1, _ := http.NewRequest("GET", proxyURL("https://vlscppe.microsoft.com/tags?"+q1.Encode()), nil)
|
|
req1.Header.Set("User-Agent", UA)
|
|
setWorkerSecret(req1)
|
|
client.Do(req1)
|
|
|
|
q2 := url.Values{}
|
|
q2.Set("instanceId", CUSTOMER_ID)
|
|
q2.Set("PageId", "si")
|
|
q2.Set("session_id", sessionID)
|
|
req2, _ := http.NewRequest("GET", proxyURL("https://ov-df.microsoft.com/mdt.js?"+q2.Encode()), nil)
|
|
req2.Header.Set("User-Agent", UA)
|
|
setWorkerSecret(req2)
|
|
resp2, err := client.Do(req2)
|
|
if err != nil {
|
|
return sessionID, jar, nil
|
|
}
|
|
defer resp2.Body.Close()
|
|
|
|
bodyBytes, _ := io.ReadAll(resp2.Body)
|
|
mdtText := string(bodyBytes)
|
|
|
|
reW := regexp.MustCompile(`[&?]w=([^&"'\s]+)`)
|
|
reRt := regexp.MustCompile(`rticks[="]+\+?\s*(\d{10,})`)
|
|
|
|
wMatch := reW.FindStringSubmatch(mdtText)
|
|
rtMatch := reRt.FindStringSubmatch(mdtText)
|
|
|
|
if len(wMatch) > 1 && len(rtMatch) > 1 {
|
|
wVal := wMatch[1]
|
|
rtVal := rtMatch[1]
|
|
mdt := time.Now().UnixMilli()
|
|
|
|
q3 := url.Values{}
|
|
q3.Set("session_id", sessionID)
|
|
q3.Set("CustomerId", CUSTOMER_ID)
|
|
q3.Set("PageId", "si")
|
|
q3.Set("w", wVal)
|
|
q3.Set("mdt", fmt.Sprintf("%d", mdt))
|
|
q3.Set("rticks", rtVal)
|
|
fpURL := proxyURL("https://ov-df.microsoft.com/?" + q3.Encode())
|
|
|
|
req3, _ := http.NewRequest("GET", fpURL, nil)
|
|
req3.Header.Set("User-Agent", UA)
|
|
setWorkerSecret(req3)
|
|
client.Do(req3)
|
|
}
|
|
|
|
return sessionID, jar, nil
|
|
}
|
|
|
|
func mapDownloadType(typeNum int) string {
|
|
switch typeNum {
|
|
case 0:
|
|
return "x86"
|
|
case 1:
|
|
return "x64"
|
|
case 2:
|
|
return "ARM64"
|
|
default:
|
|
return fmt.Sprintf("type_%d", typeNum)
|
|
}
|
|
}
|
|
|
|
// --- Microsoft fetch functions (called via singleflight) ---
|
|
|
|
// fetchSkuInfoFromMS performs the full Microsoft session + SKU info fetch.
|
|
// Returns (rawJSON, httpErrorCode, error). httpErrorCode is non-zero only on
|
|
// rate-limit/API errors that should be stored in the negative cache.
|
|
func fetchSkuInfoFromMS(productID string) ([]byte, int, error) {
|
|
sessionID, jar, _ := setupSession()
|
|
|
|
cacheMutex.Lock()
|
|
sessionCache[productID] = SessionEntry{SessionID: sessionID, CreatedAt: time.Now(), Jar: jar}
|
|
cacheMutex.Unlock()
|
|
|
|
skuQ := url.Values{}
|
|
skuQ.Set("profile", PROFILE)
|
|
skuQ.Set("productEditionId", productID)
|
|
skuQ.Set("SKU", "undefined")
|
|
skuQ.Set("friendlyFileName", "undefined")
|
|
skuQ.Set("Locale", LOCALE)
|
|
skuQ.Set("sessionID", sessionID)
|
|
reqURL := proxyURL("https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?" + skuQ.Encode())
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second, Jar: jar}
|
|
var finalData map[string]interface{}
|
|
var finalRaw []byte
|
|
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
if attempt > 0 {
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
req, _ := http.NewRequest("GET", reqURL, nil)
|
|
req.Header.Set("User-Agent", UA)
|
|
req.Header.Set("Referer", getReferer(productID))
|
|
req.Header.Set("Accept", "application/json")
|
|
setWorkerSecret(req)
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
if attempt == 2 {
|
|
return nil, 0, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
return nil, http.StatusTooManyRequests, &rateLimitError{"Microsoft is rate-limiting requests. Please try again shortly."}
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
if attempt == 2 {
|
|
return nil, http.StatusBadGateway, fmt.Errorf("Microsoft API returned HTTP %d: %s", resp.StatusCode, truncateBody(string(bodyBytes)))
|
|
}
|
|
continue
|
|
}
|
|
|
|
if len(bodyBytes) > 0 && bodyBytes[0] == '"' {
|
|
var unquoted string
|
|
json.Unmarshal(bodyBytes, &unquoted)
|
|
bodyBytes = []byte(unquoted)
|
|
}
|
|
|
|
var data map[string]interface{}
|
|
json.Unmarshal(bodyBytes, &data)
|
|
|
|
if errs, ok := data["Errors"].([]interface{}); ok && len(errs) > 0 {
|
|
if attempt == 2 {
|
|
msg := "Microsoft API error"
|
|
code := http.StatusBadGateway
|
|
if errMap, ok := errs[0].(map[string]interface{}); ok {
|
|
if val, exists := errMap["Value"]; exists {
|
|
msg = val.(string)
|
|
}
|
|
// 715-123130 rate-limit code
|
|
if typeNum, ok := errMap["Type"].(float64); ok && int(typeNum) == 9 {
|
|
code = http.StatusTooManyRequests
|
|
return nil, code, &rateLimitError{msg}
|
|
}
|
|
}
|
|
return nil, code, fmt.Errorf("%s", msg)
|
|
}
|
|
continue
|
|
}
|
|
|
|
finalData = data
|
|
finalRaw = bodyBytes
|
|
break
|
|
}
|
|
|
|
skus, ok := finalData["Skus"].([]interface{})
|
|
if !ok || len(skus) == 0 {
|
|
return nil, http.StatusNotFound, fmt.Errorf("no languages found for this product ID")
|
|
}
|
|
|
|
log.Printf("MS fetch /skuinfo: product_id=%s -> %d languages\n", productID, len(skus))
|
|
return finalRaw, 0, nil
|
|
}
|
|
|
|
// fetchDownloadLinksFromMS performs the Microsoft download link fetch.
|
|
// Returns (rawJSON, error). rateLimitError is returned on 429/715-123130.
|
|
func fetchDownloadLinksFromMS(productID, skuID string) ([]byte, error) {
|
|
var sessionID string
|
|
var jar *simpleCookieJar
|
|
cacheMutex.RLock()
|
|
cached, exists := sessionCache[productID]
|
|
cacheMutex.RUnlock()
|
|
|
|
if exists && time.Since(cached.CreatedAt) < SESSION_TTL {
|
|
sessionID = cached.SessionID
|
|
jar = cached.Jar
|
|
log.Printf("MS fetch /proxy: reusing session %s for product_id=%s sku_id=%s\n", sessionID[:8], productID, skuID)
|
|
} else {
|
|
log.Printf("MS fetch /proxy: new session for product_id=%s sku_id=%s\n", productID, skuID)
|
|
sessionID, jar, _ = setupSession()
|
|
|
|
// Warm the session with a SKU info call so cookies from setup carry through
|
|
warmupQ := url.Values{}
|
|
warmupQ.Set("profile", PROFILE)
|
|
warmupQ.Set("productEditionId", productID)
|
|
warmupQ.Set("SKU", "undefined")
|
|
warmupQ.Set("friendlyFileName", "undefined")
|
|
warmupQ.Set("Locale", LOCALE)
|
|
warmupQ.Set("sessionID", sessionID)
|
|
warmupURL := proxyURL("https://www.microsoft.com/software-download-connector/api/getskuinformationbyproductedition?" + warmupQ.Encode())
|
|
|
|
warmupClient := &http.Client{Timeout: 10 * time.Second, Jar: jar}
|
|
req, _ := http.NewRequest("GET", warmupURL, nil)
|
|
req.Header.Set("User-Agent", UA)
|
|
req.Header.Set("Referer", getReferer(productID))
|
|
req.Header.Set("Accept", "application/json")
|
|
setWorkerSecret(req)
|
|
warmupClient.Do(req)
|
|
|
|
cacheMutex.Lock()
|
|
sessionCache[productID] = SessionEntry{SessionID: sessionID, CreatedAt: time.Now(), Jar: jar}
|
|
cacheMutex.Unlock()
|
|
}
|
|
|
|
// Guard against sessions cached before the cookie jar was introduced
|
|
if jar == nil {
|
|
jar = &simpleCookieJar{}
|
|
}
|
|
|
|
proxyQ := url.Values{}
|
|
proxyQ.Set("profile", PROFILE)
|
|
proxyQ.Set("productEditionId", "undefined")
|
|
proxyQ.Set("SKU", skuID)
|
|
proxyQ.Set("friendlyFileName", "undefined")
|
|
proxyQ.Set("Locale", LOCALE)
|
|
proxyQ.Set("sessionID", sessionID)
|
|
reqURL := proxyURL("https://www.microsoft.com/software-download-connector/api/GetProductDownloadLinksBySku?" + proxyQ.Encode())
|
|
|
|
downloadClient := &http.Client{Timeout: 15 * time.Second, Jar: jar}
|
|
req, _ := http.NewRequest("GET", reqURL, nil)
|
|
req.Header.Set("User-Agent", UA)
|
|
req.Header.Set("Referer", getReferer(productID))
|
|
req.Header.Set("Accept", "application/json")
|
|
setWorkerSecret(req)
|
|
|
|
resp, err := downloadClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
return nil, &rateLimitError{"Microsoft is rate-limiting requests. Please try again shortly."}
|
|
}
|
|
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
bodyStr := string(bodyBytes)
|
|
// Sentinel WAF block — the session is tainted; evict it so the next request
|
|
// starts a clean session rather than hammering the same poisoned one.
|
|
if strings.Contains(bodyStr, "Sentinel") {
|
|
cacheMutex.Lock()
|
|
delete(sessionCache, productID)
|
|
cacheMutex.Unlock()
|
|
log.Printf("MS fetch /proxy: Sentinel block for product_id=%s — session evicted\n", productID)
|
|
return nil, &rateLimitError{"Sentinel marked this request as rejected."}
|
|
}
|
|
return nil, fmt.Errorf("Microsoft API returned HTTP %d: %s", resp.StatusCode, truncateBody(bodyStr))
|
|
}
|
|
|
|
if len(bodyBytes) > 0 && bodyBytes[0] == '"' {
|
|
var unquoted string
|
|
json.Unmarshal(bodyBytes, &unquoted)
|
|
bodyBytes = []byte(unquoted)
|
|
}
|
|
|
|
var data map[string]interface{}
|
|
json.Unmarshal(bodyBytes, &data)
|
|
|
|
if errs, ok := data["Errors"].([]interface{}); ok && len(errs) > 0 {
|
|
if errMap, ok := errs[0].(map[string]interface{}); ok {
|
|
if typeNum, exists := errMap["Type"].(float64); exists && int(typeNum) == 9 {
|
|
msg := "Your IP has been temporarily blocked by Microsoft. Please try again later. (Code 715-123130)"
|
|
if val, ok := errMap["Value"].(string); ok {
|
|
msg = val
|
|
}
|
|
return nil, &rateLimitError{msg}
|
|
}
|
|
if val, exists := errMap["Value"].(string); exists {
|
|
if strings.Contains(val, "Sentinel") {
|
|
cacheMutex.Lock()
|
|
delete(sessionCache, productID)
|
|
cacheMutex.Unlock()
|
|
log.Printf("MS fetch /proxy: Sentinel block for product_id=%s — session evicted\n", productID)
|
|
return nil, &rateLimitError{"Sentinel marked this request as rejected."}
|
|
}
|
|
return nil, fmt.Errorf("%s", val)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("Microsoft API error")
|
|
}
|
|
|
|
opts, ok := data["ProductDownloadOptions"].([]interface{})
|
|
if !ok || len(opts) == 0 {
|
|
return nil, fmt.Errorf("no download links found for this SKU")
|
|
}
|
|
|
|
for _, optRaw := range opts {
|
|
if optMap, ok := optRaw.(map[string]interface{}); ok {
|
|
if arch, hasArch := optMap["Architecture"]; !hasArch || arch == nil {
|
|
if dType, hasDType := optMap["DownloadType"].(float64); hasDType {
|
|
optMap["Architecture"] = mapDownloadType(int(dType))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Printf("MS fetch /proxy: product_id=%s sku_id=%s -> %d links\n", productID, skuID, len(opts))
|
|
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
enc.Encode(data)
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// --- Background helpers ---
|
|
|
|
func cleanupSessions() {
|
|
for {
|
|
time.Sleep(1 * time.Minute)
|
|
cacheMutex.Lock()
|
|
now := time.Now()
|
|
for key, entry := range sessionCache {
|
|
if now.Sub(entry.CreatedAt) > SESSION_TTL {
|
|
delete(sessionCache, key)
|
|
}
|
|
}
|
|
cacheMutex.Unlock()
|
|
}
|
|
}
|
|
|
|
// --- CORS middleware ---
|
|
|
|
func enableCORS(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "https://msdl.tech-latest.com" || strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://100.") || strings.HasPrefix(origin, "http://192.168.") {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
w.Header().Set("Access-Control-Expose-Headers", "X-MSDL-Link-Status, X-MSDL-Link-Expires, X-MSDL-Lockdown")
|
|
if r.Method == "OPTIONS" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func respondJSONError(w http.ResponseWriter, status int, message string) {
|
|
log.Printf("ERROR [%d]: %s\n", status, message)
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
|
}
|
|
|
|
// --- /skuinfo endpoint ---
|
|
|
|
func handleSkuInfo(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
productID := r.URL.Query().Get("product_id")
|
|
if productID == "" {
|
|
respondJSONError(w, http.StatusBadRequest, "product_id query parameter is required")
|
|
return
|
|
}
|
|
if _, err := strconv.Atoi(productID); err != nil {
|
|
respondJSONError(w, http.StatusBadRequest, "product_id must be a numeric value")
|
|
return
|
|
}
|
|
|
|
atomic.AddInt64(&mSkuRequests, 1)
|
|
negKey := "sku:" + productID
|
|
|
|
// 1. Check negative cache (short-circuit during rate-limit events)
|
|
negCacheMu.RLock()
|
|
neg, hasNeg := negCache[negKey]
|
|
negCacheMu.RUnlock()
|
|
if hasNeg && time.Now().Before(neg.ExpiresAt) {
|
|
atomic.AddInt64(&mSkuNegHits, 1)
|
|
log.Printf("/skuinfo: product_id=%s -> neg cache hit\n", productID)
|
|
respondJSONError(w, neg.HTTPCode, neg.Message)
|
|
return
|
|
}
|
|
|
|
// 2. Check SKU cache (7-day TTL)
|
|
skuCacheMu.RLock()
|
|
entry, hasSku := skuCache[productID]
|
|
skuCacheMu.RUnlock()
|
|
if hasSku && time.Now().Before(entry.ExpiresAt) {
|
|
atomic.AddInt64(&mSkuCacheHits, 1)
|
|
log.Printf("/skuinfo: product_id=%s -> cache hit\n", productID)
|
|
w.Write(entry.RawJSON)
|
|
return
|
|
}
|
|
|
|
// 3. Singleflight: collapse concurrent cache misses into one Microsoft fetch
|
|
sfKey := "sku:" + productID
|
|
type sfResult struct {
|
|
raw []byte
|
|
code int
|
|
}
|
|
v, err, _ := sfGroup.Do(sfKey, func() (interface{}, error) {
|
|
raw, code, err := fetchSkuInfoFromMS(productID)
|
|
return sfResult{raw: raw, code: code}, err
|
|
})
|
|
|
|
if err != nil {
|
|
res := v.(sfResult)
|
|
code := res.code
|
|
if code == 0 {
|
|
code = http.StatusBadGateway
|
|
}
|
|
// 4. Store in negative cache only for rate-limit / API errors
|
|
if _, isRL := err.(*rateLimitError); isRL || code == http.StatusTooManyRequests {
|
|
negCacheMu.Lock()
|
|
negCache[negKey] = negCacheEntry{
|
|
Message: err.Error(),
|
|
HTTPCode: http.StatusTooManyRequests,
|
|
ExpiresAt: time.Now().Add(negCacheTTL),
|
|
}
|
|
negCacheMu.Unlock()
|
|
}
|
|
respondJSONError(w, code, err.Error())
|
|
return
|
|
}
|
|
|
|
res := v.(sfResult)
|
|
|
|
// 5. Store in SKU cache with 7-day TTL + jitter
|
|
skuCacheMu.Lock()
|
|
skuCache[productID] = skuCacheEntry{
|
|
RawJSON: res.raw,
|
|
ExpiresAt: time.Now().Add(jitter(skuCacheTTL, 5*time.Minute)),
|
|
}
|
|
skuCacheMu.Unlock()
|
|
|
|
atomic.AddInt64(&mSkuFetches, 1)
|
|
log.Printf("/skuinfo: product_id=%s -> fetched and cached\n", productID)
|
|
w.Write(res.raw)
|
|
}
|
|
|
|
// --- /proxy endpoint ---
|
|
|
|
func handleProxy(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
productID := r.URL.Query().Get("product_id")
|
|
skuID := r.URL.Query().Get("sku_id")
|
|
|
|
if productID == "" || skuID == "" {
|
|
respondJSONError(w, http.StatusBadRequest, "product_id and sku_id query parameters are required")
|
|
return
|
|
}
|
|
if _, err := strconv.Atoi(productID); err != nil {
|
|
respondJSONError(w, http.StatusBadRequest, "product_id must be a numeric value")
|
|
return
|
|
}
|
|
if !validSkuID.MatchString(skuID) {
|
|
respondJSONError(w, http.StatusBadRequest, "sku_id contains invalid characters")
|
|
return
|
|
}
|
|
|
|
atomic.AddInt64(&mLinkRequests, 1)
|
|
cacheKey := productID + ":" + skuID
|
|
negKey := "link:" + cacheKey
|
|
forceRefresh := r.URL.Query().Get("force") == "true"
|
|
|
|
// 0. Sentinel lockdown gate — checked before everything, including force refresh.
|
|
// When a Sentinel block is active, zero Microsoft API calls are made regardless
|
|
// of force=true, to avoid extending the block timer.
|
|
negCacheMu.RLock()
|
|
lockdownEntry, hasLockdown := negCache[negKey]
|
|
negCacheMu.RUnlock()
|
|
if hasLockdown && time.Now().Before(lockdownEntry.ExpiresAt) && lockdownEntry.IsSentinel {
|
|
atomic.AddInt64(&mLinkNegHits, 1)
|
|
w.Header().Set("X-MSDL-Lockdown", "1")
|
|
linkCacheMu.RLock()
|
|
staleEntry, hasStale := linkCache[cacheKey]
|
|
linkCacheMu.RUnlock()
|
|
if hasStale {
|
|
if time.Now().Before(staleEntry.ExpiresAt) {
|
|
// Entry is still within its normal TTL (e.g. just contributed by
|
|
// a CLI run) — genuinely fresh, not actually stale, just being
|
|
// served via the lockdown gate before the usual cache check.
|
|
atomic.AddInt64(&mLinkCacheHits, 1)
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, serving cached (expires %s)\n",
|
|
productID, skuID, staleEntry.ExpiresAt.Format(time.RFC3339))
|
|
w.Header().Set("X-MSDL-Link-Status", "cached")
|
|
} else {
|
|
atomic.AddInt64(&mLinkStale, 1)
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, serving stale (expires %s)\n",
|
|
productID, skuID, lockdownEntry.ExpiresAt.Format(time.RFC3339))
|
|
w.Header().Set("X-MSDL-Link-Status", "stale")
|
|
}
|
|
if exp := extractRawExpiry(staleEntry.RawJSON); exp != "" {
|
|
w.Header().Set("X-MSDL-Link-Expires", exp)
|
|
}
|
|
w.Write(staleEntry.RawJSON)
|
|
return
|
|
}
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> lockdown active, no stale available\n", productID, skuID)
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": lockdownEntry.Message,
|
|
"lockdown": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
// 1. Check link cache (dynamic TTL, stale-on-failure) — skipped when force=true
|
|
linkCacheMu.RLock()
|
|
cached, hasCached := linkCache[cacheKey]
|
|
linkCacheMu.RUnlock()
|
|
|
|
if !forceRefresh && hasCached && time.Now().Before(cached.ExpiresAt) {
|
|
atomic.AddInt64(&mLinkCacheHits, 1)
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> cache hit\n", productID, skuID)
|
|
w.Header().Set("X-MSDL-Link-Status", "cached")
|
|
if exp := extractRawExpiry(cached.RawJSON); exp != "" {
|
|
w.Header().Set("X-MSDL-Link-Expires", exp)
|
|
}
|
|
w.Write(cached.RawJSON)
|
|
return
|
|
}
|
|
|
|
// 1b. Redis L2 cache — check after in-memory miss, before calling Microsoft
|
|
if !forceRefresh && !hasCached && rdb != nil {
|
|
rctx, rcancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
|
redisRaw, redisErr := rdb.Get(rctx, redisKey(productID, skuID)).Bytes()
|
|
rcancel()
|
|
if redisErr == nil && len(redisRaw) > 0 {
|
|
exp := parseLinkExpiry(redisRaw)
|
|
if time.Now().Before(exp) {
|
|
linkCacheMu.Lock()
|
|
linkCache[cacheKey] = linkCacheEntry{RawJSON: redisRaw, ExpiresAt: exp, FetchedAt: time.Now()}
|
|
linkCacheMu.Unlock()
|
|
atomic.AddInt64(&mLinkCacheHits, 1)
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> redis L2 hit\n", productID, skuID)
|
|
w.Header().Set("X-MSDL-Link-Status", "cached")
|
|
if rawExp := extractRawExpiry(redisRaw); rawExp != "" {
|
|
w.Header().Set("X-MSDL-Link-Expires", rawExp)
|
|
}
|
|
w.Write(redisRaw)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Check negative cache — but only if no stale data and not a force refresh
|
|
if !forceRefresh && !hasCached {
|
|
negCacheMu.RLock()
|
|
neg, hasNeg := negCache[negKey]
|
|
negCacheMu.RUnlock()
|
|
if hasNeg && time.Now().Before(neg.ExpiresAt) {
|
|
atomic.AddInt64(&mLinkNegHits, 1)
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> neg cache hit\n", productID, skuID)
|
|
respondJSONError(w, neg.HTTPCode, neg.Message)
|
|
return
|
|
}
|
|
}
|
|
|
|
// 3. Singleflight: one fetch per (product, sku) key
|
|
sfKey := "link:" + cacheKey
|
|
if forceRefresh {
|
|
sfKey = "link:force:" + cacheKey
|
|
}
|
|
v, err, _ := sfGroup.Do(sfKey, func() (interface{}, error) {
|
|
return fetchDownloadLinksFromMS(productID, skuID)
|
|
})
|
|
|
|
if err != nil {
|
|
isSentinel := strings.Contains(err.Error(), "Sentinel")
|
|
|
|
// Stale-on-failure: serve expired cache entry rather than erroring out.
|
|
if hasCached {
|
|
atomic.AddInt64(&mLinkStale, 1)
|
|
|
|
if isSentinel {
|
|
// Sentinel lockdown: set 90-min neg cache, add lockdown header, skip
|
|
// background refresh — retrying extends the Sentinel block timer.
|
|
negCacheMu.Lock()
|
|
negCache[negKey] = negCacheEntry{
|
|
Message: err.Error(),
|
|
HTTPCode: http.StatusTooManyRequests,
|
|
ExpiresAt: time.Now().Add(lockdownTTL),
|
|
IsSentinel: true,
|
|
}
|
|
negCacheMu.Unlock()
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> Sentinel lockdown (90min), serving stale\n", productID, skuID)
|
|
w.Header().Set("X-MSDL-Lockdown", "1")
|
|
w.Header().Set("X-MSDL-Link-Status", "stale")
|
|
if exp := extractRawExpiry(cached.RawJSON); exp != "" {
|
|
w.Header().Set("X-MSDL-Link-Expires", exp)
|
|
}
|
|
w.Write(cached.RawJSON)
|
|
return
|
|
}
|
|
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> fetch failed (%v), serving stale\n", productID, skuID, err)
|
|
go func() {
|
|
// Use singleflight so concurrent stale serves don't all hit Microsoft
|
|
sfBgKey := "link:" + cacheKey
|
|
sfGroup.Do(sfBgKey, func() (interface{}, error) {
|
|
raw, bgErr := fetchDownloadLinksFromMS(productID, skuID)
|
|
if bgErr != nil {
|
|
log.Printf("/proxy: background refresh failed for %s:%s: %v\n", productID, skuID, bgErr)
|
|
return nil, bgErr
|
|
}
|
|
exp := parseLinkExpiry(raw)
|
|
linkCacheMu.Lock()
|
|
linkCache[cacheKey] = linkCacheEntry{RawJSON: raw, ExpiresAt: exp, FetchedAt: time.Now()}
|
|
linkCacheMu.Unlock()
|
|
redisWriteLink(productID, skuID, raw, exp)
|
|
log.Printf("/proxy: background refresh succeeded for %s:%s, cached until %s\n", productID, skuID, exp.Format(time.RFC3339))
|
|
return nil, nil
|
|
})
|
|
}()
|
|
w.Header().Set("X-MSDL-Link-Status", "stale")
|
|
if exp := extractRawExpiry(cached.RawJSON); exp != "" {
|
|
w.Header().Set("X-MSDL-Link-Expires", exp)
|
|
}
|
|
w.Write(cached.RawJSON)
|
|
return
|
|
}
|
|
|
|
// No stale data available.
|
|
code := http.StatusBadGateway
|
|
if _, isRL := err.(*rateLimitError); isRL {
|
|
code = http.StatusTooManyRequests
|
|
ttl := negCacheTTL
|
|
if isSentinel {
|
|
ttl = lockdownTTL
|
|
}
|
|
negCacheMu.Lock()
|
|
negCache[negKey] = negCacheEntry{
|
|
Message: err.Error(),
|
|
HTTPCode: code,
|
|
ExpiresAt: time.Now().Add(ttl),
|
|
IsSentinel: isSentinel,
|
|
}
|
|
negCacheMu.Unlock()
|
|
if isSentinel {
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> Sentinel lockdown (90min), no stale\n", productID, skuID)
|
|
w.Header().Set("X-MSDL-Lockdown", "1")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"error": err.Error(),
|
|
"lockdown": true,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
respondJSONError(w, code, err.Error())
|
|
return
|
|
}
|
|
|
|
raw := v.([]byte)
|
|
|
|
// 5. Dynamic TTL: parse `se` from signed URL, subtract 30min buffer, add jitter
|
|
expiresAt := parseLinkExpiry(raw)
|
|
|
|
linkCacheMu.Lock()
|
|
linkCache[cacheKey] = linkCacheEntry{
|
|
RawJSON: raw,
|
|
ExpiresAt: expiresAt,
|
|
FetchedAt: time.Now(),
|
|
}
|
|
linkCacheMu.Unlock()
|
|
redisWriteLink(productID, skuID, raw, expiresAt)
|
|
|
|
atomic.AddInt64(&mLinkFetches, 1)
|
|
if forceRefresh {
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> force refresh, cached until %s\n", productID, skuID, expiresAt.Format(time.RFC3339))
|
|
} else {
|
|
log.Printf("/proxy: product_id=%s sku_id=%s -> fetched and cached until %s\n", productID, skuID, expiresAt.Format(time.RFC3339))
|
|
}
|
|
w.Header().Set("X-MSDL-Link-Status", "fresh")
|
|
if exp := extractRawExpiry(raw); exp != "" {
|
|
w.Header().Set("X-MSDL-Link-Expires", exp)
|
|
}
|
|
w.Write(raw)
|
|
}
|
|
|
|
// --- /contribute endpoint ---
|
|
|
|
func handleContribute(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// Check CONTRIBUTE_SECRET header
|
|
if secret := os.Getenv("CONTRIBUTE_SECRET"); secret != "" {
|
|
if r.Header.Get("X-Contribute-Secret") != secret {
|
|
respondJSONError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Per-IP rate limit (~5/min)
|
|
ip := r.RemoteAddr
|
|
if idx := strings.LastIndex(ip, ":"); idx != -1 {
|
|
ip = ip[:idx]
|
|
}
|
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
ip = strings.TrimSpace(strings.SplitN(fwd, ",", 2)[0])
|
|
}
|
|
if !contributeRL.allow(ip) {
|
|
respondJSONError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
return
|
|
}
|
|
|
|
// Parse body
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB cap
|
|
if err != nil {
|
|
respondJSONError(w, http.StatusBadRequest, "failed to read body")
|
|
return
|
|
}
|
|
var req struct {
|
|
ProductID string `json:"product_id"`
|
|
SkuID string `json:"sku_id"`
|
|
RawJSON json.RawMessage `json:"raw_json"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
respondJSONError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
productID := req.ProductID
|
|
skuID := req.SkuID
|
|
|
|
// Validation 1: product in catalog
|
|
if !validContributeProducts[productID] {
|
|
log.Printf("contribute: product_id=%s sku_id=%s -> rejected (unknown product)\n", productID, skuID)
|
|
respondJSONError(w, http.StatusUnprocessableEntity, "unknown product_id")
|
|
return
|
|
}
|
|
|
|
// Validation 2: sku_id format
|
|
if !validSkuID.MatchString(skuID) {
|
|
log.Printf("contribute: product_id=%s sku_id=%s -> rejected (invalid sku_id format)\n", productID, skuID)
|
|
respondJSONError(w, http.StatusUnprocessableEntity, "invalid sku_id")
|
|
return
|
|
}
|
|
|
|
raw := []byte(req.RawJSON)
|
|
if len(raw) == 0 {
|
|
respondJSONError(w, http.StatusBadRequest, "raw_json is required")
|
|
return
|
|
}
|
|
|
|
// Validation 3: expiry must be > 1 hour out.
|
|
// Use extractRawExpiry (direct expiry timestamp) — NOT parseLinkExpiry, which has
|
|
// a 1h safety floor that would accept already-expired URLs.
|
|
seStr := extractRawExpiry(raw)
|
|
if seStr == "" {
|
|
log.Printf("contribute: product_id=%s sku_id=%s -> rejected (no valid expiry param)\n", productID, skuID)
|
|
respondJSONError(w, http.StatusUnprocessableEntity, "no valid signed expiry found in download URL")
|
|
return
|
|
}
|
|
seTime, _ := time.Parse(time.RFC3339, seStr)
|
|
if time.Until(seTime) < time.Hour {
|
|
log.Printf("contribute: product_id=%s sku_id=%s -> rejected (expiry < 1h: %s)\n", productID, skuID, seStr)
|
|
respondJSONError(w, http.StatusUnprocessableEntity, "link expires in less than 1 hour")
|
|
return
|
|
}
|
|
expiresAt := parseLinkExpiry(raw) // cache TTL (se minus 30m buffer + jitter)
|
|
|
|
// Validation 4: all download URLs must be from allowed Microsoft CDN hosts
|
|
var data map[string]interface{}
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
respondJSONError(w, http.StatusUnprocessableEntity, "raw_json is not valid JSON")
|
|
return
|
|
}
|
|
opts, _ := data["ProductDownloadOptions"].([]interface{})
|
|
if len(opts) == 0 {
|
|
log.Printf("contribute: product_id=%s sku_id=%s -> rejected (no ProductDownloadOptions)\n", productID, skuID)
|
|
respondJSONError(w, http.StatusUnprocessableEntity, "raw_json has no ProductDownloadOptions")
|
|
return
|
|
}
|
|
for _, opt := range opts {
|
|
m, ok := opt.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
uri, _ := m["Uri"].(string)
|
|
if uri == "" || !isAllowedCDNHost(uri) {
|
|
log.Printf("contribute: product_id=%s sku_id=%s -> rejected (disallowed host: %s)\n", productID, skuID, uri)
|
|
respondJSONError(w, http.StatusUnprocessableEntity, "download URL host not in allow-list")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Write to in-memory cache + Redis
|
|
cacheKey := productID + ":" + skuID
|
|
linkCacheMu.Lock()
|
|
linkCache[cacheKey] = linkCacheEntry{RawJSON: raw, ExpiresAt: expiresAt, FetchedAt: time.Now()}
|
|
linkCacheMu.Unlock()
|
|
redisWriteLink(productID, skuID, raw, expiresAt)
|
|
|
|
log.Printf("contribute: product_id=%s sku_id=%s -> accepted, cached until %s\n", productID, skuID, expiresAt.Format(time.RFC3339))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- /evallinks endpoint ---
|
|
|
|
func handleEvalLinks(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
product := r.URL.Query().Get("product")
|
|
if product == "" {
|
|
respondJSONError(w, http.StatusBadRequest, "product query parameter is required")
|
|
return
|
|
}
|
|
|
|
evalProduct, ok := evalProductMap[product]
|
|
if !ok {
|
|
respondJSONError(w, http.StatusNotFound, "Unknown eval product: "+product)
|
|
return
|
|
}
|
|
|
|
atomic.AddInt64(&mEvalRequests, 1)
|
|
evalCacheMu.RLock()
|
|
cached, exists := evalCache[product]
|
|
evalCacheMu.RUnlock()
|
|
|
|
if exists && time.Since(cached.CachedAt) < evalCacheTTL {
|
|
atomic.AddInt64(&mEvalCacheHits, 1)
|
|
log.Printf("/evallinks: product=%s -> cache hit (%d links)\n", product, len(cached.Links))
|
|
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: cached.Links})
|
|
return
|
|
}
|
|
|
|
links, err := fetchEvalLinks(evalProduct.EvalURL)
|
|
if err != nil {
|
|
// Stale-on-failure for eval links too
|
|
if exists {
|
|
atomic.AddInt64(&mEvalStale, 1)
|
|
log.Printf("/evallinks: product=%s -> fetch failed, serving stale\n", product)
|
|
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: cached.Links})
|
|
return
|
|
}
|
|
respondJSONError(w, http.StatusBadGateway, "Failed to fetch eval links: "+err.Error())
|
|
return
|
|
}
|
|
if len(links) == 0 {
|
|
if exists {
|
|
atomic.AddInt64(&mEvalStale, 1)
|
|
log.Printf("/evallinks: product=%s -> 0 links returned, serving stale\n", product)
|
|
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: cached.Links})
|
|
return
|
|
}
|
|
respondJSONError(w, http.StatusNotFound, "No download links found for this eval product")
|
|
return
|
|
}
|
|
|
|
evalCacheMu.Lock()
|
|
evalCache[product] = evalCacheEntry{Links: links, CachedAt: time.Now()}
|
|
evalCacheMu.Unlock()
|
|
|
|
log.Printf("/evallinks: product=%s -> %d links cached\n", product, len(links))
|
|
json.NewEncoder(w).Encode(EvalLinksResponse{Product: product, Name: evalProduct.Name, Links: links})
|
|
}
|
|
|
|
// --- Cache eviction (prevents unbounded map growth) ---
|
|
|
|
func cleanupCaches() {
|
|
ticker := time.NewTicker(30 * time.Minute)
|
|
for range ticker.C {
|
|
now := time.Now()
|
|
var skuDel, linkDel, negDel int
|
|
|
|
skuCacheMu.Lock()
|
|
for k, v := range skuCache {
|
|
if now.After(v.ExpiresAt) {
|
|
delete(skuCache, k)
|
|
skuDel++
|
|
}
|
|
}
|
|
skuCacheMu.Unlock()
|
|
|
|
// Keep link entries for 4h past soft expiry — stale-on-failure window
|
|
linkCacheMu.Lock()
|
|
for k, v := range linkCache {
|
|
if now.After(v.ExpiresAt.Add(4 * time.Hour)) {
|
|
delete(linkCache, k)
|
|
linkDel++
|
|
}
|
|
}
|
|
linkCacheMu.Unlock()
|
|
|
|
negCacheMu.Lock()
|
|
for k, v := range negCache {
|
|
if now.After(v.ExpiresAt) {
|
|
delete(negCache, k)
|
|
negDel++
|
|
}
|
|
}
|
|
negCacheMu.Unlock()
|
|
|
|
if skuDel+linkDel+negDel > 0 {
|
|
log.Printf("cache cleanup: evicted sku=%d link=%d neg=%d\n", skuDel, linkDel, negDel)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- /telemetry endpoint ---
|
|
|
|
func handleTelemetry(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
ip := r.RemoteAddr
|
|
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
ip = strings.TrimSpace(strings.SplitN(fwd, ",", 2)[0])
|
|
}
|
|
if !telemetryRL.allow(ip) {
|
|
respondJSONError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
return
|
|
}
|
|
|
|
var p telemetryPayload
|
|
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
|
respondJSONError(w, http.StatusBadRequest, "invalid JSON")
|
|
return
|
|
}
|
|
|
|
validActions := map[string]bool{"fetch": true, "eval": true, "list": true, "interactive": true}
|
|
if !validActions[p.Action] {
|
|
respondJSONError(w, http.StatusBadRequest, "invalid action")
|
|
return
|
|
}
|
|
|
|
if rdb != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
|
defer cancel()
|
|
rdb.HIncrBy(ctx, "msdl:telemetry:actions", p.Action, 1)
|
|
validPlatforms := map[string]bool{"windows": true, "darwin": true, "linux": true}
|
|
if validPlatforms[p.Platform] {
|
|
rdb.HIncrBy(ctx, "msdl:telemetry:platforms", p.Platform, 1)
|
|
}
|
|
if p.Version != "" && len(p.Version) <= 20 {
|
|
rdb.HIncrBy(ctx, "msdl:telemetry:versions", p.Version, 1)
|
|
}
|
|
if p.ProductID != "" && len(p.ProductID) <= 10 {
|
|
rdb.HIncrBy(ctx, "msdl:telemetry:products", p.ProductID, 1)
|
|
}
|
|
result := "success"
|
|
if !p.Success {
|
|
result = "failed"
|
|
}
|
|
rdb.HIncrBy(ctx, "msdl:telemetry:results", result, 1)
|
|
if !p.Success && p.Error != "" && len(p.Error) <= 200 {
|
|
rdb.HIncrBy(ctx, "msdl:telemetry:errors", p.Error, 1)
|
|
if strings.Contains(p.Error, "Sentinel") {
|
|
// All-time Sentinel-rejection count, plus an approximate
|
|
// distinct-source count (via HyperLogLog on the reporting
|
|
// IP) so /metrics can show whether rejections are
|
|
// concentrated in a few sources or spread across many,
|
|
// without ever storing raw IPs.
|
|
rdb.Incr(ctx, "msdl:telemetry:sentinel_count")
|
|
rdb.PFAdd(ctx, "msdl:telemetry:sentinel_hll", ip)
|
|
}
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("{}"))
|
|
}
|
|
|
|
// --- /cli/version endpoint ---
|
|
|
|
func handleCLIVersion(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"latest": latestCLIVersion})
|
|
}
|
|
|
|
// --- /install.sh endpoint ---
|
|
|
|
// handleInstallScript serves the Linux/macOS install script embedded at
|
|
// build time from install.sh. Auto-detects OS/arch and always resolves the
|
|
// latest GitHub release, so it needs no per-release maintenance.
|
|
func handleInstallScript(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/x-sh; charset=utf-8")
|
|
w.Write(installScript)
|
|
}
|
|
|
|
// --- /metrics endpoint ---
|
|
|
|
// loadTelemetryFromRedis reads all telemetry counters from Redis.
|
|
// Returns nil maps when Redis is unavailable.
|
|
func loadTelemetryFromRedis() map[string]map[string]string {
|
|
if rdb == nil {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
|
defer cancel()
|
|
keys := []string{
|
|
"msdl:telemetry:actions",
|
|
"msdl:telemetry:platforms",
|
|
"msdl:telemetry:versions",
|
|
"msdl:telemetry:products",
|
|
"msdl:telemetry:results",
|
|
"msdl:telemetry:errors",
|
|
}
|
|
out := make(map[string]map[string]string, len(keys))
|
|
for _, k := range keys {
|
|
vals, err := rdb.HGetAll(ctx, k).Result()
|
|
if err == nil {
|
|
// Strip the "msdl:telemetry:" prefix for the JSON key
|
|
name := strings.TrimPrefix(k, "msdl:telemetry:")
|
|
out[name] = vals
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|
secret := os.Getenv("METRICS_SECRET")
|
|
if secret == "" {
|
|
respondJSONError(w, http.StatusForbidden, "metrics endpoint not configured")
|
|
return
|
|
}
|
|
|
|
provided := r.URL.Query().Get("secret")
|
|
if provided == "" {
|
|
provided = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
}
|
|
if provided != secret {
|
|
respondJSONError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
skuReqs := atomic.LoadInt64(&mSkuRequests)
|
|
skuHits := atomic.LoadInt64(&mSkuCacheHits)
|
|
skuFetches := atomic.LoadInt64(&mSkuFetches)
|
|
skuNeg := atomic.LoadInt64(&mSkuNegHits)
|
|
|
|
linkReqs := atomic.LoadInt64(&mLinkRequests)
|
|
linkHits := atomic.LoadInt64(&mLinkCacheHits)
|
|
linkFetches := atomic.LoadInt64(&mLinkFetches)
|
|
linkNeg := atomic.LoadInt64(&mLinkNegHits)
|
|
linkStale := atomic.LoadInt64(&mLinkStale)
|
|
|
|
evalReqs := atomic.LoadInt64(&mEvalRequests)
|
|
evalHits := atomic.LoadInt64(&mEvalCacheHits)
|
|
evalStale := atomic.LoadInt64(&mEvalStale)
|
|
|
|
hitRate := func(hits, total int64) string {
|
|
if total == 0 {
|
|
return "N/A"
|
|
}
|
|
return fmt.Sprintf("%.1f%%", float64(hits)/float64(total)*100)
|
|
}
|
|
|
|
skuCacheMu.RLock()
|
|
skuSize := len(skuCache)
|
|
skuCacheMu.RUnlock()
|
|
linkCacheMu.RLock()
|
|
linkSize := len(linkCache)
|
|
linkCacheMu.RUnlock()
|
|
negCacheMu.RLock()
|
|
negSize := len(negCache)
|
|
negCacheMu.RUnlock()
|
|
evalCacheMu.RLock()
|
|
evalSize := len(evalCache)
|
|
evalCacheMu.RUnlock()
|
|
|
|
telemetry := loadTelemetryFromRedis()
|
|
sentinelErrors, sentinelDistinctSources := loadSentinelStatsFromRedis()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
"sku": map[string]interface{}{
|
|
"requests": skuReqs,
|
|
"cache_hits": skuHits,
|
|
"ms_fetches": skuFetches,
|
|
"neg_hits": skuNeg,
|
|
"hit_rate": hitRate(skuHits, skuReqs),
|
|
"cache_size": skuSize,
|
|
},
|
|
"link": map[string]interface{}{
|
|
"requests": linkReqs,
|
|
"cache_hits": linkHits,
|
|
"ms_fetches": linkFetches,
|
|
"neg_hits": linkNeg,
|
|
"stale": linkStale,
|
|
"hit_rate": hitRate(linkHits, linkReqs),
|
|
"cache_size": linkSize,
|
|
},
|
|
"eval": map[string]interface{}{
|
|
"requests": evalReqs,
|
|
"cache_hits": evalHits,
|
|
"stale": evalStale,
|
|
"hit_rate": hitRate(evalHits, evalReqs),
|
|
"cache_size": evalSize,
|
|
},
|
|
"neg_cache_size": negSize,
|
|
"total_ms_fetches": skuFetches + linkFetches,
|
|
"telemetry": telemetry,
|
|
"sentinel_errors": sentinelErrors,
|
|
"sentinel_distinct_sources_est": sentinelDistinctSources,
|
|
})
|
|
}
|
|
|
|
// loadSentinelStatsFromRedis returns the all-time Sentinel-rejection count
|
|
// and an approximate distinct-source count (from the HyperLogLog seeded in
|
|
// handleTelemetry). Returns (0, 0) when Redis is unavailable.
|
|
func loadSentinelStatsFromRedis() (int64, int64) {
|
|
if rdb == nil {
|
|
return 0, 0
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
|
defer cancel()
|
|
count, _ := rdb.Get(ctx, "msdl:telemetry:sentinel_count").Int64()
|
|
distinct, _ := rdb.PFCount(ctx, "msdl:telemetry:sentinel_hll").Result()
|
|
return count, distinct
|
|
}
|
|
|
|
func warmEvalCache() {
|
|
for slug, product := range evalProductMap {
|
|
go func(s string, p EvalProduct) {
|
|
links, err := fetchEvalLinks(p.EvalURL)
|
|
if err != nil || len(links) == 0 {
|
|
log.Printf("eval warm: %s failed: %v\n", s, err)
|
|
return
|
|
}
|
|
evalCacheMu.Lock()
|
|
evalCache[s] = evalCacheEntry{Links: links, CachedAt: time.Now()}
|
|
evalCacheMu.Unlock()
|
|
log.Printf("eval warm: %s -> %d links cached\n", s, len(links))
|
|
}(slug, product)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
initRedis()
|
|
go redisSeedLinkCache()
|
|
go cleanupSessions()
|
|
go cleanupCaches()
|
|
go warmEvalCache()
|
|
go startMetricsFlusher()
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/skuinfo", handleSkuInfo)
|
|
mux.HandleFunc("/proxy", handleProxy)
|
|
mux.HandleFunc("/contribute", handleContribute)
|
|
mux.HandleFunc("/evallinks", handleEvalLinks)
|
|
mux.HandleFunc("/metrics", handleMetrics)
|
|
mux.HandleFunc("/telemetry", handleTelemetry)
|
|
mux.HandleFunc("/cli/version", handleCLIVersion)
|
|
mux.HandleFunc("/install.sh", handleInstallScript)
|
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Write([]byte("MSDL API v3 is running"))
|
|
})
|
|
|
|
handler := enableCORS(mux)
|
|
srv := &http.Server{Addr: PORT, Handler: handler}
|
|
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
go func() {
|
|
<-sigCh
|
|
log.Println("shutdown: flushing metrics to Redis...")
|
|
flushMetricsToRedis()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
srv.Shutdown(ctx)
|
|
}()
|
|
|
|
log.Printf("Go Backend running on http://localhost%s\n", PORT)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatal(err)
|
|
}
|
|
}
|