mirror of
https://github.com/OpenStaticFish/MicroServices.git
synced 2026-10-13 06:35:12 +00:00
Harden site analyzer concurrency
This commit is contained in:
@@ -217,6 +217,39 @@ Override the endpoint if needed:
|
|||||||
scripts/analyze-site --endpoint http://localhost:8090/analyze example.com
|
scripts/analyze-site --endpoint http://localhost:8090/analyze example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Load-test a running analyzer with fixed request and concurrency levels:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/load-test-site-analyzer --url https://example.com --requests 100 --concurrency 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Single-instance benchmark from the local Kind deployment, targeting `https://example.com` with `1000` requests per run and `MAX_CONCURRENT_ANALYSES=20`:
|
||||||
|
|
||||||
|
| Concurrency | HTTP 200 | HTTP 429 | Throughput | Avg latency | Max latency |
|
||||||
|
| ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| 5 | 1000 | 0 | 136.23 req/s | 0.029s | 0.068s |
|
||||||
|
| 10 | 1000 | 0 | 248.62 req/s | 0.032s | 0.047s |
|
||||||
|
| 15 | 1000 | 0 | 346.15 req/s | 0.034s | 0.052s |
|
||||||
|
| 20 | 1000 | 0 | 391.14 req/s | 0.039s | 0.061s |
|
||||||
|
| 25 | 759 | 241 | 535.11 req/s | 0.035s | 0.074s |
|
||||||
|
| 30 | 688 | 312 | 590.35 req/s | 0.036s | 0.292s |
|
||||||
|
|
||||||
|
Current guidance: treat `20` concurrent in-flight analyses as the safe per-instance ceiling. Above that, the service intentionally sheds load with `429` instead of queueing unbounded work.
|
||||||
|
|
||||||
|
Operational endpoints:
|
||||||
|
|
||||||
|
- `GET /health`: cheap liveness check
|
||||||
|
- `GET /ready`: readiness check; returns unavailable when the pod is saturated
|
||||||
|
- `GET /metrics`: Prometheus-style counters and gauges
|
||||||
|
|
||||||
|
Runtime controls:
|
||||||
|
|
||||||
|
- `MAX_CONCURRENT_ANALYSES`: maximum in-flight analyses per pod before returning `429`
|
||||||
|
- `ANALYSIS_TIMEOUT`: whole-analysis deadline, default `15s`
|
||||||
|
- `FETCH_TIMEOUT`: outbound HTTP fetch deadline, default `10s`
|
||||||
|
- `MAX_REQUEST_BYTES`: request body cap, default `4096`
|
||||||
|
- `MAX_RESPONSE_BYTES`: fetched response body cap, default `2097152`
|
||||||
|
|
||||||
The response includes:
|
The response includes:
|
||||||
|
|
||||||
- `hosting.provider`: the visible edge/provider inferred from DNS, reverse DNS, and HTTP headers
|
- `hosting.provider`: the visible edge/provider inferred from DNS, reverse DNS, and HTTP headers
|
||||||
|
|||||||
+40
-6
@@ -13,11 +13,17 @@ spec:
|
|||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: site-analyzer
|
app: site-analyzer
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxSurge: 1
|
||||||
|
maxUnavailable: 0
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: site-analyzer
|
app: site-analyzer
|
||||||
spec:
|
spec:
|
||||||
|
terminationGracePeriodSeconds: 30
|
||||||
containers:
|
containers:
|
||||||
- name: site-analyzer
|
- name: site-analyzer
|
||||||
image: site-analyzer
|
image: site-analyzer
|
||||||
@@ -26,25 +32,53 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: PORT
|
- name: PORT
|
||||||
value: "8090"
|
value: "8090"
|
||||||
|
- name: MAX_CONCURRENT_ANALYSES
|
||||||
|
value: "20"
|
||||||
|
- name: ANALYSIS_TIMEOUT
|
||||||
|
value: "15s"
|
||||||
|
- name: FETCH_TIMEOUT
|
||||||
|
value: "10s"
|
||||||
|
- name: MAX_REQUEST_BYTES
|
||||||
|
value: "4096"
|
||||||
|
- name: MAX_RESPONSE_BYTES
|
||||||
|
value: "2097152"
|
||||||
|
- name: READ_HEADER_TIMEOUT
|
||||||
|
value: "2s"
|
||||||
|
- name: READ_TIMEOUT
|
||||||
|
value: "5s"
|
||||||
|
- name: WRITE_TIMEOUT
|
||||||
|
value: "20s"
|
||||||
|
- name: IDLE_TIMEOUT
|
||||||
|
value: "60s"
|
||||||
|
- name: SHUTDOWN_TIMEOUT
|
||||||
|
value: "25s"
|
||||||
|
- name: MAX_IDLE_CONNS
|
||||||
|
value: "200"
|
||||||
|
- name: MAX_IDLE_CONNS_PER_HOST
|
||||||
|
value: "20"
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "64Mi"
|
memory: "128Mi"
|
||||||
cpu: "100m"
|
cpu: "250m"
|
||||||
limits:
|
limits:
|
||||||
memory: "256Mi"
|
memory: "512Mi"
|
||||||
cpu: "500m"
|
cpu: "1000m"
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
port: 8090
|
port: 8090
|
||||||
initialDelaySeconds: 2
|
initialDelaySeconds: 2
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 1
|
||||||
|
failureThreshold: 3
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /ready
|
||||||
port: 8090
|
port: 8090
|
||||||
initialDelaySeconds: 2
|
initialDelaySeconds: 2
|
||||||
periodSeconds: 5
|
periodSeconds: 3
|
||||||
|
timeoutSeconds: 1
|
||||||
|
failureThreshold: 2
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
|
|||||||
Executable
+96
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
endpoint="${SITE_ANALYZER_ENDPOINT:-http://localhost:8090/analyze}"
|
||||||
|
site_url="https://example.com"
|
||||||
|
requests="100"
|
||||||
|
concurrency="10"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage:
|
||||||
|
scripts/load-test-site-analyzer [--endpoint URL] [--url SITE_URL] [--requests N] [--concurrency N]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
scripts/load-test-site-analyzer --url https://example.com --requests 100 --concurrency 10
|
||||||
|
scripts/load-test-site-analyzer --endpoint http://site-analyzer.example.com/analyze --url example.com --requests 1000 --concurrency 50
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--endpoint)
|
||||||
|
endpoint="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--endpoint=*)
|
||||||
|
endpoint="${1#--endpoint=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--url)
|
||||||
|
site_url="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--url=*)
|
||||||
|
site_url="${1#--url=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--requests)
|
||||||
|
requests="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--requests=*)
|
||||||
|
requests="${1#--requests=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--concurrency)
|
||||||
|
concurrency="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--concurrency=*)
|
||||||
|
concurrency="${1#--concurrency=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
payload=$(jq -cn --arg url "$site_url" '{url: $url}' 2>/dev/null || printf '{"url":"%s"}' "$site_url")
|
||||||
|
results_file=$(mktemp)
|
||||||
|
start_epoch=$(date +%s%N)
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$results_file"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
export endpoint payload results_file
|
||||||
|
|
||||||
|
seq "$requests" | xargs -n 1 -P "$concurrency" sh -c '
|
||||||
|
result=$(curl -sS -o /dev/null -w "%{http_code} %{time_total}" -H "Content-Type: application/json" -X POST --data "$payload" "$endpoint" || printf "000 0")
|
||||||
|
printf "%s\n" "$result" >>"$results_file"
|
||||||
|
'
|
||||||
|
|
||||||
|
end_epoch=$(date +%s%N)
|
||||||
|
elapsed_ns=$((end_epoch - start_epoch))
|
||||||
|
elapsed=$(awk -v ns="$elapsed_ns" 'BEGIN { printf "%.3f", ns / 1000000000 }')
|
||||||
|
rps=$(awk -v requests="$requests" -v ns="$elapsed_ns" 'BEGIN { printf "%.2f", requests / (ns / 1000000000) }')
|
||||||
|
|
||||||
|
echo "endpoint: $endpoint"
|
||||||
|
echo "url: $site_url"
|
||||||
|
echo "requests: $requests"
|
||||||
|
echo "concurrency: $concurrency"
|
||||||
|
echo "elapsed_seconds: $elapsed"
|
||||||
|
echo "requests_per_second: $rps"
|
||||||
|
echo "status_codes:"
|
||||||
|
cut -d ' ' -f 1 "$results_file" | sort | uniq -c
|
||||||
|
echo "latency_seconds:"
|
||||||
|
awk '{ count++; sum += $2; if ($2 > max) max = $2 } END { if (count > 0) printf "avg %.3f\nmax %.3f\n", sum / count, max }' "$results_file"
|
||||||
+358
-29
@@ -1,7 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -9,8 +11,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
@@ -80,29 +86,260 @@ type fetchedSite struct {
|
|||||||
TLS bool
|
TLS bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
type config struct {
|
||||||
port := os.Getenv("PORT")
|
port string
|
||||||
if port == "" {
|
maxConcurrentAnalyses int
|
||||||
port = "8090"
|
analysisTimeout time.Duration
|
||||||
}
|
fetchTimeout time.Duration
|
||||||
|
maxRequestBytes int64
|
||||||
http.HandleFunc("/analyze", handleAnalyze)
|
maxResponseBytes int64
|
||||||
http.HandleFunc("/health", handleHealth)
|
readHeaderTimeout time.Duration
|
||||||
|
readTimeout time.Duration
|
||||||
log.Printf("site-analyzer listening on :%s", port)
|
writeTimeout time.Duration
|
||||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
idleTimeout time.Duration
|
||||||
|
shutdownTimeout time.Duration
|
||||||
|
maxIdleConns int
|
||||||
|
maxIdleConnsPerHost int
|
||||||
|
responseHeaderTimeout time.Duration
|
||||||
|
tlsHandshakeTimeout time.Duration
|
||||||
|
expectContinueTimeout time.Duration
|
||||||
|
idleConnTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
type app struct {
|
||||||
|
config config
|
||||||
|
client *http.Client
|
||||||
|
semaphore chan struct{}
|
||||||
|
metrics *metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
type metrics struct {
|
||||||
|
requestsTotal atomic.Int64
|
||||||
|
analyzeRequestsTotal atomic.Int64
|
||||||
|
analyzeRejectedTotal atomic.Int64
|
||||||
|
analyzeErrorsTotal atomic.Int64
|
||||||
|
analyzeDurationCount atomic.Int64
|
||||||
|
analyzeDurationNanos atomic.Int64
|
||||||
|
activeAnalyses atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var errUnsafeTarget = errors.New("target resolves to a private or otherwise unsafe address")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
application := newApp(cfg)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/analyze", application.handleAnalyze)
|
||||||
|
mux.HandleFunc("/health", application.handleHealth)
|
||||||
|
mux.HandleFunc("/ready", application.handleReady)
|
||||||
|
mux.HandleFunc("/metrics", application.handleMetrics)
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: ":" + cfg.port,
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: cfg.readHeaderTimeout,
|
||||||
|
ReadTimeout: cfg.readTimeout,
|
||||||
|
WriteTimeout: cfg.writeTimeout,
|
||||||
|
IdleTimeout: cfg.idleTimeout,
|
||||||
|
MaxHeaderBytes: 16 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
serverErrors := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
log.Printf("site-analyzer listening on :%s max_concurrent_analyses=%d analysis_timeout=%s", cfg.port, cfg.maxConcurrentAnalyses, cfg.analysisTimeout)
|
||||||
|
serverErrors <- server.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
shutdownSignals := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(shutdownSignals, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-serverErrors:
|
||||||
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
case signal := <-shutdownSignals:
|
||||||
|
log.Printf("received %s, shutting down", signal)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cfg.shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("graceful shutdown failed: %v", err)
|
||||||
|
if closeErr := server.Close(); closeErr != nil {
|
||||||
|
log.Printf("forced shutdown failed: %v", closeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newApp(cfg config) *app {
|
||||||
|
application := &app{
|
||||||
|
config: cfg,
|
||||||
|
semaphore: make(chan struct{}, cfg.maxConcurrentAnalyses),
|
||||||
|
metrics: &metrics{},
|
||||||
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{
|
||||||
|
Proxy: http.ProxyFromEnvironment,
|
||||||
|
DialContext: application.safeDialContext,
|
||||||
|
MaxIdleConns: cfg.maxIdleConns,
|
||||||
|
MaxIdleConnsPerHost: cfg.maxIdleConnsPerHost,
|
||||||
|
IdleConnTimeout: cfg.idleConnTimeout,
|
||||||
|
TLSHandshakeTimeout: cfg.tlsHandshakeTimeout,
|
||||||
|
ResponseHeaderTimeout: cfg.responseHeaderTimeout,
|
||||||
|
ExpectContinueTimeout: cfg.expectContinueTimeout,
|
||||||
|
}
|
||||||
|
application.client = &http.Client{
|
||||||
|
Timeout: cfg.fetchTimeout,
|
||||||
|
Transport: transport,
|
||||||
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
|
if len(via) >= 5 {
|
||||||
|
return errors.New("stopped after 5 redirects")
|
||||||
|
}
|
||||||
|
return validateTargetURL(req.URL)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return application
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() config {
|
||||||
|
analysisTimeout := durationEnv("ANALYSIS_TIMEOUT", 15*time.Second)
|
||||||
|
fetchTimeout := durationEnv("FETCH_TIMEOUT", 10*time.Second)
|
||||||
|
writeTimeout := durationEnv("WRITE_TIMEOUT", analysisTimeout+5*time.Second)
|
||||||
|
if writeTimeout <= analysisTimeout {
|
||||||
|
writeTimeout = analysisTimeout + 5*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
return config{
|
||||||
|
port: stringEnv("PORT", "8090"),
|
||||||
|
maxConcurrentAnalyses: intEnv("MAX_CONCURRENT_ANALYSES", 20),
|
||||||
|
analysisTimeout: analysisTimeout,
|
||||||
|
fetchTimeout: fetchTimeout,
|
||||||
|
maxRequestBytes: int64Env("MAX_REQUEST_BYTES", 4*1024),
|
||||||
|
maxResponseBytes: int64Env("MAX_RESPONSE_BYTES", 2*1024*1024),
|
||||||
|
readHeaderTimeout: durationEnv("READ_HEADER_TIMEOUT", 2*time.Second),
|
||||||
|
readTimeout: durationEnv("READ_TIMEOUT", 5*time.Second),
|
||||||
|
writeTimeout: writeTimeout,
|
||||||
|
idleTimeout: durationEnv("IDLE_TIMEOUT", 60*time.Second),
|
||||||
|
shutdownTimeout: durationEnv("SHUTDOWN_TIMEOUT", 25*time.Second),
|
||||||
|
maxIdleConns: intEnv("MAX_IDLE_CONNS", 200),
|
||||||
|
maxIdleConnsPerHost: intEnv("MAX_IDLE_CONNS_PER_HOST", 20),
|
||||||
|
responseHeaderTimeout: durationEnv("RESPONSE_HEADER_TIMEOUT", 5*time.Second),
|
||||||
|
tlsHandshakeTimeout: durationEnv("TLS_HANDSHAKE_TIMEOUT", 5*time.Second),
|
||||||
|
expectContinueTimeout: durationEnv("EXPECT_CONTINUE_TIMEOUT", time.Second),
|
||||||
|
idleConnTimeout: durationEnv("IDLE_CONN_TIMEOUT", 90*time.Second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringEnv(name string, fallback string) string {
|
||||||
|
value := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func intEnv(name string, fallback int) int {
|
||||||
|
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64Env(name string, fallback int64) int64 {
|
||||||
|
value, err := strconv.ParseInt(strings.TrimSpace(os.Getenv(name)), 10, 64)
|
||||||
|
if err != nil || value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationEnv(name string, fallback time.Duration) time.Duration {
|
||||||
|
value := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if value == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
duration, err := time.ParseDuration(value)
|
||||||
|
if err != nil || duration <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.metrics.requestsTotal.Add(1)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, "ok")
|
fmt.Fprint(w, "ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAnalyze(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.metrics.requestsTotal.Add(1)
|
||||||
|
if int(a.metrics.activeAnalyses.Load()) >= a.config.maxConcurrentAnalyses {
|
||||||
|
respondWithError(w, http.StatusServiceUnavailable, "Service saturated", "maximum concurrent analyses are already running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, "ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.metrics.requestsTotal.Add(1)
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
|
||||||
|
durationSeconds := float64(a.metrics.analyzeDurationNanos.Load()) / float64(time.Second)
|
||||||
|
fmt.Fprintf(w, "# HELP site_analyzer_requests_total Total HTTP requests received.\n")
|
||||||
|
fmt.Fprintf(w, "# TYPE site_analyzer_requests_total counter\n")
|
||||||
|
fmt.Fprintf(w, "site_analyzer_requests_total %d\n", a.metrics.requestsTotal.Load())
|
||||||
|
fmt.Fprintf(w, "# HELP site_analyzer_analyze_requests_total Total analyze requests received.\n")
|
||||||
|
fmt.Fprintf(w, "# TYPE site_analyzer_analyze_requests_total counter\n")
|
||||||
|
fmt.Fprintf(w, "site_analyzer_analyze_requests_total %d\n", a.metrics.analyzeRequestsTotal.Load())
|
||||||
|
fmt.Fprintf(w, "# HELP site_analyzer_analyze_rejected_total Analyze requests rejected because the pod is saturated.\n")
|
||||||
|
fmt.Fprintf(w, "# TYPE site_analyzer_analyze_rejected_total counter\n")
|
||||||
|
fmt.Fprintf(w, "site_analyzer_analyze_rejected_total %d\n", a.metrics.analyzeRejectedTotal.Load())
|
||||||
|
fmt.Fprintf(w, "# HELP site_analyzer_analyze_errors_total Analyze requests that failed before producing a response.\n")
|
||||||
|
fmt.Fprintf(w, "# TYPE site_analyzer_analyze_errors_total counter\n")
|
||||||
|
fmt.Fprintf(w, "site_analyzer_analyze_errors_total %d\n", a.metrics.analyzeErrorsTotal.Load())
|
||||||
|
fmt.Fprintf(w, "# HELP site_analyzer_active_analyses Current number of in-flight analyses.\n")
|
||||||
|
fmt.Fprintf(w, "# TYPE site_analyzer_active_analyses gauge\n")
|
||||||
|
fmt.Fprintf(w, "site_analyzer_active_analyses %d\n", a.metrics.activeAnalyses.Load())
|
||||||
|
fmt.Fprintf(w, "# HELP site_analyzer_max_concurrent_analyses Configured maximum in-flight analyses per pod.\n")
|
||||||
|
fmt.Fprintf(w, "# TYPE site_analyzer_max_concurrent_analyses gauge\n")
|
||||||
|
fmt.Fprintf(w, "site_analyzer_max_concurrent_analyses %d\n", a.config.maxConcurrentAnalyses)
|
||||||
|
fmt.Fprintf(w, "# HELP site_analyzer_analyze_duration_seconds Total analyze request duration.\n")
|
||||||
|
fmt.Fprintf(w, "# TYPE site_analyzer_analyze_duration_seconds summary\n")
|
||||||
|
fmt.Fprintf(w, "site_analyzer_analyze_duration_seconds_sum %.6f\n", durationSeconds)
|
||||||
|
fmt.Fprintf(w, "site_analyzer_analyze_duration_seconds_count %d\n", a.metrics.analyzeDurationCount.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) handleAnalyze(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
a.metrics.requestsTotal.Add(1)
|
||||||
|
a.metrics.analyzeRequestsTotal.Add(1)
|
||||||
|
defer func() {
|
||||||
|
a.metrics.analyzeDurationCount.Add(1)
|
||||||
|
a.metrics.analyzeDurationNanos.Add(time.Since(start).Nanoseconds())
|
||||||
|
}()
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed", "Only POST requests are supported")
|
respondWithError(w, http.StatusMethodNotAllowed, "Method not allowed", "Only POST requests are supported")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
select {
|
||||||
|
case a.semaphore <- struct{}{}:
|
||||||
|
a.metrics.activeAnalyses.Add(1)
|
||||||
|
defer func() {
|
||||||
|
<-a.semaphore
|
||||||
|
a.metrics.activeAnalyses.Add(-1)
|
||||||
|
}()
|
||||||
|
default:
|
||||||
|
a.metrics.analyzeRejectedTotal.Add(1)
|
||||||
|
respondWithError(w, http.StatusTooManyRequests, "Too many requests", "maximum concurrent analyses are already running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, a.config.maxRequestBytes)
|
||||||
|
|
||||||
var req AnalyzeRequest
|
var req AnalyzeRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -114,8 +351,20 @@ func handleAnalyze(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := analyzeSite(req.URL)
|
ctx, cancel := context.WithTimeout(r.Context(), a.config.analysisTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
result, err := a.analyzeSite(ctx, req.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
a.metrics.analyzeErrorsTotal.Add(1)
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
respondWithError(w, http.StatusGatewayTimeout, "Analysis timed out", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, errUnsafeTarget) || strings.Contains(err.Error(), "url") {
|
||||||
|
respondWithError(w, http.StatusBadRequest, "Invalid target", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
respondWithError(w, http.StatusInternalServerError, "Analysis failed", err.Error())
|
respondWithError(w, http.StatusInternalServerError, "Analysis failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,12 +373,15 @@ func handleAnalyze(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(result)
|
json.NewEncoder(w).Encode(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func analyzeSite(rawURL string) (*AnalyzeResponse, error) {
|
func (a *app) analyzeSite(ctx context.Context, rawURL string) (*AnalyzeResponse, error) {
|
||||||
normalizedURL := normalizeURL(rawURL)
|
normalizedURL := normalizeURL(rawURL)
|
||||||
parsed, err := url.Parse(normalizedURL)
|
parsed, err := url.Parse(normalizedURL)
|
||||||
if err != nil || parsed.Hostname() == "" {
|
if err != nil || parsed.Hostname() == "" {
|
||||||
return nil, fmt.Errorf("invalid url")
|
return nil, fmt.Errorf("invalid url")
|
||||||
}
|
}
|
||||||
|
if err := validateTargetURL(parsed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
domain := strings.ToLower(parsed.Hostname())
|
domain := strings.ToLower(parsed.Hostname())
|
||||||
response := &AnalyzeResponse{
|
response := &AnalyzeResponse{
|
||||||
@@ -148,11 +400,14 @@ func analyzeSite(rawURL string) (*AnalyzeResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var warnings []string
|
var warnings []string
|
||||||
if err := analyzeDNS(domain, &response.DNS, &response.Hosting); err != nil {
|
if err := analyzeDNS(ctx, domain, &response.DNS, &response.Hosting); err != nil {
|
||||||
|
if errors.Is(err, errUnsafeTarget) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
warnings = append(warnings, "dns lookup failed: "+err.Error())
|
warnings = append(warnings, "dns lookup failed: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
fetched, err := fetchSite(normalizedURL)
|
fetched, err := a.fetchSite(ctx, normalizedURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warnings = append(warnings, "http fetch failed: "+err.Error())
|
warnings = append(warnings, "http fetch failed: "+err.Error())
|
||||||
} else {
|
} else {
|
||||||
@@ -175,15 +430,22 @@ func analyzeSite(rawURL string) (*AnalyzeResponse, error) {
|
|||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func analyzeDNS(domain string, dnsInfo *DNSInfo, hosting *HostingInfo) error {
|
func analyzeDNS(ctx context.Context, domain string, dnsInfo *DNSInfo, hosting *HostingInfo) error {
|
||||||
var firstErr error
|
var firstErr error
|
||||||
|
|
||||||
if ips, err := net.LookupHost(domain); err == nil {
|
resolver := net.DefaultResolver
|
||||||
|
if ips, err := resolver.LookupHost(ctx, domain); err == nil {
|
||||||
|
for _, value := range ips {
|
||||||
|
ip := net.ParseIP(value)
|
||||||
|
if !isSafeIP(ip) {
|
||||||
|
return errUnsafeTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
hosting.IPAddresses = sortedUnique(ips)
|
hosting.IPAddresses = sortedUnique(ips)
|
||||||
dnsInfo.Records["A"] = filterIPs(ips, false)
|
dnsInfo.Records["A"] = filterIPs(ips, false)
|
||||||
dnsInfo.Records["AAAA"] = filterIPs(ips, true)
|
dnsInfo.Records["AAAA"] = filterIPs(ips, true)
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
names, err := net.LookupAddr(ip)
|
names, err := resolver.LookupAddr(ctx, ip)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
hosting.ReverseDNS = append(hosting.ReverseDNS, names...)
|
hosting.ReverseDNS = append(hosting.ReverseDNS, names...)
|
||||||
}
|
}
|
||||||
@@ -193,7 +455,7 @@ func analyzeDNS(domain string, dnsInfo *DNSInfo, hosting *HostingInfo) error {
|
|||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
nsDomain, nsRecords, err := lookupNameservers(domain)
|
nsDomain, nsRecords, err := lookupNameservers(ctx, domain)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, ns := range nsRecords {
|
for _, ns := range nsRecords {
|
||||||
dnsInfo.Nameservers = append(dnsInfo.Nameservers, strings.TrimSuffix(strings.ToLower(ns.Host), "."))
|
dnsInfo.Nameservers = append(dnsInfo.Nameservers, strings.TrimSuffix(strings.ToLower(ns.Host), "."))
|
||||||
@@ -207,7 +469,7 @@ func analyzeDNS(domain string, dnsInfo *DNSInfo, hosting *HostingInfo) error {
|
|||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
if mxRecords, err := net.LookupMX(domain); err == nil {
|
if mxRecords, err := resolver.LookupMX(ctx, domain); err == nil {
|
||||||
for _, mx := range mxRecords {
|
for _, mx := range mxRecords {
|
||||||
dnsInfo.Records["MX"] = append(dnsInfo.Records["MX"], strings.TrimSuffix(strings.ToLower(mx.Host), "."))
|
dnsInfo.Records["MX"] = append(dnsInfo.Records["MX"], strings.TrimSuffix(strings.ToLower(mx.Host), "."))
|
||||||
}
|
}
|
||||||
@@ -223,12 +485,12 @@ func analyzeDNS(domain string, dnsInfo *DNSInfo, hosting *HostingInfo) error {
|
|||||||
return firstErr
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func lookupNameservers(domain string) (string, []*net.NS, error) {
|
func lookupNameservers(ctx context.Context, domain string) (string, []*net.NS, error) {
|
||||||
labels := strings.Split(domain, ".")
|
labels := strings.Split(domain, ".")
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for i := 0; i <= len(labels)-2; i++ {
|
for i := 0; i <= len(labels)-2; i++ {
|
||||||
candidate := strings.Join(labels[i:], ".")
|
candidate := strings.Join(labels[i:], ".")
|
||||||
records, err := net.LookupNS(candidate)
|
records, err := net.DefaultResolver.LookupNS(ctx, candidate)
|
||||||
if err == nil && len(records) > 0 {
|
if err == nil && len(records) > 0 {
|
||||||
return candidate, records, nil
|
return candidate, records, nil
|
||||||
}
|
}
|
||||||
@@ -237,22 +499,21 @@ func lookupNameservers(domain string) (string, []*net.NS, error) {
|
|||||||
return "", nil, lastErr
|
return "", nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSite(targetURL string) (*fetchedSite, error) {
|
func (a *app) fetchSite(ctx context.Context, targetURL string) (*fetchedSite, error) {
|
||||||
client := &http.Client{Timeout: 20 * time.Second}
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||||
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
|
||||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, a.config.maxResponseBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -266,6 +527,74 @@ func fetchSite(targetURL string) (*fetchedSite, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *app) safeDialContext(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return nil, errors.New("missing host")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver := net.DefaultResolver
|
||||||
|
addresses, err := resolver.LookupIPAddr(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(addresses) == 0 {
|
||||||
|
return nil, errors.New("host resolved to no addresses")
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
dialer := &net.Dialer{Timeout: a.config.fetchTimeout, KeepAlive: 30 * time.Second}
|
||||||
|
for _, resolved := range addresses {
|
||||||
|
ip := resolved.IP
|
||||||
|
if !isSafeIP(ip) {
|
||||||
|
return nil, errUnsafeTarget
|
||||||
|
}
|
||||||
|
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||||
|
if err == nil {
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return nil, errUnsafeTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTargetURL(targetURL *url.URL) error {
|
||||||
|
if targetURL == nil || targetURL.Hostname() == "" {
|
||||||
|
return errors.New("invalid url")
|
||||||
|
}
|
||||||
|
if targetURL.User != nil {
|
||||||
|
return errors.New("url credentials are not supported")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(targetURL.Scheme) {
|
||||||
|
case "http", "https":
|
||||||
|
default:
|
||||||
|
return errors.New("only http and https urls are supported")
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(targetURL.Hostname()); ip != nil && !isSafeIP(ip) {
|
||||||
|
return errUnsafeTarget
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSafeIP(ip net.IP) bool {
|
||||||
|
if ip == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
return !(ip4[0] == 169 && ip4[1] == 254)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func analyzeSecurity(site *fetchedSite) SecurityInfo {
|
func analyzeSecurity(site *fetchedSite) SecurityInfo {
|
||||||
securityHeaders := []string{"strict-transport-security", "content-security-policy", "x-frame-options", "x-content-type-options", "referrer-policy", "permissions-policy"}
|
securityHeaders := []string{"strict-transport-security", "content-security-policy", "x-frame-options", "x-content-type-options", "referrer-policy", "permissions-policy"}
|
||||||
return SecurityInfo{
|
return SecurityInfo{
|
||||||
|
|||||||
Reference in New Issue
Block a user