15
internal/clouddetect/cloud_ranges.txt
Normal file
15
internal/clouddetect/cloud_ranges.txt
Normal file
@@ -0,0 +1,15 @@
|
||||
# Cloud provider IP ranges (partial list for demonstration)
|
||||
Cloudflare:
|
||||
103.21.244.0/22
|
||||
104.16.0.0/12
|
||||
2606:4700::/32
|
||||
|
||||
AWS_CloudFront:
|
||||
13.32.0.0/15
|
||||
13.54.0.0/15
|
||||
2600:9000::/28
|
||||
|
||||
Azure_Front_Door:
|
||||
40.112.0.0/13
|
||||
52.239.0.0/16
|
||||
2603:1030::/32
|
128
internal/clouddetect/detect.go
Normal file
128
internal/clouddetect/detect.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package clouddetect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result represents the outcome of a cloud proxy detection.
|
||||
type Result struct {
|
||||
IsProxy bool `json:"is_proxy"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// knownCIDRs maps providers to a slice of CIDR strings that represent their IP ranges.
|
||||
// In a production implementation these would be loaded from data files; for this prototype
|
||||
// a small representative subset is hard‑coded.
|
||||
var knownCIDRs = map[string][]string{
|
||||
"Cloudflare": {
|
||||
"103.21.244.0/22",
|
||||
"104.16.0.0/12",
|
||||
"2606:4700::/32",
|
||||
},
|
||||
"AWS CloudFront": {
|
||||
"13.32.0.0/15",
|
||||
"13.54.0.0/15",
|
||||
"2600:9000::/28",
|
||||
},
|
||||
"Azure Front Door": {
|
||||
"40.112.0.0/13",
|
||||
"52.239.0.0/16",
|
||||
"2603:1030::/32",
|
||||
},
|
||||
}
|
||||
|
||||
// Detect attempts to determine whether the target is behind a known cloud proxy.
|
||||
// It performs DNS resolution, checks the resolved IPs against known CIDR ranges,
|
||||
// and inspects HTTP response headers for provider signatures.
|
||||
func Detect(target string) (*Result, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Resolve IP addresses for the target.
|
||||
// Strip any port component if present (e.g., "example.com:443").
|
||||
host := target
|
||||
if strings.Contains(target, ":") {
|
||||
if h, _, err := net.SplitHostPort(target); err == nil {
|
||||
host = h
|
||||
}
|
||||
}
|
||||
var ips []net.IP
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
// DNS lookup failed; continue without IPs.
|
||||
ips = nil
|
||||
}
|
||||
// Duplicate error handling removed – the previous block already handles DNS failures.
|
||||
|
||||
// Prepare CIDR parsers.
|
||||
parsedCIDRs := make(map[string][]*net.IPNet)
|
||||
for prov, cidrs := range knownCIDRs {
|
||||
for _, cidr := range cidrs {
|
||||
_, ipnet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
continue // skip malformed entries
|
||||
}
|
||||
parsedCIDRs[prov] = append(parsedCIDRs[prov], ipnet)
|
||||
}
|
||||
}
|
||||
// Load additional CIDR ranges from external file (cloud_ranges.txt).
|
||||
if extCIDRs, err := LoadCIDRs("internal/clouddetect/cloud_ranges.txt"); err == nil {
|
||||
for prov, nets := range extCIDRs {
|
||||
parsedCIDRs[prov] = append(parsedCIDRs[prov], nets...)
|
||||
}
|
||||
}
|
||||
|
||||
// Check each resolved IP against the CIDR tables.
|
||||
for _, ip := range ips {
|
||||
for prov, nets := range parsedCIDRs {
|
||||
for _, net := range nets {
|
||||
if net.Contains(ip) {
|
||||
return &Result{
|
||||
IsProxy: true,
|
||||
Provider: prov,
|
||||
Details: fmt.Sprintf("IP %s matches %s range", ip, prov),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no CIDR match, attempt an HTTP HEAD request to look for provider headers.
|
||||
// Use the original target (host) for the request to avoid port issues.
|
||||
url := fmt.Sprintf("http://%s", host)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||
if err == nil {
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
serverHeader := resp.Header.Get("Server")
|
||||
viaHeader := resp.Header.Get("Via")
|
||||
|
||||
// Simple heuristics based on common header values.
|
||||
if strings.Contains(strings.ToLower(serverHeader), "cloudflare") ||
|
||||
strings.Contains(strings.ToLower(viaHeader), "cloudflare") {
|
||||
return &Result{IsProxy: true, Provider: "Cloudflare", Details: "Detected via HTTP headers"}, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(serverHeader), "amazon") ||
|
||||
strings.Contains(strings.ToLower(viaHeader), "aws") {
|
||||
return &Result{IsProxy: true, Provider: "AWS CloudFront", Details: "Detected via HTTP headers"}, nil
|
||||
}
|
||||
if strings.Contains(strings.ToLower(serverHeader), "azure") ||
|
||||
strings.Contains(strings.ToLower(viaHeader), "azure") {
|
||||
return &Result{IsProxy: true, Provider: "Azure Front Door", Details: "Detected via HTTP headers"}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No proxy indicators found.
|
||||
return &Result{IsProxy: false}, nil
|
||||
}
|
47
internal/clouddetect/detect_test.go
Normal file
47
internal/clouddetect/detect_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package clouddetect
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDetect_HeaderBasedDetection verifies that Detect can identify a Cloudflare proxy
|
||||
// by inspecting the Server header returned from an HTTP HEAD request.
|
||||
func TestDetect_HeaderBasedDetection(t *testing.T) {
|
||||
// Create a test server that mimics a Cloudflare‑protected endpoint.
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Server", "cloudflare")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Extract host without scheme (e.g., "127.0.0.1:XXXXX").
|
||||
host := strings.TrimPrefix(ts.URL, "http://")
|
||||
|
||||
// Run detection against the test server.
|
||||
res, err := Detect(host)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect returned error: %v", err)
|
||||
}
|
||||
if !res.IsProxy {
|
||||
t.Fatalf("Expected proxy detection, got IsProxy=false")
|
||||
}
|
||||
if res.Provider != "Cloudflare" {
|
||||
t.Fatalf("Expected provider Cloudflare, got %s", res.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetect_NoProxy ensures that a target without known CIDR or header signatures
|
||||
// is reported as not being behind a proxy.
|
||||
func TestDetect_NoProxy(t *testing.T) {
|
||||
// Use localhost where no special headers are set.
|
||||
res, err := Detect("localhost")
|
||||
if err != nil {
|
||||
t.Fatalf("Detect returned error: %v", err)
|
||||
}
|
||||
if res.IsProxy {
|
||||
t.Fatalf("Expected no proxy detection, got IsProxy=true")
|
||||
}
|
||||
}
|
18
internal/clouddetect/error.go
Normal file
18
internal/clouddetect/error.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package clouddetect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GracefulShutdown handles context cancellation and cleanup for detection operations.
|
||||
func GracefulShutdown(ctx context.Context) {
|
||||
// Placeholder for future shutdown logic.
|
||||
// Currently, simply wait for context cancellation or a timeout.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Context cancelled.
|
||||
case <-time.After(5 * time.Second):
|
||||
// Timeout reached.
|
||||
}
|
||||
}
|
50
internal/clouddetect/loader.go
Normal file
50
internal/clouddetect/loader.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package clouddetect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoadCIDRs reads the cloud_ranges.txt file and parses the CIDR blocks for each provider.
|
||||
// The file format is simple: a provider name followed by a colon, then one CIDR per line.
|
||||
// Blank lines and lines starting with '#' are ignored.
|
||||
func LoadCIDRs(filePath string) (map[string][]*net.IPNet, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
cidrs := make(map[string][]*net.IPNet)
|
||||
var currentProvider string
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// Provider header, e.g., "Cloudflare:"
|
||||
if strings.HasSuffix(line, ":") {
|
||||
currentProvider = strings.TrimSuffix(line, ":")
|
||||
continue
|
||||
}
|
||||
// CIDR entry
|
||||
if currentProvider == "" {
|
||||
// Skip CIDR without a provider header.
|
||||
continue
|
||||
}
|
||||
_, ipnet, err := net.ParseCIDR(line)
|
||||
if err != nil {
|
||||
// Invalid CIDR – skip it.
|
||||
continue
|
||||
}
|
||||
cidrs[currentProvider] = append(cidrs[currentProvider], ipnet)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cidrs, nil
|
||||
}
|
Reference in New Issue
Block a user