feat(tls): 更新 DoWithTLS 所有调用点至新三模式签名
- DoWithTLS 签名变更:(bool/profile) → (TLSMode, profile) - 所有调用方传入 account.GetTLSMode() 以支持 node/utls/off 三模式 - gateway_service.go、gemini_messages_compat、forward_as_* 全部更新 - claude_usage_service 的 ClaudeUsageFetchOptions 新增 TLSMode 字段 - 新增 decompressResponseBody(gzip/brotli/deflate)到 http_upstream.go - 新增 antigravity_privacy_service.go(setAntigravityPrivacy) - admin_service 新增 ForceOpenAIPrivacy/EnsureAntigravityPrivacy/ForceAntigravityPrivacy - antigravity.Client 新增 SetUserSettings/FetchUserInfo API
This commit is contained in:
parent
574fa9dfbd
commit
85ed193ff0
218
antigravity/capture/capture_tls.sh
Executable file
218
antigravity/capture/capture_tls.sh
Executable file
@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# capture_tls.sh - Capture TLS ClientHello fingerprints (JA3)
|
||||
#
|
||||
# mitmproxy terminates TLS so it can't see the real JA3 that
|
||||
# Claude CLI / Antigravity sends to Anthropic. This script
|
||||
# captures the REAL TLS fingerprint using tshark.
|
||||
#
|
||||
# Usage:
|
||||
# # Run BEFORE starting claude login / claude "hello"
|
||||
# # (don't use HTTPS_PROXY for this - direct connection)
|
||||
#
|
||||
# sudo ./capture_tls.sh # capture on default interface
|
||||
# sudo ./capture_tls.sh en0 # specify interface
|
||||
# sudo ./capture_tls.sh en0 30 # capture for 30 seconds
|
||||
#
|
||||
# Output:
|
||||
# ./captures/tls_capture_<timestamp>.txt
|
||||
# ./captures/tls_capture_<timestamp>.pcap
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IFACE="${1:-en0}"
|
||||
DURATION="${2:-60}"
|
||||
OUTDIR="./captures"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
PCAP_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.pcap"
|
||||
TXT_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.txt"
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
# Resolve target IPs
|
||||
echo "Resolving target domains..."
|
||||
DOMAINS=(
|
||||
"api.anthropic.com"
|
||||
"platform.claude.com"
|
||||
"claude.ai"
|
||||
"cloudaicompanion.googleapis.com"
|
||||
"generativelanguage.googleapis.com"
|
||||
"oauth2.googleapis.com"
|
||||
"accounts.google.com"
|
||||
)
|
||||
|
||||
HOST_FILTER=""
|
||||
for domain in "${DOMAINS[@]}"; do
|
||||
ips=$(dig +short "$domain" 2>/dev/null | grep -E '^[0-9]+\.' | head -5)
|
||||
for ip in $ips; do
|
||||
if [ -n "$HOST_FILTER" ]; then
|
||||
HOST_FILTER="$HOST_FILTER or host $ip"
|
||||
else
|
||||
HOST_FILTER="host $ip"
|
||||
fi
|
||||
done
|
||||
echo " $domain → $ips"
|
||||
done
|
||||
|
||||
if [ -z "$HOST_FILTER" ]; then
|
||||
echo "ERROR: Could not resolve any target domains"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CAPTURE_FILTER="tcp port 443 and ($HOST_FILTER)"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " TLS Fingerprint Capture"
|
||||
echo " Interface: $IFACE"
|
||||
echo " Duration: ${DURATION}s"
|
||||
echo " Filter: $CAPTURE_FILTER"
|
||||
echo " PCAP: $PCAP_FILE"
|
||||
echo " Report: $TXT_FILE"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo ">>> Now run 'claude login' or 'claude \"hello\"' in another terminal <<<"
|
||||
echo ">>> Press Ctrl+C to stop early <<<"
|
||||
echo ""
|
||||
|
||||
# Capture pcap in background
|
||||
tshark -i "$IFACE" -f "$CAPTURE_FILTER" -w "$PCAP_FILE" -a "duration:$DURATION" 2>/dev/null &
|
||||
TSHARK_PID=$!
|
||||
|
||||
# Wait for capture to complete or Ctrl+C
|
||||
trap "kill $TSHARK_PID 2>/dev/null; wait $TSHARK_PID 2>/dev/null" INT TERM
|
||||
wait $TSHARK_PID 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "Capture complete. Analyzing..."
|
||||
echo ""
|
||||
|
||||
# ─── Analysis ───
|
||||
|
||||
{
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " TLS ClientHello Fingerprint Report"
|
||||
echo " Captured: $(date)"
|
||||
echo " PCAP: $PCAP_FILE"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Extract JA3 fingerprints
|
||||
echo "─── JA3 Fingerprints (ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e frame.time \
|
||||
-e ip.dst \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ja3 \
|
||||
-e tls.handshake.ja3_full \
|
||||
2>/dev/null | while IFS=$'\t' read -r ts dst sni ja3 ja3_full; do
|
||||
echo " Time: $ts"
|
||||
echo " Dest IP: $dst"
|
||||
echo " SNI: $sni"
|
||||
echo " JA3 Hash: $ja3"
|
||||
if [ -n "$ja3_full" ]; then
|
||||
echo " JA3 Full: $ja3_full"
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── TLS Versions ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.version \
|
||||
-e tls.handshake.extensions.supported_version \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r sni ver supported; do
|
||||
echo " SNI: $sni"
|
||||
echo " Record Version: $ver"
|
||||
echo " Supported Versions: $supported"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── ALPN Protocols ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.extensions_alpn_str \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r sni alpn; do
|
||||
echo " SNI: $sni → ALPN: $alpn"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "─── Cipher Suites (per ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ciphersuite \
|
||||
2>/dev/null | head -5 | while IFS=$'\t' read -r sni ciphers; do
|
||||
echo " SNI: $sni"
|
||||
echo " Cipher Suites:"
|
||||
echo " $ciphers" | tr ',' '\n' | while read -r c; do
|
||||
echo " $c"
|
||||
done
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── Extensions (per ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.extension.type \
|
||||
2>/dev/null | head -5 | while IFS=$'\t' read -r sni exts; do
|
||||
echo " SNI: $sni"
|
||||
echo " Extensions: $exts"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── Unique JA3 Summary ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ja3 \
|
||||
2>/dev/null | sort | uniq -c | sort -rn | while read -r count sni ja3; do
|
||||
echo " ${count}x SNI: $sni JA3: $ja3"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── TCP Fingerprint (Initial Window Size, TTL) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tcp.flags.syn == 1 && tcp.flags.ack == 0" \
|
||||
-T fields \
|
||||
-e ip.dst \
|
||||
-e ip.ttl \
|
||||
-e tcp.window_size_value \
|
||||
-e tcp.options.mss_val \
|
||||
-e tcp.options.wscale.shift \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r dst ttl win mss wscale; do
|
||||
echo " Dest: $dst TTL: $ttl Window: $win MSS: $mss WScale: $wscale"
|
||||
done
|
||||
|
||||
} 2>/dev/null | tee "$TXT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " Report saved to: $TXT_FILE"
|
||||
echo " PCAP saved to: $PCAP_FILE"
|
||||
echo ""
|
||||
echo " To re-analyze: tshark -r $PCAP_FILE -Y 'tls.handshake.type==1' ..."
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
506
antigravity/capture/capture_traffic.py
Normal file
506
antigravity/capture/capture_traffic.py
Normal file
@ -0,0 +1,506 @@
|
||||
"""
|
||||
MiniGravity Traffic Capture - mitmproxy addon
|
||||
|
||||
Captures and categorizes traffic from Claude Code and Antigravity IDE.
|
||||
Records: headers (with ordering), body, TLS info, timing.
|
||||
|
||||
Usage:
|
||||
# Claude Code (terminal)
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 claude login
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 claude "hello"
|
||||
|
||||
# Antigravity (VS Code) - set proxy in VS Code settings or env
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 code .
|
||||
|
||||
# Start mitmproxy with this addon
|
||||
mitmproxy -s capture_traffic.py --set stream_large_bodies=10m
|
||||
# or headless:
|
||||
mitmdump -s capture_traffic.py --set stream_large_bodies=10m
|
||||
|
||||
Output:
|
||||
./captures/ - JSON files per request
|
||||
./captures/_summary.jsonl - One-line-per-request summary
|
||||
./captures/_report.txt - Human-readable report (generated on exit)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from mitmproxy import http, ctx, tls
|
||||
from mitmproxy.net.http.http1.assemble import assemble_request_head
|
||||
|
||||
|
||||
# ─── Target domains and classification ───
|
||||
|
||||
TARGET_DOMAINS = {
|
||||
# Claude / Anthropic
|
||||
"claude.ai",
|
||||
"platform.claude.com",
|
||||
"api.anthropic.com",
|
||||
# Google / Antigravity
|
||||
"accounts.google.com",
|
||||
"oauth2.googleapis.com",
|
||||
"cloudaicompanion.googleapis.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
# Telemetry
|
||||
"http-intake.logs.us5.datadoghq.com",
|
||||
"sentry.io",
|
||||
}
|
||||
|
||||
|
||||
def classify_request(flow: http.HTTPFlow) -> dict:
|
||||
"""Classify a request by source tool and purpose."""
|
||||
host = flow.request.pretty_host
|
||||
path = flow.request.path
|
||||
method = flow.request.method
|
||||
ua = flow.request.headers.get("user-agent", "")
|
||||
|
||||
# Determine source tool
|
||||
source = "unknown"
|
||||
if "claude-cli" in ua or "claude-code" in ua:
|
||||
source = "claude-cli"
|
||||
elif "node" in ua.lower() and ("stainless" in str(flow.request.headers)):
|
||||
source = "claude-cli"
|
||||
elif "axios" in ua:
|
||||
source = "claude-cli-sdk"
|
||||
elif "vscode" in ua.lower() or "visual studio" in ua.lower():
|
||||
source = "vscode-extension"
|
||||
elif "electron" in ua.lower():
|
||||
source = "desktop-app"
|
||||
elif "chrome" in ua.lower() or "safari" in ua.lower() or "mozilla" in ua.lower():
|
||||
source = "browser"
|
||||
elif "node" in ua.lower():
|
||||
source = "node-generic"
|
||||
elif "python" in ua.lower():
|
||||
source = "python-client"
|
||||
elif "go-http" in ua.lower() or "go/" in ua.lower():
|
||||
source = "go-client"
|
||||
|
||||
# Determine request purpose
|
||||
purpose = "unknown"
|
||||
|
||||
# OAuth flows
|
||||
if "/oauth/authorize" in path:
|
||||
purpose = "oauth-authorize"
|
||||
elif "/oauth/token" in path or "/v1/oauth/token" in path:
|
||||
# Distinguish exchange vs refresh
|
||||
body = _get_request_body_str(flow)
|
||||
if "refresh_token" in body:
|
||||
purpose = "oauth-token-refresh"
|
||||
else:
|
||||
purpose = "oauth-token-exchange"
|
||||
elif "/o/oauth2" in path or "/oauth2/" in path:
|
||||
purpose = "google-oauth"
|
||||
|
||||
# API calls
|
||||
elif "/v1/messages" in path:
|
||||
purpose = "api-messages"
|
||||
elif "/v1/complete" in path:
|
||||
purpose = "api-complete"
|
||||
|
||||
# Organization / setup
|
||||
elif "/api/organizations" in path:
|
||||
purpose = "org-list"
|
||||
elif "/v1/oauth/" in path and "/authorize" in path:
|
||||
purpose = "oauth-authorize-api"
|
||||
|
||||
# Telemetry
|
||||
elif "/api/event_logging" in path:
|
||||
purpose = "telemetry-otel"
|
||||
elif "datadoghq.com" in host:
|
||||
purpose = "telemetry-datadog"
|
||||
elif "sentry" in host:
|
||||
purpose = "telemetry-sentry"
|
||||
|
||||
# Google AI
|
||||
elif "cloudaicompanion" in host:
|
||||
purpose = "antigravity-api"
|
||||
elif "generativelanguage" in host:
|
||||
purpose = "gemini-api"
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"purpose": purpose,
|
||||
}
|
||||
|
||||
|
||||
def _get_request_body_str(flow: http.HTTPFlow) -> str:
|
||||
"""Safely get request body as string."""
|
||||
try:
|
||||
if flow.request.content:
|
||||
return flow.request.content.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _get_response_body_str(flow: http.HTTPFlow, max_len: int = 4096) -> str:
|
||||
"""Safely get response body as string, truncated."""
|
||||
try:
|
||||
if flow.response and flow.response.content:
|
||||
body = flow.response.content.decode("utf-8", errors="replace")
|
||||
if len(body) > max_len:
|
||||
return body[:max_len] + f"\n... [truncated, total {len(body)} bytes]"
|
||||
return body
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_json_body(body_str: str) -> any:
|
||||
"""Try to parse body as JSON, return raw string if fails."""
|
||||
if not body_str:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body_str)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return body_str
|
||||
|
||||
|
||||
def _get_tls_info(flow: http.HTTPFlow) -> dict:
|
||||
"""Extract available TLS information from the flow."""
|
||||
info = {}
|
||||
if flow.server_conn and flow.server_conn.tls_version:
|
||||
info["tls_version"] = flow.server_conn.tls_version
|
||||
if flow.server_conn and hasattr(flow.server_conn, "alpn_proto_negotiated"):
|
||||
info["alpn"] = (
|
||||
flow.server_conn.alpn_proto_negotiated.decode()
|
||||
if flow.server_conn.alpn_proto_negotiated
|
||||
else None
|
||||
)
|
||||
|
||||
# Client TLS info (what the client sent to mitmproxy)
|
||||
if flow.client_conn:
|
||||
if hasattr(flow.client_conn, "tls_version") and flow.client_conn.tls_version:
|
||||
info["client_tls_version"] = flow.client_conn.tls_version
|
||||
if (
|
||||
hasattr(flow.client_conn, "alpn_proto_negotiated")
|
||||
and flow.client_conn.alpn_proto_negotiated
|
||||
):
|
||||
info["client_alpn"] = flow.client_conn.alpn_proto_negotiated.decode()
|
||||
# SNI
|
||||
if hasattr(flow.client_conn, "sni") and flow.client_conn.sni:
|
||||
info["client_sni"] = flow.client_conn.sni
|
||||
|
||||
return info
|
||||
|
||||
|
||||
class TrafficCapture:
|
||||
def __init__(self):
|
||||
self.capture_dir = Path("./captures")
|
||||
self.capture_dir.mkdir(exist_ok=True)
|
||||
self.summary_file = self.capture_dir / "_summary.jsonl"
|
||||
self.counter = 0
|
||||
self.captures = []
|
||||
|
||||
# Write session start marker
|
||||
session_start = {
|
||||
"event": "session_start",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"note": "New capture session started",
|
||||
}
|
||||
with open(self.summary_file, "a") as f:
|
||||
f.write(json.dumps(session_start) + "\n")
|
||||
|
||||
ctx.log.info(
|
||||
f"[capture] Traffic capture started. Output: {self.capture_dir.absolute()}"
|
||||
)
|
||||
|
||||
def request(self, flow: http.HTTPFlow):
|
||||
"""Tag requests to target domains."""
|
||||
host = flow.request.pretty_host
|
||||
is_target = any(host == d or host.endswith("." + d) for d in TARGET_DOMAINS)
|
||||
flow.metadata["is_target"] = is_target
|
||||
if is_target:
|
||||
flow.metadata["capture_time_start"] = time.time()
|
||||
|
||||
def response(self, flow: http.HTTPFlow):
|
||||
"""Capture complete request/response for target domains."""
|
||||
if not flow.metadata.get("is_target"):
|
||||
return
|
||||
|
||||
self.counter += 1
|
||||
classification = classify_request(flow)
|
||||
elapsed = None
|
||||
if flow.metadata.get("capture_time_start"):
|
||||
elapsed = round(time.time() - flow.metadata["capture_time_start"], 3)
|
||||
|
||||
# Build ordered header list (order matters for fingerprinting!)
|
||||
request_headers_ordered = [
|
||||
[k, v] for k, v in flow.request.headers.fields
|
||||
]
|
||||
request_headers_ordered_decoded = []
|
||||
for k, v in request_headers_ordered:
|
||||
try:
|
||||
request_headers_ordered_decoded.append(
|
||||
[k.decode("utf-8", errors="replace"),
|
||||
v.decode("utf-8", errors="replace")]
|
||||
)
|
||||
except AttributeError:
|
||||
request_headers_ordered_decoded.append([str(k), str(v)])
|
||||
|
||||
response_headers_ordered = []
|
||||
if flow.response:
|
||||
for k, v in flow.response.headers.fields:
|
||||
try:
|
||||
response_headers_ordered.append(
|
||||
[k.decode("utf-8", errors="replace"),
|
||||
v.decode("utf-8", errors="replace")]
|
||||
)
|
||||
except AttributeError:
|
||||
response_headers_ordered.append([str(k), str(v)])
|
||||
|
||||
req_body = _get_request_body_str(flow)
|
||||
resp_body = _get_response_body_str(flow)
|
||||
|
||||
# Redact sensitive values
|
||||
req_body_parsed = _parse_json_body(req_body)
|
||||
if isinstance(req_body_parsed, dict):
|
||||
req_body_parsed = _redact_sensitive(req_body_parsed)
|
||||
|
||||
resp_body_parsed = _parse_json_body(resp_body)
|
||||
if isinstance(resp_body_parsed, dict):
|
||||
resp_body_parsed = _redact_sensitive(resp_body_parsed)
|
||||
|
||||
record = {
|
||||
"id": self.counter,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"elapsed_sec": elapsed,
|
||||
|
||||
# Classification
|
||||
"source": classification["source"],
|
||||
"purpose": classification["purpose"],
|
||||
|
||||
# Request
|
||||
"request": {
|
||||
"method": flow.request.method,
|
||||
"url": flow.request.pretty_url,
|
||||
"host": flow.request.pretty_host,
|
||||
"path": flow.request.path,
|
||||
"http_version": flow.request.http_version,
|
||||
"headers_ordered": request_headers_ordered_decoded,
|
||||
"body": req_body_parsed,
|
||||
"content_length": len(flow.request.content) if flow.request.content else 0,
|
||||
},
|
||||
|
||||
# Response
|
||||
"response": {
|
||||
"status_code": flow.response.status_code if flow.response else None,
|
||||
"http_version": flow.response.http_version if flow.response else None,
|
||||
"headers_ordered": response_headers_ordered,
|
||||
"body": resp_body_parsed,
|
||||
"content_length": (
|
||||
len(flow.response.content)
|
||||
if flow.response and flow.response.content
|
||||
else 0
|
||||
),
|
||||
},
|
||||
|
||||
# TLS
|
||||
"tls": _get_tls_info(flow),
|
||||
|
||||
# Connection
|
||||
"connection": {
|
||||
"client_address": (
|
||||
f"{flow.client_conn.peername[0]}:{flow.client_conn.peername[1]}"
|
||||
if flow.client_conn.peername
|
||||
else None
|
||||
),
|
||||
"server_address": (
|
||||
f"{flow.server_conn.peername[0]}:{flow.server_conn.peername[1]}"
|
||||
if flow.server_conn and flow.server_conn.peername
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
self.captures.append(record)
|
||||
|
||||
# Save individual capture file
|
||||
filename = (
|
||||
f"{self.counter:04d}_{classification['source']}"
|
||||
f"_{classification['purpose']}"
|
||||
f"_{flow.request.pretty_host}.json"
|
||||
)
|
||||
filepath = self.capture_dir / filename
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(record, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
# Append to summary
|
||||
summary_line = {
|
||||
"id": self.counter,
|
||||
"ts": datetime.now(timezone.utc).strftime("%H:%M:%S"),
|
||||
"source": classification["source"],
|
||||
"purpose": classification["purpose"],
|
||||
"method": flow.request.method,
|
||||
"url": flow.request.pretty_url[:120],
|
||||
"status": flow.response.status_code if flow.response else None,
|
||||
"ua": flow.request.headers.get("user-agent", "")[:80],
|
||||
"elapsed": elapsed,
|
||||
}
|
||||
with open(self.summary_file, "a") as f:
|
||||
f.write(json.dumps(summary_line) + "\n")
|
||||
|
||||
# Console output
|
||||
status = flow.response.status_code if flow.response else "???"
|
||||
ctx.log.info(
|
||||
f"[capture #{self.counter}] "
|
||||
f"[{classification['source']}] "
|
||||
f"[{classification['purpose']}] "
|
||||
f"{flow.request.method} {flow.request.pretty_url[:80]} "
|
||||
f"→ {status} "
|
||||
f"({elapsed}s)"
|
||||
)
|
||||
|
||||
# Highlight important findings
|
||||
ua = flow.request.headers.get("user-agent", "")
|
||||
if classification["purpose"] in (
|
||||
"oauth-token-exchange",
|
||||
"oauth-token-refresh",
|
||||
):
|
||||
ctx.log.warn(
|
||||
f"[capture] TOKEN EXCHANGE/REFRESH detected!\n"
|
||||
f" UA: {ua}\n"
|
||||
f" Headers: {[h[0] for h in request_headers_ordered_decoded]}"
|
||||
)
|
||||
|
||||
def done(self):
|
||||
"""Generate report on exit."""
|
||||
if not self.captures:
|
||||
ctx.log.info("[capture] No captures recorded.")
|
||||
return
|
||||
|
||||
report_path = self.capture_dir / "_report.txt"
|
||||
with open(report_path, "w") as f:
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write(" MiniGravity Traffic Capture Report\n")
|
||||
f.write(f" Generated: {datetime.now().isoformat()}\n")
|
||||
f.write(f" Total requests captured: {len(self.captures)}\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
|
||||
# Group by source
|
||||
by_source = {}
|
||||
for cap in self.captures:
|
||||
src = cap["source"]
|
||||
if src not in by_source:
|
||||
by_source[src] = []
|
||||
by_source[src].append(cap)
|
||||
|
||||
for source, caps in sorted(by_source.items()):
|
||||
f.write(f"\n{'─' * 60}\n")
|
||||
f.write(f" Source: {source} ({len(caps)} requests)\n")
|
||||
f.write(f"{'─' * 60}\n\n")
|
||||
|
||||
# Group by purpose within source
|
||||
by_purpose = {}
|
||||
for cap in caps:
|
||||
p = cap["purpose"]
|
||||
if p not in by_purpose:
|
||||
by_purpose[p] = []
|
||||
by_purpose[p].append(cap)
|
||||
|
||||
for purpose, pcaps in sorted(by_purpose.items()):
|
||||
f.write(f" [{purpose}] ({len(pcaps)} requests)\n\n")
|
||||
|
||||
for cap in pcaps:
|
||||
req = cap["request"]
|
||||
f.write(f" #{cap['id']} {req['method']} {req['url'][:100]}\n")
|
||||
f.write(f" HTTP Version: {req['http_version']}\n")
|
||||
|
||||
f.write(" Request Headers (ordered):\n")
|
||||
for hdr in req["headers_ordered"]:
|
||||
val = hdr[1]
|
||||
# Truncate long values
|
||||
if len(val) > 100:
|
||||
val = val[:100] + "..."
|
||||
f.write(f" {hdr[0]}: {val}\n")
|
||||
|
||||
if req["body"]:
|
||||
body_str = json.dumps(
|
||||
req["body"], indent=6, ensure_ascii=False, default=str
|
||||
)
|
||||
if len(body_str) > 500:
|
||||
body_str = body_str[:500] + "\n ..."
|
||||
f.write(f" Request Body:\n {body_str}\n")
|
||||
|
||||
resp = cap["response"]
|
||||
f.write(f" Response: {resp['status_code']}\n")
|
||||
|
||||
if cap["tls"]:
|
||||
f.write(f" TLS: {json.dumps(cap['tls'])}\n")
|
||||
|
||||
f.write("\n")
|
||||
|
||||
# Comparison section
|
||||
f.write(f"\n{'=' * 80}\n")
|
||||
f.write(" FINGERPRINT COMPARISON\n")
|
||||
f.write(f"{'=' * 80}\n\n")
|
||||
|
||||
# Collect unique UA per source+purpose
|
||||
ua_map = {}
|
||||
for cap in self.captures:
|
||||
key = f"{cap['source']}:{cap['purpose']}"
|
||||
ua = dict(cap["request"]["headers_ordered"]).get("user-agent", "N/A")
|
||||
if key not in ua_map:
|
||||
ua_map[key] = set()
|
||||
ua_map[key].add(ua)
|
||||
|
||||
f.write(" User-Agent by source:purpose\n")
|
||||
for key, uas in sorted(ua_map.items()):
|
||||
for ua in uas:
|
||||
f.write(f" {key:40s} → {ua}\n")
|
||||
|
||||
# Collect header sets per source+purpose
|
||||
f.write("\n Header names by source:purpose\n")
|
||||
header_map = {}
|
||||
for cap in self.captures:
|
||||
key = f"{cap['source']}:{cap['purpose']}"
|
||||
hdrs = tuple(h[0].lower() for h in cap["request"]["headers_ordered"])
|
||||
if key not in header_map:
|
||||
header_map[key] = set()
|
||||
header_map[key].add(hdrs)
|
||||
|
||||
for key, hdr_sets in sorted(header_map.items()):
|
||||
for hdrs in hdr_sets:
|
||||
f.write(f" {key}:\n")
|
||||
for h in hdrs:
|
||||
f.write(f" - {h}\n")
|
||||
f.write("\n")
|
||||
|
||||
ctx.log.info(
|
||||
f"[capture] Report written to {report_path.absolute()}\n"
|
||||
f"[capture] {len(self.captures)} requests captured in {self.capture_dir.absolute()}"
|
||||
)
|
||||
|
||||
|
||||
def _redact_sensitive(d: dict) -> dict:
|
||||
"""Redact sensitive values in a dict, preserving structure."""
|
||||
sensitive_keys = {
|
||||
"access_token", "refresh_token", "code", "code_verifier",
|
||||
"session_key", "sessionKey", "password", "secret",
|
||||
"authorization", "cookie",
|
||||
}
|
||||
result = {}
|
||||
for k, v in d.items():
|
||||
if k.lower() in {s.lower() for s in sensitive_keys}:
|
||||
if isinstance(v, str) and len(v) > 8:
|
||||
result[k] = v[:4] + "****" + v[-4:]
|
||||
else:
|
||||
result[k] = "****"
|
||||
elif isinstance(v, dict):
|
||||
result[k] = _redact_sensitive(v)
|
||||
elif isinstance(v, list):
|
||||
result[k] = [
|
||||
_redact_sensitive(item) if isinstance(item, dict) else item
|
||||
for item in v
|
||||
]
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
addons = [TrafficCapture()]
|
||||
@ -0,0 +1,129 @@
|
||||
{
|
||||
"id": 1,
|
||||
"timestamp": "2026-03-26T16:28:57.647791+00:00",
|
||||
"elapsed_sec": 0.322,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"host": "downloads.claude.ai",
|
||||
"path": "/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"downloads.claude.ai"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"x-guploader-uploadid",
|
||||
"AMNfjG29CnIrYUAyZBJSnylKbYWnv3VH6x45qXwHunjwYiMbCueqWoZ3CouUPbV2VjfNtKXGrIpIQNI"
|
||||
],
|
||||
[
|
||||
"x-goog-generation",
|
||||
"1774486030779283"
|
||||
],
|
||||
[
|
||||
"x-goog-metageneration",
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-encoding",
|
||||
"identity"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"crc32c=/q0yrA=="
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"md5=tRgumXLHnEzHzEWYd8YEyg=="
|
||||
],
|
||||
[
|
||||
"x-goog-storage-class",
|
||||
"STANDARD"
|
||||
],
|
||||
[
|
||||
"accept-ranges",
|
||||
"bytes"
|
||||
],
|
||||
[
|
||||
"Content-Length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"UploadServer"
|
||||
],
|
||||
[
|
||||
"via",
|
||||
"1.1 google"
|
||||
],
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:28:57 GMT"
|
||||
],
|
||||
[
|
||||
"Age",
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Last-Modified",
|
||||
"Thu, 26 Mar 2026 16:17:10 GMT"
|
||||
],
|
||||
[
|
||||
"ETag",
|
||||
"\"b5182e9972c79c4cc7cc459877c604ca\""
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"text/plain"
|
||||
],
|
||||
[
|
||||
"Cache-Control",
|
||||
"public,no-cache,max-age=300"
|
||||
],
|
||||
[
|
||||
"Alt-Svc",
|
||||
"h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000"
|
||||
]
|
||||
],
|
||||
"body": "b10b583de281385442474e836644534b938b2678",
|
||||
"content_length": 40
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "downloads.claude.ai"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:55671",
|
||||
"server_address": "198.18.0.44:443"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
{
|
||||
"id": 2,
|
||||
"timestamp": "2026-03-26T16:28:57.668166+00:00",
|
||||
"elapsed_sec": 0.481,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"host": "api.anthropic.com",
|
||||
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"api.anthropic.com"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:28:57 GMT"
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"application/json"
|
||||
],
|
||||
[
|
||||
"Transfer-Encoding",
|
||||
"chunked"
|
||||
],
|
||||
[
|
||||
"Connection",
|
||||
"keep-alive"
|
||||
],
|
||||
[
|
||||
"x-request-id",
|
||||
"a26ee618-f205-4a23-87b1-6225e17b92ef"
|
||||
],
|
||||
[
|
||||
"access-control-allow-origin",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"access-control-allow-methods",
|
||||
"GET, OPTIONS"
|
||||
],
|
||||
[
|
||||
"access-control-allow-headers",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"x-envoy-upstream-service-time",
|
||||
"9"
|
||||
],
|
||||
[
|
||||
"Content-Encoding",
|
||||
"gzip"
|
||||
],
|
||||
[
|
||||
"vary",
|
||||
"Accept-Encoding"
|
||||
],
|
||||
[
|
||||
"Server",
|
||||
"cloudflare"
|
||||
],
|
||||
[
|
||||
"server-timing",
|
||||
"x-originResponse;dur=11"
|
||||
],
|
||||
[
|
||||
"cf-cache-status",
|
||||
"DYNAMIC"
|
||||
],
|
||||
[
|
||||
"set-cookie",
|
||||
"_cfuvid=XtplK6T__J5GJ7ZHZ75.1K.blAKEiURZzIRFOxbjm0U-1774542537.272683-1.0.1.1-Folg8_rQ2RrBi0Img0NdFQUYWxTawBjeo7zj11dFizU; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
[
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
[
|
||||
"X-Robots-Tag",
|
||||
"none"
|
||||
],
|
||||
[
|
||||
"CF-RAY",
|
||||
"9e278809ffffe371-NRT"
|
||||
]
|
||||
],
|
||||
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search & Summarize: <br>\\\"Summarize my Q2 product strategy doc\\\"</p><p id=\\\"\\\">Create New Designs from Conversation:<br>\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"</p><p id=\\\"\\\">Autofill Charts:<br>\\\"Add a chart showing monthly signups in NZ for Q1\\\"</p><p id=\\\"\\\">Autofill Brand Templates:<br>\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"</p><p id=\\\"\\\">Import Files via Link:<br>\\\"Import this PDF [insert URL] into Canva\\\"</p><p id=\\\"\\\">Resize or Export:<br>\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"</p>\",\n \"imageUrls\": [\n {\n \"prompt\": \"Generate a sales report presentation with outline \",\n \"imageUrl\": \"https://storage.goo\n... [truncated, total 185385 bytes]",
|
||||
"content_length": 185487
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "api.anthropic.com"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:55668",
|
||||
"server_address": "198.18.0.32:443"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
{
|
||||
"id": 3,
|
||||
"timestamp": "2026-03-26T16:30:00.064058+00:00",
|
||||
"elapsed_sec": 0.731,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"host": "api.anthropic.com",
|
||||
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"api.anthropic.com"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:29:59 GMT"
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"application/json"
|
||||
],
|
||||
[
|
||||
"Transfer-Encoding",
|
||||
"chunked"
|
||||
],
|
||||
[
|
||||
"Connection",
|
||||
"keep-alive"
|
||||
],
|
||||
[
|
||||
"x-request-id",
|
||||
"ddcd43e3-8799-43b9-9d49-8dcc6a0b90dd"
|
||||
],
|
||||
[
|
||||
"access-control-allow-origin",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"access-control-allow-methods",
|
||||
"GET, OPTIONS"
|
||||
],
|
||||
[
|
||||
"access-control-allow-headers",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"x-envoy-upstream-service-time",
|
||||
"8"
|
||||
],
|
||||
[
|
||||
"Content-Encoding",
|
||||
"gzip"
|
||||
],
|
||||
[
|
||||
"vary",
|
||||
"Accept-Encoding"
|
||||
],
|
||||
[
|
||||
"Server",
|
||||
"cloudflare"
|
||||
],
|
||||
[
|
||||
"server-timing",
|
||||
"x-originResponse;dur=10"
|
||||
],
|
||||
[
|
||||
"cf-cache-status",
|
||||
"DYNAMIC"
|
||||
],
|
||||
[
|
||||
"set-cookie",
|
||||
"_cfuvid=DUvPIDhglzXAjPSEJhKi0nemis9e5knKw1jmUxq8LnE-1774542599.5569572-1.0.1.1-5oD..eF758shBNx1g_VrkNhd2HcST2hu4QKN5ciERz4; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
[
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
[
|
||||
"X-Robots-Tag",
|
||||
"none"
|
||||
],
|
||||
[
|
||||
"CF-RAY",
|
||||
"9e27898f3c1eefbb-NRT"
|
||||
]
|
||||
],
|
||||
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search & Summarize: <br>\\\"Summarize my Q2 product strategy doc\\\"</p><p id=\\\"\\\">Create New Designs from Conversation:<br>\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"</p><p id=\\\"\\\">Autofill Charts:<br>\\\"Add a chart showing monthly signups in NZ for Q1\\\"</p><p id=\\\"\\\">Autofill Brand Templates:<br>\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"</p><p id=\\\"\\\">Import Files via Link:<br>\\\"Import this PDF [insert URL] into Canva\\\"</p><p id=\\\"\\\">Resize or Export:<br>\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"</p>\",\n \"imageUrls\": [\n {\n \"prompt\": \"Generate a sales report presentation with outline \",\n \"imageUrl\": \"https://storage.goo\n... [truncated, total 185385 bytes]",
|
||||
"content_length": 185487
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "api.anthropic.com"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:55998",
|
||||
"server_address": "198.18.0.32:443"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
{
|
||||
"id": 4,
|
||||
"timestamp": "2026-03-26T16:30:00.153789+00:00",
|
||||
"elapsed_sec": 0.833,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"host": "downloads.claude.ai",
|
||||
"path": "/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"downloads.claude.ai"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"x-guploader-uploadid",
|
||||
"AMNfjG37C4G0lUKtWOt8YyD-JuE6Y6MUhtxm8P77pzlb0lJzsdb6sG8xLwNpaolt4FWHSJVblZLmjWM"
|
||||
],
|
||||
[
|
||||
"x-goog-generation",
|
||||
"1774486030779283"
|
||||
],
|
||||
[
|
||||
"x-goog-metageneration",
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-encoding",
|
||||
"identity"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"crc32c=/q0yrA=="
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"md5=tRgumXLHnEzHzEWYd8YEyg=="
|
||||
],
|
||||
[
|
||||
"x-goog-storage-class",
|
||||
"STANDARD"
|
||||
],
|
||||
[
|
||||
"accept-ranges",
|
||||
"bytes"
|
||||
],
|
||||
[
|
||||
"Content-Length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"UploadServer"
|
||||
],
|
||||
[
|
||||
"via",
|
||||
"1.1 google"
|
||||
],
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:29:59 GMT"
|
||||
],
|
||||
[
|
||||
"Age",
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Last-Modified",
|
||||
"Thu, 26 Mar 2026 16:17:10 GMT"
|
||||
],
|
||||
[
|
||||
"ETag",
|
||||
"\"b5182e9972c79c4cc7cc459877c604ca\""
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"text/plain"
|
||||
],
|
||||
[
|
||||
"Cache-Control",
|
||||
"public,no-cache,max-age=300"
|
||||
],
|
||||
[
|
||||
"Alt-Svc",
|
||||
"h3=\":443\"; ma=2592000"
|
||||
]
|
||||
],
|
||||
"body": "b10b583de281385442474e836644534b938b2678",
|
||||
"content_length": 40
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "downloads.claude.ai"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:56003",
|
||||
"server_address": "198.18.0.44:443"
|
||||
}
|
||||
}
|
||||
68
antigravity/capture/captures/_report.txt
Normal file
68
antigravity/capture/captures/_report.txt
Normal file
@ -0,0 +1,68 @@
|
||||
================================================================================
|
||||
MiniGravity Traffic Capture Report
|
||||
Generated: 2026-03-27T00:50:12.040880
|
||||
Total requests captured: 4
|
||||
================================================================================
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
Source: claude-cli-sdk (4 requests)
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
[unknown] (4 requests)
|
||||
|
||||
#1 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: downloads.claude.ai
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
|
||||
|
||||
#2 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: api.anthropic.com
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
|
||||
|
||||
#3 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: api.anthropic.com
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
|
||||
|
||||
#4 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: downloads.claude.ai
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
|
||||
|
||||
|
||||
================================================================================
|
||||
FINGERPRINT COMPARISON
|
||||
================================================================================
|
||||
|
||||
User-Agent by source:purpose
|
||||
claude-cli-sdk:unknown → N/A
|
||||
|
||||
Header names by source:purpose
|
||||
claude-cli-sdk:unknown:
|
||||
- accept
|
||||
- accept-encoding
|
||||
- user-agent
|
||||
- host
|
||||
|
||||
5
antigravity/capture/captures/_summary.jsonl
Normal file
5
antigravity/capture/captures/_summary.jsonl
Normal file
@ -0,0 +1,5 @@
|
||||
{"event": "session_start", "timestamp": "2026-03-26T16:28:39.558811+00:00", "note": "New capture session started"}
|
||||
{"id": 1, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.322}
|
||||
{"id": 2, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.481}
|
||||
{"id": 3, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.731}
|
||||
{"id": 4, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.833}
|
||||
240
antigravity/capture/ja3_extract.py
Normal file
240
antigravity/capture/ja3_extract.py
Normal file
@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract JA3 fingerprint from pcap file (no tshark needed).
|
||||
Parses TLS ClientHello directly from raw packets.
|
||||
"""
|
||||
import struct
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
def parse_pcap(filepath):
|
||||
"""Parse pcap file and extract TLS ClientHello JA3 fingerprints."""
|
||||
results = []
|
||||
with open(filepath, 'rb') as f:
|
||||
# Read pcap global header
|
||||
magic = struct.unpack('<I', f.read(4))[0]
|
||||
if magic == 0xa1b2c3d4:
|
||||
endian = '<'
|
||||
elif magic == 0xd4c3b2a1:
|
||||
endian = '>'
|
||||
else:
|
||||
print(f"Not a pcap file (magic: {hex(magic)})")
|
||||
return results
|
||||
|
||||
f.read(20) # rest of global header
|
||||
|
||||
packet_num = 0
|
||||
while True:
|
||||
# Read packet header
|
||||
pkt_hdr = f.read(16)
|
||||
if len(pkt_hdr) < 16:
|
||||
break
|
||||
ts_sec, ts_usec, incl_len, orig_len = struct.unpack(f'{endian}IIII', pkt_hdr)
|
||||
pkt_data = f.read(incl_len)
|
||||
if len(pkt_data) < incl_len:
|
||||
break
|
||||
packet_num += 1
|
||||
|
||||
# Parse Ethernet header (14 bytes)
|
||||
if len(pkt_data) < 14:
|
||||
continue
|
||||
eth_type = struct.unpack('!H', pkt_data[12:14])[0]
|
||||
if eth_type != 0x0800: # IPv4
|
||||
continue
|
||||
|
||||
# Parse IP header
|
||||
ip_start = 14
|
||||
if len(pkt_data) < ip_start + 20:
|
||||
continue
|
||||
ip_ver_ihl = pkt_data[ip_start]
|
||||
ip_ihl = (ip_ver_ihl & 0x0F) * 4
|
||||
ip_proto = pkt_data[ip_start + 9]
|
||||
dst_ip = '.'.join(str(b) for b in pkt_data[ip_start+16:ip_start+20])
|
||||
|
||||
if ip_proto != 6: # TCP
|
||||
continue
|
||||
|
||||
# Parse TCP header
|
||||
tcp_start = ip_start + ip_ihl
|
||||
if len(pkt_data) < tcp_start + 20:
|
||||
continue
|
||||
dst_port = struct.unpack('!H', pkt_data[tcp_start+2:tcp_start+4])[0]
|
||||
tcp_data_offset = ((pkt_data[tcp_start + 12] >> 4) & 0xF) * 4
|
||||
|
||||
# TLS record starts after TCP header
|
||||
tls_start = tcp_start + tcp_data_offset
|
||||
if len(pkt_data) < tls_start + 6:
|
||||
continue
|
||||
|
||||
# Check for TLS Handshake (content type 22)
|
||||
if pkt_data[tls_start] != 22:
|
||||
continue
|
||||
|
||||
tls_version = struct.unpack('!H', pkt_data[tls_start+1:tls_start+3])[0]
|
||||
tls_length = struct.unpack('!H', pkt_data[tls_start+3:tls_start+5])[0]
|
||||
|
||||
# Check for ClientHello (handshake type 1)
|
||||
hs_start = tls_start + 5
|
||||
if len(pkt_data) < hs_start + 4:
|
||||
continue
|
||||
if pkt_data[hs_start] != 1: # ClientHello
|
||||
continue
|
||||
|
||||
# Parse ClientHello
|
||||
try:
|
||||
ja3 = extract_ja3(pkt_data, hs_start, dst_ip, dst_port)
|
||||
if ja3:
|
||||
results.append(ja3)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
def extract_ja3(data, hs_start, dst_ip, dst_port):
|
||||
"""Extract JA3 components from ClientHello."""
|
||||
# Handshake header: type(1) + length(3)
|
||||
pos = hs_start + 4
|
||||
|
||||
# ClientHello: version(2) + random(32)
|
||||
if len(data) < pos + 34:
|
||||
return None
|
||||
ch_version = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 34 # skip version + random
|
||||
|
||||
# Session ID
|
||||
if len(data) < pos + 1:
|
||||
return None
|
||||
session_id_len = data[pos]
|
||||
pos += 1 + session_id_len
|
||||
|
||||
# Cipher Suites
|
||||
if len(data) < pos + 2:
|
||||
return None
|
||||
cs_len = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 2
|
||||
if len(data) < pos + cs_len:
|
||||
return None
|
||||
|
||||
cipher_suites = []
|
||||
for i in range(0, cs_len, 2):
|
||||
cs = struct.unpack('!H', data[pos+i:pos+i+2])[0]
|
||||
# Skip GREASE values
|
||||
if (cs & 0x0f0f) == 0x0a0a:
|
||||
continue
|
||||
cipher_suites.append(str(cs))
|
||||
pos += cs_len
|
||||
|
||||
# Compression methods
|
||||
if len(data) < pos + 1:
|
||||
return None
|
||||
comp_len = data[pos]
|
||||
pos += 1 + comp_len
|
||||
|
||||
# Extensions
|
||||
extensions = []
|
||||
elliptic_curves = []
|
||||
ec_point_formats = []
|
||||
supported_versions = []
|
||||
sni = ""
|
||||
|
||||
if len(data) > pos + 2:
|
||||
ext_total_len = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 2
|
||||
ext_end = pos + ext_total_len
|
||||
|
||||
while pos + 4 <= ext_end and pos + 4 <= len(data):
|
||||
ext_type = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
ext_len = struct.unpack('!H', data[pos+2:pos+4])[0]
|
||||
ext_data_start = pos + 4
|
||||
|
||||
# Skip GREASE
|
||||
if (ext_type & 0x0f0f) == 0x0a0a:
|
||||
pos = ext_data_start + ext_len
|
||||
continue
|
||||
|
||||
extensions.append(str(ext_type))
|
||||
|
||||
# SNI (type 0)
|
||||
if ext_type == 0 and ext_len > 5:
|
||||
try:
|
||||
name_len = struct.unpack('!H', data[ext_data_start+3:ext_data_start+5])[0]
|
||||
sni = data[ext_data_start+5:ext_data_start+5+name_len].decode('ascii', errors='replace')
|
||||
except:
|
||||
pass
|
||||
|
||||
# Supported Groups / Elliptic Curves (type 10)
|
||||
if ext_type == 10 and ext_len >= 2:
|
||||
curves_len = struct.unpack('!H', data[ext_data_start:ext_data_start+2])[0]
|
||||
for i in range(0, curves_len, 2):
|
||||
if ext_data_start + 2 + i + 2 <= len(data):
|
||||
curve = struct.unpack('!H', data[ext_data_start+2+i:ext_data_start+2+i+2])[0]
|
||||
if (curve & 0x0f0f) != 0x0a0a:
|
||||
elliptic_curves.append(str(curve))
|
||||
|
||||
# EC Point Formats (type 11)
|
||||
if ext_type == 11 and ext_len >= 1:
|
||||
fmt_len = data[ext_data_start]
|
||||
for i in range(fmt_len):
|
||||
if ext_data_start + 1 + i < len(data):
|
||||
ec_point_formats.append(str(data[ext_data_start+1+i]))
|
||||
|
||||
# Supported Versions (type 43)
|
||||
if ext_type == 43 and ext_len >= 1:
|
||||
sv_len = data[ext_data_start]
|
||||
for i in range(0, sv_len, 2):
|
||||
if ext_data_start + 1 + i + 2 <= len(data):
|
||||
ver = struct.unpack('!H', data[ext_data_start+1+i:ext_data_start+1+i+2])[0]
|
||||
if (ver & 0x0f0f) != 0x0a0a:
|
||||
supported_versions.append(hex(ver))
|
||||
|
||||
pos = ext_data_start + ext_len
|
||||
|
||||
# Build JA3 string: TLSVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats
|
||||
ja3_str = ','.join([
|
||||
str(ch_version),
|
||||
'-'.join(cipher_suites),
|
||||
'-'.join(extensions),
|
||||
'-'.join(elliptic_curves),
|
||||
'-'.join(ec_point_formats),
|
||||
])
|
||||
ja3_hash = hashlib.md5(ja3_str.encode()).hexdigest()
|
||||
|
||||
return {
|
||||
'dst_ip': dst_ip,
|
||||
'dst_port': dst_port,
|
||||
'sni': sni,
|
||||
'ja3_hash': ja3_hash,
|
||||
'ja3_string': ja3_str,
|
||||
'tls_version': hex(ch_version),
|
||||
'cipher_count': len(cipher_suites),
|
||||
'extension_count': len(extensions),
|
||||
'supported_versions': supported_versions,
|
||||
'ciphers': cipher_suites[:10], # first 10 for display
|
||||
}
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 ja3_extract.py <pcap_file>")
|
||||
sys.exit(1)
|
||||
|
||||
results = parse_pcap(sys.argv[1])
|
||||
if not results:
|
||||
print("No TLS ClientHello found in pcap")
|
||||
sys.exit(0)
|
||||
|
||||
# Deduplicate by JA3 hash + SNI
|
||||
seen = set()
|
||||
for r in results:
|
||||
key = f"{r['ja3_hash']}:{r['sni']}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
print(f"SNI: {r['sni']}")
|
||||
print(f"Dest: {r['dst_ip']}:{r['dst_port']}")
|
||||
print(f"JA3 Hash: {r['ja3_hash']}")
|
||||
print(f"TLS Ver: {r['tls_version']}")
|
||||
print(f"Ciphers: {r['cipher_count']} suites (first 10: {r['ciphers']})")
|
||||
print(f"Extensions: {r['extension_count']}")
|
||||
print(f"Sup. Vers: {r['supported_versions']}")
|
||||
print(f"JA3 Full: {r['ja3_string'][:200]}...")
|
||||
print()
|
||||
99
antigravity/capture/run.sh
Executable file
99
antigravity/capture/run.sh
Executable file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# run.sh - One-command capture for Claude Code / Antigravity
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh # Start both mitmproxy + tshark
|
||||
# ./run.sh mitm # mitmproxy only (HTTP layer)
|
||||
# ./run.sh tls # tshark only (TLS layer)
|
||||
# ./run.sh tls en0 # tshark on specific interface
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MODE="${1:-both}"
|
||||
IFACE="${2:-en0}"
|
||||
|
||||
# Check dependencies
|
||||
check_dep() {
|
||||
if ! command -v "$1" &>/dev/null; then
|
||||
echo "ERROR: $1 not found. Install with: $2"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p ./captures
|
||||
|
||||
case "$MODE" in
|
||||
mitm|mitmproxy)
|
||||
check_dep mitmproxy "brew install mitmproxy"
|
||||
echo ""
|
||||
echo "Starting mitmproxy on :8080"
|
||||
echo ""
|
||||
echo "To capture Claude Code traffic:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
|
||||
echo ""
|
||||
echo "To capture VS Code / Antigravity traffic:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 code ."
|
||||
echo ""
|
||||
mitmdump -s capture_traffic.py \
|
||||
--set stream_large_bodies=10m \
|
||||
--set console_eventlog_verbosity=warn \
|
||||
-p 8080
|
||||
;;
|
||||
|
||||
tls|tshark)
|
||||
check_dep tshark "brew install wireshark"
|
||||
echo "Starting TLS capture (requires sudo)..."
|
||||
sudo bash ./capture_tls.sh "$IFACE" 120
|
||||
;;
|
||||
|
||||
both)
|
||||
check_dep mitmproxy "brew install mitmproxy"
|
||||
check_dep tshark "brew install wireshark"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo " MiniGravity Traffic Capture"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Starting two capture layers:"
|
||||
echo " 1. mitmproxy (:8080) → HTTP headers/body"
|
||||
echo " 2. tshark → TLS fingerprints"
|
||||
echo ""
|
||||
echo " Step 1: In another terminal, run:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
|
||||
echo ""
|
||||
echo " Step 2: After login, run:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
|
||||
echo ""
|
||||
echo " Step 3: Press Ctrl+C here when done"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Start tshark in background (needs sudo)
|
||||
echo "[*] Starting tshark (may ask for sudo password)..."
|
||||
sudo bash ./capture_tls.sh "$IFACE" 300 &
|
||||
TSHARK_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
# Start mitmproxy in foreground
|
||||
echo "[*] Starting mitmproxy..."
|
||||
mitmdump -s capture_traffic.py \
|
||||
--set stream_large_bodies=10m \
|
||||
--set console_eventlog_verbosity=warn \
|
||||
-p 8080
|
||||
|
||||
# Cleanup tshark on exit
|
||||
sudo kill "$TSHARK_PID" 2>/dev/null || true
|
||||
wait "$TSHARK_PID" 2>/dev/null || true
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 [mitm|tls|both] [interface]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -445,6 +445,18 @@ func (s *stubAdminService) EnsureOpenAIPrivacy(ctx context.Context, account *ser
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stubAdminService) EnsureAntigravityPrivacy(ctx context.Context, account *service.Account) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ForceOpenAIPrivacy(ctx context.Context, account *service.Account) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ForceAntigravityPrivacy(ctx context.Context, account *service.Account) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*service.ReplaceUserGroupResult, error) {
|
||||
return &service.ReplaceUserGroupResult{MigratedKeys: 0}, nil
|
||||
}
|
||||
|
||||
@ -756,3 +756,134 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
|
||||
|
||||
return nil, nil, lastErr
|
||||
}
|
||||
|
||||
// privacyBaseURL 隐私设置 API 仅使用 daily 端点(与 Antigravity 客户端行为一致)
|
||||
const privacyBaseURL = antigravityDailyBaseURL
|
||||
|
||||
// SetUserSettingsRequest setUserSettings 请求体
|
||||
type SetUserSettingsRequest struct {
|
||||
UserSettings map[string]any `json:"user_settings"`
|
||||
}
|
||||
|
||||
// FetchUserInfoRequest fetchUserInfo 请求体
|
||||
type FetchUserInfoRequest struct {
|
||||
Project string `json:"project"`
|
||||
}
|
||||
|
||||
// FetchUserInfoResponse fetchUserInfo 响应体
|
||||
type FetchUserInfoResponse struct {
|
||||
UserSettings map[string]any `json:"userSettings,omitempty"`
|
||||
RegionCode string `json:"regionCode,omitempty"`
|
||||
}
|
||||
|
||||
// IsPrivate 判断隐私是否已设置:userSettings 为空或不含 telemetryEnabled 表示已设置
|
||||
func (r *FetchUserInfoResponse) IsPrivate() bool {
|
||||
if r == nil || r.UserSettings == nil {
|
||||
return true
|
||||
}
|
||||
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
|
||||
return !hasTelemetry
|
||||
}
|
||||
|
||||
// SetUserSettingsResponse setUserSettings 响应体
|
||||
type SetUserSettingsResponse struct {
|
||||
UserSettings map[string]any `json:"userSettings,omitempty"`
|
||||
}
|
||||
|
||||
// IsSuccess 判断 setUserSettings 是否成功:返回 {"userSettings":{}} 且无 telemetryEnabled
|
||||
func (r *SetUserSettingsResponse) IsSuccess() bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
if len(r.UserSettings) == 0 {
|
||||
return true
|
||||
}
|
||||
_, hasTelemetry := r.UserSettings["telemetryEnabled"]
|
||||
return !hasTelemetry
|
||||
}
|
||||
|
||||
// SetUserSettings 调用 setUserSettings API 设置用户隐私,返回解析后的响应
|
||||
func (c *Client) SetUserSettings(ctx context.Context, accessToken string) (*SetUserSettingsResponse, error) {
|
||||
payload := SetUserSettingsRequest{UserSettings: map[string]any{}}
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
apiURL := privacyBaseURL + "/v1internal:setUserSettings"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
|
||||
req.Host = "daily-cloudcode-pa.googleapis.com"
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("setUserSettings 请求失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("setUserSettings 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result SetUserSettingsResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// FetchUserInfo 调用 fetchUserInfo API 获取用户隐私设置状态
|
||||
func (c *Client) FetchUserInfo(ctx context.Context, accessToken, projectID string) (*FetchUserInfoResponse, error) {
|
||||
reqBody := FetchUserInfoRequest{Project: projectID}
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
apiURL := privacyBaseURL + "/v1internal:fetchUserInfo"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", GetUserAgent())
|
||||
req.Header.Set("X-Goog-Api-Client", "gl-node/22.21.1")
|
||||
req.Host = "daily-cloudcode-pa.googleapis.com"
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetchUserInfo 请求失败: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetchUserInfo 失败 (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result FetchUserInfoResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@ -68,9 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
|
||||
|
||||
var resp *http.Response
|
||||
|
||||
// 如果有 TLS Profile 且有 HTTPUpstream,使用 DoWithTLS
|
||||
if opts.TLSProfile != nil && s.httpUpstream != nil {
|
||||
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSProfile)
|
||||
// 如果有 TLS Mode(非 off)且有 HTTPUpstream,使用 DoWithTLS
|
||||
if opts.TLSMode != service.TLSModeOff && s.httpUpstream != nil {
|
||||
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSMode, opts.TLSProfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err)
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -13,6 +15,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
|
||||
@ -879,3 +882,36 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
|
||||
}
|
||||
return &trackedBody{ReadCloser: body, onClose: onClose}
|
||||
}
|
||||
|
||||
// decompressResponseBody 根据 Content-Encoding 对响应体进行解压
|
||||
// 支持 gzip、br(brotli)、deflate;解压后更新响应头以反映明文内容
|
||||
func decompressResponseBody(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
enc := strings.ToLower(resp.Header.Get("Content-Encoding"))
|
||||
switch enc {
|
||||
case "gzip":
|
||||
gr, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Body = io.NopCloser(gr)
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
case "br":
|
||||
resp.Body = io.NopCloser(brotli.NewReader(resp.Body))
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
case "deflate":
|
||||
resp.Body = io.NopCloser(flate.NewReader(resp.Body))
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,7 +304,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -394,7 +394,7 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, TLSModeOff, nil)
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -524,7 +524,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -614,7 +614,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||
}
|
||||
@ -887,7 +887,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
}
|
||||
soraTLSProfile := s.resolveSoraTLSProfile()
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), soraTLSProfile)
|
||||
if err != nil {
|
||||
recorder.addStep("me", "failed", 0, "network_error", err.Error())
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
@ -952,7 +952,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
subReq.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
subReq.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, soraTLSProfile)
|
||||
subResp, subErr := s.httpUpstream.DoWithTLS(subReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), soraTLSProfile)
|
||||
if subErr != nil {
|
||||
recorder.addStep("subscription", "failed", 0, "network_error", subErr.Error())
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: fmt.Sprintf("Subscription check skipped: %s", subErr.Error())})
|
||||
@ -1139,7 +1139,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
|
||||
req.Header.Set("Origin", "https://sora.chatgpt.com")
|
||||
req.Header.Set("Referer", "https://sora.chatgpt.com/")
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), tlsProfile)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
|
||||
@ -245,6 +245,7 @@ type ClaudeUsageFetchOptions struct {
|
||||
AccessToken string // OAuth access token
|
||||
ProxyURL string // 代理 URL(可选)
|
||||
AccountID int64 // 账号 ID(用于连接池隔离)
|
||||
TLSMode TLSMode // TLS 模式(off/node/utls)
|
||||
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profile(nil 表示不启用)
|
||||
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
||||
}
|
||||
@ -1162,6 +1163,7 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
|
||||
AccessToken: accessToken,
|
||||
ProxyURL: proxyURL,
|
||||
AccountID: account.ID,
|
||||
TLSMode: account.GetTLSMode(),
|
||||
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
|
||||
}
|
||||
|
||||
|
||||
@ -65,6 +65,12 @@ type AdminService interface {
|
||||
SetAccountError(ctx context.Context, id int64, errorMsg string) error
|
||||
// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号 privacy_mode,未设置则尝试关闭训练数据共享并持久化。
|
||||
EnsureOpenAIPrivacy(ctx context.Context, account *Account) string
|
||||
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号 privacy_mode,未设置则调用 setUserSettings 并持久化。
|
||||
EnsureAntigravityPrivacy(ctx context.Context, account *Account) string
|
||||
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
|
||||
ForceOpenAIPrivacy(ctx context.Context, account *Account) string
|
||||
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
|
||||
ForceAntigravityPrivacy(ctx context.Context, account *Account) string
|
||||
SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error)
|
||||
BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error)
|
||||
CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error
|
||||
@ -2661,3 +2667,112 @@ func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Acc
|
||||
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode})
|
||||
return mode
|
||||
}
|
||||
|
||||
// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。
|
||||
func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string {
|
||||
if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
|
||||
return ""
|
||||
}
|
||||
if s.privacyClientFactory == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
token, _ := account.Credentials["access_token"].(string)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
|
||||
proxyURL = p.URL()
|
||||
}
|
||||
}
|
||||
|
||||
mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL)
|
||||
if mode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
|
||||
logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
|
||||
return mode
|
||||
}
|
||||
if account.Extra == nil {
|
||||
account.Extra = make(map[string]any)
|
||||
}
|
||||
account.Extra["privacy_mode"] = mode
|
||||
return mode
|
||||
}
|
||||
|
||||
// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。
|
||||
// 如果 Extra["privacy_mode"] 已存在(无论成功或失败),直接跳过。
|
||||
func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string {
|
||||
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
||||
return ""
|
||||
}
|
||||
if account.Extra != nil {
|
||||
if existing, ok := account.Extra["privacy_mode"].(string); ok && existing != "" {
|
||||
return existing
|
||||
}
|
||||
}
|
||||
|
||||
token, _ := account.Credentials["access_token"].(string)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
projectID, _ := account.Credentials["project_id"].(string)
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
|
||||
proxyURL = p.URL()
|
||||
}
|
||||
}
|
||||
|
||||
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
|
||||
if mode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
|
||||
logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
|
||||
return mode
|
||||
}
|
||||
applyAntigravityPrivacyMode(account, mode)
|
||||
return mode
|
||||
}
|
||||
|
||||
// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。
|
||||
func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string {
|
||||
if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth {
|
||||
return ""
|
||||
}
|
||||
|
||||
token, _ := account.Credentials["access_token"].(string)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
projectID, _ := account.Credentials["project_id"].(string)
|
||||
|
||||
var proxyURL string
|
||||
if account.ProxyID != nil {
|
||||
if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil {
|
||||
proxyURL = p.URL()
|
||||
}
|
||||
}
|
||||
|
||||
mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL)
|
||||
if mode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil {
|
||||
logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err)
|
||||
return mode
|
||||
}
|
||||
applyAntigravityPrivacyMode(account, mode)
|
||||
return mode
|
||||
}
|
||||
|
||||
81
backend/internal/service/antigravity_privacy_service.go
Normal file
81
backend/internal/service/antigravity_privacy_service.go
Normal file
@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
const (
|
||||
AntigravityPrivacySet = "privacy_set"
|
||||
AntigravityPrivacyFailed = "privacy_set_failed"
|
||||
)
|
||||
|
||||
// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
|
||||
// 流程:
|
||||
// 1. setUserSettings 清空设置 → 检查返回值 {\"userSettings\":{}}
|
||||
// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id)
|
||||
//
|
||||
// 返回 privacy_mode 值:\"privacy_set\" 成功,\"privacy_set_failed\" 失败,空串表示无法执行。
|
||||
func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
|
||||
if accessToken == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_client_error", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
// 第 1 步:调用 setUserSettings,检查返回值
|
||||
setResp, err := client.SetUserSettings(ctx, accessToken)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_set_failed", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
if !setResp.IsSuccess() {
|
||||
slog.Warn("antigravity_privacy_set_response_not_empty",
|
||||
"user_settings", setResp.UserSettings,
|
||||
)
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
// 第 2 步:调用 fetchUserInfo 二次验证隐私是否已生效
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
slog.Warn("antigravity_privacy_missing_project_id")
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
userInfo, err := client.FetchUserInfo(ctx, accessToken, projectID)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_verify_failed", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
if !userInfo.IsPrivate() {
|
||||
slog.Warn("antigravity_privacy_verify_not_private",
|
||||
"user_settings", userInfo.UserSettings,
|
||||
)
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
slog.Info("antigravity_privacy_set_success")
|
||||
return AntigravityPrivacySet
|
||||
}
|
||||
|
||||
func applyAntigravityPrivacyMode(account *Account, mode string) {
|
||||
if account == nil || strings.TrimSpace(mode) == "" {
|
||||
return
|
||||
}
|
||||
extra := make(map[string]any, len(account.Extra)+1)
|
||||
for k, v := range account.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
extra["privacy_mode"] = mode
|
||||
account.Extra = extra
|
||||
}
|
||||
@ -120,7 +120,7 @@ func (s *GatewayService) ForwardAsChatCompletions(
|
||||
}
|
||||
|
||||
// 11. Send request
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@ -117,7 +117,7 @@ func (s *GatewayService) ForwardAsResponses(
|
||||
}
|
||||
|
||||
// 11. Send request
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@ -4140,7 +4140,8 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 解析 TLS 指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
|
||||
// 解析 TLS 模式和指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
|
||||
tlsMode := account.GetTLSMode()
|
||||
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
|
||||
|
||||
// 调试日志:记录即将转发的账号信息
|
||||
@ -4165,7 +4166,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -4243,7 +4244,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
retryReq, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseRetryCtx()
|
||||
if buildErr == nil {
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
if retryErr == nil {
|
||||
if retryResp.StatusCode < 400 {
|
||||
logger.LegacyPrintf("service.gateway", "Account %d: thinking block retry succeeded (blocks downgraded)", account.ID)
|
||||
@ -4278,7 +4279,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
retryReq2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseRetryCtx2()
|
||||
if buildErr2 == nil {
|
||||
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
if retryErr2 == nil {
|
||||
resp = retryResp2
|
||||
break
|
||||
@ -4349,7 +4350,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
budgetRetryReq, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
|
||||
releaseBudgetRetryCtx()
|
||||
if buildErr == nil {
|
||||
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
|
||||
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
if retryErr == nil {
|
||||
resp = budgetRetryResp
|
||||
break
|
||||
@ -4655,7 +4656,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -5373,7 +5374,7 @@ func (s *GatewayService) executeBedrockUpstream(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, nil)
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, TLSModeOff, nil)
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -7985,7 +7986,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
|
||||
@ -8013,7 +8014,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
filteredBody := FilterThinkingBlocksForRetry(body)
|
||||
retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode)
|
||||
if buildErr == nil {
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if retryErr == nil {
|
||||
resp = retryResp
|
||||
respBody, err = readUpstreamResponseBodyLimited(resp.Body, maxReadBytes)
|
||||
@ -8102,7 +8103,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
|
||||
@ -724,7 +724,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -1227,7 +1227,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -2583,7 +2583,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
|
||||
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -6,6 +6,18 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// TLSMode 定义账号级别的 TLS 指纹模式
|
||||
type TLSMode string
|
||||
|
||||
const (
|
||||
// TLSModeOff 不启用 TLS 指纹,直接使用标准 Go HTTP 客户端
|
||||
TLSModeOff TLSMode = "off"
|
||||
// TLSModeNode 通过本地 Node.js TLS 代理发请求,天然匹配 Claude CLI 指纹
|
||||
TLSModeNode TLSMode = "node"
|
||||
// TLSModeUTLS 使用 uTLS 库模拟指定 Profile 的 TLS ClientHello
|
||||
TLSModeUTLS TLSMode = "utls"
|
||||
)
|
||||
|
||||
// HTTPUpstream 上游 HTTP 请求接口
|
||||
// 用于向上游 API(Claude、OpenAI、Gemini 等)发送请求
|
||||
type HTTPUpstream interface {
|
||||
@ -14,11 +26,11 @@ type HTTPUpstream interface {
|
||||
|
||||
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
|
||||
//
|
||||
// profile 参数:
|
||||
// - nil: 不启用 TLS 指纹,行为与 Do 方法相同
|
||||
// - non-nil: 使用指定的 Profile 进行 TLS 指纹伪装
|
||||
// mode 参数决定指纹策略:
|
||||
// - TLSModeOff / "": 不启用,行为与 Do 相同
|
||||
// - TLSModeNode: 走本地 Node.js TLS 代理(需 gateway.node_tls_proxy.enabled=true)
|
||||
// - TLSModeUTLS: 用 profile 模拟 TLS ClientHello(profile 为 nil 时降级为 Off)
|
||||
//
|
||||
// Profile 由调用方通过 TLSFingerprintProfileService 解析后传入,
|
||||
// 支持按账号绑定的数据库 profile 或内置默认 profile。
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error)
|
||||
// profile 仅在 mode=TLSModeUTLS 时生效,来自数据库或内置默认值。
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, mode TLSMode, profile *tlsfingerprint.Profile) (*http.Response, error)
|
||||
}
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
#!/bin/bash
|
||||
# sub2api 指纹防泄露 iptables 规则
|
||||
# 确保只有 Node.js TLS Proxy 能直连上游 HTTPS,
|
||||
# sub2api Go 进程即使有 bug 也无法绕过。
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash setup-firewall.sh [apply|remove|status]
|
||||
#
|
||||
# 前置条件:
|
||||
# - Node.js proxy 以专用用户 "nodeproxy" 运行
|
||||
# - 创建用户: sudo useradd -r -s /usr/sbin/nologin nodeproxy
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NODE_PROXY_USER="${MG_NODE_PROXY_USER:-nodeproxy}"
|
||||
CHAIN_NAME="MG_FINGERPRINT"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||
|
||||
apply_rules() {
|
||||
log "Applying fingerprint firewall rules..."
|
||||
|
||||
# 验证用户存在
|
||||
if ! id "$NODE_PROXY_USER" &>/dev/null; then
|
||||
log "ERROR: User '$NODE_PROXY_USER' does not exist."
|
||||
log "Create it: sudo useradd -r -s /usr/sbin/nologin $NODE_PROXY_USER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建自定义链(幂等)
|
||||
iptables -N "$CHAIN_NAME" 2>/dev/null || iptables -F "$CHAIN_NAME"
|
||||
|
||||
# === Rule 1: QUIC 阻断 — 丢弃所有出站 UDP 443/4433 ===
|
||||
iptables -A "$CHAIN_NAME" -p udp --dport 443 -j DROP \
|
||||
-m comment --comment "MG: block QUIC/HTTP3 UDP 443"
|
||||
iptables -A "$CHAIN_NAME" -p udp --dport 4433 -j DROP \
|
||||
-m comment --comment "MG: block QUIC alt UDP 4433"
|
||||
|
||||
# === Rule 2: 允许 Node.js proxy 出站 TCP 443 ===
|
||||
iptables -A "$CHAIN_NAME" -p tcp --dport 443 \
|
||||
-m owner --uid-owner "$NODE_PROXY_USER" -j ACCEPT \
|
||||
-m comment --comment "MG: allow nodeproxy TCP 443"
|
||||
|
||||
# === Rule 3: 阻止其他进程直连 TCP 443 ===
|
||||
iptables -A "$CHAIN_NAME" -p tcp --dport 443 -j REJECT --reject-with tcp-reset \
|
||||
-m comment --comment "MG: block non-proxy TCP 443"
|
||||
|
||||
# 将自定义链挂载到 OUTPUT(幂等)
|
||||
if ! iptables -C OUTPUT -j "$CHAIN_NAME" 2>/dev/null; then
|
||||
iptables -A OUTPUT -j "$CHAIN_NAME"
|
||||
fi
|
||||
|
||||
# === Rule 4: IPv6 全面阻断 ===
|
||||
ip6tables -N "${CHAIN_NAME}_V6" 2>/dev/null || ip6tables -F "${CHAIN_NAME}_V6"
|
||||
# 允许回环
|
||||
ip6tables -A "${CHAIN_NAME}_V6" -o lo -j ACCEPT \
|
||||
-m comment --comment "MG: allow IPv6 loopback"
|
||||
# 阻断其他 IPv6 出站
|
||||
ip6tables -A "${CHAIN_NAME}_V6" -j DROP \
|
||||
-m comment --comment "MG: block all IPv6 outbound"
|
||||
|
||||
if ! ip6tables -C OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null; then
|
||||
ip6tables -A OUTPUT -j "${CHAIN_NAME}_V6"
|
||||
fi
|
||||
|
||||
log "Firewall rules applied successfully."
|
||||
log " - UDP 443/4433: BLOCKED (QUIC)"
|
||||
log " - TCP 443: ONLY '$NODE_PROXY_USER' allowed"
|
||||
log " - IPv6 outbound: BLOCKED"
|
||||
}
|
||||
|
||||
remove_rules() {
|
||||
log "Removing fingerprint firewall rules..."
|
||||
|
||||
# 从 OUTPUT 移除引用
|
||||
iptables -D OUTPUT -j "$CHAIN_NAME" 2>/dev/null || true
|
||||
ip6tables -D OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
|
||||
# 清空并删除自定义链
|
||||
iptables -F "$CHAIN_NAME" 2>/dev/null || true
|
||||
iptables -X "$CHAIN_NAME" 2>/dev/null || true
|
||||
ip6tables -F "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
ip6tables -X "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
|
||||
log "Firewall rules removed."
|
||||
}
|
||||
|
||||
show_status() {
|
||||
log "=== IPv4 MG_FINGERPRINT chain ==="
|
||||
iptables -L "$CHAIN_NAME" -n -v 2>/dev/null || echo "(not found)"
|
||||
echo
|
||||
log "=== IPv6 MG_FINGERPRINT_V6 chain ==="
|
||||
ip6tables -L "${CHAIN_NAME}_V6" -n -v 2>/dev/null || echo "(not found)"
|
||||
}
|
||||
|
||||
case "${1:-apply}" in
|
||||
apply) apply_rules ;;
|
||||
remove) remove_rules ;;
|
||||
status) show_status ;;
|
||||
*)
|
||||
echo "Usage: $0 [apply|remove|status]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# save-patches.sh — 将 Antigravity 自定义改动导出为 patch 文件
|
||||
# 用法: ./tools/scripts/save-patches.sh [输出目录]
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUTPUT_DIR="${1:-$REPO_ROOT/tools/patches}"
|
||||
UPSTREAM="origin/main"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# 检查是否有新的 upstream commits
|
||||
DIVERGED=$(git log --oneline "$UPSTREAM"..HEAD 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$DIVERGED" -eq 0 ]; then
|
||||
echo "[save-patches] 没有领先 upstream 的 commits,无需保存。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# 导出 patches
|
||||
git format-patch "$UPSTREAM"..HEAD --output-directory "$OUTPUT_DIR" --no-stat
|
||||
|
||||
COUNT=$(ls "$OUTPUT_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "[save-patches] ✅ 已导出 $COUNT 个 patch 到 $OUTPUT_DIR/"
|
||||
echo ""
|
||||
echo "恢复方法(在全新 upstream checkout 上):"
|
||||
echo " git am $OUTPUT_DIR/*.patch"
|
||||
echo " # 或逐一应用:"
|
||||
echo " for p in $OUTPUT_DIR/*.patch; do git am \"\$p\" || git am --skip; done"
|
||||
@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-upstream.sh — 从 upstream (origin/main) 同步更新,保留自定义改动
|
||||
# 用法: ./tools/scripts/sync-upstream.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
UPSTREAM="origin/main"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "========================================"
|
||||
echo " Antigravity Fork — Upstream Sync Tool"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Step 1: 检查工作区
|
||||
if ! git diff --quiet || ! git diff --staged --quiet; then
|
||||
echo "❌ 工作区有未提交的改动,请先 git stash 或 git commit"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Fetch
|
||||
echo "[1/4] Fetching upstream..."
|
||||
git fetch origin
|
||||
|
||||
# Step 3: 检查是否有新 commits
|
||||
NEW=$(git log --oneline HEAD.."$UPSTREAM" 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$NEW" -eq 0 ]; then
|
||||
echo "✅ 已是最新,无需同步。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "上游有 $NEW 个新 commits:"
|
||||
git log --oneline HEAD.."$UPSTREAM"
|
||||
echo ""
|
||||
|
||||
# Step 4: 备份当前 patches
|
||||
PATCH_DIR="/tmp/antigravity-patches-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "[2/4] 备份自定义 patches 到 $PATCH_DIR ..."
|
||||
mkdir -p "$PATCH_DIR"
|
||||
git format-patch "$UPSTREAM"..HEAD -o "$PATCH_DIR/" --no-stat
|
||||
BACKED=$(ls "$PATCH_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " 已备份 $BACKED 个 patch"
|
||||
|
||||
# Step 5: Rebase
|
||||
echo ""
|
||||
echo "[3/4] 执行 rebase (git rebase $UPSTREAM)..."
|
||||
echo " 如果出现冲突,请参考 .agents/workflows/sync-upstream.md 中的冲突解决指南"
|
||||
echo ""
|
||||
if ! git rebase "$UPSTREAM"; then
|
||||
echo ""
|
||||
echo "❌ Rebase 出现冲突!"
|
||||
echo ""
|
||||
echo "请按以下步骤处理:"
|
||||
echo " 1. 查看冲突文件: git diff --name-only --diff-filter=U"
|
||||
echo " 2. 解决冲突(参考 .agents/workflows/sync-upstream.md)"
|
||||
echo " 3. git add <解决的文件>"
|
||||
echo " 4. git rebase --continue"
|
||||
echo ""
|
||||
echo "备份的 patches 在: $PATCH_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 6: 编译验证
|
||||
echo "[4/4] 编译验证..."
|
||||
if ! (cd "$REPO_ROOT/backend" && go build ./... 2>&1); then
|
||||
echo ""
|
||||
echo "❌ 编译失败!rebase 后有破坏性改动需要修复。"
|
||||
echo "备份的 patches 在: $PATCH_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 同步完成!"
|
||||
echo ""
|
||||
echo "自定义改动(我方 commits)已成功移植到最新 upstream 上。"
|
||||
echo "请运行以下命令推送:"
|
||||
echo " git push origin main --force-with-lease"
|
||||
echo ""
|
||||
echo "备份路径(可删除): $PATCH_DIR"
|
||||
@ -1,24 +0,0 @@
|
||||
FROM node:24.13.0-slim
|
||||
|
||||
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
|
||||
LABEL description="Node.js TLS Forward Proxy - native JA3/JA4 fingerprint matching"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY proxy.js package.json ./
|
||||
|
||||
# 零依赖,不需要 npm install
|
||||
|
||||
ENV PROXY_PORT=3456
|
||||
ENV PROXY_HOST=0.0.0.0
|
||||
ENV UPSTREAM_HOST=api.anthropic.com
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
# 健康检查:用 Node.js 内置 http 模块,不依赖 curl
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=5s \
|
||||
CMD node -e "const http=require('http');const r=http.get('http://127.0.0.1:'+(process.env.PROXY_PORT||3456)+'/__health',s=>{process.exit(s.statusCode===200?0:1)});r.on('error',()=>process.exit(1));r.setTimeout(3000,()=>{r.destroy();process.exit(1)})"
|
||||
|
||||
USER node
|
||||
CMD ["node", "proxy.js"]
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "node-tls-proxy",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Node.js TLS forward proxy for native JA3/JA4 fingerprint matching",
|
||||
"main": "proxy.js",
|
||||
"scripts": {
|
||||
"start": "node proxy.js",
|
||||
"health": "curl -s http://127.0.0.1:${PROXY_PORT:-3456}/__health | jq ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@ -1,727 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const http2 = require('http2');
|
||||
const net = require('net');
|
||||
const crypto = require('crypto');
|
||||
// os 模块不引用 — 避免暴露真实主机信息
|
||||
|
||||
// ─── 配置 ───────────────────────────────────────────────
|
||||
const UPSTREAM_HOST = process.env.UPSTREAM_HOST || 'api.anthropic.com';
|
||||
const LISTEN_PORT = parseInt(process.env.PROXY_PORT || '3456', 10);
|
||||
const LISTEN_HOST = process.env.PROXY_HOST || '127.0.0.1';
|
||||
const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || '';
|
||||
const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10);
|
||||
const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10);
|
||||
const TELEMETRY_ENABLED = process.env.TELEMETRY_ENABLED !== 'false'; // 默认开启
|
||||
const DD_API_KEY = process.env.DD_API_KEY || 'pubbbf48e6d78dae54bceaa4acf463299bf';
|
||||
const CLI_VERSION = process.env.CLI_VERSION || '2.1.81';
|
||||
const BUILD_TIME = process.env.BUILD_TIME || '2026-03-20T21:26:18Z';
|
||||
// 伪装的 Node 版本(CLI 2.1.81 打包的 Node 版本)
|
||||
const FAKE_NODE_VERSION = process.env.FAKE_NODE_VERSION || 'v22.14.0';
|
||||
|
||||
const log = (level, msg, extra = {}) => {
|
||||
const entry = { time: new Date().toISOString(), level, msg, ...extra };
|
||||
process.stderr.write(JSON.stringify(entry) + '\n');
|
||||
};
|
||||
|
||||
const HEALTH_PATH = '/__health';
|
||||
const h2Hosts = new Set();
|
||||
const h2Sessions = new Map();
|
||||
|
||||
// ─── 虚拟主机身份生成 ─────────────────────────────────────
|
||||
// 每个账号基于 seed 生成全局唯一的主机身份,看起来像一台真实的个人开发机
|
||||
// 匹配 CLI 的 OTEL detectResources: hostDetector + processDetector + serviceInstanceIdDetector
|
||||
//
|
||||
// 设计原则:
|
||||
// 1. 同一账号(seed)永远产出同一台"机器"的特征
|
||||
// 2. 不同账号的特征互不相同(无共享池、无碰撞)
|
||||
// 3. 每个字段都像人手动设置的,不是程序生成的
|
||||
|
||||
// hostname 构造词表 — 组合后空间 > 100万,基本不碰撞
|
||||
const HN_PREFIX = ['dev','code','work','build','my','home','lab','eng','hack','prog','desk','box','main','personal','linux'];
|
||||
const HN_MIDDLE = ['','station','machine','server','node','pc','setup','rig','env','hub'];
|
||||
const HN_STYLE = ['dash','dot','bare']; // 连接风格
|
||||
|
||||
// 用户名词表 — 真实开发者常用,组合后也是高基数
|
||||
const UN_FIRST = ['alex','sam','chris','jordan','max','lee','kai','pat','jamie','taylor','morgan','casey','drew','avery','riley','blake','quinn','reese','cameron','skyler','dev','coder','user','admin','ubuntu','runner'];
|
||||
const UN_SUFFIX = ['','dev','eng','42','_dev','01','x','z','_','99','007'];
|
||||
|
||||
function generateHostIdentity(seed) {
|
||||
// 确定性哈希工具:同一 seed+suffix 永远返回同一结果
|
||||
const h = (suffix) => crypto.createHash('sha256').update(seed + ':' + suffix).digest();
|
||||
|
||||
// ── hostname: 组合生成,如 "alex-devstation", "work-box-7f3a" ──
|
||||
const hb = h('hostname');
|
||||
const prefix = HN_PREFIX[hb.readUInt8(0) % HN_PREFIX.length];
|
||||
const middle = HN_MIDDLE[hb.readUInt8(1) % HN_MIDDLE.length];
|
||||
const style = HN_STYLE[hb.readUInt8(2) % HN_STYLE.length];
|
||||
const tail = hb.slice(3, 5).toString('hex'); // 4 hex chars 保证唯一
|
||||
let hostname;
|
||||
if (middle) {
|
||||
const sep = style === 'dot' ? '.' : style === 'dash' ? '-' : '';
|
||||
hostname = `${prefix}${sep}${middle}`;
|
||||
} else {
|
||||
// 无中间词时必须加 hex 尾缀,避免 hostname 太短(如裸 "my"、"dev")
|
||||
hostname = `${prefix}-${tail}`;
|
||||
}
|
||||
// 有中间词时 50% 概率加 hex 尾缀(真实场景很多人用 hostname 如 "dev-box-a3f2")
|
||||
if (middle && hb.readUInt8(5) % 2 === 0) hostname += `-${tail}`;
|
||||
|
||||
// ── username: 组合生成,如 "alexdev", "sam42", "chris_dev" ──
|
||||
const ub = h('username');
|
||||
const first = UN_FIRST[ub.readUInt8(0) % UN_FIRST.length];
|
||||
const suffix = UN_SUFFIX[ub.readUInt8(1) % UN_SUFFIX.length];
|
||||
const username = `${first}${suffix}`;
|
||||
|
||||
// ── terminal & shell: 按权重分布(xterm-256color 占大多数) ──
|
||||
const termRoll = h('terminal').readUInt8(0) % 100;
|
||||
const terminal = termRoll < 60 ? 'xterm-256color' :
|
||||
termRoll < 75 ? 'screen-256color' :
|
||||
termRoll < 88 ? 'tmux-256color' :
|
||||
termRoll < 95 ? 'alacritty' : 'rxvt-unicode-256color';
|
||||
|
||||
const shellRoll = h('shell').readUInt8(0) % 100;
|
||||
const shell = shellRoll < 55 ? '/bin/bash' :
|
||||
shellRoll < 85 ? '/bin/zsh' :
|
||||
shellRoll < 95 ? '/usr/bin/bash' : '/usr/bin/zsh';
|
||||
|
||||
// ── host.id: /etc/machine-id (32 hex chars, Linux 标准) ──
|
||||
const machineId = h('machine-id').slice(0, 16).toString('hex');
|
||||
|
||||
// ── PID: 每个 session 随机生成,模拟每次启动新进程 ──
|
||||
// 不用 seed 确定性生成,因为真实 CLI 每次启动都是新 PID
|
||||
const pid = 1000 + Math.floor(Math.random() * 64000);
|
||||
|
||||
// ── kernel version: 模拟真实 Linux 发行版 ──
|
||||
const kb = h('kernel');
|
||||
const kernelMajor = 5 + (kb.readUInt8(0) % 2); // 5 or 6
|
||||
const kernelMinor = kb.readUInt8(1) % 20;
|
||||
const kernelPatch = kb.readUInt8(2) % 200;
|
||||
const ubuntuBuild = 50 + (kb.readUInt8(3) % 150);
|
||||
const osVersion = `#${ubuntuBuild}-Ubuntu SMP`;
|
||||
|
||||
// ── 可执行文件路径: 按安装方式分布 ──
|
||||
const pathRoll = h('execpath').readUInt8(0) % 100;
|
||||
const executablePath = pathRoll < 40 ? `/home/${username}/.claude/local/claude` :
|
||||
pathRoll < 70 ? '/usr/local/bin/claude' :
|
||||
pathRoll < 90 ? `/home/${username}/.local/bin/claude` :
|
||||
'/usr/bin/claude';
|
||||
|
||||
return {
|
||||
hostname,
|
||||
username,
|
||||
terminal,
|
||||
shell,
|
||||
machineId,
|
||||
pid,
|
||||
arch: 'x64',
|
||||
osType: 'Linux',
|
||||
osVersion,
|
||||
kernelRelease: `${kernelMajor}.${kernelMinor}.${kernelPatch}-generic`,
|
||||
// service.instance.id: 每个 session 唯一(CLI 用 randomUUID)
|
||||
serviceInstanceId: crypto.randomUUID(),
|
||||
executablePath,
|
||||
executableName: 'claude',
|
||||
command: 'claude',
|
||||
commandArgs: [],
|
||||
runtimeName: 'nodejs',
|
||||
runtimeVersion: FAKE_NODE_VERSION.replace('v', ''),
|
||||
// ripgrep 信息也按 seed 生成,不同账号不一样
|
||||
ripgrepVersion: (() => {
|
||||
const rv = h('ripgrep');
|
||||
const versions = ['14.1.1','14.1.0','14.0.2','13.0.0','13.0.1','14.0.1','14.0.0'];
|
||||
return versions[rv.readUInt8(0) % versions.length];
|
||||
})(),
|
||||
ripgrepPath: (() => {
|
||||
const rp = h('rgpath');
|
||||
const paths = ['/usr/bin/rg','/usr/local/bin/rg','/home/'+username+'/.cargo/bin/rg','/snap/bin/rg','/usr/bin/rg','/usr/bin/rg'];
|
||||
return paths[rp.readUInt8(0) % paths.length];
|
||||
})(),
|
||||
// MCP server 数量(真实用户 0~6 个,影响启动事件序列)
|
||||
mcpServerCount: 1 + (h('mcp').readUInt8(0) % 5), // 1~5
|
||||
mcpFailCount: h('mcp').readUInt8(1) % 3, // 0~2 个失败
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 遥测模拟 ────────────────────────────────────────────
|
||||
|
||||
// 每个 device_id 的会话状态
|
||||
const sessionStates = new Map();
|
||||
|
||||
function getOrCreateSession(deviceId) {
|
||||
if (sessionStates.has(deviceId)) return sessionStates.get(deviceId);
|
||||
const hostId = generateHostIdentity(deviceId);
|
||||
const state = {
|
||||
sessionId: crypto.randomUUID(),
|
||||
deviceId,
|
||||
hostId,
|
||||
startTime: Date.now(),
|
||||
requestCount: 0,
|
||||
// 追踪 ripgrep 是否已上报
|
||||
ripgrepReported: false,
|
||||
};
|
||||
sessionStates.set(deviceId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function generateDeviceId(accountSeed) {
|
||||
return crypto.createHash('sha256').update(`device:${accountSeed}`).digest('hex');
|
||||
}
|
||||
|
||||
// ─── OTEL Resource Attributes (匹配 CLI 的 detectResources) ───
|
||||
|
||||
function buildEnvBlock(hostId) {
|
||||
return {
|
||||
platform: 'linux',
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
terminal: hostId.terminal,
|
||||
package_managers: 'npm',
|
||||
runtimes: 'node',
|
||||
is_running_with_bun: false,
|
||||
is_ci: false,
|
||||
is_claubbit: false,
|
||||
is_github_action: false,
|
||||
is_claude_code_action: false,
|
||||
is_claude_ai_auth: false,
|
||||
version: CLI_VERSION,
|
||||
arch: hostId.arch,
|
||||
is_claude_code_remote: false,
|
||||
deployment_environment: 'unknown-linux',
|
||||
is_conductor: false,
|
||||
version_base: CLI_VERSION,
|
||||
build_time: BUILD_TIME,
|
||||
is_local_agent_mode: false,
|
||||
vcs: 'git',
|
||||
platform_raw: 'linux',
|
||||
};
|
||||
}
|
||||
|
||||
function buildProcessMetrics(uptime) {
|
||||
// 模拟真实 CLI 的内存曲线:RSS 随 uptime 缓慢增长
|
||||
const baseRss = 180_000_000 + Math.min(uptime * 50_000, 200_000_000);
|
||||
const rss = Math.floor(baseRss + Math.random() * 80_000_000);
|
||||
const heapTotal = Math.floor(rss * 0.6 + Math.random() * 10_000_000);
|
||||
const heapUsed = Math.floor(heapTotal * 0.5 + Math.random() * heapTotal * 0.3);
|
||||
return Buffer.from(JSON.stringify({
|
||||
uptime,
|
||||
rss,
|
||||
heapTotal,
|
||||
heapUsed,
|
||||
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
|
||||
arrayBuffers: Math.floor(Math.random() * 10_000),
|
||||
constrainedMemory: 0,
|
||||
cpuUsage: {
|
||||
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
|
||||
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
|
||||
},
|
||||
cpuPercent: Math.random() * 200,
|
||||
})).toString('base64');
|
||||
}
|
||||
|
||||
function buildEvent(eventName, session, model, betas, extraData, timestampOverride) {
|
||||
const uptime = (Date.now() - session.startTime) / 1000;
|
||||
const processMetrics = buildProcessMetrics(uptime);
|
||||
// 缓存最近一次的 process metrics,供 DataDog 日志复用(保持两边一致)
|
||||
session._lastProcessMetrics = { uptime, raw: processMetrics };
|
||||
const eventData = {
|
||||
event_name: eventName,
|
||||
client_timestamp: timestampOverride || new Date().toISOString(),
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
session_id: session.sessionId,
|
||||
user_type: 'external',
|
||||
betas: betas || 'claude-code-20250219,interleaved-thinking-2025-05-14',
|
||||
env: buildEnvBlock(session.hostId),
|
||||
entrypoint: 'cli',
|
||||
is_interactive: true,
|
||||
client_type: 'cli',
|
||||
process: processMetrics,
|
||||
event_id: crypto.randomUUID(),
|
||||
device_id: session.deviceId,
|
||||
// 注意:不加 resource 字段 — event_logging/batch 是自定义端点,
|
||||
// OTEL resource attributes 由 CLI 通过单独的 OTLP exporter 发送,不在这里
|
||||
};
|
||||
// 合并额外字段(用于特定事件的附加数据)
|
||||
if (extraData) Object.assign(eventData, extraData);
|
||||
return {
|
||||
event_type: 'ClaudeCodeInternalEvent',
|
||||
event_data: eventData,
|
||||
};
|
||||
}
|
||||
|
||||
// 发送遥测到 api.anthropic.com/api/event_logging/batch
|
||||
function sendTelemetryEvents(events, session) {
|
||||
if (!TELEMETRY_ENABLED || events.length === 0) return;
|
||||
|
||||
const body = JSON.stringify({ events });
|
||||
const headers = {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': `claude-code/${CLI_VERSION}`,
|
||||
'x-service-name': 'claude-code',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
// 如果有 session,注入 OTEL trace headers(匹配 CLI 的 W3C Trace Context)
|
||||
if (session) {
|
||||
const traceId = crypto.randomBytes(16).toString('hex');
|
||||
const spanId = crypto.randomBytes(8).toString('hex');
|
||||
headers['traceparent'] = `00-${traceId}-${spanId}-01`;
|
||||
}
|
||||
|
||||
const opts = {
|
||||
hostname: 'api.anthropic.com',
|
||||
port: 443,
|
||||
path: '/api/event_logging/batch',
|
||||
method: 'POST',
|
||||
headers,
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
res.resume(); // drain
|
||||
log('debug', 'telemetry_sent', { status: res.statusCode, events: events.length });
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
log('debug', 'telemetry_error', { error: err.message });
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.end(body);
|
||||
}
|
||||
|
||||
// 发送 DataDog 日志
|
||||
function sendDatadogLog(eventName, session, model) {
|
||||
if (!TELEMETRY_ENABLED) return;
|
||||
|
||||
const hostId = session.hostId;
|
||||
const uptime = (Date.now() - session.startTime) / 1000;
|
||||
|
||||
// 复用 Anthropic 事件侧缓存的 process metrics(保持两边数值一致)
|
||||
// 如果没有缓存(首次调用),现场生成
|
||||
let pm;
|
||||
if (session._lastProcessMetrics && Math.abs(session._lastProcessMetrics.uptime - uptime) < 2) {
|
||||
pm = JSON.parse(Buffer.from(session._lastProcessMetrics.raw, 'base64').toString());
|
||||
} else {
|
||||
const baseRss = 180_000_000 + Math.min(uptime * 50_000, 200_000_000);
|
||||
const rss = Math.floor(baseRss + Math.random() * 80_000_000);
|
||||
const heapTotal = Math.floor(rss * 0.6 + Math.random() * 10_000_000);
|
||||
const heapUsed = Math.floor(heapTotal * 0.5 + Math.random() * heapTotal * 0.3);
|
||||
pm = {
|
||||
uptime,
|
||||
rss,
|
||||
heapTotal,
|
||||
heapUsed,
|
||||
external: 14_000_000 + Math.floor(Math.random() * 2_000_000),
|
||||
arrayBuffers: Math.floor(Math.random() * 10_000),
|
||||
constrainedMemory: 0,
|
||||
cpuUsage: {
|
||||
user: Math.floor(uptime * 10_000 + Math.random() * 300_000),
|
||||
system: Math.floor(uptime * 2_000 + Math.random() * 80_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const entry = {
|
||||
ddsource: 'nodejs',
|
||||
ddtags: `event:${eventName},arch:${hostId.arch},client_type:cli,model:${model || 'claude-sonnet-4-6'},platform:linux,user_type:external,version:${CLI_VERSION},version_base:${CLI_VERSION}`,
|
||||
message: eventName,
|
||||
service: 'claude-code',
|
||||
hostname: hostId.hostname,
|
||||
env: 'external',
|
||||
model: model || 'claude-sonnet-4-6',
|
||||
session_id: session.sessionId,
|
||||
user_type: 'external',
|
||||
entrypoint: 'cli',
|
||||
is_interactive: 'true',
|
||||
client_type: 'cli',
|
||||
process_metrics: pm,
|
||||
platform: 'linux',
|
||||
platform_raw: 'linux',
|
||||
arch: hostId.arch,
|
||||
node_version: FAKE_NODE_VERSION,
|
||||
version: CLI_VERSION,
|
||||
version_base: CLI_VERSION,
|
||||
build_time: BUILD_TIME,
|
||||
deployment_environment: 'unknown-linux',
|
||||
vcs: 'git',
|
||||
};
|
||||
|
||||
const body = JSON.stringify([entry]);
|
||||
const opts = {
|
||||
hostname: 'http-intake.logs.us5.datadoghq.com',
|
||||
port: 443,
|
||||
path: '/api/v2/logs',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'axios/1.13.6',
|
||||
'dd-api-key': DD_API_KEY,
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
timeout: 10000,
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => { res.resume(); });
|
||||
req.on('error', () => {});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.end(body);
|
||||
}
|
||||
|
||||
// 请求前发遥测(模拟 CLI 启动 + 初始化事件)
|
||||
function emitPreRequestTelemetry(reqHeaders, body) {
|
||||
const accountSeed = reqHeaders['x-forwarded-host'] || 'default';
|
||||
const deviceId = generateDeviceId(accountSeed + ':' + (reqHeaders['authorization'] || '').slice(-16));
|
||||
const session = getOrCreateSession(deviceId);
|
||||
session.requestCount++;
|
||||
|
||||
// 从请求体解析真实 model
|
||||
let model = 'claude-sonnet-4-6';
|
||||
try {
|
||||
const parsed = JSON.parse(body.toString());
|
||||
if (parsed.model) model = parsed.model;
|
||||
} catch (_) {}
|
||||
|
||||
const betas = reqHeaders['anthropic-beta'] || 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24';
|
||||
|
||||
// 首次请求:发完整启动事件序列(匹配真实 CLI 的时序)
|
||||
if (session.requestCount === 1) {
|
||||
const hostId = session.hostId;
|
||||
// 生成递增的时间戳,模拟真实 CLI 启动流程的时间差
|
||||
const baseTime = Date.now();
|
||||
const ts = (offsetMs) => new Date(baseTime + offsetMs).toISOString();
|
||||
|
||||
// 第一批:启动 + 工具检测 + MCP 连接事件
|
||||
const batch1 = [
|
||||
buildEvent('tengu_started', session, model, betas, null, ts(0)),
|
||||
buildEvent('tengu_init', session, model, betas, null, ts(80 + Math.floor(Math.random() * 120))),
|
||||
// tengu_ripgrep_availability — CLI 必发的工具检测事件,版本/路径按账号不同
|
||||
buildEvent('tengu_ripgrep_availability', session, model, betas, {
|
||||
ripgrep_available: true,
|
||||
ripgrep_version: hostId.ripgrepVersion,
|
||||
ripgrep_path: hostId.ripgrepPath,
|
||||
}, ts(200 + Math.floor(Math.random() * 150))),
|
||||
];
|
||||
// MCP 连接事件:数量按账号不同(真实用户配置的 MCP server 数量差异很大)
|
||||
let mcpOffset = 400;
|
||||
const mcpSuccessCount = hostId.mcpServerCount - hostId.mcpFailCount;
|
||||
for (let i = 0; i < hostId.mcpFailCount; i++) {
|
||||
mcpOffset += 100 + Math.floor(Math.random() * 300);
|
||||
batch1.push(buildEvent('tengu_mcp_server_connection_failed', session, model, betas, null, ts(mcpOffset)));
|
||||
}
|
||||
for (let i = 0; i < mcpSuccessCount; i++) {
|
||||
mcpOffset += 200 + Math.floor(Math.random() * 500);
|
||||
batch1.push(buildEvent('tengu_mcp_server_connection_succeeded', session, model, betas, null, ts(mcpOffset)));
|
||||
}
|
||||
|
||||
session.ripgrepReported = true;
|
||||
sendTelemetryEvents(batch1, session);
|
||||
sendDatadogLog('tengu_started', session, model);
|
||||
sendDatadogLog('tengu_init', session, model);
|
||||
|
||||
// 第二批延迟发送(真实 CLI 间隔约 30 秒)
|
||||
setTimeout(() => {
|
||||
const batch2 = [
|
||||
buildEvent('tengu_session_init', session, model, betas),
|
||||
buildEvent('tengu_context_loaded', session, model, betas),
|
||||
];
|
||||
sendTelemetryEvents(batch2, session);
|
||||
}, 25000 + Math.floor(Math.random() * 10000));
|
||||
}
|
||||
|
||||
// 每次请求:发 request_started
|
||||
const events = [
|
||||
buildEvent('tengu_api_request_started', session, model, betas),
|
||||
];
|
||||
sendTelemetryEvents(events, session);
|
||||
}
|
||||
|
||||
// 请求后发遥测
|
||||
function emitPostRequestTelemetry(reqHeaders, statusCode, body) {
|
||||
const accountSeed = reqHeaders['x-forwarded-host'] || 'default';
|
||||
const deviceId = generateDeviceId(accountSeed + ':' + (reqHeaders['authorization'] || '').slice(-16));
|
||||
const session = getOrCreateSession(deviceId);
|
||||
|
||||
let model = 'claude-sonnet-4-6';
|
||||
try {
|
||||
const parsed = JSON.parse(body.toString());
|
||||
if (parsed.model) model = parsed.model;
|
||||
} catch (_) {}
|
||||
|
||||
const betas = reqHeaders['anthropic-beta'] || 'claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24';
|
||||
|
||||
// 请求完成事件
|
||||
const events = [
|
||||
buildEvent('tengu_api_request_completed', session, model, betas),
|
||||
buildEvent('tengu_conversation_turn_completed', session, model, betas),
|
||||
];
|
||||
sendTelemetryEvents(events, session);
|
||||
sendDatadogLog('tengu_api_request_completed', session, model);
|
||||
|
||||
// 模拟错误遥测(低概率,匹配 TelemetrySafeError)
|
||||
if (statusCode >= 400 && Math.random() < 0.5) {
|
||||
const errorEvent = buildEvent('tengu_api_request_error', session, model, betas, {
|
||||
error_type: 'TelemetrySafeError',
|
||||
error_code: statusCode,
|
||||
error_message: statusCode === 429 ? 'rate_limit_exceeded' :
|
||||
statusCode === 529 ? 'overloaded' :
|
||||
statusCode >= 500 ? 'server_error' : 'client_error',
|
||||
});
|
||||
sendTelemetryEvents([errorEvent], session);
|
||||
}
|
||||
|
||||
// 随机发额外事件(仅使用已知的真实 CLI 事件名)
|
||||
if (Math.random() < 0.3) {
|
||||
setTimeout(() => {
|
||||
const extra = [
|
||||
buildEvent('tengu_tool_use_completed', session, model, betas),
|
||||
];
|
||||
sendTelemetryEvents(extra, session);
|
||||
}, 2000 + Math.floor(Math.random() * 5000));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── H2 session 管理 ────────────────────────────────────
|
||||
function getOrCreateH2Session(host) {
|
||||
const existing = h2Sessions.get(host);
|
||||
if (existing && !existing.closed && !existing.destroyed) return existing;
|
||||
if (existing) { try { existing.close(); } catch (_) {} }
|
||||
|
||||
const session = http2.connect(`https://${host}`);
|
||||
session.on('error', (err) => {
|
||||
log('warn', 'h2_session_error', { host, error: err.message });
|
||||
h2Sessions.delete(host);
|
||||
try { session.close(); } catch (_) {}
|
||||
});
|
||||
session.on('close', () => h2Sessions.delete(host));
|
||||
session.on('goaway', () => { h2Sessions.delete(host); try { session.close(); } catch (_) {} });
|
||||
session.setTimeout(IDLE_TIMEOUT, () => { session.close(); h2Sessions.delete(host); });
|
||||
h2Sessions.set(host, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
function waitForConnect(session) {
|
||||
if (session.connected) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
session.once('connect', resolve);
|
||||
session.once('error', reject);
|
||||
const t = setTimeout(() => reject(new Error('h2 connect timeout')), CONNECT_TIMEOUT);
|
||||
session.once('connect', () => clearTimeout(t));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── CONNECT 隧道 ────────────────────────────────────────
|
||||
function connectViaProxy(proxyUrl, targetHost, targetPort) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proxy = new URL(proxyUrl);
|
||||
const conn = net.connect(parseInt(proxy.port || '80', 10), proxy.hostname, () => {
|
||||
const auth = proxy.username
|
||||
? `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}`).toString('base64')}\r\n`
|
||||
: '';
|
||||
conn.write(`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${auth}\r\n`);
|
||||
});
|
||||
conn.once('error', reject);
|
||||
conn.setTimeout(CONNECT_TIMEOUT, () => conn.destroy(new Error('CONNECT timeout')));
|
||||
let buf = '';
|
||||
conn.on('data', function onData(chunk) {
|
||||
buf += chunk.toString();
|
||||
const idx = buf.indexOf('\r\n\r\n');
|
||||
if (idx === -1) return;
|
||||
conn.removeListener('data', onData);
|
||||
const code = parseInt(buf.split(' ')[1], 10);
|
||||
if (code === 200) { conn.setTimeout(0); resolve(conn); }
|
||||
else { conn.destroy(); reject(new Error(`CONNECT ${code}`)); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 收集请求体 ──────────────────────────────────────────
|
||||
function collectBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', () => resolve(Buffer.concat(chunks)));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── H1 代理 ─────────────────────────────────────────────
|
||||
function sendViaH1(targetHost, method, path, reqHeaders, body, res, savedHeaders) {
|
||||
return new Promise((resolve) => {
|
||||
const headers = { ...reqHeaders, host: targetHost };
|
||||
['x-forwarded-host', 'connection', 'keep-alive', 'proxy-connection', 'transfer-encoding'].forEach(h => delete headers[h]);
|
||||
if (body.length > 0) headers['content-length'] = String(body.length);
|
||||
|
||||
const opts = { hostname: targetHost, port: 443, path, method, headers, servername: targetHost, timeout: CONNECT_TIMEOUT };
|
||||
const startTime = Date.now();
|
||||
|
||||
const finish = (requestOpts) => {
|
||||
const proxyReq = https.request(requestOpts);
|
||||
proxyReq.on('response', (proxyRes) => {
|
||||
log('info', 'proxy_response', { host: targetHost, status: proxyRes.statusCode, path, proto: 'h1' });
|
||||
const rh = { ...proxyRes.headers };
|
||||
delete rh['connection']; delete rh['keep-alive'];
|
||||
res.writeHead(proxyRes.statusCode, rh);
|
||||
proxyRes.pipe(res, { end: true });
|
||||
// 请求完成后发遥测
|
||||
if (path.includes('/v1/messages') && savedHeaders) {
|
||||
emitPostRequestTelemetry(savedHeaders, proxyRes.statusCode, body);
|
||||
}
|
||||
resolve('ok');
|
||||
});
|
||||
proxyReq.on('error', (err) => {
|
||||
if (err.message === 'socket hang up' && (Date.now() - startTime) < 2000) {
|
||||
log('info', 'h1_rejected_switching_to_h2', { host: targetHost });
|
||||
h2Hosts.add(targetHost);
|
||||
sendViaH2(targetHost, method, path, reqHeaders, body, res, savedHeaders).then(() => resolve('h2'));
|
||||
return;
|
||||
}
|
||||
log('error', 'h1_error', { error: err.message, host: targetHost, path });
|
||||
if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: err.message })); }
|
||||
resolve('error');
|
||||
});
|
||||
proxyReq.on('timeout', () => proxyReq.destroy(new Error('timeout')));
|
||||
proxyReq.end(body);
|
||||
};
|
||||
|
||||
if (UPSTREAM_PROXY) {
|
||||
connectViaProxy(UPSTREAM_PROXY, targetHost, 443)
|
||||
.then((socket) => { opts.socket = socket; opts.agent = false; finish(opts); })
|
||||
.catch((err) => { log('error', 'tunnel_failed', { error: err.message }); if (!res.headersSent) { res.writeHead(502); res.end('tunnel error'); } resolve('error'); });
|
||||
} else {
|
||||
finish(opts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── H2 代理 ─────────────────────────────────────────────
|
||||
async function sendViaH2(targetHost, method, path, reqHeaders, body, res, savedHeaders) {
|
||||
try {
|
||||
const session = getOrCreateH2Session(targetHost);
|
||||
await waitForConnect(session);
|
||||
|
||||
const headers = {};
|
||||
const skip = new Set(['host','connection','keep-alive','proxy-connection','transfer-encoding','upgrade','x-forwarded-host','http2-settings']);
|
||||
for (const [k, v] of Object.entries(reqHeaders)) {
|
||||
if (!skip.has(k.toLowerCase())) headers[k] = v;
|
||||
}
|
||||
headers[':method'] = method;
|
||||
headers[':path'] = path;
|
||||
headers[':authority'] = targetHost;
|
||||
headers[':scheme'] = 'https';
|
||||
if (body.length > 0) headers['content-length'] = String(body.length);
|
||||
|
||||
const stream = session.request(headers);
|
||||
let responded = false;
|
||||
|
||||
stream.on('response', (h2h) => {
|
||||
responded = true;
|
||||
const status = h2h[':status'] || 502;
|
||||
const rh = {};
|
||||
for (const [k, v] of Object.entries(h2h)) { if (!k.startsWith(':')) rh[k] = v; }
|
||||
log('info', 'proxy_response', { host: targetHost, status, path, proto: 'h2' });
|
||||
res.writeHead(status, rh);
|
||||
stream.on('data', (c) => res.write(c));
|
||||
stream.on('end', () => res.end());
|
||||
if (path.includes('/v1/messages') && savedHeaders) {
|
||||
emitPostRequestTelemetry(savedHeaders, status);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
if (err.message && err.message.includes('NGHTTP2')) {
|
||||
h2Sessions.delete(targetHost);
|
||||
try { session.close(); } catch (_) {}
|
||||
}
|
||||
if (responded) { if (!res.writableEnded) res.end(); return; }
|
||||
log('error', 'h2_error', { error: err.message, host: targetHost, path });
|
||||
if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: err.message })); }
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
if (!responded && !res.headersSent) {
|
||||
log('warn', 'h2_no_response', { host: targetHost, path });
|
||||
res.writeHead(502); res.end('{"error":"h2_no_response"}');
|
||||
} else if (!res.writableEnded) { res.end(); }
|
||||
});
|
||||
|
||||
stream.setTimeout(CONNECT_TIMEOUT, () => stream.close());
|
||||
stream.end(body);
|
||||
} catch (err) {
|
||||
log('error', 'h2_exception', { error: err.message, host: targetHost });
|
||||
h2Sessions.delete(targetHost);
|
||||
if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: err.message })); }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 请求入口 ─────────────────────────────────────────────
|
||||
async function proxyRequest(req, res) {
|
||||
const targetHost = req.headers['x-forwarded-host'] || UPSTREAM_HOST;
|
||||
log('info', 'proxy_request', { host: targetHost, method: req.method, path: req.url });
|
||||
|
||||
// 保存原始 headers 用于遥测
|
||||
const savedHeaders = { ...req.headers };
|
||||
|
||||
const body = await collectBody(req);
|
||||
|
||||
// 请求前发遥测(仅 /v1/messages 请求)
|
||||
if (req.url.includes('/v1/messages') && TELEMETRY_ENABLED) {
|
||||
emitPreRequestTelemetry(savedHeaders, body);
|
||||
// 随机延迟 50-200ms 模拟真实 CLI 行为
|
||||
await new Promise(r => setTimeout(r, 50 + Math.floor(Math.random() * 150)));
|
||||
}
|
||||
|
||||
if (h2Hosts.has(targetHost)) {
|
||||
await sendViaH2(targetHost, req.method, req.url, req.headers, body, res, savedHeaders);
|
||||
} else {
|
||||
await sendViaH1(targetHost, req.method, req.url, req.headers, body, res, savedHeaders);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP 服务器 ─────────────────────────────────────────
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === HEALTH_PATH) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok', node: process.version, openssl: process.versions.openssl,
|
||||
uptime: process.uptime(), h2Hosts: [...h2Hosts],
|
||||
telemetry: TELEMETRY_ENABLED, sessions: sessionStates.size,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
proxyRequest(req, res).catch((err) => {
|
||||
log('error', 'unhandled', { error: err.message });
|
||||
if (!res.headersSent) { res.writeHead(500); res.end('internal error'); }
|
||||
});
|
||||
});
|
||||
|
||||
server.timeout = 0;
|
||||
server.keepAliveTimeout = IDLE_TIMEOUT;
|
||||
server.headersTimeout = 60000;
|
||||
server.listen(LISTEN_PORT, LISTEN_HOST, () => {
|
||||
log('info', 'node-tls-proxy started', {
|
||||
listen: `${LISTEN_HOST}:${LISTEN_PORT}`, node: process.version, openssl: process.versions.openssl,
|
||||
telemetry: TELEMETRY_ENABLED,
|
||||
});
|
||||
});
|
||||
|
||||
// 定期清理过期 session(1 小时无活动)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, state] of sessionStates) {
|
||||
if (now - state.startTime > 3600_000) sessionStates.delete(id);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
let stopping = false;
|
||||
function shutdown(sig) {
|
||||
if (stopping) return; stopping = true;
|
||||
for (const s of h2Sessions.values()) try { s.close(); } catch (_) {}
|
||||
h2Sessions.clear();
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 5000);
|
||||
}
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('uncaughtException', (e) => log('error', 'uncaught', { error: e.message }));
|
||||
process.on('unhandledRejection', (r) => log('error', 'rejection', { error: String(r) }));
|
||||
Loading…
x
Reference in New Issue
Block a user