diff --git a/antigravity/capture/capture_tls.sh b/antigravity/capture/capture_tls.sh new file mode 100755 index 00000000..7cefd933 --- /dev/null +++ b/antigravity/capture/capture_tls.sh @@ -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_.txt +# ./captures/tls_capture_.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 "═══════════════════════════════════════════════════════" diff --git a/antigravity/capture/capture_traffic.py b/antigravity/capture/capture_traffic.py new file mode 100644 index 00000000..0eb1ea95 --- /dev/null +++ b/antigravity/capture/capture_traffic.py @@ -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()] diff --git a/antigravity/capture/captures/0001_claude-cli-sdk_unknown_downloads.claude.ai.json b/antigravity/capture/captures/0001_claude-cli-sdk_unknown_downloads.claude.ai.json new file mode 100644 index 00000000..6e6d4c9c --- /dev/null +++ b/antigravity/capture/captures/0001_claude-cli-sdk_unknown_downloads.claude.ai.json @@ -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" + } +} \ No newline at end of file diff --git a/antigravity/capture/captures/0002_claude-cli-sdk_unknown_api.anthropic.com.json b/antigravity/capture/captures/0002_claude-cli-sdk_unknown_api.anthropic.com.json new file mode 100644 index 00000000..ff55b1fa --- /dev/null +++ b/antigravity/capture/captures/0002_claude-cli-sdk_unknown_api.anthropic.com.json @@ -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\": \"

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.

You can use the Canva connector to:

Browse, Search & Summarize:
\\\"Summarize my Q2 product strategy doc\\\"

Create New Designs from Conversation:
\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"

Autofill Charts:
\\\"Add a chart showing monthly signups in NZ for Q1\\\"

Autofill Brand Templates:
\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"

Import Files via Link:
\\\"Import this PDF [insert URL] into Canva\\\"

Resize or Export:
\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"

\",\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" + } +} \ No newline at end of file diff --git a/antigravity/capture/captures/0003_claude-cli-sdk_unknown_api.anthropic.com.json b/antigravity/capture/captures/0003_claude-cli-sdk_unknown_api.anthropic.com.json new file mode 100644 index 00000000..601d5f43 --- /dev/null +++ b/antigravity/capture/captures/0003_claude-cli-sdk_unknown_api.anthropic.com.json @@ -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\": \"

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.

You can use the Canva connector to:

Browse, Search & Summarize:
\\\"Summarize my Q2 product strategy doc\\\"

Create New Designs from Conversation:
\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"

Autofill Charts:
\\\"Add a chart showing monthly signups in NZ for Q1\\\"

Autofill Brand Templates:
\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"

Import Files via Link:
\\\"Import this PDF [insert URL] into Canva\\\"

Resize or Export:
\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"

\",\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" + } +} \ No newline at end of file diff --git a/antigravity/capture/captures/0004_claude-cli-sdk_unknown_downloads.claude.ai.json b/antigravity/capture/captures/0004_claude-cli-sdk_unknown_downloads.claude.ai.json new file mode 100644 index 00000000..f7696dda --- /dev/null +++ b/antigravity/capture/captures/0004_claude-cli-sdk_unknown_downloads.claude.ai.json @@ -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" + } +} \ No newline at end of file diff --git a/antigravity/capture/captures/_report.txt b/antigravity/capture/captures/_report.txt new file mode 100644 index 00000000..b06f7b02 --- /dev/null +++ b/antigravity/capture/captures/_report.txt @@ -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 + diff --git a/antigravity/capture/captures/_summary.jsonl b/antigravity/capture/captures/_summary.jsonl new file mode 100644 index 00000000..48d2603c --- /dev/null +++ b/antigravity/capture/captures/_summary.jsonl @@ -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} diff --git a/antigravity/capture/ja3_extract.py b/antigravity/capture/ja3_extract.py new file mode 100644 index 00000000..9166f4a7 --- /dev/null +++ b/antigravity/capture/ja3_extract.py @@ -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('> 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 ") + 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() diff --git a/antigravity/capture/run.sh b/antigravity/capture/run.sh new file mode 100755 index 00000000..a73c1064 --- /dev/null +++ b/antigravity/capture/run.sh @@ -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 diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 4ed0a623..9759cef5 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -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 } diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index c1ac7771..6be6aff8 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -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 +} diff --git a/backend/internal/repository/claude_usage_service.go b/backend/internal/repository/claude_usage_service.go index b44adde2..c6723ef7 100644 --- a/backend/internal/repository/claude_usage_service.go +++ b/backend/internal/repository/claude_usage_service.go @@ -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) } diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index f888d1ff..2e83092e 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -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 + } +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 72009e2c..29781dc7 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -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 } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 0e5741d8..5cbd5848 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -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), } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ed85ee34..b95ff678 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -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 +} diff --git a/backend/internal/service/antigravity_privacy_service.go b/backend/internal/service/antigravity_privacy_service.go new file mode 100644 index 00000000..984bc43f --- /dev/null +++ b/backend/internal/service/antigravity_privacy_service.go @@ -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 +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 37b38f76..893dd6be 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -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() diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index 2c917112..97aedaf4 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -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() diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index b46f0400..ee447d2f 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -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{ diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 490722ce..fb1aef13 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -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 } diff --git a/backend/internal/service/http_upstream_port.go b/backend/internal/service/http_upstream_port.go index e8e76957..43caf760 100644 --- a/backend/internal/service/http_upstream_port.go +++ b/backend/internal/service/http_upstream_port.go @@ -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) } diff --git a/tools/firewall/setup-firewall.sh b/tools/firewall/setup-firewall.sh deleted file mode 100755 index 14041d88..00000000 --- a/tools/firewall/setup-firewall.sh +++ /dev/null @@ -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 diff --git a/tools/maintenance/save-patches.sh b/tools/maintenance/save-patches.sh deleted file mode 100755 index ba861d44..00000000 --- a/tools/maintenance/save-patches.sh +++ /dev/null @@ -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" diff --git a/tools/maintenance/sync-upstream.sh b/tools/maintenance/sync-upstream.sh deleted file mode 100755 index dfc58133..00000000 --- a/tools/maintenance/sync-upstream.sh +++ /dev/null @@ -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" diff --git a/tools/node-tls-proxy/Dockerfile b/tools/node-tls-proxy/Dockerfile deleted file mode 100644 index cbcb4f93..00000000 --- a/tools/node-tls-proxy/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM node:24.13.0-slim - -LABEL maintainer="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"] diff --git a/tools/node-tls-proxy/package.json b/tools/node-tls-proxy/package.json deleted file mode 100644 index a68007cb..00000000 --- a/tools/node-tls-proxy/package.json +++ /dev/null @@ -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" - } -} diff --git a/tools/node-tls-proxy/proxy.js b/tools/node-tls-proxy/proxy.js deleted file mode 100644 index cb5c6c3f..00000000 --- a/tools/node-tls-proxy/proxy.js +++ /dev/null @@ -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) }));