feat(tls): 更新 DoWithTLS 所有调用点至新三模式签名
Some checks failed
CI / test (push) Failing after 10s
CI / golangci-lint (push) Failing after 6s
Security Scan / backend-security (push) Failing after 8s
Security Scan / frontend-security (push) Failing after 7s

- DoWithTLS 签名变更:(bool/profile) → (TLSMode, profile)
- 所有调用方传入 account.GetTLSMode() 以支持 node/utls/off 三模式
- gateway_service.go、gemini_messages_compat、forward_as_* 全部更新
- claude_usage_service 的 ClaudeUsageFetchOptions 新增 TLSMode 字段
- 新增 decompressResponseBody(gzip/brotli/deflate)到 http_upstream.go
- 新增 antigravity_privacy_service.go(setAntigravityPrivacy)
- admin_service 新增 ForceOpenAIPrivacy/EnsureAntigravityPrivacy/ForceAntigravityPrivacy
- antigravity.Client 新增 SetUserSettings/FetchUserInfo API
This commit is contained in:
win 2026-03-27 22:29:17 +08:00
parent 574fa9dfbd
commit 85ed193ff0
29 changed files with 2065 additions and 1012 deletions

View File

@ -0,0 +1,218 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# capture_tls.sh - Capture TLS ClientHello fingerprints (JA3)
#
# mitmproxy terminates TLS so it can't see the real JA3 that
# Claude CLI / Antigravity sends to Anthropic. This script
# captures the REAL TLS fingerprint using tshark.
#
# Usage:
# # Run BEFORE starting claude login / claude "hello"
# # (don't use HTTPS_PROXY for this - direct connection)
#
# sudo ./capture_tls.sh # capture on default interface
# sudo ./capture_tls.sh en0 # specify interface
# sudo ./capture_tls.sh en0 30 # capture for 30 seconds
#
# Output:
# ./captures/tls_capture_<timestamp>.txt
# ./captures/tls_capture_<timestamp>.pcap
# ─────────────────────────────────────────────────────────────
set -euo pipefail
IFACE="${1:-en0}"
DURATION="${2:-60}"
OUTDIR="./captures"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
PCAP_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.pcap"
TXT_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.txt"
mkdir -p "$OUTDIR"
# Resolve target IPs
echo "Resolving target domains..."
DOMAINS=(
"api.anthropic.com"
"platform.claude.com"
"claude.ai"
"cloudaicompanion.googleapis.com"
"generativelanguage.googleapis.com"
"oauth2.googleapis.com"
"accounts.google.com"
)
HOST_FILTER=""
for domain in "${DOMAINS[@]}"; do
ips=$(dig +short "$domain" 2>/dev/null | grep -E '^[0-9]+\.' | head -5)
for ip in $ips; do
if [ -n "$HOST_FILTER" ]; then
HOST_FILTER="$HOST_FILTER or host $ip"
else
HOST_FILTER="host $ip"
fi
done
echo " $domain$ips"
done
if [ -z "$HOST_FILTER" ]; then
echo "ERROR: Could not resolve any target domains"
exit 1
fi
CAPTURE_FILTER="tcp port 443 and ($HOST_FILTER)"
echo ""
echo "═══════════════════════════════════════════════════════"
echo " TLS Fingerprint Capture"
echo " Interface: $IFACE"
echo " Duration: ${DURATION}s"
echo " Filter: $CAPTURE_FILTER"
echo " PCAP: $PCAP_FILE"
echo " Report: $TXT_FILE"
echo "═══════════════════════════════════════════════════════"
echo ""
echo ">>> Now run 'claude login' or 'claude \"hello\"' in another terminal <<<"
echo ">>> Press Ctrl+C to stop early <<<"
echo ""
# Capture pcap in background
tshark -i "$IFACE" -f "$CAPTURE_FILTER" -w "$PCAP_FILE" -a "duration:$DURATION" 2>/dev/null &
TSHARK_PID=$!
# Wait for capture to complete or Ctrl+C
trap "kill $TSHARK_PID 2>/dev/null; wait $TSHARK_PID 2>/dev/null" INT TERM
wait $TSHARK_PID 2>/dev/null || true
echo ""
echo "Capture complete. Analyzing..."
echo ""
# ─── Analysis ───
{
echo "═══════════════════════════════════════════════════════"
echo " TLS ClientHello Fingerprint Report"
echo " Captured: $(date)"
echo " PCAP: $PCAP_FILE"
echo "═══════════════════════════════════════════════════════"
echo ""
# Extract JA3 fingerprints
echo "─── JA3 Fingerprints (ClientHello) ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e frame.time \
-e ip.dst \
-e tls.handshake.extensions_server_name \
-e tls.handshake.ja3 \
-e tls.handshake.ja3_full \
2>/dev/null | while IFS=$'\t' read -r ts dst sni ja3 ja3_full; do
echo " Time: $ts"
echo " Dest IP: $dst"
echo " SNI: $sni"
echo " JA3 Hash: $ja3"
if [ -n "$ja3_full" ]; then
echo " JA3 Full: $ja3_full"
fi
echo ""
done
echo ""
echo "─── TLS Versions ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.version \
-e tls.handshake.extensions.supported_version \
2>/dev/null | sort -u | while IFS=$'\t' read -r sni ver supported; do
echo " SNI: $sni"
echo " Record Version: $ver"
echo " Supported Versions: $supported"
echo ""
done
echo ""
echo "─── ALPN Protocols ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.extensions_alpn_str \
2>/dev/null | sort -u | while IFS=$'\t' read -r sni alpn; do
echo " SNI: $sni → ALPN: $alpn"
done
echo ""
echo ""
echo "─── Cipher Suites (per ClientHello) ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.ciphersuite \
2>/dev/null | head -5 | while IFS=$'\t' read -r sni ciphers; do
echo " SNI: $sni"
echo " Cipher Suites:"
echo " $ciphers" | tr ',' '\n' | while read -r c; do
echo " $c"
done
echo ""
done
echo ""
echo "─── Extensions (per ClientHello) ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.extension.type \
2>/dev/null | head -5 | while IFS=$'\t' read -r sni exts; do
echo " SNI: $sni"
echo " Extensions: $exts"
echo ""
done
echo ""
echo "─── Unique JA3 Summary ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tls.handshake.type == 1" \
-T fields \
-e tls.handshake.extensions_server_name \
-e tls.handshake.ja3 \
2>/dev/null | sort | uniq -c | sort -rn | while read -r count sni ja3; do
echo " ${count}x SNI: $sni JA3: $ja3"
done
echo ""
echo "─── TCP Fingerprint (Initial Window Size, TTL) ───"
echo ""
tshark -r "$PCAP_FILE" \
-Y "tcp.flags.syn == 1 && tcp.flags.ack == 0" \
-T fields \
-e ip.dst \
-e ip.ttl \
-e tcp.window_size_value \
-e tcp.options.mss_val \
-e tcp.options.wscale.shift \
2>/dev/null | sort -u | while IFS=$'\t' read -r dst ttl win mss wscale; do
echo " Dest: $dst TTL: $ttl Window: $win MSS: $mss WScale: $wscale"
done
} 2>/dev/null | tee "$TXT_FILE"
echo ""
echo "═══════════════════════════════════════════════════════"
echo " Report saved to: $TXT_FILE"
echo " PCAP saved to: $PCAP_FILE"
echo ""
echo " To re-analyze: tshark -r $PCAP_FILE -Y 'tls.handshake.type==1' ..."
echo "═══════════════════════════════════════════════════════"

View File

@ -0,0 +1,506 @@
"""
MiniGravity Traffic Capture - mitmproxy addon
Captures and categorizes traffic from Claude Code and Antigravity IDE.
Records: headers (with ordering), body, TLS info, timing.
Usage:
# Claude Code (terminal)
HTTPS_PROXY=http://127.0.0.1:8080 claude login
HTTPS_PROXY=http://127.0.0.1:8080 claude "hello"
# Antigravity (VS Code) - set proxy in VS Code settings or env
HTTPS_PROXY=http://127.0.0.1:8080 code .
# Start mitmproxy with this addon
mitmproxy -s capture_traffic.py --set stream_large_bodies=10m
# or headless:
mitmdump -s capture_traffic.py --set stream_large_bodies=10m
Output:
./captures/ - JSON files per request
./captures/_summary.jsonl - One-line-per-request summary
./captures/_report.txt - Human-readable report (generated on exit)
"""
import json
import os
import time
import hashlib
from datetime import datetime, timezone
from pathlib import Path
from mitmproxy import http, ctx, tls
from mitmproxy.net.http.http1.assemble import assemble_request_head
# ─── Target domains and classification ───
TARGET_DOMAINS = {
# Claude / Anthropic
"claude.ai",
"platform.claude.com",
"api.anthropic.com",
# Google / Antigravity
"accounts.google.com",
"oauth2.googleapis.com",
"cloudaicompanion.googleapis.com",
"generativelanguage.googleapis.com",
# Telemetry
"http-intake.logs.us5.datadoghq.com",
"sentry.io",
}
def classify_request(flow: http.HTTPFlow) -> dict:
"""Classify a request by source tool and purpose."""
host = flow.request.pretty_host
path = flow.request.path
method = flow.request.method
ua = flow.request.headers.get("user-agent", "")
# Determine source tool
source = "unknown"
if "claude-cli" in ua or "claude-code" in ua:
source = "claude-cli"
elif "node" in ua.lower() and ("stainless" in str(flow.request.headers)):
source = "claude-cli"
elif "axios" in ua:
source = "claude-cli-sdk"
elif "vscode" in ua.lower() or "visual studio" in ua.lower():
source = "vscode-extension"
elif "electron" in ua.lower():
source = "desktop-app"
elif "chrome" in ua.lower() or "safari" in ua.lower() or "mozilla" in ua.lower():
source = "browser"
elif "node" in ua.lower():
source = "node-generic"
elif "python" in ua.lower():
source = "python-client"
elif "go-http" in ua.lower() or "go/" in ua.lower():
source = "go-client"
# Determine request purpose
purpose = "unknown"
# OAuth flows
if "/oauth/authorize" in path:
purpose = "oauth-authorize"
elif "/oauth/token" in path or "/v1/oauth/token" in path:
# Distinguish exchange vs refresh
body = _get_request_body_str(flow)
if "refresh_token" in body:
purpose = "oauth-token-refresh"
else:
purpose = "oauth-token-exchange"
elif "/o/oauth2" in path or "/oauth2/" in path:
purpose = "google-oauth"
# API calls
elif "/v1/messages" in path:
purpose = "api-messages"
elif "/v1/complete" in path:
purpose = "api-complete"
# Organization / setup
elif "/api/organizations" in path:
purpose = "org-list"
elif "/v1/oauth/" in path and "/authorize" in path:
purpose = "oauth-authorize-api"
# Telemetry
elif "/api/event_logging" in path:
purpose = "telemetry-otel"
elif "datadoghq.com" in host:
purpose = "telemetry-datadog"
elif "sentry" in host:
purpose = "telemetry-sentry"
# Google AI
elif "cloudaicompanion" in host:
purpose = "antigravity-api"
elif "generativelanguage" in host:
purpose = "gemini-api"
return {
"source": source,
"purpose": purpose,
}
def _get_request_body_str(flow: http.HTTPFlow) -> str:
"""Safely get request body as string."""
try:
if flow.request.content:
return flow.request.content.decode("utf-8", errors="replace")
except Exception:
pass
return ""
def _get_response_body_str(flow: http.HTTPFlow, max_len: int = 4096) -> str:
"""Safely get response body as string, truncated."""
try:
if flow.response and flow.response.content:
body = flow.response.content.decode("utf-8", errors="replace")
if len(body) > max_len:
return body[:max_len] + f"\n... [truncated, total {len(body)} bytes]"
return body
except Exception:
pass
return ""
def _parse_json_body(body_str: str) -> any:
"""Try to parse body as JSON, return raw string if fails."""
if not body_str:
return None
try:
return json.loads(body_str)
except (json.JSONDecodeError, ValueError):
return body_str
def _get_tls_info(flow: http.HTTPFlow) -> dict:
"""Extract available TLS information from the flow."""
info = {}
if flow.server_conn and flow.server_conn.tls_version:
info["tls_version"] = flow.server_conn.tls_version
if flow.server_conn and hasattr(flow.server_conn, "alpn_proto_negotiated"):
info["alpn"] = (
flow.server_conn.alpn_proto_negotiated.decode()
if flow.server_conn.alpn_proto_negotiated
else None
)
# Client TLS info (what the client sent to mitmproxy)
if flow.client_conn:
if hasattr(flow.client_conn, "tls_version") and flow.client_conn.tls_version:
info["client_tls_version"] = flow.client_conn.tls_version
if (
hasattr(flow.client_conn, "alpn_proto_negotiated")
and flow.client_conn.alpn_proto_negotiated
):
info["client_alpn"] = flow.client_conn.alpn_proto_negotiated.decode()
# SNI
if hasattr(flow.client_conn, "sni") and flow.client_conn.sni:
info["client_sni"] = flow.client_conn.sni
return info
class TrafficCapture:
def __init__(self):
self.capture_dir = Path("./captures")
self.capture_dir.mkdir(exist_ok=True)
self.summary_file = self.capture_dir / "_summary.jsonl"
self.counter = 0
self.captures = []
# Write session start marker
session_start = {
"event": "session_start",
"timestamp": datetime.now(timezone.utc).isoformat(),
"note": "New capture session started",
}
with open(self.summary_file, "a") as f:
f.write(json.dumps(session_start) + "\n")
ctx.log.info(
f"[capture] Traffic capture started. Output: {self.capture_dir.absolute()}"
)
def request(self, flow: http.HTTPFlow):
"""Tag requests to target domains."""
host = flow.request.pretty_host
is_target = any(host == d or host.endswith("." + d) for d in TARGET_DOMAINS)
flow.metadata["is_target"] = is_target
if is_target:
flow.metadata["capture_time_start"] = time.time()
def response(self, flow: http.HTTPFlow):
"""Capture complete request/response for target domains."""
if not flow.metadata.get("is_target"):
return
self.counter += 1
classification = classify_request(flow)
elapsed = None
if flow.metadata.get("capture_time_start"):
elapsed = round(time.time() - flow.metadata["capture_time_start"], 3)
# Build ordered header list (order matters for fingerprinting!)
request_headers_ordered = [
[k, v] for k, v in flow.request.headers.fields
]
request_headers_ordered_decoded = []
for k, v in request_headers_ordered:
try:
request_headers_ordered_decoded.append(
[k.decode("utf-8", errors="replace"),
v.decode("utf-8", errors="replace")]
)
except AttributeError:
request_headers_ordered_decoded.append([str(k), str(v)])
response_headers_ordered = []
if flow.response:
for k, v in flow.response.headers.fields:
try:
response_headers_ordered.append(
[k.decode("utf-8", errors="replace"),
v.decode("utf-8", errors="replace")]
)
except AttributeError:
response_headers_ordered.append([str(k), str(v)])
req_body = _get_request_body_str(flow)
resp_body = _get_response_body_str(flow)
# Redact sensitive values
req_body_parsed = _parse_json_body(req_body)
if isinstance(req_body_parsed, dict):
req_body_parsed = _redact_sensitive(req_body_parsed)
resp_body_parsed = _parse_json_body(resp_body)
if isinstance(resp_body_parsed, dict):
resp_body_parsed = _redact_sensitive(resp_body_parsed)
record = {
"id": self.counter,
"timestamp": datetime.now(timezone.utc).isoformat(),
"elapsed_sec": elapsed,
# Classification
"source": classification["source"],
"purpose": classification["purpose"],
# Request
"request": {
"method": flow.request.method,
"url": flow.request.pretty_url,
"host": flow.request.pretty_host,
"path": flow.request.path,
"http_version": flow.request.http_version,
"headers_ordered": request_headers_ordered_decoded,
"body": req_body_parsed,
"content_length": len(flow.request.content) if flow.request.content else 0,
},
# Response
"response": {
"status_code": flow.response.status_code if flow.response else None,
"http_version": flow.response.http_version if flow.response else None,
"headers_ordered": response_headers_ordered,
"body": resp_body_parsed,
"content_length": (
len(flow.response.content)
if flow.response and flow.response.content
else 0
),
},
# TLS
"tls": _get_tls_info(flow),
# Connection
"connection": {
"client_address": (
f"{flow.client_conn.peername[0]}:{flow.client_conn.peername[1]}"
if flow.client_conn.peername
else None
),
"server_address": (
f"{flow.server_conn.peername[0]}:{flow.server_conn.peername[1]}"
if flow.server_conn and flow.server_conn.peername
else None
),
},
}
self.captures.append(record)
# Save individual capture file
filename = (
f"{self.counter:04d}_{classification['source']}"
f"_{classification['purpose']}"
f"_{flow.request.pretty_host}.json"
)
filepath = self.capture_dir / filename
with open(filepath, "w") as f:
json.dump(record, f, indent=2, ensure_ascii=False, default=str)
# Append to summary
summary_line = {
"id": self.counter,
"ts": datetime.now(timezone.utc).strftime("%H:%M:%S"),
"source": classification["source"],
"purpose": classification["purpose"],
"method": flow.request.method,
"url": flow.request.pretty_url[:120],
"status": flow.response.status_code if flow.response else None,
"ua": flow.request.headers.get("user-agent", "")[:80],
"elapsed": elapsed,
}
with open(self.summary_file, "a") as f:
f.write(json.dumps(summary_line) + "\n")
# Console output
status = flow.response.status_code if flow.response else "???"
ctx.log.info(
f"[capture #{self.counter}] "
f"[{classification['source']}] "
f"[{classification['purpose']}] "
f"{flow.request.method} {flow.request.pretty_url[:80]} "
f"{status} "
f"({elapsed}s)"
)
# Highlight important findings
ua = flow.request.headers.get("user-agent", "")
if classification["purpose"] in (
"oauth-token-exchange",
"oauth-token-refresh",
):
ctx.log.warn(
f"[capture] TOKEN EXCHANGE/REFRESH detected!\n"
f" UA: {ua}\n"
f" Headers: {[h[0] for h in request_headers_ordered_decoded]}"
)
def done(self):
"""Generate report on exit."""
if not self.captures:
ctx.log.info("[capture] No captures recorded.")
return
report_path = self.capture_dir / "_report.txt"
with open(report_path, "w") as f:
f.write("=" * 80 + "\n")
f.write(" MiniGravity Traffic Capture Report\n")
f.write(f" Generated: {datetime.now().isoformat()}\n")
f.write(f" Total requests captured: {len(self.captures)}\n")
f.write("=" * 80 + "\n\n")
# Group by source
by_source = {}
for cap in self.captures:
src = cap["source"]
if src not in by_source:
by_source[src] = []
by_source[src].append(cap)
for source, caps in sorted(by_source.items()):
f.write(f"\n{'' * 60}\n")
f.write(f" Source: {source} ({len(caps)} requests)\n")
f.write(f"{'' * 60}\n\n")
# Group by purpose within source
by_purpose = {}
for cap in caps:
p = cap["purpose"]
if p not in by_purpose:
by_purpose[p] = []
by_purpose[p].append(cap)
for purpose, pcaps in sorted(by_purpose.items()):
f.write(f" [{purpose}] ({len(pcaps)} requests)\n\n")
for cap in pcaps:
req = cap["request"]
f.write(f" #{cap['id']} {req['method']} {req['url'][:100]}\n")
f.write(f" HTTP Version: {req['http_version']}\n")
f.write(" Request Headers (ordered):\n")
for hdr in req["headers_ordered"]:
val = hdr[1]
# Truncate long values
if len(val) > 100:
val = val[:100] + "..."
f.write(f" {hdr[0]}: {val}\n")
if req["body"]:
body_str = json.dumps(
req["body"], indent=6, ensure_ascii=False, default=str
)
if len(body_str) > 500:
body_str = body_str[:500] + "\n ..."
f.write(f" Request Body:\n {body_str}\n")
resp = cap["response"]
f.write(f" Response: {resp['status_code']}\n")
if cap["tls"]:
f.write(f" TLS: {json.dumps(cap['tls'])}\n")
f.write("\n")
# Comparison section
f.write(f"\n{'=' * 80}\n")
f.write(" FINGERPRINT COMPARISON\n")
f.write(f"{'=' * 80}\n\n")
# Collect unique UA per source+purpose
ua_map = {}
for cap in self.captures:
key = f"{cap['source']}:{cap['purpose']}"
ua = dict(cap["request"]["headers_ordered"]).get("user-agent", "N/A")
if key not in ua_map:
ua_map[key] = set()
ua_map[key].add(ua)
f.write(" User-Agent by source:purpose\n")
for key, uas in sorted(ua_map.items()):
for ua in uas:
f.write(f" {key:40s}{ua}\n")
# Collect header sets per source+purpose
f.write("\n Header names by source:purpose\n")
header_map = {}
for cap in self.captures:
key = f"{cap['source']}:{cap['purpose']}"
hdrs = tuple(h[0].lower() for h in cap["request"]["headers_ordered"])
if key not in header_map:
header_map[key] = set()
header_map[key].add(hdrs)
for key, hdr_sets in sorted(header_map.items()):
for hdrs in hdr_sets:
f.write(f" {key}:\n")
for h in hdrs:
f.write(f" - {h}\n")
f.write("\n")
ctx.log.info(
f"[capture] Report written to {report_path.absolute()}\n"
f"[capture] {len(self.captures)} requests captured in {self.capture_dir.absolute()}"
)
def _redact_sensitive(d: dict) -> dict:
"""Redact sensitive values in a dict, preserving structure."""
sensitive_keys = {
"access_token", "refresh_token", "code", "code_verifier",
"session_key", "sessionKey", "password", "secret",
"authorization", "cookie",
}
result = {}
for k, v in d.items():
if k.lower() in {s.lower() for s in sensitive_keys}:
if isinstance(v, str) and len(v) > 8:
result[k] = v[:4] + "****" + v[-4:]
else:
result[k] = "****"
elif isinstance(v, dict):
result[k] = _redact_sensitive(v)
elif isinstance(v, list):
result[k] = [
_redact_sensitive(item) if isinstance(item, dict) else item
for item in v
]
else:
result[k] = v
return result
addons = [TrafficCapture()]

View File

@ -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"
}
}

View File

@ -0,0 +1,125 @@
{
"id": 2,
"timestamp": "2026-03-26T16:28:57.668166+00:00",
"elapsed_sec": 0.481,
"source": "claude-cli-sdk",
"purpose": "unknown",
"request": {
"method": "GET",
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
"host": "api.anthropic.com",
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Accept",
"application/json, text/plain, */*"
],
[
"Accept-Encoding",
"gzip, compress, deflate, br"
],
[
"User-Agent",
"axios/1.13.6"
],
[
"Host",
"api.anthropic.com"
]
],
"body": null,
"content_length": 0
},
"response": {
"status_code": 200,
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Date",
"Thu, 26 Mar 2026 16:28:57 GMT"
],
[
"Content-Type",
"application/json"
],
[
"Transfer-Encoding",
"chunked"
],
[
"Connection",
"keep-alive"
],
[
"x-request-id",
"a26ee618-f205-4a23-87b1-6225e17b92ef"
],
[
"access-control-allow-origin",
"*"
],
[
"access-control-allow-methods",
"GET, OPTIONS"
],
[
"access-control-allow-headers",
"*"
],
[
"x-envoy-upstream-service-time",
"9"
],
[
"Content-Encoding",
"gzip"
],
[
"vary",
"Accept-Encoding"
],
[
"Server",
"cloudflare"
],
[
"server-timing",
"x-originResponse;dur=11"
],
[
"cf-cache-status",
"DYNAMIC"
],
[
"set-cookie",
"_cfuvid=XtplK6T__J5GJ7ZHZ75.1K.blAKEiURZzIRFOxbjm0U-1774542537.272683-1.0.1.1-Folg8_rQ2RrBi0Img0NdFQUYWxTawBjeo7zj11dFizU; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
],
[
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'"
],
[
"X-Robots-Tag",
"none"
],
[
"CF-RAY",
"9e278809ffffe371-NRT"
]
],
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search &amp; Summarize: <br>\\\"Summarize my Q2 product strategy doc\\\"</p><p id=\\\"\\\">Create New Designs from Conversation:<br>\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"</p><p id=\\\"\\\">Autofill Charts:<br>\\\"Add a chart showing monthly signups in NZ for Q1\\\"</p><p id=\\\"\\\">Autofill Brand Templates:<br>\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"</p><p id=\\\"\\\">Import Files via Link:<br>\\\"Import this PDF [insert URL] into Canva\\\"</p><p id=\\\"\\\">Resize or Export:<br>\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"</p>\",\n \"imageUrls\": [\n {\n \"prompt\": \"Generate a sales report presentation with outline \",\n \"imageUrl\": \"https://storage.goo\n... [truncated, total 185385 bytes]",
"content_length": 185487
},
"tls": {
"tls_version": "TLSv1.3",
"alpn": "http/1.1",
"client_tls_version": "TLSv1.3",
"client_alpn": "http/1.1",
"client_sni": "api.anthropic.com"
},
"connection": {
"client_address": "127.0.0.1:55668",
"server_address": "198.18.0.32:443"
}
}

View File

@ -0,0 +1,125 @@
{
"id": 3,
"timestamp": "2026-03-26T16:30:00.064058+00:00",
"elapsed_sec": 0.731,
"source": "claude-cli-sdk",
"purpose": "unknown",
"request": {
"method": "GET",
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
"host": "api.anthropic.com",
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Accept",
"application/json, text/plain, */*"
],
[
"Accept-Encoding",
"gzip, compress, deflate, br"
],
[
"User-Agent",
"axios/1.13.6"
],
[
"Host",
"api.anthropic.com"
]
],
"body": null,
"content_length": 0
},
"response": {
"status_code": 200,
"http_version": "HTTP/1.1",
"headers_ordered": [
[
"Date",
"Thu, 26 Mar 2026 16:29:59 GMT"
],
[
"Content-Type",
"application/json"
],
[
"Transfer-Encoding",
"chunked"
],
[
"Connection",
"keep-alive"
],
[
"x-request-id",
"ddcd43e3-8799-43b9-9d49-8dcc6a0b90dd"
],
[
"access-control-allow-origin",
"*"
],
[
"access-control-allow-methods",
"GET, OPTIONS"
],
[
"access-control-allow-headers",
"*"
],
[
"x-envoy-upstream-service-time",
"8"
],
[
"Content-Encoding",
"gzip"
],
[
"vary",
"Accept-Encoding"
],
[
"Server",
"cloudflare"
],
[
"server-timing",
"x-originResponse;dur=10"
],
[
"cf-cache-status",
"DYNAMIC"
],
[
"set-cookie",
"_cfuvid=DUvPIDhglzXAjPSEJhKi0nemis9e5knKw1jmUxq8LnE-1774542599.5569572-1.0.1.1-5oD..eF758shBNx1g_VrkNhd2HcST2hu4QKN5ciERz4; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
],
[
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'"
],
[
"X-Robots-Tag",
"none"
],
[
"CF-RAY",
"9e27898f3c1eefbb-NRT"
]
],
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search &amp; Summarize: <br>\\\"Summarize my Q2 product strategy doc\\\"</p><p id=\\\"\\\">Create New Designs from Conversation:<br>\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"</p><p id=\\\"\\\">Autofill Charts:<br>\\\"Add a chart showing monthly signups in NZ for Q1\\\"</p><p id=\\\"\\\">Autofill Brand Templates:<br>\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"</p><p id=\\\"\\\">Import Files via Link:<br>\\\"Import this PDF [insert URL] into Canva\\\"</p><p id=\\\"\\\">Resize or Export:<br>\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"</p>\",\n \"imageUrls\": [\n {\n \"prompt\": \"Generate a sales report presentation with outline \",\n \"imageUrl\": \"https://storage.goo\n... [truncated, total 185385 bytes]",
"content_length": 185487
},
"tls": {
"tls_version": "TLSv1.3",
"alpn": "http/1.1",
"client_tls_version": "TLSv1.3",
"client_alpn": "http/1.1",
"client_sni": "api.anthropic.com"
},
"connection": {
"client_address": "127.0.0.1:55998",
"server_address": "198.18.0.32:443"
}
}

View File

@ -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"
}
}

View File

@ -0,0 +1,68 @@
================================================================================
MiniGravity Traffic Capture Report
Generated: 2026-03-27T00:50:12.040880
Total requests captured: 4
================================================================================
────────────────────────────────────────────────────────────
Source: claude-cli-sdk (4 requests)
────────────────────────────────────────────────────────────
[unknown] (4 requests)
#1 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
HTTP Version: HTTP/1.1
Request Headers (ordered):
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, compress, deflate, br
User-Agent: axios/1.13.6
Host: downloads.claude.ai
Response: 200
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
#2 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
HTTP Version: HTTP/1.1
Request Headers (ordered):
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, compress, deflate, br
User-Agent: axios/1.13.6
Host: api.anthropic.com
Response: 200
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
#3 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
HTTP Version: HTTP/1.1
Request Headers (ordered):
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, compress, deflate, br
User-Agent: axios/1.13.6
Host: api.anthropic.com
Response: 200
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
#4 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
HTTP Version: HTTP/1.1
Request Headers (ordered):
Accept: application/json, text/plain, */*
Accept-Encoding: gzip, compress, deflate, br
User-Agent: axios/1.13.6
Host: downloads.claude.ai
Response: 200
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
================================================================================
FINGERPRINT COMPARISON
================================================================================
User-Agent by source:purpose
claude-cli-sdk:unknown → N/A
Header names by source:purpose
claude-cli-sdk:unknown:
- accept
- accept-encoding
- user-agent
- host

View File

@ -0,0 +1,5 @@
{"event": "session_start", "timestamp": "2026-03-26T16:28:39.558811+00:00", "note": "New capture session started"}
{"id": 1, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.322}
{"id": 2, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.481}
{"id": 3, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.731}
{"id": 4, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.833}

View File

@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
Extract JA3 fingerprint from pcap file (no tshark needed).
Parses TLS ClientHello directly from raw packets.
"""
import struct
import hashlib
import sys
def parse_pcap(filepath):
"""Parse pcap file and extract TLS ClientHello JA3 fingerprints."""
results = []
with open(filepath, 'rb') as f:
# Read pcap global header
magic = struct.unpack('<I', f.read(4))[0]
if magic == 0xa1b2c3d4:
endian = '<'
elif magic == 0xd4c3b2a1:
endian = '>'
else:
print(f"Not a pcap file (magic: {hex(magic)})")
return results
f.read(20) # rest of global header
packet_num = 0
while True:
# Read packet header
pkt_hdr = f.read(16)
if len(pkt_hdr) < 16:
break
ts_sec, ts_usec, incl_len, orig_len = struct.unpack(f'{endian}IIII', pkt_hdr)
pkt_data = f.read(incl_len)
if len(pkt_data) < incl_len:
break
packet_num += 1
# Parse Ethernet header (14 bytes)
if len(pkt_data) < 14:
continue
eth_type = struct.unpack('!H', pkt_data[12:14])[0]
if eth_type != 0x0800: # IPv4
continue
# Parse IP header
ip_start = 14
if len(pkt_data) < ip_start + 20:
continue
ip_ver_ihl = pkt_data[ip_start]
ip_ihl = (ip_ver_ihl & 0x0F) * 4
ip_proto = pkt_data[ip_start + 9]
dst_ip = '.'.join(str(b) for b in pkt_data[ip_start+16:ip_start+20])
if ip_proto != 6: # TCP
continue
# Parse TCP header
tcp_start = ip_start + ip_ihl
if len(pkt_data) < tcp_start + 20:
continue
dst_port = struct.unpack('!H', pkt_data[tcp_start+2:tcp_start+4])[0]
tcp_data_offset = ((pkt_data[tcp_start + 12] >> 4) & 0xF) * 4
# TLS record starts after TCP header
tls_start = tcp_start + tcp_data_offset
if len(pkt_data) < tls_start + 6:
continue
# Check for TLS Handshake (content type 22)
if pkt_data[tls_start] != 22:
continue
tls_version = struct.unpack('!H', pkt_data[tls_start+1:tls_start+3])[0]
tls_length = struct.unpack('!H', pkt_data[tls_start+3:tls_start+5])[0]
# Check for ClientHello (handshake type 1)
hs_start = tls_start + 5
if len(pkt_data) < hs_start + 4:
continue
if pkt_data[hs_start] != 1: # ClientHello
continue
# Parse ClientHello
try:
ja3 = extract_ja3(pkt_data, hs_start, dst_ip, dst_port)
if ja3:
results.append(ja3)
except Exception as e:
pass
return results
def extract_ja3(data, hs_start, dst_ip, dst_port):
"""Extract JA3 components from ClientHello."""
# Handshake header: type(1) + length(3)
pos = hs_start + 4
# ClientHello: version(2) + random(32)
if len(data) < pos + 34:
return None
ch_version = struct.unpack('!H', data[pos:pos+2])[0]
pos += 34 # skip version + random
# Session ID
if len(data) < pos + 1:
return None
session_id_len = data[pos]
pos += 1 + session_id_len
# Cipher Suites
if len(data) < pos + 2:
return None
cs_len = struct.unpack('!H', data[pos:pos+2])[0]
pos += 2
if len(data) < pos + cs_len:
return None
cipher_suites = []
for i in range(0, cs_len, 2):
cs = struct.unpack('!H', data[pos+i:pos+i+2])[0]
# Skip GREASE values
if (cs & 0x0f0f) == 0x0a0a:
continue
cipher_suites.append(str(cs))
pos += cs_len
# Compression methods
if len(data) < pos + 1:
return None
comp_len = data[pos]
pos += 1 + comp_len
# Extensions
extensions = []
elliptic_curves = []
ec_point_formats = []
supported_versions = []
sni = ""
if len(data) > pos + 2:
ext_total_len = struct.unpack('!H', data[pos:pos+2])[0]
pos += 2
ext_end = pos + ext_total_len
while pos + 4 <= ext_end and pos + 4 <= len(data):
ext_type = struct.unpack('!H', data[pos:pos+2])[0]
ext_len = struct.unpack('!H', data[pos+2:pos+4])[0]
ext_data_start = pos + 4
# Skip GREASE
if (ext_type & 0x0f0f) == 0x0a0a:
pos = ext_data_start + ext_len
continue
extensions.append(str(ext_type))
# SNI (type 0)
if ext_type == 0 and ext_len > 5:
try:
name_len = struct.unpack('!H', data[ext_data_start+3:ext_data_start+5])[0]
sni = data[ext_data_start+5:ext_data_start+5+name_len].decode('ascii', errors='replace')
except:
pass
# Supported Groups / Elliptic Curves (type 10)
if ext_type == 10 and ext_len >= 2:
curves_len = struct.unpack('!H', data[ext_data_start:ext_data_start+2])[0]
for i in range(0, curves_len, 2):
if ext_data_start + 2 + i + 2 <= len(data):
curve = struct.unpack('!H', data[ext_data_start+2+i:ext_data_start+2+i+2])[0]
if (curve & 0x0f0f) != 0x0a0a:
elliptic_curves.append(str(curve))
# EC Point Formats (type 11)
if ext_type == 11 and ext_len >= 1:
fmt_len = data[ext_data_start]
for i in range(fmt_len):
if ext_data_start + 1 + i < len(data):
ec_point_formats.append(str(data[ext_data_start+1+i]))
# Supported Versions (type 43)
if ext_type == 43 and ext_len >= 1:
sv_len = data[ext_data_start]
for i in range(0, sv_len, 2):
if ext_data_start + 1 + i + 2 <= len(data):
ver = struct.unpack('!H', data[ext_data_start+1+i:ext_data_start+1+i+2])[0]
if (ver & 0x0f0f) != 0x0a0a:
supported_versions.append(hex(ver))
pos = ext_data_start + ext_len
# Build JA3 string: TLSVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats
ja3_str = ','.join([
str(ch_version),
'-'.join(cipher_suites),
'-'.join(extensions),
'-'.join(elliptic_curves),
'-'.join(ec_point_formats),
])
ja3_hash = hashlib.md5(ja3_str.encode()).hexdigest()
return {
'dst_ip': dst_ip,
'dst_port': dst_port,
'sni': sni,
'ja3_hash': ja3_hash,
'ja3_string': ja3_str,
'tls_version': hex(ch_version),
'cipher_count': len(cipher_suites),
'extension_count': len(extensions),
'supported_versions': supported_versions,
'ciphers': cipher_suites[:10], # first 10 for display
}
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: python3 ja3_extract.py <pcap_file>")
sys.exit(1)
results = parse_pcap(sys.argv[1])
if not results:
print("No TLS ClientHello found in pcap")
sys.exit(0)
# Deduplicate by JA3 hash + SNI
seen = set()
for r in results:
key = f"{r['ja3_hash']}:{r['sni']}"
if key in seen:
continue
seen.add(key)
print(f"SNI: {r['sni']}")
print(f"Dest: {r['dst_ip']}:{r['dst_port']}")
print(f"JA3 Hash: {r['ja3_hash']}")
print(f"TLS Ver: {r['tls_version']}")
print(f"Ciphers: {r['cipher_count']} suites (first 10: {r['ciphers']})")
print(f"Extensions: {r['extension_count']}")
print(f"Sup. Vers: {r['supported_versions']}")
print(f"JA3 Full: {r['ja3_string'][:200]}...")
print()

99
antigravity/capture/run.sh Executable file
View File

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# run.sh - One-command capture for Claude Code / Antigravity
#
# Usage:
# ./run.sh # Start both mitmproxy + tshark
# ./run.sh mitm # mitmproxy only (HTTP layer)
# ./run.sh tls # tshark only (TLS layer)
# ./run.sh tls en0 # tshark on specific interface
# ─────────────────────────────────────────────────────────────
set -euo pipefail
cd "$(dirname "$0")"
MODE="${1:-both}"
IFACE="${2:-en0}"
# Check dependencies
check_dep() {
if ! command -v "$1" &>/dev/null; then
echo "ERROR: $1 not found. Install with: $2"
exit 1
fi
}
mkdir -p ./captures
case "$MODE" in
mitm|mitmproxy)
check_dep mitmproxy "brew install mitmproxy"
echo ""
echo "Starting mitmproxy on :8080"
echo ""
echo "To capture Claude Code traffic:"
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
echo ""
echo "To capture VS Code / Antigravity traffic:"
echo " HTTPS_PROXY=http://127.0.0.1:8080 code ."
echo ""
mitmdump -s capture_traffic.py \
--set stream_large_bodies=10m \
--set console_eventlog_verbosity=warn \
-p 8080
;;
tls|tshark)
check_dep tshark "brew install wireshark"
echo "Starting TLS capture (requires sudo)..."
sudo bash ./capture_tls.sh "$IFACE" 120
;;
both)
check_dep mitmproxy "brew install mitmproxy"
check_dep tshark "brew install wireshark"
echo ""
echo "═══════════════════════════════════════════════"
echo " MiniGravity Traffic Capture"
echo "═══════════════════════════════════════════════"
echo ""
echo " Starting two capture layers:"
echo " 1. mitmproxy (:8080) → HTTP headers/body"
echo " 2. tshark → TLS fingerprints"
echo ""
echo " Step 1: In another terminal, run:"
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
echo ""
echo " Step 2: After login, run:"
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
echo ""
echo " Step 3: Press Ctrl+C here when done"
echo "═══════════════════════════════════════════════"
echo ""
# Start tshark in background (needs sudo)
echo "[*] Starting tshark (may ask for sudo password)..."
sudo bash ./capture_tls.sh "$IFACE" 300 &
TSHARK_PID=$!
sleep 2
# Start mitmproxy in foreground
echo "[*] Starting mitmproxy..."
mitmdump -s capture_traffic.py \
--set stream_large_bodies=10m \
--set console_eventlog_verbosity=warn \
-p 8080
# Cleanup tshark on exit
sudo kill "$TSHARK_PID" 2>/dev/null || true
wait "$TSHARK_PID" 2>/dev/null || true
;;
*)
echo "Usage: $0 [mitm|tls|both] [interface]"
exit 1
;;
esac

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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、brbrotli、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
}
}

View File

@ -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
}

View File

@ -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 指纹 Profilenil 表示不启用)
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),
}

View File

@ -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
}

View File

@ -0,0 +1,81 @@
package service
import (
"context"
"log/slog"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
)
const (
AntigravityPrivacySet = "privacy_set"
AntigravityPrivacyFailed = "privacy_set_failed"
)
// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
// 流程:
// 1. setUserSettings 清空设置 → 检查返回值 {\"userSettings\":{}}
// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id
//
// 返回 privacy_mode 值:\"privacy_set\" 成功,\"privacy_set_failed\" 失败,空串表示无法执行。
func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
if accessToken == "" {
return ""
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
client, err := antigravity.NewClient(proxyURL)
if err != nil {
slog.Warn("antigravity_privacy_client_error", "error", err.Error())
return AntigravityPrivacyFailed
}
// 第 1 步:调用 setUserSettings检查返回值
setResp, err := client.SetUserSettings(ctx, accessToken)
if err != nil {
slog.Warn("antigravity_privacy_set_failed", "error", err.Error())
return AntigravityPrivacyFailed
}
if !setResp.IsSuccess() {
slog.Warn("antigravity_privacy_set_response_not_empty",
"user_settings", setResp.UserSettings,
)
return AntigravityPrivacyFailed
}
// 第 2 步:调用 fetchUserInfo 二次验证隐私是否已生效
if strings.TrimSpace(projectID) == "" {
slog.Warn("antigravity_privacy_missing_project_id")
return AntigravityPrivacyFailed
}
userInfo, err := client.FetchUserInfo(ctx, accessToken, projectID)
if err != nil {
slog.Warn("antigravity_privacy_verify_failed", "error", err.Error())
return AntigravityPrivacyFailed
}
if !userInfo.IsPrivate() {
slog.Warn("antigravity_privacy_verify_not_private",
"user_settings", userInfo.UserSettings,
)
return AntigravityPrivacyFailed
}
slog.Info("antigravity_privacy_set_success")
return AntigravityPrivacySet
}
func applyAntigravityPrivacyMode(account *Account, mode string) {
if account == nil || strings.TrimSpace(mode) == "" {
return
}
extra := make(map[string]any, len(account.Extra)+1)
for k, v := range account.Extra {
extra[k] = v
}
extra["privacy_mode"] = mode
account.Extra = extra
}

View File

@ -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()

View File

@ -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()

View File

@ -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{

View File

@ -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
}

View File

@ -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 请求接口
// 用于向上游 APIClaude、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 ClientHelloprofile 为 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)
}

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -1,24 +0,0 @@
FROM node:24.13.0-slim
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
LABEL description="Node.js TLS Forward Proxy - native JA3/JA4 fingerprint matching"
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
WORKDIR /app
COPY proxy.js package.json ./
# 零依赖,不需要 npm install
ENV PROXY_PORT=3456
ENV PROXY_HOST=0.0.0.0
ENV UPSTREAM_HOST=api.anthropic.com
EXPOSE 3456
# 健康检查:用 Node.js 内置 http 模块,不依赖 curl
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=5s \
CMD node -e "const http=require('http');const r=http.get('http://127.0.0.1:'+(process.env.PROXY_PORT||3456)+'/__health',s=>{process.exit(s.statusCode===200?0:1)});r.on('error',()=>process.exit(1));r.setTimeout(3000,()=>{r.destroy();process.exit(1)})"
USER node
CMD ["node", "proxy.js"]

View File

@ -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"
}
}

View File

@ -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,
});
});
// 定期清理过期 session1 小时无活动)
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) }));