Compare commits
9 Commits
e4697aa421
...
85ed193ff0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ed193ff0 | ||
|
|
574fa9dfbd | ||
|
|
d0a242df03 | ||
|
|
c2cf79154a | ||
|
|
96b71a1399 | ||
|
|
71ade841fb | ||
|
|
ee23e67c85 | ||
|
|
dcc341b846 | ||
|
|
d827560223 |
218
antigravity/capture/capture_tls.sh
Executable file
218
antigravity/capture/capture_tls.sh
Executable file
@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# capture_tls.sh - Capture TLS ClientHello fingerprints (JA3)
|
||||
#
|
||||
# mitmproxy terminates TLS so it can't see the real JA3 that
|
||||
# Claude CLI / Antigravity sends to Anthropic. This script
|
||||
# captures the REAL TLS fingerprint using tshark.
|
||||
#
|
||||
# Usage:
|
||||
# # Run BEFORE starting claude login / claude "hello"
|
||||
# # (don't use HTTPS_PROXY for this - direct connection)
|
||||
#
|
||||
# sudo ./capture_tls.sh # capture on default interface
|
||||
# sudo ./capture_tls.sh en0 # specify interface
|
||||
# sudo ./capture_tls.sh en0 30 # capture for 30 seconds
|
||||
#
|
||||
# Output:
|
||||
# ./captures/tls_capture_<timestamp>.txt
|
||||
# ./captures/tls_capture_<timestamp>.pcap
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IFACE="${1:-en0}"
|
||||
DURATION="${2:-60}"
|
||||
OUTDIR="./captures"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
PCAP_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.pcap"
|
||||
TXT_FILE="${OUTDIR}/tls_capture_${TIMESTAMP}.txt"
|
||||
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
# Resolve target IPs
|
||||
echo "Resolving target domains..."
|
||||
DOMAINS=(
|
||||
"api.anthropic.com"
|
||||
"platform.claude.com"
|
||||
"claude.ai"
|
||||
"cloudaicompanion.googleapis.com"
|
||||
"generativelanguage.googleapis.com"
|
||||
"oauth2.googleapis.com"
|
||||
"accounts.google.com"
|
||||
)
|
||||
|
||||
HOST_FILTER=""
|
||||
for domain in "${DOMAINS[@]}"; do
|
||||
ips=$(dig +short "$domain" 2>/dev/null | grep -E '^[0-9]+\.' | head -5)
|
||||
for ip in $ips; do
|
||||
if [ -n "$HOST_FILTER" ]; then
|
||||
HOST_FILTER="$HOST_FILTER or host $ip"
|
||||
else
|
||||
HOST_FILTER="host $ip"
|
||||
fi
|
||||
done
|
||||
echo " $domain → $ips"
|
||||
done
|
||||
|
||||
if [ -z "$HOST_FILTER" ]; then
|
||||
echo "ERROR: Could not resolve any target domains"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CAPTURE_FILTER="tcp port 443 and ($HOST_FILTER)"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " TLS Fingerprint Capture"
|
||||
echo " Interface: $IFACE"
|
||||
echo " Duration: ${DURATION}s"
|
||||
echo " Filter: $CAPTURE_FILTER"
|
||||
echo " PCAP: $PCAP_FILE"
|
||||
echo " Report: $TXT_FILE"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo ">>> Now run 'claude login' or 'claude \"hello\"' in another terminal <<<"
|
||||
echo ">>> Press Ctrl+C to stop early <<<"
|
||||
echo ""
|
||||
|
||||
# Capture pcap in background
|
||||
tshark -i "$IFACE" -f "$CAPTURE_FILTER" -w "$PCAP_FILE" -a "duration:$DURATION" 2>/dev/null &
|
||||
TSHARK_PID=$!
|
||||
|
||||
# Wait for capture to complete or Ctrl+C
|
||||
trap "kill $TSHARK_PID 2>/dev/null; wait $TSHARK_PID 2>/dev/null" INT TERM
|
||||
wait $TSHARK_PID 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "Capture complete. Analyzing..."
|
||||
echo ""
|
||||
|
||||
# ─── Analysis ───
|
||||
|
||||
{
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " TLS ClientHello Fingerprint Report"
|
||||
echo " Captured: $(date)"
|
||||
echo " PCAP: $PCAP_FILE"
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Extract JA3 fingerprints
|
||||
echo "─── JA3 Fingerprints (ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e frame.time \
|
||||
-e ip.dst \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ja3 \
|
||||
-e tls.handshake.ja3_full \
|
||||
2>/dev/null | while IFS=$'\t' read -r ts dst sni ja3 ja3_full; do
|
||||
echo " Time: $ts"
|
||||
echo " Dest IP: $dst"
|
||||
echo " SNI: $sni"
|
||||
echo " JA3 Hash: $ja3"
|
||||
if [ -n "$ja3_full" ]; then
|
||||
echo " JA3 Full: $ja3_full"
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── TLS Versions ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.version \
|
||||
-e tls.handshake.extensions.supported_version \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r sni ver supported; do
|
||||
echo " SNI: $sni"
|
||||
echo " Record Version: $ver"
|
||||
echo " Supported Versions: $supported"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── ALPN Protocols ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.extensions_alpn_str \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r sni alpn; do
|
||||
echo " SNI: $sni → ALPN: $alpn"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
echo "─── Cipher Suites (per ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ciphersuite \
|
||||
2>/dev/null | head -5 | while IFS=$'\t' read -r sni ciphers; do
|
||||
echo " SNI: $sni"
|
||||
echo " Cipher Suites:"
|
||||
echo " $ciphers" | tr ',' '\n' | while read -r c; do
|
||||
echo " $c"
|
||||
done
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── Extensions (per ClientHello) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.extension.type \
|
||||
2>/dev/null | head -5 | while IFS=$'\t' read -r sni exts; do
|
||||
echo " SNI: $sni"
|
||||
echo " Extensions: $exts"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── Unique JA3 Summary ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tls.handshake.type == 1" \
|
||||
-T fields \
|
||||
-e tls.handshake.extensions_server_name \
|
||||
-e tls.handshake.ja3 \
|
||||
2>/dev/null | sort | uniq -c | sort -rn | while read -r count sni ja3; do
|
||||
echo " ${count}x SNI: $sni JA3: $ja3"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "─── TCP Fingerprint (Initial Window Size, TTL) ───"
|
||||
echo ""
|
||||
tshark -r "$PCAP_FILE" \
|
||||
-Y "tcp.flags.syn == 1 && tcp.flags.ack == 0" \
|
||||
-T fields \
|
||||
-e ip.dst \
|
||||
-e ip.ttl \
|
||||
-e tcp.window_size_value \
|
||||
-e tcp.options.mss_val \
|
||||
-e tcp.options.wscale.shift \
|
||||
2>/dev/null | sort -u | while IFS=$'\t' read -r dst ttl win mss wscale; do
|
||||
echo " Dest: $dst TTL: $ttl Window: $win MSS: $mss WScale: $wscale"
|
||||
done
|
||||
|
||||
} 2>/dev/null | tee "$TXT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
echo " Report saved to: $TXT_FILE"
|
||||
echo " PCAP saved to: $PCAP_FILE"
|
||||
echo ""
|
||||
echo " To re-analyze: tshark -r $PCAP_FILE -Y 'tls.handshake.type==1' ..."
|
||||
echo "═══════════════════════════════════════════════════════"
|
||||
506
antigravity/capture/capture_traffic.py
Normal file
506
antigravity/capture/capture_traffic.py
Normal file
@ -0,0 +1,506 @@
|
||||
"""
|
||||
MiniGravity Traffic Capture - mitmproxy addon
|
||||
|
||||
Captures and categorizes traffic from Claude Code and Antigravity IDE.
|
||||
Records: headers (with ordering), body, TLS info, timing.
|
||||
|
||||
Usage:
|
||||
# Claude Code (terminal)
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 claude login
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 claude "hello"
|
||||
|
||||
# Antigravity (VS Code) - set proxy in VS Code settings or env
|
||||
HTTPS_PROXY=http://127.0.0.1:8080 code .
|
||||
|
||||
# Start mitmproxy with this addon
|
||||
mitmproxy -s capture_traffic.py --set stream_large_bodies=10m
|
||||
# or headless:
|
||||
mitmdump -s capture_traffic.py --set stream_large_bodies=10m
|
||||
|
||||
Output:
|
||||
./captures/ - JSON files per request
|
||||
./captures/_summary.jsonl - One-line-per-request summary
|
||||
./captures/_report.txt - Human-readable report (generated on exit)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from mitmproxy import http, ctx, tls
|
||||
from mitmproxy.net.http.http1.assemble import assemble_request_head
|
||||
|
||||
|
||||
# ─── Target domains and classification ───
|
||||
|
||||
TARGET_DOMAINS = {
|
||||
# Claude / Anthropic
|
||||
"claude.ai",
|
||||
"platform.claude.com",
|
||||
"api.anthropic.com",
|
||||
# Google / Antigravity
|
||||
"accounts.google.com",
|
||||
"oauth2.googleapis.com",
|
||||
"cloudaicompanion.googleapis.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
# Telemetry
|
||||
"http-intake.logs.us5.datadoghq.com",
|
||||
"sentry.io",
|
||||
}
|
||||
|
||||
|
||||
def classify_request(flow: http.HTTPFlow) -> dict:
|
||||
"""Classify a request by source tool and purpose."""
|
||||
host = flow.request.pretty_host
|
||||
path = flow.request.path
|
||||
method = flow.request.method
|
||||
ua = flow.request.headers.get("user-agent", "")
|
||||
|
||||
# Determine source tool
|
||||
source = "unknown"
|
||||
if "claude-cli" in ua or "claude-code" in ua:
|
||||
source = "claude-cli"
|
||||
elif "node" in ua.lower() and ("stainless" in str(flow.request.headers)):
|
||||
source = "claude-cli"
|
||||
elif "axios" in ua:
|
||||
source = "claude-cli-sdk"
|
||||
elif "vscode" in ua.lower() or "visual studio" in ua.lower():
|
||||
source = "vscode-extension"
|
||||
elif "electron" in ua.lower():
|
||||
source = "desktop-app"
|
||||
elif "chrome" in ua.lower() or "safari" in ua.lower() or "mozilla" in ua.lower():
|
||||
source = "browser"
|
||||
elif "node" in ua.lower():
|
||||
source = "node-generic"
|
||||
elif "python" in ua.lower():
|
||||
source = "python-client"
|
||||
elif "go-http" in ua.lower() or "go/" in ua.lower():
|
||||
source = "go-client"
|
||||
|
||||
# Determine request purpose
|
||||
purpose = "unknown"
|
||||
|
||||
# OAuth flows
|
||||
if "/oauth/authorize" in path:
|
||||
purpose = "oauth-authorize"
|
||||
elif "/oauth/token" in path or "/v1/oauth/token" in path:
|
||||
# Distinguish exchange vs refresh
|
||||
body = _get_request_body_str(flow)
|
||||
if "refresh_token" in body:
|
||||
purpose = "oauth-token-refresh"
|
||||
else:
|
||||
purpose = "oauth-token-exchange"
|
||||
elif "/o/oauth2" in path or "/oauth2/" in path:
|
||||
purpose = "google-oauth"
|
||||
|
||||
# API calls
|
||||
elif "/v1/messages" in path:
|
||||
purpose = "api-messages"
|
||||
elif "/v1/complete" in path:
|
||||
purpose = "api-complete"
|
||||
|
||||
# Organization / setup
|
||||
elif "/api/organizations" in path:
|
||||
purpose = "org-list"
|
||||
elif "/v1/oauth/" in path and "/authorize" in path:
|
||||
purpose = "oauth-authorize-api"
|
||||
|
||||
# Telemetry
|
||||
elif "/api/event_logging" in path:
|
||||
purpose = "telemetry-otel"
|
||||
elif "datadoghq.com" in host:
|
||||
purpose = "telemetry-datadog"
|
||||
elif "sentry" in host:
|
||||
purpose = "telemetry-sentry"
|
||||
|
||||
# Google AI
|
||||
elif "cloudaicompanion" in host:
|
||||
purpose = "antigravity-api"
|
||||
elif "generativelanguage" in host:
|
||||
purpose = "gemini-api"
|
||||
|
||||
return {
|
||||
"source": source,
|
||||
"purpose": purpose,
|
||||
}
|
||||
|
||||
|
||||
def _get_request_body_str(flow: http.HTTPFlow) -> str:
|
||||
"""Safely get request body as string."""
|
||||
try:
|
||||
if flow.request.content:
|
||||
return flow.request.content.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _get_response_body_str(flow: http.HTTPFlow, max_len: int = 4096) -> str:
|
||||
"""Safely get response body as string, truncated."""
|
||||
try:
|
||||
if flow.response and flow.response.content:
|
||||
body = flow.response.content.decode("utf-8", errors="replace")
|
||||
if len(body) > max_len:
|
||||
return body[:max_len] + f"\n... [truncated, total {len(body)} bytes]"
|
||||
return body
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_json_body(body_str: str) -> any:
|
||||
"""Try to parse body as JSON, return raw string if fails."""
|
||||
if not body_str:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body_str)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return body_str
|
||||
|
||||
|
||||
def _get_tls_info(flow: http.HTTPFlow) -> dict:
|
||||
"""Extract available TLS information from the flow."""
|
||||
info = {}
|
||||
if flow.server_conn and flow.server_conn.tls_version:
|
||||
info["tls_version"] = flow.server_conn.tls_version
|
||||
if flow.server_conn and hasattr(flow.server_conn, "alpn_proto_negotiated"):
|
||||
info["alpn"] = (
|
||||
flow.server_conn.alpn_proto_negotiated.decode()
|
||||
if flow.server_conn.alpn_proto_negotiated
|
||||
else None
|
||||
)
|
||||
|
||||
# Client TLS info (what the client sent to mitmproxy)
|
||||
if flow.client_conn:
|
||||
if hasattr(flow.client_conn, "tls_version") and flow.client_conn.tls_version:
|
||||
info["client_tls_version"] = flow.client_conn.tls_version
|
||||
if (
|
||||
hasattr(flow.client_conn, "alpn_proto_negotiated")
|
||||
and flow.client_conn.alpn_proto_negotiated
|
||||
):
|
||||
info["client_alpn"] = flow.client_conn.alpn_proto_negotiated.decode()
|
||||
# SNI
|
||||
if hasattr(flow.client_conn, "sni") and flow.client_conn.sni:
|
||||
info["client_sni"] = flow.client_conn.sni
|
||||
|
||||
return info
|
||||
|
||||
|
||||
class TrafficCapture:
|
||||
def __init__(self):
|
||||
self.capture_dir = Path("./captures")
|
||||
self.capture_dir.mkdir(exist_ok=True)
|
||||
self.summary_file = self.capture_dir / "_summary.jsonl"
|
||||
self.counter = 0
|
||||
self.captures = []
|
||||
|
||||
# Write session start marker
|
||||
session_start = {
|
||||
"event": "session_start",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"note": "New capture session started",
|
||||
}
|
||||
with open(self.summary_file, "a") as f:
|
||||
f.write(json.dumps(session_start) + "\n")
|
||||
|
||||
ctx.log.info(
|
||||
f"[capture] Traffic capture started. Output: {self.capture_dir.absolute()}"
|
||||
)
|
||||
|
||||
def request(self, flow: http.HTTPFlow):
|
||||
"""Tag requests to target domains."""
|
||||
host = flow.request.pretty_host
|
||||
is_target = any(host == d or host.endswith("." + d) for d in TARGET_DOMAINS)
|
||||
flow.metadata["is_target"] = is_target
|
||||
if is_target:
|
||||
flow.metadata["capture_time_start"] = time.time()
|
||||
|
||||
def response(self, flow: http.HTTPFlow):
|
||||
"""Capture complete request/response for target domains."""
|
||||
if not flow.metadata.get("is_target"):
|
||||
return
|
||||
|
||||
self.counter += 1
|
||||
classification = classify_request(flow)
|
||||
elapsed = None
|
||||
if flow.metadata.get("capture_time_start"):
|
||||
elapsed = round(time.time() - flow.metadata["capture_time_start"], 3)
|
||||
|
||||
# Build ordered header list (order matters for fingerprinting!)
|
||||
request_headers_ordered = [
|
||||
[k, v] for k, v in flow.request.headers.fields
|
||||
]
|
||||
request_headers_ordered_decoded = []
|
||||
for k, v in request_headers_ordered:
|
||||
try:
|
||||
request_headers_ordered_decoded.append(
|
||||
[k.decode("utf-8", errors="replace"),
|
||||
v.decode("utf-8", errors="replace")]
|
||||
)
|
||||
except AttributeError:
|
||||
request_headers_ordered_decoded.append([str(k), str(v)])
|
||||
|
||||
response_headers_ordered = []
|
||||
if flow.response:
|
||||
for k, v in flow.response.headers.fields:
|
||||
try:
|
||||
response_headers_ordered.append(
|
||||
[k.decode("utf-8", errors="replace"),
|
||||
v.decode("utf-8", errors="replace")]
|
||||
)
|
||||
except AttributeError:
|
||||
response_headers_ordered.append([str(k), str(v)])
|
||||
|
||||
req_body = _get_request_body_str(flow)
|
||||
resp_body = _get_response_body_str(flow)
|
||||
|
||||
# Redact sensitive values
|
||||
req_body_parsed = _parse_json_body(req_body)
|
||||
if isinstance(req_body_parsed, dict):
|
||||
req_body_parsed = _redact_sensitive(req_body_parsed)
|
||||
|
||||
resp_body_parsed = _parse_json_body(resp_body)
|
||||
if isinstance(resp_body_parsed, dict):
|
||||
resp_body_parsed = _redact_sensitive(resp_body_parsed)
|
||||
|
||||
record = {
|
||||
"id": self.counter,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"elapsed_sec": elapsed,
|
||||
|
||||
# Classification
|
||||
"source": classification["source"],
|
||||
"purpose": classification["purpose"],
|
||||
|
||||
# Request
|
||||
"request": {
|
||||
"method": flow.request.method,
|
||||
"url": flow.request.pretty_url,
|
||||
"host": flow.request.pretty_host,
|
||||
"path": flow.request.path,
|
||||
"http_version": flow.request.http_version,
|
||||
"headers_ordered": request_headers_ordered_decoded,
|
||||
"body": req_body_parsed,
|
||||
"content_length": len(flow.request.content) if flow.request.content else 0,
|
||||
},
|
||||
|
||||
# Response
|
||||
"response": {
|
||||
"status_code": flow.response.status_code if flow.response else None,
|
||||
"http_version": flow.response.http_version if flow.response else None,
|
||||
"headers_ordered": response_headers_ordered,
|
||||
"body": resp_body_parsed,
|
||||
"content_length": (
|
||||
len(flow.response.content)
|
||||
if flow.response and flow.response.content
|
||||
else 0
|
||||
),
|
||||
},
|
||||
|
||||
# TLS
|
||||
"tls": _get_tls_info(flow),
|
||||
|
||||
# Connection
|
||||
"connection": {
|
||||
"client_address": (
|
||||
f"{flow.client_conn.peername[0]}:{flow.client_conn.peername[1]}"
|
||||
if flow.client_conn.peername
|
||||
else None
|
||||
),
|
||||
"server_address": (
|
||||
f"{flow.server_conn.peername[0]}:{flow.server_conn.peername[1]}"
|
||||
if flow.server_conn and flow.server_conn.peername
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
self.captures.append(record)
|
||||
|
||||
# Save individual capture file
|
||||
filename = (
|
||||
f"{self.counter:04d}_{classification['source']}"
|
||||
f"_{classification['purpose']}"
|
||||
f"_{flow.request.pretty_host}.json"
|
||||
)
|
||||
filepath = self.capture_dir / filename
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(record, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
# Append to summary
|
||||
summary_line = {
|
||||
"id": self.counter,
|
||||
"ts": datetime.now(timezone.utc).strftime("%H:%M:%S"),
|
||||
"source": classification["source"],
|
||||
"purpose": classification["purpose"],
|
||||
"method": flow.request.method,
|
||||
"url": flow.request.pretty_url[:120],
|
||||
"status": flow.response.status_code if flow.response else None,
|
||||
"ua": flow.request.headers.get("user-agent", "")[:80],
|
||||
"elapsed": elapsed,
|
||||
}
|
||||
with open(self.summary_file, "a") as f:
|
||||
f.write(json.dumps(summary_line) + "\n")
|
||||
|
||||
# Console output
|
||||
status = flow.response.status_code if flow.response else "???"
|
||||
ctx.log.info(
|
||||
f"[capture #{self.counter}] "
|
||||
f"[{classification['source']}] "
|
||||
f"[{classification['purpose']}] "
|
||||
f"{flow.request.method} {flow.request.pretty_url[:80]} "
|
||||
f"→ {status} "
|
||||
f"({elapsed}s)"
|
||||
)
|
||||
|
||||
# Highlight important findings
|
||||
ua = flow.request.headers.get("user-agent", "")
|
||||
if classification["purpose"] in (
|
||||
"oauth-token-exchange",
|
||||
"oauth-token-refresh",
|
||||
):
|
||||
ctx.log.warn(
|
||||
f"[capture] TOKEN EXCHANGE/REFRESH detected!\n"
|
||||
f" UA: {ua}\n"
|
||||
f" Headers: {[h[0] for h in request_headers_ordered_decoded]}"
|
||||
)
|
||||
|
||||
def done(self):
|
||||
"""Generate report on exit."""
|
||||
if not self.captures:
|
||||
ctx.log.info("[capture] No captures recorded.")
|
||||
return
|
||||
|
||||
report_path = self.capture_dir / "_report.txt"
|
||||
with open(report_path, "w") as f:
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write(" MiniGravity Traffic Capture Report\n")
|
||||
f.write(f" Generated: {datetime.now().isoformat()}\n")
|
||||
f.write(f" Total requests captured: {len(self.captures)}\n")
|
||||
f.write("=" * 80 + "\n\n")
|
||||
|
||||
# Group by source
|
||||
by_source = {}
|
||||
for cap in self.captures:
|
||||
src = cap["source"]
|
||||
if src not in by_source:
|
||||
by_source[src] = []
|
||||
by_source[src].append(cap)
|
||||
|
||||
for source, caps in sorted(by_source.items()):
|
||||
f.write(f"\n{'─' * 60}\n")
|
||||
f.write(f" Source: {source} ({len(caps)} requests)\n")
|
||||
f.write(f"{'─' * 60}\n\n")
|
||||
|
||||
# Group by purpose within source
|
||||
by_purpose = {}
|
||||
for cap in caps:
|
||||
p = cap["purpose"]
|
||||
if p not in by_purpose:
|
||||
by_purpose[p] = []
|
||||
by_purpose[p].append(cap)
|
||||
|
||||
for purpose, pcaps in sorted(by_purpose.items()):
|
||||
f.write(f" [{purpose}] ({len(pcaps)} requests)\n\n")
|
||||
|
||||
for cap in pcaps:
|
||||
req = cap["request"]
|
||||
f.write(f" #{cap['id']} {req['method']} {req['url'][:100]}\n")
|
||||
f.write(f" HTTP Version: {req['http_version']}\n")
|
||||
|
||||
f.write(" Request Headers (ordered):\n")
|
||||
for hdr in req["headers_ordered"]:
|
||||
val = hdr[1]
|
||||
# Truncate long values
|
||||
if len(val) > 100:
|
||||
val = val[:100] + "..."
|
||||
f.write(f" {hdr[0]}: {val}\n")
|
||||
|
||||
if req["body"]:
|
||||
body_str = json.dumps(
|
||||
req["body"], indent=6, ensure_ascii=False, default=str
|
||||
)
|
||||
if len(body_str) > 500:
|
||||
body_str = body_str[:500] + "\n ..."
|
||||
f.write(f" Request Body:\n {body_str}\n")
|
||||
|
||||
resp = cap["response"]
|
||||
f.write(f" Response: {resp['status_code']}\n")
|
||||
|
||||
if cap["tls"]:
|
||||
f.write(f" TLS: {json.dumps(cap['tls'])}\n")
|
||||
|
||||
f.write("\n")
|
||||
|
||||
# Comparison section
|
||||
f.write(f"\n{'=' * 80}\n")
|
||||
f.write(" FINGERPRINT COMPARISON\n")
|
||||
f.write(f"{'=' * 80}\n\n")
|
||||
|
||||
# Collect unique UA per source+purpose
|
||||
ua_map = {}
|
||||
for cap in self.captures:
|
||||
key = f"{cap['source']}:{cap['purpose']}"
|
||||
ua = dict(cap["request"]["headers_ordered"]).get("user-agent", "N/A")
|
||||
if key not in ua_map:
|
||||
ua_map[key] = set()
|
||||
ua_map[key].add(ua)
|
||||
|
||||
f.write(" User-Agent by source:purpose\n")
|
||||
for key, uas in sorted(ua_map.items()):
|
||||
for ua in uas:
|
||||
f.write(f" {key:40s} → {ua}\n")
|
||||
|
||||
# Collect header sets per source+purpose
|
||||
f.write("\n Header names by source:purpose\n")
|
||||
header_map = {}
|
||||
for cap in self.captures:
|
||||
key = f"{cap['source']}:{cap['purpose']}"
|
||||
hdrs = tuple(h[0].lower() for h in cap["request"]["headers_ordered"])
|
||||
if key not in header_map:
|
||||
header_map[key] = set()
|
||||
header_map[key].add(hdrs)
|
||||
|
||||
for key, hdr_sets in sorted(header_map.items()):
|
||||
for hdrs in hdr_sets:
|
||||
f.write(f" {key}:\n")
|
||||
for h in hdrs:
|
||||
f.write(f" - {h}\n")
|
||||
f.write("\n")
|
||||
|
||||
ctx.log.info(
|
||||
f"[capture] Report written to {report_path.absolute()}\n"
|
||||
f"[capture] {len(self.captures)} requests captured in {self.capture_dir.absolute()}"
|
||||
)
|
||||
|
||||
|
||||
def _redact_sensitive(d: dict) -> dict:
|
||||
"""Redact sensitive values in a dict, preserving structure."""
|
||||
sensitive_keys = {
|
||||
"access_token", "refresh_token", "code", "code_verifier",
|
||||
"session_key", "sessionKey", "password", "secret",
|
||||
"authorization", "cookie",
|
||||
}
|
||||
result = {}
|
||||
for k, v in d.items():
|
||||
if k.lower() in {s.lower() for s in sensitive_keys}:
|
||||
if isinstance(v, str) and len(v) > 8:
|
||||
result[k] = v[:4] + "****" + v[-4:]
|
||||
else:
|
||||
result[k] = "****"
|
||||
elif isinstance(v, dict):
|
||||
result[k] = _redact_sensitive(v)
|
||||
elif isinstance(v, list):
|
||||
result[k] = [
|
||||
_redact_sensitive(item) if isinstance(item, dict) else item
|
||||
for item in v
|
||||
]
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
addons = [TrafficCapture()]
|
||||
@ -0,0 +1,129 @@
|
||||
{
|
||||
"id": 1,
|
||||
"timestamp": "2026-03-26T16:28:57.647791+00:00",
|
||||
"elapsed_sec": 0.322,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"host": "downloads.claude.ai",
|
||||
"path": "/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"downloads.claude.ai"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"x-guploader-uploadid",
|
||||
"AMNfjG29CnIrYUAyZBJSnylKbYWnv3VH6x45qXwHunjwYiMbCueqWoZ3CouUPbV2VjfNtKXGrIpIQNI"
|
||||
],
|
||||
[
|
||||
"x-goog-generation",
|
||||
"1774486030779283"
|
||||
],
|
||||
[
|
||||
"x-goog-metageneration",
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-encoding",
|
||||
"identity"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"crc32c=/q0yrA=="
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"md5=tRgumXLHnEzHzEWYd8YEyg=="
|
||||
],
|
||||
[
|
||||
"x-goog-storage-class",
|
||||
"STANDARD"
|
||||
],
|
||||
[
|
||||
"accept-ranges",
|
||||
"bytes"
|
||||
],
|
||||
[
|
||||
"Content-Length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"UploadServer"
|
||||
],
|
||||
[
|
||||
"via",
|
||||
"1.1 google"
|
||||
],
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:28:57 GMT"
|
||||
],
|
||||
[
|
||||
"Age",
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Last-Modified",
|
||||
"Thu, 26 Mar 2026 16:17:10 GMT"
|
||||
],
|
||||
[
|
||||
"ETag",
|
||||
"\"b5182e9972c79c4cc7cc459877c604ca\""
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"text/plain"
|
||||
],
|
||||
[
|
||||
"Cache-Control",
|
||||
"public,no-cache,max-age=300"
|
||||
],
|
||||
[
|
||||
"Alt-Svc",
|
||||
"h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000"
|
||||
]
|
||||
],
|
||||
"body": "b10b583de281385442474e836644534b938b2678",
|
||||
"content_length": 40
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "downloads.claude.ai"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:55671",
|
||||
"server_address": "198.18.0.44:443"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
{
|
||||
"id": 2,
|
||||
"timestamp": "2026-03-26T16:28:57.668166+00:00",
|
||||
"elapsed_sec": 0.481,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"host": "api.anthropic.com",
|
||||
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"api.anthropic.com"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:28:57 GMT"
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"application/json"
|
||||
],
|
||||
[
|
||||
"Transfer-Encoding",
|
||||
"chunked"
|
||||
],
|
||||
[
|
||||
"Connection",
|
||||
"keep-alive"
|
||||
],
|
||||
[
|
||||
"x-request-id",
|
||||
"a26ee618-f205-4a23-87b1-6225e17b92ef"
|
||||
],
|
||||
[
|
||||
"access-control-allow-origin",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"access-control-allow-methods",
|
||||
"GET, OPTIONS"
|
||||
],
|
||||
[
|
||||
"access-control-allow-headers",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"x-envoy-upstream-service-time",
|
||||
"9"
|
||||
],
|
||||
[
|
||||
"Content-Encoding",
|
||||
"gzip"
|
||||
],
|
||||
[
|
||||
"vary",
|
||||
"Accept-Encoding"
|
||||
],
|
||||
[
|
||||
"Server",
|
||||
"cloudflare"
|
||||
],
|
||||
[
|
||||
"server-timing",
|
||||
"x-originResponse;dur=11"
|
||||
],
|
||||
[
|
||||
"cf-cache-status",
|
||||
"DYNAMIC"
|
||||
],
|
||||
[
|
||||
"set-cookie",
|
||||
"_cfuvid=XtplK6T__J5GJ7ZHZ75.1K.blAKEiURZzIRFOxbjm0U-1774542537.272683-1.0.1.1-Folg8_rQ2RrBi0Img0NdFQUYWxTawBjeo7zj11dFizU; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
[
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
[
|
||||
"X-Robots-Tag",
|
||||
"none"
|
||||
],
|
||||
[
|
||||
"CF-RAY",
|
||||
"9e278809ffffe371-NRT"
|
||||
]
|
||||
],
|
||||
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search & Summarize: <br>\\\"Summarize my Q2 product strategy doc\\\"</p><p id=\\\"\\\">Create New Designs from Conversation:<br>\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"</p><p id=\\\"\\\">Autofill Charts:<br>\\\"Add a chart showing monthly signups in NZ for Q1\\\"</p><p id=\\\"\\\">Autofill Brand Templates:<br>\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"</p><p id=\\\"\\\">Import Files via Link:<br>\\\"Import this PDF [insert URL] into Canva\\\"</p><p id=\\\"\\\">Resize or Export:<br>\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"</p>\",\n \"imageUrls\": [\n {\n \"prompt\": \"Generate a sales report presentation with outline \",\n \"imageUrl\": \"https://storage.goo\n... [truncated, total 185385 bytes]",
|
||||
"content_length": 185487
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "api.anthropic.com"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:55668",
|
||||
"server_address": "198.18.0.32:443"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
{
|
||||
"id": 3,
|
||||
"timestamp": "2026-03-26T16:30:00.064058+00:00",
|
||||
"elapsed_sec": 0.731,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"host": "api.anthropic.com",
|
||||
"path": "/mcp-registry/v0/servers?version=latest&visibility=commercial",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"api.anthropic.com"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:29:59 GMT"
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"application/json"
|
||||
],
|
||||
[
|
||||
"Transfer-Encoding",
|
||||
"chunked"
|
||||
],
|
||||
[
|
||||
"Connection",
|
||||
"keep-alive"
|
||||
],
|
||||
[
|
||||
"x-request-id",
|
||||
"ddcd43e3-8799-43b9-9d49-8dcc6a0b90dd"
|
||||
],
|
||||
[
|
||||
"access-control-allow-origin",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"access-control-allow-methods",
|
||||
"GET, OPTIONS"
|
||||
],
|
||||
[
|
||||
"access-control-allow-headers",
|
||||
"*"
|
||||
],
|
||||
[
|
||||
"x-envoy-upstream-service-time",
|
||||
"8"
|
||||
],
|
||||
[
|
||||
"Content-Encoding",
|
||||
"gzip"
|
||||
],
|
||||
[
|
||||
"vary",
|
||||
"Accept-Encoding"
|
||||
],
|
||||
[
|
||||
"Server",
|
||||
"cloudflare"
|
||||
],
|
||||
[
|
||||
"server-timing",
|
||||
"x-originResponse;dur=10"
|
||||
],
|
||||
[
|
||||
"cf-cache-status",
|
||||
"DYNAMIC"
|
||||
],
|
||||
[
|
||||
"set-cookie",
|
||||
"_cfuvid=DUvPIDhglzXAjPSEJhKi0nemis9e5knKw1jmUxq8LnE-1774542599.5569572-1.0.1.1-5oD..eF758shBNx1g_VrkNhd2HcST2hu4QKN5ciERz4; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
[
|
||||
"Content-Security-Policy",
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
[
|
||||
"X-Robots-Tag",
|
||||
"none"
|
||||
],
|
||||
[
|
||||
"CF-RAY",
|
||||
"9e27898f3c1eefbb-NRT"
|
||||
]
|
||||
],
|
||||
"body": "{\n \"servers\": [\n {\n \"server\": {\n \"name\": \"com.canva.mcp/canva\",\n \"version\": \"1.0.0\",\n \"description\": \"Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your AI workflow—an AI-powered design agent that helps you create polished visuals faster, with less friction.\",\n \"title\": \"Canva\",\n \"remotes\": [\n {\n \"type\": \"streamable-http\",\n \"url\": \"https://mcp.canva.com/mcp\"\n }\n ]\n },\n \"_meta\": {\n \"com.anthropic.api/mcp-registry\": {\n \"uuid\": \"eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"type\": \"remote\",\n \"toolNames\": [\n \"search-designs\",\n \"get-design\",\n \"get-design-pages\",\n \"get-design-content\",\n \"search\",\n \"fetch\",\n \"import-design-from-url\",\n \"get-design-import-from-url-status\",\n \"export-design\",\n \"get-export-formats\",\n \"get-design-export-status\",\n \"create-folder\",\n \"move-item-to-folder\",\n \"list-folder-items\",\n \"add-comment-thread-to-design\",\n \"generate-design\",\n \"get-design-generation-job\"\n ],\n \"promptNames\": [],\n \"isAuthless\": false,\n \"displayName\": \"Canva\",\n \"oneLiner\": \"Search, create, autofill, and export Canva designs\",\n \"iconUrl\": \"https://mcp.canva.com/mcp\",\n \"documentation\": \"https://www.canva.dev/docs/connect/canva-mcp-server-setup/\",\n \"support\": \"https://www.canva.com/en_au/help/\",\n \"privacyPolicy\": \"https://www.canva.com/policies/privacy-policy/\",\n \"url\": \"https://mcp.canva.com/mcp\",\n \"author\": {\n \"name\": \"Canva\",\n \"url\": \"https://canva.com\"\n },\n \"slug\": \"canva\",\n \"directoryUrl\": \"http://claude.ai/directory/eb9240f2-e1c1-43c1-828f-0fda40c22e4c\",\n \"claudeCodeCopyText\": \"claude mcp add --transport http canva https://mcp.canva.com/mcp\",\n \"permissions\": \"Read and write\",\n \"useCases\": [\n \"design\"\n ],\n \"worksWith\": [\n \"claude\",\n \"claude-api\",\n \"claude-code\"\n ],\n \"publishedOn\": \"Fri Jan 16 2026 19:33:48 GMT+0000 (Coordinated Universal Time)\",\n \"createdOn\": \"Thu Aug 28 2025 14:02:54 GMT+0000 (Coordinated Universal Time)\",\n \"updatedOn\": \"Fri Jan 16 2026 19:33:28 GMT+0000 (Coordinated Universal Time)\",\n \"logo\": \"canva\",\n \"backgroundPattern\": \"Line 1\",\n \"heroVideoId\": \"wXC2u36w2Rc\",\n \"heroVideoPreviewLink\": \"https://cdn.sanity.io/files/4zrzovbb/website/4925fcd732bab964631e2678e413dfa2a549a2a9.mp4\",\n \"serverLabel\": \"mcp.canva.com\",\n \"itemId\": \"68b0618e3f55cc591b6b22a2\",\n \"collectionId\": \"68b05e2de975b4de7dd02d9d\",\n \"localeId\": \"68a44d4040f98a4adf2207b5\",\n \"htmlContent\": \"<p id=\\\"\\\">Browse, summarize, autofill, and even generate new Canva designs directly from Claude. Make Canva a native part of your workflow—helping you create polished visuals faster, with less friction.</p><p id=\\\"\\\">You can use the Canva connector to: <br><br>Browse, Search & Summarize: <br>\\\"Summarize my Q2 product strategy doc\\\"</p><p id=\\\"\\\">Create New Designs from Conversation:<br>\\\"Generate a pitch deck for our AI launch with 5 slides and a bold tone\\\"</p><p id=\\\"\\\">Autofill Charts:<br>\\\"Add a chart showing monthly signups in NZ for Q1\\\"</p><p id=\\\"\\\">Autofill Brand Templates:<br>\\\"Populate our branded template with content for a product launch presentation, 8 slides, professional tone\\\"</p><p id=\\\"\\\">Import Files via Link:<br>\\\"Import this PDF [insert URL] into Canva\\\"</p><p id=\\\"\\\">Resize or Export:<br>\\\"Resize my Instagram post for LinkedIn and export as a PNG\\\"</p>\",\n \"imageUrls\": [\n {\n \"prompt\": \"Generate a sales report presentation with outline \",\n \"imageUrl\": \"https://storage.goo\n... [truncated, total 185385 bytes]",
|
||||
"content_length": 185487
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "api.anthropic.com"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:55998",
|
||||
"server_address": "198.18.0.32:443"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
{
|
||||
"id": 4,
|
||||
"timestamp": "2026-03-26T16:30:00.153789+00:00",
|
||||
"elapsed_sec": 0.833,
|
||||
"source": "claude-cli-sdk",
|
||||
"purpose": "unknown",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"host": "downloads.claude.ai",
|
||||
"path": "/claude-code-releases/plugins/claude-plugins-official/latest",
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"Accept",
|
||||
"application/json, text/plain, */*"
|
||||
],
|
||||
[
|
||||
"Accept-Encoding",
|
||||
"gzip, compress, deflate, br"
|
||||
],
|
||||
[
|
||||
"User-Agent",
|
||||
"axios/1.13.6"
|
||||
],
|
||||
[
|
||||
"Host",
|
||||
"downloads.claude.ai"
|
||||
]
|
||||
],
|
||||
"body": null,
|
||||
"content_length": 0
|
||||
},
|
||||
"response": {
|
||||
"status_code": 200,
|
||||
"http_version": "HTTP/1.1",
|
||||
"headers_ordered": [
|
||||
[
|
||||
"x-guploader-uploadid",
|
||||
"AMNfjG37C4G0lUKtWOt8YyD-JuE6Y6MUhtxm8P77pzlb0lJzsdb6sG8xLwNpaolt4FWHSJVblZLmjWM"
|
||||
],
|
||||
[
|
||||
"x-goog-generation",
|
||||
"1774486030779283"
|
||||
],
|
||||
[
|
||||
"x-goog-metageneration",
|
||||
"1"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-encoding",
|
||||
"identity"
|
||||
],
|
||||
[
|
||||
"x-goog-stored-content-length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"crc32c=/q0yrA=="
|
||||
],
|
||||
[
|
||||
"x-goog-hash",
|
||||
"md5=tRgumXLHnEzHzEWYd8YEyg=="
|
||||
],
|
||||
[
|
||||
"x-goog-storage-class",
|
||||
"STANDARD"
|
||||
],
|
||||
[
|
||||
"accept-ranges",
|
||||
"bytes"
|
||||
],
|
||||
[
|
||||
"Content-Length",
|
||||
"40"
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"UploadServer"
|
||||
],
|
||||
[
|
||||
"via",
|
||||
"1.1 google"
|
||||
],
|
||||
[
|
||||
"Date",
|
||||
"Thu, 26 Mar 2026 16:29:59 GMT"
|
||||
],
|
||||
[
|
||||
"Age",
|
||||
"0"
|
||||
],
|
||||
[
|
||||
"Last-Modified",
|
||||
"Thu, 26 Mar 2026 16:17:10 GMT"
|
||||
],
|
||||
[
|
||||
"ETag",
|
||||
"\"b5182e9972c79c4cc7cc459877c604ca\""
|
||||
],
|
||||
[
|
||||
"Content-Type",
|
||||
"text/plain"
|
||||
],
|
||||
[
|
||||
"Cache-Control",
|
||||
"public,no-cache,max-age=300"
|
||||
],
|
||||
[
|
||||
"Alt-Svc",
|
||||
"h3=\":443\"; ma=2592000"
|
||||
]
|
||||
],
|
||||
"body": "b10b583de281385442474e836644534b938b2678",
|
||||
"content_length": 40
|
||||
},
|
||||
"tls": {
|
||||
"tls_version": "TLSv1.3",
|
||||
"alpn": "http/1.1",
|
||||
"client_tls_version": "TLSv1.3",
|
||||
"client_alpn": "http/1.1",
|
||||
"client_sni": "downloads.claude.ai"
|
||||
},
|
||||
"connection": {
|
||||
"client_address": "127.0.0.1:56003",
|
||||
"server_address": "198.18.0.44:443"
|
||||
}
|
||||
}
|
||||
68
antigravity/capture/captures/_report.txt
Normal file
68
antigravity/capture/captures/_report.txt
Normal file
@ -0,0 +1,68 @@
|
||||
================================================================================
|
||||
MiniGravity Traffic Capture Report
|
||||
Generated: 2026-03-27T00:50:12.040880
|
||||
Total requests captured: 4
|
||||
================================================================================
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
Source: claude-cli-sdk (4 requests)
|
||||
────────────────────────────────────────────────────────────
|
||||
|
||||
[unknown] (4 requests)
|
||||
|
||||
#1 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: downloads.claude.ai
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
|
||||
|
||||
#2 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: api.anthropic.com
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
|
||||
|
||||
#3 GET https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: api.anthropic.com
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "api.anthropic.com"}
|
||||
|
||||
#4 GET https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest
|
||||
HTTP Version: HTTP/1.1
|
||||
Request Headers (ordered):
|
||||
Accept: application/json, text/plain, */*
|
||||
Accept-Encoding: gzip, compress, deflate, br
|
||||
User-Agent: axios/1.13.6
|
||||
Host: downloads.claude.ai
|
||||
Response: 200
|
||||
TLS: {"tls_version": "TLSv1.3", "alpn": "http/1.1", "client_tls_version": "TLSv1.3", "client_alpn": "http/1.1", "client_sni": "downloads.claude.ai"}
|
||||
|
||||
|
||||
================================================================================
|
||||
FINGERPRINT COMPARISON
|
||||
================================================================================
|
||||
|
||||
User-Agent by source:purpose
|
||||
claude-cli-sdk:unknown → N/A
|
||||
|
||||
Header names by source:purpose
|
||||
claude-cli-sdk:unknown:
|
||||
- accept
|
||||
- accept-encoding
|
||||
- user-agent
|
||||
- host
|
||||
|
||||
5
antigravity/capture/captures/_summary.jsonl
Normal file
5
antigravity/capture/captures/_summary.jsonl
Normal file
@ -0,0 +1,5 @@
|
||||
{"event": "session_start", "timestamp": "2026-03-26T16:28:39.558811+00:00", "note": "New capture session started"}
|
||||
{"id": 1, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.322}
|
||||
{"id": 2, "ts": "16:28:57", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.481}
|
||||
{"id": 3, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://api.anthropic.com/mcp-registry/v0/servers?version=latest&visibility=commercial", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.731}
|
||||
{"id": 4, "ts": "16:30:00", "source": "claude-cli-sdk", "purpose": "unknown", "method": "GET", "url": "https://downloads.claude.ai/claude-code-releases/plugins/claude-plugins-official/latest", "status": 200, "ua": "axios/1.13.6", "elapsed": 0.833}
|
||||
240
antigravity/capture/ja3_extract.py
Normal file
240
antigravity/capture/ja3_extract.py
Normal file
@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract JA3 fingerprint from pcap file (no tshark needed).
|
||||
Parses TLS ClientHello directly from raw packets.
|
||||
"""
|
||||
import struct
|
||||
import hashlib
|
||||
import sys
|
||||
|
||||
def parse_pcap(filepath):
|
||||
"""Parse pcap file and extract TLS ClientHello JA3 fingerprints."""
|
||||
results = []
|
||||
with open(filepath, 'rb') as f:
|
||||
# Read pcap global header
|
||||
magic = struct.unpack('<I', f.read(4))[0]
|
||||
if magic == 0xa1b2c3d4:
|
||||
endian = '<'
|
||||
elif magic == 0xd4c3b2a1:
|
||||
endian = '>'
|
||||
else:
|
||||
print(f"Not a pcap file (magic: {hex(magic)})")
|
||||
return results
|
||||
|
||||
f.read(20) # rest of global header
|
||||
|
||||
packet_num = 0
|
||||
while True:
|
||||
# Read packet header
|
||||
pkt_hdr = f.read(16)
|
||||
if len(pkt_hdr) < 16:
|
||||
break
|
||||
ts_sec, ts_usec, incl_len, orig_len = struct.unpack(f'{endian}IIII', pkt_hdr)
|
||||
pkt_data = f.read(incl_len)
|
||||
if len(pkt_data) < incl_len:
|
||||
break
|
||||
packet_num += 1
|
||||
|
||||
# Parse Ethernet header (14 bytes)
|
||||
if len(pkt_data) < 14:
|
||||
continue
|
||||
eth_type = struct.unpack('!H', pkt_data[12:14])[0]
|
||||
if eth_type != 0x0800: # IPv4
|
||||
continue
|
||||
|
||||
# Parse IP header
|
||||
ip_start = 14
|
||||
if len(pkt_data) < ip_start + 20:
|
||||
continue
|
||||
ip_ver_ihl = pkt_data[ip_start]
|
||||
ip_ihl = (ip_ver_ihl & 0x0F) * 4
|
||||
ip_proto = pkt_data[ip_start + 9]
|
||||
dst_ip = '.'.join(str(b) for b in pkt_data[ip_start+16:ip_start+20])
|
||||
|
||||
if ip_proto != 6: # TCP
|
||||
continue
|
||||
|
||||
# Parse TCP header
|
||||
tcp_start = ip_start + ip_ihl
|
||||
if len(pkt_data) < tcp_start + 20:
|
||||
continue
|
||||
dst_port = struct.unpack('!H', pkt_data[tcp_start+2:tcp_start+4])[0]
|
||||
tcp_data_offset = ((pkt_data[tcp_start + 12] >> 4) & 0xF) * 4
|
||||
|
||||
# TLS record starts after TCP header
|
||||
tls_start = tcp_start + tcp_data_offset
|
||||
if len(pkt_data) < tls_start + 6:
|
||||
continue
|
||||
|
||||
# Check for TLS Handshake (content type 22)
|
||||
if pkt_data[tls_start] != 22:
|
||||
continue
|
||||
|
||||
tls_version = struct.unpack('!H', pkt_data[tls_start+1:tls_start+3])[0]
|
||||
tls_length = struct.unpack('!H', pkt_data[tls_start+3:tls_start+5])[0]
|
||||
|
||||
# Check for ClientHello (handshake type 1)
|
||||
hs_start = tls_start + 5
|
||||
if len(pkt_data) < hs_start + 4:
|
||||
continue
|
||||
if pkt_data[hs_start] != 1: # ClientHello
|
||||
continue
|
||||
|
||||
# Parse ClientHello
|
||||
try:
|
||||
ja3 = extract_ja3(pkt_data, hs_start, dst_ip, dst_port)
|
||||
if ja3:
|
||||
results.append(ja3)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return results
|
||||
|
||||
def extract_ja3(data, hs_start, dst_ip, dst_port):
|
||||
"""Extract JA3 components from ClientHello."""
|
||||
# Handshake header: type(1) + length(3)
|
||||
pos = hs_start + 4
|
||||
|
||||
# ClientHello: version(2) + random(32)
|
||||
if len(data) < pos + 34:
|
||||
return None
|
||||
ch_version = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 34 # skip version + random
|
||||
|
||||
# Session ID
|
||||
if len(data) < pos + 1:
|
||||
return None
|
||||
session_id_len = data[pos]
|
||||
pos += 1 + session_id_len
|
||||
|
||||
# Cipher Suites
|
||||
if len(data) < pos + 2:
|
||||
return None
|
||||
cs_len = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 2
|
||||
if len(data) < pos + cs_len:
|
||||
return None
|
||||
|
||||
cipher_suites = []
|
||||
for i in range(0, cs_len, 2):
|
||||
cs = struct.unpack('!H', data[pos+i:pos+i+2])[0]
|
||||
# Skip GREASE values
|
||||
if (cs & 0x0f0f) == 0x0a0a:
|
||||
continue
|
||||
cipher_suites.append(str(cs))
|
||||
pos += cs_len
|
||||
|
||||
# Compression methods
|
||||
if len(data) < pos + 1:
|
||||
return None
|
||||
comp_len = data[pos]
|
||||
pos += 1 + comp_len
|
||||
|
||||
# Extensions
|
||||
extensions = []
|
||||
elliptic_curves = []
|
||||
ec_point_formats = []
|
||||
supported_versions = []
|
||||
sni = ""
|
||||
|
||||
if len(data) > pos + 2:
|
||||
ext_total_len = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
pos += 2
|
||||
ext_end = pos + ext_total_len
|
||||
|
||||
while pos + 4 <= ext_end and pos + 4 <= len(data):
|
||||
ext_type = struct.unpack('!H', data[pos:pos+2])[0]
|
||||
ext_len = struct.unpack('!H', data[pos+2:pos+4])[0]
|
||||
ext_data_start = pos + 4
|
||||
|
||||
# Skip GREASE
|
||||
if (ext_type & 0x0f0f) == 0x0a0a:
|
||||
pos = ext_data_start + ext_len
|
||||
continue
|
||||
|
||||
extensions.append(str(ext_type))
|
||||
|
||||
# SNI (type 0)
|
||||
if ext_type == 0 and ext_len > 5:
|
||||
try:
|
||||
name_len = struct.unpack('!H', data[ext_data_start+3:ext_data_start+5])[0]
|
||||
sni = data[ext_data_start+5:ext_data_start+5+name_len].decode('ascii', errors='replace')
|
||||
except:
|
||||
pass
|
||||
|
||||
# Supported Groups / Elliptic Curves (type 10)
|
||||
if ext_type == 10 and ext_len >= 2:
|
||||
curves_len = struct.unpack('!H', data[ext_data_start:ext_data_start+2])[0]
|
||||
for i in range(0, curves_len, 2):
|
||||
if ext_data_start + 2 + i + 2 <= len(data):
|
||||
curve = struct.unpack('!H', data[ext_data_start+2+i:ext_data_start+2+i+2])[0]
|
||||
if (curve & 0x0f0f) != 0x0a0a:
|
||||
elliptic_curves.append(str(curve))
|
||||
|
||||
# EC Point Formats (type 11)
|
||||
if ext_type == 11 and ext_len >= 1:
|
||||
fmt_len = data[ext_data_start]
|
||||
for i in range(fmt_len):
|
||||
if ext_data_start + 1 + i < len(data):
|
||||
ec_point_formats.append(str(data[ext_data_start+1+i]))
|
||||
|
||||
# Supported Versions (type 43)
|
||||
if ext_type == 43 and ext_len >= 1:
|
||||
sv_len = data[ext_data_start]
|
||||
for i in range(0, sv_len, 2):
|
||||
if ext_data_start + 1 + i + 2 <= len(data):
|
||||
ver = struct.unpack('!H', data[ext_data_start+1+i:ext_data_start+1+i+2])[0]
|
||||
if (ver & 0x0f0f) != 0x0a0a:
|
||||
supported_versions.append(hex(ver))
|
||||
|
||||
pos = ext_data_start + ext_len
|
||||
|
||||
# Build JA3 string: TLSVersion,Ciphers,Extensions,EllipticCurves,ECPointFormats
|
||||
ja3_str = ','.join([
|
||||
str(ch_version),
|
||||
'-'.join(cipher_suites),
|
||||
'-'.join(extensions),
|
||||
'-'.join(elliptic_curves),
|
||||
'-'.join(ec_point_formats),
|
||||
])
|
||||
ja3_hash = hashlib.md5(ja3_str.encode()).hexdigest()
|
||||
|
||||
return {
|
||||
'dst_ip': dst_ip,
|
||||
'dst_port': dst_port,
|
||||
'sni': sni,
|
||||
'ja3_hash': ja3_hash,
|
||||
'ja3_string': ja3_str,
|
||||
'tls_version': hex(ch_version),
|
||||
'cipher_count': len(cipher_suites),
|
||||
'extension_count': len(extensions),
|
||||
'supported_versions': supported_versions,
|
||||
'ciphers': cipher_suites[:10], # first 10 for display
|
||||
}
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 ja3_extract.py <pcap_file>")
|
||||
sys.exit(1)
|
||||
|
||||
results = parse_pcap(sys.argv[1])
|
||||
if not results:
|
||||
print("No TLS ClientHello found in pcap")
|
||||
sys.exit(0)
|
||||
|
||||
# Deduplicate by JA3 hash + SNI
|
||||
seen = set()
|
||||
for r in results:
|
||||
key = f"{r['ja3_hash']}:{r['sni']}"
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
print(f"SNI: {r['sni']}")
|
||||
print(f"Dest: {r['dst_ip']}:{r['dst_port']}")
|
||||
print(f"JA3 Hash: {r['ja3_hash']}")
|
||||
print(f"TLS Ver: {r['tls_version']}")
|
||||
print(f"Ciphers: {r['cipher_count']} suites (first 10: {r['ciphers']})")
|
||||
print(f"Extensions: {r['extension_count']}")
|
||||
print(f"Sup. Vers: {r['supported_versions']}")
|
||||
print(f"JA3 Full: {r['ja3_string'][:200]}...")
|
||||
print()
|
||||
99
antigravity/capture/run.sh
Executable file
99
antigravity/capture/run.sh
Executable file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# run.sh - One-command capture for Claude Code / Antigravity
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh # Start both mitmproxy + tshark
|
||||
# ./run.sh mitm # mitmproxy only (HTTP layer)
|
||||
# ./run.sh tls # tshark only (TLS layer)
|
||||
# ./run.sh tls en0 # tshark on specific interface
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MODE="${1:-both}"
|
||||
IFACE="${2:-en0}"
|
||||
|
||||
# Check dependencies
|
||||
check_dep() {
|
||||
if ! command -v "$1" &>/dev/null; then
|
||||
echo "ERROR: $1 not found. Install with: $2"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
mkdir -p ./captures
|
||||
|
||||
case "$MODE" in
|
||||
mitm|mitmproxy)
|
||||
check_dep mitmproxy "brew install mitmproxy"
|
||||
echo ""
|
||||
echo "Starting mitmproxy on :8080"
|
||||
echo ""
|
||||
echo "To capture Claude Code traffic:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
|
||||
echo ""
|
||||
echo "To capture VS Code / Antigravity traffic:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 code ."
|
||||
echo ""
|
||||
mitmdump -s capture_traffic.py \
|
||||
--set stream_large_bodies=10m \
|
||||
--set console_eventlog_verbosity=warn \
|
||||
-p 8080
|
||||
;;
|
||||
|
||||
tls|tshark)
|
||||
check_dep tshark "brew install wireshark"
|
||||
echo "Starting TLS capture (requires sudo)..."
|
||||
sudo bash ./capture_tls.sh "$IFACE" 120
|
||||
;;
|
||||
|
||||
both)
|
||||
check_dep mitmproxy "brew install mitmproxy"
|
||||
check_dep tshark "brew install wireshark"
|
||||
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo " MiniGravity Traffic Capture"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Starting two capture layers:"
|
||||
echo " 1. mitmproxy (:8080) → HTTP headers/body"
|
||||
echo " 2. tshark → TLS fingerprints"
|
||||
echo ""
|
||||
echo " Step 1: In another terminal, run:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude login"
|
||||
echo ""
|
||||
echo " Step 2: After login, run:"
|
||||
echo " HTTPS_PROXY=http://127.0.0.1:8080 claude 'hello'"
|
||||
echo ""
|
||||
echo " Step 3: Press Ctrl+C here when done"
|
||||
echo "═══════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Start tshark in background (needs sudo)
|
||||
echo "[*] Starting tshark (may ask for sudo password)..."
|
||||
sudo bash ./capture_tls.sh "$IFACE" 300 &
|
||||
TSHARK_PID=$!
|
||||
|
||||
sleep 2
|
||||
|
||||
# Start mitmproxy in foreground
|
||||
echo "[*] Starting mitmproxy..."
|
||||
mitmdump -s capture_traffic.py \
|
||||
--set stream_large_bodies=10m \
|
||||
--set console_eventlog_verbosity=warn \
|
||||
-p 8080
|
||||
|
||||
# Cleanup tshark on exit
|
||||
sudo kill "$TSHARK_PID" 2>/dev/null || true
|
||||
wait "$TSHARK_PID" 2>/dev/null || true
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Usage: $0 [mitm|tls|both] [interface]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -1 +1 @@
|
||||
0.1.104
|
||||
0.1.105
|
||||
@ -137,14 +137,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
|
||||
usageCache := service.NewUsageCache()
|
||||
identityCache := repository.NewIdentityCache(redisClient)
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache)
|
||||
geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oauthRefreshAPI)
|
||||
gatewayCache := repository.NewGatewayCache(redisClient)
|
||||
schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db)
|
||||
schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig)
|
||||
antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI, tempUnschedCache)
|
||||
antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig)
|
||||
tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client)
|
||||
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
|
||||
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
|
||||
accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService)
|
||||
accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig)
|
||||
rpmCache := repository.NewRPMCache(redisClient)
|
||||
@ -176,7 +179,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||
claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI)
|
||||
digestSessionStore := service.NewDigestSessionStore()
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService)
|
||||
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oauthRefreshAPI)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||
@ -208,12 +211,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
errorPassthroughCache := repository.NewErrorPassthroughCache(redisClient)
|
||||
errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache)
|
||||
errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService)
|
||||
tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService)
|
||||
adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService)
|
||||
scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db)
|
||||
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
|
||||
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
|
||||
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, adminAPIKeyHandler, scheduledTestHandler)
|
||||
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler)
|
||||
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
|
||||
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
|
||||
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
|
||||
|
||||
@ -29,6 +29,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
||||
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
|
||||
"github.com/Wei-Shaw/sub2api/ent/setting"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
@ -73,6 +74,8 @@ type Client struct {
|
||||
SecuritySecret *SecuritySecretClient
|
||||
// Setting is the client for interacting with the Setting builders.
|
||||
Setting *SettingClient
|
||||
// TLSFingerprintProfile is the client for interacting with the TLSFingerprintProfile builders.
|
||||
TLSFingerprintProfile *TLSFingerprintProfileClient
|
||||
// UsageCleanupTask is the client for interacting with the UsageCleanupTask builders.
|
||||
UsageCleanupTask *UsageCleanupTaskClient
|
||||
// UsageLog is the client for interacting with the UsageLog builders.
|
||||
@ -112,6 +115,7 @@ func (c *Client) init() {
|
||||
c.RedeemCode = NewRedeemCodeClient(c.config)
|
||||
c.SecuritySecret = NewSecuritySecretClient(c.config)
|
||||
c.Setting = NewSettingClient(c.config)
|
||||
c.TLSFingerprintProfile = NewTLSFingerprintProfileClient(c.config)
|
||||
c.UsageCleanupTask = NewUsageCleanupTaskClient(c.config)
|
||||
c.UsageLog = NewUsageLogClient(c.config)
|
||||
c.User = NewUserClient(c.config)
|
||||
@ -225,6 +229,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) {
|
||||
RedeemCode: NewRedeemCodeClient(cfg),
|
||||
SecuritySecret: NewSecuritySecretClient(cfg),
|
||||
Setting: NewSettingClient(cfg),
|
||||
TLSFingerprintProfile: NewTLSFingerprintProfileClient(cfg),
|
||||
UsageCleanupTask: NewUsageCleanupTaskClient(cfg),
|
||||
UsageLog: NewUsageLogClient(cfg),
|
||||
User: NewUserClient(cfg),
|
||||
@ -265,6 +270,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error)
|
||||
RedeemCode: NewRedeemCodeClient(cfg),
|
||||
SecuritySecret: NewSecuritySecretClient(cfg),
|
||||
Setting: NewSettingClient(cfg),
|
||||
TLSFingerprintProfile: NewTLSFingerprintProfileClient(cfg),
|
||||
UsageCleanupTask: NewUsageCleanupTaskClient(cfg),
|
||||
UsageLog: NewUsageLogClient(cfg),
|
||||
User: NewUserClient(cfg),
|
||||
@ -304,8 +310,9 @@ func (c *Client) Use(hooks ...Hook) {
|
||||
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
|
||||
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PromoCode,
|
||||
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
|
||||
c.UsageCleanupTask, c.UsageLog, c.User, c.UserAllowedGroup,
|
||||
c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription,
|
||||
c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
|
||||
c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
|
||||
c.UserSubscription,
|
||||
} {
|
||||
n.Use(hooks...)
|
||||
}
|
||||
@ -318,8 +325,9 @@ func (c *Client) Intercept(interceptors ...Interceptor) {
|
||||
c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead,
|
||||
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, c.PromoCode,
|
||||
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
|
||||
c.UsageCleanupTask, c.UsageLog, c.User, c.UserAllowedGroup,
|
||||
c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription,
|
||||
c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
|
||||
c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
|
||||
c.UserSubscription,
|
||||
} {
|
||||
n.Intercept(interceptors...)
|
||||
}
|
||||
@ -356,6 +364,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) {
|
||||
return c.SecuritySecret.mutate(ctx, m)
|
||||
case *SettingMutation:
|
||||
return c.Setting.mutate(ctx, m)
|
||||
case *TLSFingerprintProfileMutation:
|
||||
return c.TLSFingerprintProfile.mutate(ctx, m)
|
||||
case *UsageCleanupTaskMutation:
|
||||
return c.UsageCleanupTask.mutate(ctx, m)
|
||||
case *UsageLogMutation:
|
||||
@ -2612,6 +2622,139 @@ func (c *SettingClient) mutate(ctx context.Context, m *SettingMutation) (Value,
|
||||
}
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileClient is a client for the TLSFingerprintProfile schema.
|
||||
type TLSFingerprintProfileClient struct {
|
||||
config
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileClient returns a client for the TLSFingerprintProfile from the given config.
|
||||
func NewTLSFingerprintProfileClient(c config) *TLSFingerprintProfileClient {
|
||||
return &TLSFingerprintProfileClient{config: c}
|
||||
}
|
||||
|
||||
// Use adds a list of mutation hooks to the hooks stack.
|
||||
// A call to `Use(f, g, h)` equals to `tlsfingerprintprofile.Hooks(f(g(h())))`.
|
||||
func (c *TLSFingerprintProfileClient) Use(hooks ...Hook) {
|
||||
c.hooks.TLSFingerprintProfile = append(c.hooks.TLSFingerprintProfile, hooks...)
|
||||
}
|
||||
|
||||
// Intercept adds a list of query interceptors to the interceptors stack.
|
||||
// A call to `Intercept(f, g, h)` equals to `tlsfingerprintprofile.Intercept(f(g(h())))`.
|
||||
func (c *TLSFingerprintProfileClient) Intercept(interceptors ...Interceptor) {
|
||||
c.inters.TLSFingerprintProfile = append(c.inters.TLSFingerprintProfile, interceptors...)
|
||||
}
|
||||
|
||||
// Create returns a builder for creating a TLSFingerprintProfile entity.
|
||||
func (c *TLSFingerprintProfileClient) Create() *TLSFingerprintProfileCreate {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpCreate)
|
||||
return &TLSFingerprintProfileCreate{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// CreateBulk returns a builder for creating a bulk of TLSFingerprintProfile entities.
|
||||
func (c *TLSFingerprintProfileClient) CreateBulk(builders ...*TLSFingerprintProfileCreate) *TLSFingerprintProfileCreateBulk {
|
||||
return &TLSFingerprintProfileCreateBulk{config: c.config, builders: builders}
|
||||
}
|
||||
|
||||
// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates
|
||||
// a builder and applies setFunc on it.
|
||||
func (c *TLSFingerprintProfileClient) MapCreateBulk(slice any, setFunc func(*TLSFingerprintProfileCreate, int)) *TLSFingerprintProfileCreateBulk {
|
||||
rv := reflect.ValueOf(slice)
|
||||
if rv.Kind() != reflect.Slice {
|
||||
return &TLSFingerprintProfileCreateBulk{err: fmt.Errorf("calling to TLSFingerprintProfileClient.MapCreateBulk with wrong type %T, need slice", slice)}
|
||||
}
|
||||
builders := make([]*TLSFingerprintProfileCreate, rv.Len())
|
||||
for i := 0; i < rv.Len(); i++ {
|
||||
builders[i] = c.Create()
|
||||
setFunc(builders[i], i)
|
||||
}
|
||||
return &TLSFingerprintProfileCreateBulk{config: c.config, builders: builders}
|
||||
}
|
||||
|
||||
// Update returns an update builder for TLSFingerprintProfile.
|
||||
func (c *TLSFingerprintProfileClient) Update() *TLSFingerprintProfileUpdate {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdate)
|
||||
return &TLSFingerprintProfileUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// UpdateOne returns an update builder for the given entity.
|
||||
func (c *TLSFingerprintProfileClient) UpdateOne(_m *TLSFingerprintProfile) *TLSFingerprintProfileUpdateOne {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdateOne, withTLSFingerprintProfile(_m))
|
||||
return &TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// UpdateOneID returns an update builder for the given id.
|
||||
func (c *TLSFingerprintProfileClient) UpdateOneID(id int64) *TLSFingerprintProfileUpdateOne {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpUpdateOne, withTLSFingerprintProfileID(id))
|
||||
return &TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// Delete returns a delete builder for TLSFingerprintProfile.
|
||||
func (c *TLSFingerprintProfileClient) Delete() *TLSFingerprintProfileDelete {
|
||||
mutation := newTLSFingerprintProfileMutation(c.config, OpDelete)
|
||||
return &TLSFingerprintProfileDelete{config: c.config, hooks: c.Hooks(), mutation: mutation}
|
||||
}
|
||||
|
||||
// DeleteOne returns a builder for deleting the given entity.
|
||||
func (c *TLSFingerprintProfileClient) DeleteOne(_m *TLSFingerprintProfile) *TLSFingerprintProfileDeleteOne {
|
||||
return c.DeleteOneID(_m.ID)
|
||||
}
|
||||
|
||||
// DeleteOneID returns a builder for deleting the given entity by its id.
|
||||
func (c *TLSFingerprintProfileClient) DeleteOneID(id int64) *TLSFingerprintProfileDeleteOne {
|
||||
builder := c.Delete().Where(tlsfingerprintprofile.ID(id))
|
||||
builder.mutation.id = &id
|
||||
builder.mutation.op = OpDeleteOne
|
||||
return &TLSFingerprintProfileDeleteOne{builder}
|
||||
}
|
||||
|
||||
// Query returns a query builder for TLSFingerprintProfile.
|
||||
func (c *TLSFingerprintProfileClient) Query() *TLSFingerprintProfileQuery {
|
||||
return &TLSFingerprintProfileQuery{
|
||||
config: c.config,
|
||||
ctx: &QueryContext{Type: TypeTLSFingerprintProfile},
|
||||
inters: c.Interceptors(),
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a TLSFingerprintProfile entity by its id.
|
||||
func (c *TLSFingerprintProfileClient) Get(ctx context.Context, id int64) (*TLSFingerprintProfile, error) {
|
||||
return c.Query().Where(tlsfingerprintprofile.ID(id)).Only(ctx)
|
||||
}
|
||||
|
||||
// GetX is like Get, but panics if an error occurs.
|
||||
func (c *TLSFingerprintProfileClient) GetX(ctx context.Context, id int64) *TLSFingerprintProfile {
|
||||
obj, err := c.Get(ctx, id)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// Hooks returns the client hooks.
|
||||
func (c *TLSFingerprintProfileClient) Hooks() []Hook {
|
||||
return c.hooks.TLSFingerprintProfile
|
||||
}
|
||||
|
||||
// Interceptors returns the client interceptors.
|
||||
func (c *TLSFingerprintProfileClient) Interceptors() []Interceptor {
|
||||
return c.inters.TLSFingerprintProfile
|
||||
}
|
||||
|
||||
func (c *TLSFingerprintProfileClient) mutate(ctx context.Context, m *TLSFingerprintProfileMutation) (Value, error) {
|
||||
switch m.Op() {
|
||||
case OpCreate:
|
||||
return (&TLSFingerprintProfileCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
|
||||
case OpUpdate:
|
||||
return (&TLSFingerprintProfileUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
|
||||
case OpUpdateOne:
|
||||
return (&TLSFingerprintProfileUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
|
||||
case OpDelete, OpDeleteOne:
|
||||
return (&TLSFingerprintProfileDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx)
|
||||
default:
|
||||
return nil, fmt.Errorf("ent: unknown TLSFingerprintProfile mutation op: %q", m.Op())
|
||||
}
|
||||
}
|
||||
|
||||
// UsageCleanupTaskClient is a client for the UsageCleanupTask schema.
|
||||
type UsageCleanupTaskClient struct {
|
||||
config
|
||||
@ -3889,16 +4032,16 @@ type (
|
||||
hooks struct {
|
||||
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
|
||||
ErrorPassthroughRule, Group, IdempotencyRecord, PromoCode, PromoCodeUsage,
|
||||
Proxy, RedeemCode, SecuritySecret, Setting, UsageCleanupTask, UsageLog, User,
|
||||
UserAllowedGroup, UserAttributeDefinition, UserAttributeValue,
|
||||
UserSubscription []ent.Hook
|
||||
Proxy, RedeemCode, SecuritySecret, Setting, TLSFingerprintProfile,
|
||||
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
|
||||
UserAttributeValue, UserSubscription []ent.Hook
|
||||
}
|
||||
inters struct {
|
||||
APIKey, Account, AccountGroup, Announcement, AnnouncementRead,
|
||||
ErrorPassthroughRule, Group, IdempotencyRecord, PromoCode, PromoCodeUsage,
|
||||
Proxy, RedeemCode, SecuritySecret, Setting, UsageCleanupTask, UsageLog, User,
|
||||
UserAllowedGroup, UserAttributeDefinition, UserAttributeValue,
|
||||
UserSubscription []ent.Interceptor
|
||||
Proxy, RedeemCode, SecuritySecret, Setting, TLSFingerprintProfile,
|
||||
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
|
||||
UserAttributeValue, UserSubscription []ent.Interceptor
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
||||
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
|
||||
"github.com/Wei-Shaw/sub2api/ent/setting"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
@ -107,6 +108,7 @@ func checkColumn(t, c string) error {
|
||||
redeemcode.Table: redeemcode.ValidColumn,
|
||||
securitysecret.Table: securitysecret.ValidColumn,
|
||||
setting.Table: setting.ValidColumn,
|
||||
tlsfingerprintprofile.Table: tlsfingerprintprofile.ValidColumn,
|
||||
usagecleanuptask.Table: usagecleanuptask.ValidColumn,
|
||||
usagelog.Table: usagelog.ValidColumn,
|
||||
user.Table: user.ValidColumn,
|
||||
|
||||
@ -177,6 +177,18 @@ func (f SettingFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, err
|
||||
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.SettingMutation", m)
|
||||
}
|
||||
|
||||
// The TLSFingerprintProfileFunc type is an adapter to allow the use of ordinary
|
||||
// function as TLSFingerprintProfile mutator.
|
||||
type TLSFingerprintProfileFunc func(context.Context, *ent.TLSFingerprintProfileMutation) (ent.Value, error)
|
||||
|
||||
// Mutate calls f(ctx, m).
|
||||
func (f TLSFingerprintProfileFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) {
|
||||
if mv, ok := m.(*ent.TLSFingerprintProfileMutation); ok {
|
||||
return f(ctx, mv)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.TLSFingerprintProfileMutation", m)
|
||||
}
|
||||
|
||||
// The UsageCleanupTaskFunc type is an adapter to allow the use of ordinary
|
||||
// function as UsageCleanupTask mutator.
|
||||
type UsageCleanupTaskFunc func(context.Context, *ent.UsageCleanupTaskMutation) (ent.Value, error)
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
|
||||
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
|
||||
"github.com/Wei-Shaw/sub2api/ent/setting"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
@ -466,6 +467,33 @@ func (f TraverseSetting) Traverse(ctx context.Context, q ent.Query) error {
|
||||
return fmt.Errorf("unexpected query type %T. expect *ent.SettingQuery", q)
|
||||
}
|
||||
|
||||
// The TLSFingerprintProfileFunc type is an adapter to allow the use of ordinary function as a Querier.
|
||||
type TLSFingerprintProfileFunc func(context.Context, *ent.TLSFingerprintProfileQuery) (ent.Value, error)
|
||||
|
||||
// Query calls f(ctx, q).
|
||||
func (f TLSFingerprintProfileFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) {
|
||||
if q, ok := q.(*ent.TLSFingerprintProfileQuery); ok {
|
||||
return f(ctx, q)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected query type %T. expect *ent.TLSFingerprintProfileQuery", q)
|
||||
}
|
||||
|
||||
// The TraverseTLSFingerprintProfile type is an adapter to allow the use of ordinary function as Traverser.
|
||||
type TraverseTLSFingerprintProfile func(context.Context, *ent.TLSFingerprintProfileQuery) error
|
||||
|
||||
// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline.
|
||||
func (f TraverseTLSFingerprintProfile) Intercept(next ent.Querier) ent.Querier {
|
||||
return next
|
||||
}
|
||||
|
||||
// Traverse calls f(ctx, q).
|
||||
func (f TraverseTLSFingerprintProfile) Traverse(ctx context.Context, q ent.Query) error {
|
||||
if q, ok := q.(*ent.TLSFingerprintProfileQuery); ok {
|
||||
return f(ctx, q)
|
||||
}
|
||||
return fmt.Errorf("unexpected query type %T. expect *ent.TLSFingerprintProfileQuery", q)
|
||||
}
|
||||
|
||||
// The UsageCleanupTaskFunc type is an adapter to allow the use of ordinary function as a Querier.
|
||||
type UsageCleanupTaskFunc func(context.Context, *ent.UsageCleanupTaskQuery) (ent.Value, error)
|
||||
|
||||
@ -686,6 +714,8 @@ func NewQuery(q ent.Query) (Query, error) {
|
||||
return &query[*ent.SecuritySecretQuery, predicate.SecuritySecret, securitysecret.OrderOption]{typ: ent.TypeSecuritySecret, tq: q}, nil
|
||||
case *ent.SettingQuery:
|
||||
return &query[*ent.SettingQuery, predicate.Setting, setting.OrderOption]{typ: ent.TypeSetting, tq: q}, nil
|
||||
case *ent.TLSFingerprintProfileQuery:
|
||||
return &query[*ent.TLSFingerprintProfileQuery, predicate.TLSFingerprintProfile, tlsfingerprintprofile.OrderOption]{typ: ent.TypeTLSFingerprintProfile, tq: q}, nil
|
||||
case *ent.UsageCleanupTaskQuery:
|
||||
return &query[*ent.UsageCleanupTaskQuery, predicate.UsageCleanupTask, usagecleanuptask.OrderOption]{typ: ent.TypeUsageCleanupTask, tq: q}, nil
|
||||
case *ent.UsageLogQuery:
|
||||
|
||||
@ -673,6 +673,30 @@ var (
|
||||
Columns: SettingsColumns,
|
||||
PrimaryKey: []*schema.Column{SettingsColumns[0]},
|
||||
}
|
||||
// TLSFingerprintProfilesColumns holds the columns for the "tls_fingerprint_profiles" table.
|
||||
TLSFingerprintProfilesColumns = []*schema.Column{
|
||||
{Name: "id", Type: field.TypeInt64, Increment: true},
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Unique: true, Size: 100},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, Size: 2147483647},
|
||||
{Name: "enable_grease", Type: field.TypeBool, Default: false},
|
||||
{Name: "cipher_suites", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "curves", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "point_formats", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "signature_algorithms", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "alpn_protocols", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "supported_versions", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "key_share_groups", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "psk_modes", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "extensions", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
}
|
||||
// TLSFingerprintProfilesTable holds the schema information for the "tls_fingerprint_profiles" table.
|
||||
TLSFingerprintProfilesTable = &schema.Table{
|
||||
Name: "tls_fingerprint_profiles",
|
||||
Columns: TLSFingerprintProfilesColumns,
|
||||
PrimaryKey: []*schema.Column{TLSFingerprintProfilesColumns[0]},
|
||||
}
|
||||
// UsageCleanupTasksColumns holds the columns for the "usage_cleanup_tasks" table.
|
||||
UsageCleanupTasksColumns = []*schema.Column{
|
||||
{Name: "id", Type: field.TypeInt64, Increment: true},
|
||||
@ -1111,6 +1135,7 @@ var (
|
||||
RedeemCodesTable,
|
||||
SecuritySecretsTable,
|
||||
SettingsTable,
|
||||
TLSFingerprintProfilesTable,
|
||||
UsageCleanupTasksTable,
|
||||
UsageLogsTable,
|
||||
UsersTable,
|
||||
@ -1175,6 +1200,9 @@ func init() {
|
||||
SettingsTable.Annotation = &entsql.Annotation{
|
||||
Table: "settings",
|
||||
}
|
||||
TLSFingerprintProfilesTable.Annotation = &entsql.Annotation{
|
||||
Table: "tls_fingerprint_profiles",
|
||||
}
|
||||
UsageCleanupTasksTable.Annotation = &entsql.Annotation{
|
||||
Table: "usage_cleanup_tasks",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -48,6 +48,9 @@ type SecuritySecret func(*sql.Selector)
|
||||
// Setting is the predicate function for setting builders.
|
||||
type Setting func(*sql.Selector)
|
||||
|
||||
// TLSFingerprintProfile is the predicate function for tlsfingerprintprofile builders.
|
||||
type TLSFingerprintProfile func(*sql.Selector)
|
||||
|
||||
// UsageCleanupTask is the predicate function for usagecleanuptask builders.
|
||||
type UsageCleanupTask func(*sql.Selector)
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema"
|
||||
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
|
||||
"github.com/Wei-Shaw/sub2api/ent/setting"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
|
||||
"github.com/Wei-Shaw/sub2api/ent/usagelog"
|
||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||
@ -746,6 +747,43 @@ func init() {
|
||||
setting.DefaultUpdatedAt = settingDescUpdatedAt.Default.(func() time.Time)
|
||||
// setting.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
|
||||
setting.UpdateDefaultUpdatedAt = settingDescUpdatedAt.UpdateDefault.(func() time.Time)
|
||||
tlsfingerprintprofileMixin := schema.TLSFingerprintProfile{}.Mixin()
|
||||
tlsfingerprintprofileMixinFields0 := tlsfingerprintprofileMixin[0].Fields()
|
||||
_ = tlsfingerprintprofileMixinFields0
|
||||
tlsfingerprintprofileFields := schema.TLSFingerprintProfile{}.Fields()
|
||||
_ = tlsfingerprintprofileFields
|
||||
// tlsfingerprintprofileDescCreatedAt is the schema descriptor for created_at field.
|
||||
tlsfingerprintprofileDescCreatedAt := tlsfingerprintprofileMixinFields0[0].Descriptor()
|
||||
// tlsfingerprintprofile.DefaultCreatedAt holds the default value on creation for the created_at field.
|
||||
tlsfingerprintprofile.DefaultCreatedAt = tlsfingerprintprofileDescCreatedAt.Default.(func() time.Time)
|
||||
// tlsfingerprintprofileDescUpdatedAt is the schema descriptor for updated_at field.
|
||||
tlsfingerprintprofileDescUpdatedAt := tlsfingerprintprofileMixinFields0[1].Descriptor()
|
||||
// tlsfingerprintprofile.DefaultUpdatedAt holds the default value on creation for the updated_at field.
|
||||
tlsfingerprintprofile.DefaultUpdatedAt = tlsfingerprintprofileDescUpdatedAt.Default.(func() time.Time)
|
||||
// tlsfingerprintprofile.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
|
||||
tlsfingerprintprofile.UpdateDefaultUpdatedAt = tlsfingerprintprofileDescUpdatedAt.UpdateDefault.(func() time.Time)
|
||||
// tlsfingerprintprofileDescName is the schema descriptor for name field.
|
||||
tlsfingerprintprofileDescName := tlsfingerprintprofileFields[0].Descriptor()
|
||||
// tlsfingerprintprofile.NameValidator is a validator for the "name" field. It is called by the builders before save.
|
||||
tlsfingerprintprofile.NameValidator = func() func(string) error {
|
||||
validators := tlsfingerprintprofileDescName.Validators
|
||||
fns := [...]func(string) error{
|
||||
validators[0].(func(string) error),
|
||||
validators[1].(func(string) error),
|
||||
}
|
||||
return func(name string) error {
|
||||
for _, fn := range fns {
|
||||
if err := fn(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
// tlsfingerprintprofileDescEnableGrease is the schema descriptor for enable_grease field.
|
||||
tlsfingerprintprofileDescEnableGrease := tlsfingerprintprofileFields[2].Descriptor()
|
||||
// tlsfingerprintprofile.DefaultEnableGrease holds the default value on creation for the enable_grease field.
|
||||
tlsfingerprintprofile.DefaultEnableGrease = tlsfingerprintprofileDescEnableGrease.Default.(bool)
|
||||
usagecleanuptaskMixin := schema.UsageCleanupTask{}.Mixin()
|
||||
usagecleanuptaskMixinFields0 := usagecleanuptaskMixin[0].Fields()
|
||||
_ = usagecleanuptaskMixinFields0
|
||||
|
||||
100
backend/ent/schema/tls_fingerprint_profile.go
Normal file
100
backend/ent/schema/tls_fingerprint_profile.go
Normal file
@ -0,0 +1,100 @@
|
||||
// Package schema 定义 Ent ORM 的数据库 schema。
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/field"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfile 定义 TLS 指纹配置模板的 schema。
|
||||
//
|
||||
// TLS 指纹模板用于模拟特定客户端(如 Claude Code / Node.js)的 TLS 握手特征。
|
||||
// 每个模板包含完整的 ClientHello 参数:加密套件、曲线、扩展等。
|
||||
// 通过 Account.Extra.tls_fingerprint_profile_id 绑定到具体账号。
|
||||
type TLSFingerprintProfile struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
// Annotations 返回 schema 的注解配置。
|
||||
func (TLSFingerprintProfile) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "tls_fingerprint_profiles"},
|
||||
}
|
||||
}
|
||||
|
||||
// Mixin 返回该 schema 使用的混入组件。
|
||||
func (TLSFingerprintProfile) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
// Fields 定义 TLS 指纹模板实体的所有字段。
|
||||
func (TLSFingerprintProfile) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// name: 模板名称,唯一标识
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
|
||||
// description: 模板描述
|
||||
field.Text("description").
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// enable_grease: 是否启用 GREASE 扩展(Chrome 使用,Node.js 不使用)
|
||||
field.Bool("enable_grease").
|
||||
Default(false),
|
||||
|
||||
// cipher_suites: TLS 加密套件列表(顺序敏感,影响 JA3)
|
||||
field.JSON("cipher_suites", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// curves: 椭圆曲线/支持的组列表
|
||||
field.JSON("curves", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// point_formats: EC 点格式列表
|
||||
field.JSON("point_formats", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// signature_algorithms: 签名算法列表
|
||||
field.JSON("signature_algorithms", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// alpn_protocols: ALPN 协议列表(如 ["http/1.1"])
|
||||
field.JSON("alpn_protocols", []string{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// supported_versions: 支持的 TLS 版本列表(如 [0x0304, 0x0303])
|
||||
field.JSON("supported_versions", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// key_share_groups: Key Share 中发送的曲线组(如 [29] 即 X25519)
|
||||
field.JSON("key_share_groups", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// psk_modes: PSK 密钥交换模式(如 [1] 即 psk_dhe_ke)
|
||||
field.JSON("psk_modes", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// extensions: TLS 扩展类型 ID 列表,按发送顺序排列
|
||||
field.JSON("extensions", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
}
|
||||
}
|
||||
275
backend/ent/tlsfingerprintprofile.go
Normal file
275
backend/ent/tlsfingerprintprofile.go
Normal file
@ -0,0 +1,275 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfile is the model entity for the TLSFingerprintProfile schema.
|
||||
type TLSFingerprintProfile struct {
|
||||
config `json:"-"`
|
||||
// ID of the ent.
|
||||
ID int64 `json:"id,omitempty"`
|
||||
// CreatedAt holds the value of the "created_at" field.
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
// UpdatedAt holds the value of the "updated_at" field.
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
// Name holds the value of the "name" field.
|
||||
Name string `json:"name,omitempty"`
|
||||
// Description holds the value of the "description" field.
|
||||
Description *string `json:"description,omitempty"`
|
||||
// EnableGrease holds the value of the "enable_grease" field.
|
||||
EnableGrease bool `json:"enable_grease,omitempty"`
|
||||
// CipherSuites holds the value of the "cipher_suites" field.
|
||||
CipherSuites []uint16 `json:"cipher_suites,omitempty"`
|
||||
// Curves holds the value of the "curves" field.
|
||||
Curves []uint16 `json:"curves,omitempty"`
|
||||
// PointFormats holds the value of the "point_formats" field.
|
||||
PointFormats []uint16 `json:"point_formats,omitempty"`
|
||||
// SignatureAlgorithms holds the value of the "signature_algorithms" field.
|
||||
SignatureAlgorithms []uint16 `json:"signature_algorithms,omitempty"`
|
||||
// AlpnProtocols holds the value of the "alpn_protocols" field.
|
||||
AlpnProtocols []string `json:"alpn_protocols,omitempty"`
|
||||
// SupportedVersions holds the value of the "supported_versions" field.
|
||||
SupportedVersions []uint16 `json:"supported_versions,omitempty"`
|
||||
// KeyShareGroups holds the value of the "key_share_groups" field.
|
||||
KeyShareGroups []uint16 `json:"key_share_groups,omitempty"`
|
||||
// PskModes holds the value of the "psk_modes" field.
|
||||
PskModes []uint16 `json:"psk_modes,omitempty"`
|
||||
// Extensions holds the value of the "extensions" field.
|
||||
Extensions []uint16 `json:"extensions,omitempty"`
|
||||
selectValues sql.SelectValues
|
||||
}
|
||||
|
||||
// scanValues returns the types for scanning values from sql.Rows.
|
||||
func (*TLSFingerprintProfile) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case tlsfingerprintprofile.FieldCipherSuites, tlsfingerprintprofile.FieldCurves, tlsfingerprintprofile.FieldPointFormats, tlsfingerprintprofile.FieldSignatureAlgorithms, tlsfingerprintprofile.FieldAlpnProtocols, tlsfingerprintprofile.FieldSupportedVersions, tlsfingerprintprofile.FieldKeyShareGroups, tlsfingerprintprofile.FieldPskModes, tlsfingerprintprofile.FieldExtensions:
|
||||
values[i] = new([]byte)
|
||||
case tlsfingerprintprofile.FieldEnableGrease:
|
||||
values[i] = new(sql.NullBool)
|
||||
case tlsfingerprintprofile.FieldID:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case tlsfingerprintprofile.FieldName, tlsfingerprintprofile.FieldDescription:
|
||||
values[i] = new(sql.NullString)
|
||||
case tlsfingerprintprofile.FieldCreatedAt, tlsfingerprintprofile.FieldUpdatedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
default:
|
||||
values[i] = new(sql.UnknownType)
|
||||
}
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// assignValues assigns the values that were returned from sql.Rows (after scanning)
|
||||
// to the TLSFingerprintProfile fields.
|
||||
func (_m *TLSFingerprintProfile) assignValues(columns []string, values []any) error {
|
||||
if m, n := len(values), len(columns); m < n {
|
||||
return fmt.Errorf("mismatch number of scan values: %d != %d", m, n)
|
||||
}
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case tlsfingerprintprofile.FieldID:
|
||||
value, ok := values[i].(*sql.NullInt64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field id", value)
|
||||
}
|
||||
_m.ID = int64(value.Int64)
|
||||
case tlsfingerprintprofile.FieldCreatedAt:
|
||||
if value, ok := values[i].(*sql.NullTime); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field created_at", values[i])
|
||||
} else if value.Valid {
|
||||
_m.CreatedAt = value.Time
|
||||
}
|
||||
case tlsfingerprintprofile.FieldUpdatedAt:
|
||||
if value, ok := values[i].(*sql.NullTime); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field updated_at", values[i])
|
||||
} else if value.Valid {
|
||||
_m.UpdatedAt = value.Time
|
||||
}
|
||||
case tlsfingerprintprofile.FieldName:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field name", values[i])
|
||||
} else if value.Valid {
|
||||
_m.Name = value.String
|
||||
}
|
||||
case tlsfingerprintprofile.FieldDescription:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field description", values[i])
|
||||
} else if value.Valid {
|
||||
_m.Description = new(string)
|
||||
*_m.Description = value.String
|
||||
}
|
||||
case tlsfingerprintprofile.FieldEnableGrease:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field enable_grease", values[i])
|
||||
} else if value.Valid {
|
||||
_m.EnableGrease = value.Bool
|
||||
}
|
||||
case tlsfingerprintprofile.FieldCipherSuites:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field cipher_suites", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.CipherSuites); err != nil {
|
||||
return fmt.Errorf("unmarshal field cipher_suites: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldCurves:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field curves", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.Curves); err != nil {
|
||||
return fmt.Errorf("unmarshal field curves: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldPointFormats:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field point_formats", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.PointFormats); err != nil {
|
||||
return fmt.Errorf("unmarshal field point_formats: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldSignatureAlgorithms:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field signature_algorithms", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.SignatureAlgorithms); err != nil {
|
||||
return fmt.Errorf("unmarshal field signature_algorithms: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldAlpnProtocols:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field alpn_protocols", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.AlpnProtocols); err != nil {
|
||||
return fmt.Errorf("unmarshal field alpn_protocols: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldSupportedVersions:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field supported_versions", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.SupportedVersions); err != nil {
|
||||
return fmt.Errorf("unmarshal field supported_versions: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldKeyShareGroups:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field key_share_groups", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.KeyShareGroups); err != nil {
|
||||
return fmt.Errorf("unmarshal field key_share_groups: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldPskModes:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field psk_modes", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.PskModes); err != nil {
|
||||
return fmt.Errorf("unmarshal field psk_modes: %w", err)
|
||||
}
|
||||
}
|
||||
case tlsfingerprintprofile.FieldExtensions:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field extensions", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.Extensions); err != nil {
|
||||
return fmt.Errorf("unmarshal field extensions: %w", err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
_m.selectValues.Set(columns[i], values[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value returns the ent.Value that was dynamically selected and assigned to the TLSFingerprintProfile.
|
||||
// This includes values selected through modifiers, order, etc.
|
||||
func (_m *TLSFingerprintProfile) Value(name string) (ent.Value, error) {
|
||||
return _m.selectValues.Get(name)
|
||||
}
|
||||
|
||||
// Update returns a builder for updating this TLSFingerprintProfile.
|
||||
// Note that you need to call TLSFingerprintProfile.Unwrap() before calling this method if this TLSFingerprintProfile
|
||||
// was returned from a transaction, and the transaction was committed or rolled back.
|
||||
func (_m *TLSFingerprintProfile) Update() *TLSFingerprintProfileUpdateOne {
|
||||
return NewTLSFingerprintProfileClient(_m.config).UpdateOne(_m)
|
||||
}
|
||||
|
||||
// Unwrap unwraps the TLSFingerprintProfile entity that was returned from a transaction after it was closed,
|
||||
// so that all future queries will be executed through the driver which created the transaction.
|
||||
func (_m *TLSFingerprintProfile) Unwrap() *TLSFingerprintProfile {
|
||||
_tx, ok := _m.config.driver.(*txDriver)
|
||||
if !ok {
|
||||
panic("ent: TLSFingerprintProfile is not a transactional entity")
|
||||
}
|
||||
_m.config.driver = _tx.drv
|
||||
return _m
|
||||
}
|
||||
|
||||
// String implements the fmt.Stringer.
|
||||
func (_m *TLSFingerprintProfile) String() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("TLSFingerprintProfile(")
|
||||
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
|
||||
builder.WriteString("created_at=")
|
||||
builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("updated_at=")
|
||||
builder.WriteString(_m.UpdatedAt.Format(time.ANSIC))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("name=")
|
||||
builder.WriteString(_m.Name)
|
||||
builder.WriteString(", ")
|
||||
if v := _m.Description; v != nil {
|
||||
builder.WriteString("description=")
|
||||
builder.WriteString(*v)
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("enable_grease=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.EnableGrease))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("cipher_suites=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.CipherSuites))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("curves=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.Curves))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("point_formats=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.PointFormats))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("signature_algorithms=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.SignatureAlgorithms))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("alpn_protocols=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.AlpnProtocols))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("supported_versions=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.SupportedVersions))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("key_share_groups=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.KeyShareGroups))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("psk_modes=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.PskModes))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("extensions=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.Extensions))
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// TLSFingerprintProfiles is a parsable slice of TLSFingerprintProfile.
|
||||
type TLSFingerprintProfiles []*TLSFingerprintProfile
|
||||
121
backend/ent/tlsfingerprintprofile/tlsfingerprintprofile.go
Normal file
121
backend/ent/tlsfingerprintprofile/tlsfingerprintprofile.go
Normal file
@ -0,0 +1,121 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package tlsfingerprintprofile
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
)
|
||||
|
||||
const (
|
||||
// Label holds the string label denoting the tlsfingerprintprofile type in the database.
|
||||
Label = "tls_fingerprint_profile"
|
||||
// FieldID holds the string denoting the id field in the database.
|
||||
FieldID = "id"
|
||||
// FieldCreatedAt holds the string denoting the created_at field in the database.
|
||||
FieldCreatedAt = "created_at"
|
||||
// FieldUpdatedAt holds the string denoting the updated_at field in the database.
|
||||
FieldUpdatedAt = "updated_at"
|
||||
// FieldName holds the string denoting the name field in the database.
|
||||
FieldName = "name"
|
||||
// FieldDescription holds the string denoting the description field in the database.
|
||||
FieldDescription = "description"
|
||||
// FieldEnableGrease holds the string denoting the enable_grease field in the database.
|
||||
FieldEnableGrease = "enable_grease"
|
||||
// FieldCipherSuites holds the string denoting the cipher_suites field in the database.
|
||||
FieldCipherSuites = "cipher_suites"
|
||||
// FieldCurves holds the string denoting the curves field in the database.
|
||||
FieldCurves = "curves"
|
||||
// FieldPointFormats holds the string denoting the point_formats field in the database.
|
||||
FieldPointFormats = "point_formats"
|
||||
// FieldSignatureAlgorithms holds the string denoting the signature_algorithms field in the database.
|
||||
FieldSignatureAlgorithms = "signature_algorithms"
|
||||
// FieldAlpnProtocols holds the string denoting the alpn_protocols field in the database.
|
||||
FieldAlpnProtocols = "alpn_protocols"
|
||||
// FieldSupportedVersions holds the string denoting the supported_versions field in the database.
|
||||
FieldSupportedVersions = "supported_versions"
|
||||
// FieldKeyShareGroups holds the string denoting the key_share_groups field in the database.
|
||||
FieldKeyShareGroups = "key_share_groups"
|
||||
// FieldPskModes holds the string denoting the psk_modes field in the database.
|
||||
FieldPskModes = "psk_modes"
|
||||
// FieldExtensions holds the string denoting the extensions field in the database.
|
||||
FieldExtensions = "extensions"
|
||||
// Table holds the table name of the tlsfingerprintprofile in the database.
|
||||
Table = "tls_fingerprint_profiles"
|
||||
)
|
||||
|
||||
// Columns holds all SQL columns for tlsfingerprintprofile fields.
|
||||
var Columns = []string{
|
||||
FieldID,
|
||||
FieldCreatedAt,
|
||||
FieldUpdatedAt,
|
||||
FieldName,
|
||||
FieldDescription,
|
||||
FieldEnableGrease,
|
||||
FieldCipherSuites,
|
||||
FieldCurves,
|
||||
FieldPointFormats,
|
||||
FieldSignatureAlgorithms,
|
||||
FieldAlpnProtocols,
|
||||
FieldSupportedVersions,
|
||||
FieldKeyShareGroups,
|
||||
FieldPskModes,
|
||||
FieldExtensions,
|
||||
}
|
||||
|
||||
// ValidColumn reports if the column name is valid (part of the table columns).
|
||||
func ValidColumn(column string) bool {
|
||||
for i := range Columns {
|
||||
if column == Columns[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
|
||||
DefaultCreatedAt func() time.Time
|
||||
// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
|
||||
DefaultUpdatedAt func() time.Time
|
||||
// UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field.
|
||||
UpdateDefaultUpdatedAt func() time.Time
|
||||
// NameValidator is a validator for the "name" field. It is called by the builders before save.
|
||||
NameValidator func(string) error
|
||||
// DefaultEnableGrease holds the default value on creation for the "enable_grease" field.
|
||||
DefaultEnableGrease bool
|
||||
)
|
||||
|
||||
// OrderOption defines the ordering options for the TLSFingerprintProfile queries.
|
||||
type OrderOption func(*sql.Selector)
|
||||
|
||||
// ByID orders the results by the id field.
|
||||
func ByID(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldID, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByCreatedAt orders the results by the created_at field.
|
||||
func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByUpdatedAt orders the results by the updated_at field.
|
||||
func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByName orders the results by the name field.
|
||||
func ByName(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldName, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByDescription orders the results by the description field.
|
||||
func ByDescription(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldDescription, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByEnableGrease orders the results by the enable_grease field.
|
||||
func ByEnableGrease(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldEnableGrease, opts...).ToFunc()
|
||||
}
|
||||
415
backend/ent/tlsfingerprintprofile/where.go
Normal file
415
backend/ent/tlsfingerprintprofile/where.go
Normal file
@ -0,0 +1,415 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package tlsfingerprintprofile
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
)
|
||||
|
||||
// ID filters vertices based on their ID field.
|
||||
func ID(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldID, id))
|
||||
}
|
||||
|
||||
// IDEQ applies the EQ predicate on the ID field.
|
||||
func IDEQ(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldID, id))
|
||||
}
|
||||
|
||||
// IDNEQ applies the NEQ predicate on the ID field.
|
||||
func IDNEQ(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldID, id))
|
||||
}
|
||||
|
||||
// IDIn applies the In predicate on the ID field.
|
||||
func IDIn(ids ...int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldID, ids...))
|
||||
}
|
||||
|
||||
// IDNotIn applies the NotIn predicate on the ID field.
|
||||
func IDNotIn(ids ...int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldID, ids...))
|
||||
}
|
||||
|
||||
// IDGT applies the GT predicate on the ID field.
|
||||
func IDGT(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldID, id))
|
||||
}
|
||||
|
||||
// IDGTE applies the GTE predicate on the ID field.
|
||||
func IDGTE(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldID, id))
|
||||
}
|
||||
|
||||
// IDLT applies the LT predicate on the ID field.
|
||||
func IDLT(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldID, id))
|
||||
}
|
||||
|
||||
// IDLTE applies the LTE predicate on the ID field.
|
||||
func IDLTE(id int64) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldID, id))
|
||||
}
|
||||
|
||||
// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
|
||||
func CreatedAt(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ.
|
||||
func UpdatedAt(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// Name applies equality check predicate on the "name" field. It's identical to NameEQ.
|
||||
func Name(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldName, v))
|
||||
}
|
||||
|
||||
// Description applies equality check predicate on the "description" field. It's identical to DescriptionEQ.
|
||||
func Description(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldDescription, v))
|
||||
}
|
||||
|
||||
// EnableGrease applies equality check predicate on the "enable_grease" field. It's identical to EnableGreaseEQ.
|
||||
func EnableGrease(v bool) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldEnableGrease, v))
|
||||
}
|
||||
|
||||
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
|
||||
func CreatedAtEQ(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtNEQ applies the NEQ predicate on the "created_at" field.
|
||||
func CreatedAtNEQ(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtIn applies the In predicate on the "created_at" field.
|
||||
func CreatedAtIn(vs ...time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldCreatedAt, vs...))
|
||||
}
|
||||
|
||||
// CreatedAtNotIn applies the NotIn predicate on the "created_at" field.
|
||||
func CreatedAtNotIn(vs ...time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldCreatedAt, vs...))
|
||||
}
|
||||
|
||||
// CreatedAtGT applies the GT predicate on the "created_at" field.
|
||||
func CreatedAtGT(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtGTE applies the GTE predicate on the "created_at" field.
|
||||
func CreatedAtGTE(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtLT applies the LT predicate on the "created_at" field.
|
||||
func CreatedAtLT(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// CreatedAtLTE applies the LTE predicate on the "created_at" field.
|
||||
func CreatedAtLTE(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldCreatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtEQ applies the EQ predicate on the "updated_at" field.
|
||||
func UpdatedAtEQ(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field.
|
||||
func UpdatedAtNEQ(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtIn applies the In predicate on the "updated_at" field.
|
||||
func UpdatedAtIn(vs ...time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldUpdatedAt, vs...))
|
||||
}
|
||||
|
||||
// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field.
|
||||
func UpdatedAtNotIn(vs ...time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldUpdatedAt, vs...))
|
||||
}
|
||||
|
||||
// UpdatedAtGT applies the GT predicate on the "updated_at" field.
|
||||
func UpdatedAtGT(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtGTE applies the GTE predicate on the "updated_at" field.
|
||||
func UpdatedAtGTE(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtLT applies the LT predicate on the "updated_at" field.
|
||||
func UpdatedAtLT(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// UpdatedAtLTE applies the LTE predicate on the "updated_at" field.
|
||||
func UpdatedAtLTE(v time.Time) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldUpdatedAt, v))
|
||||
}
|
||||
|
||||
// NameEQ applies the EQ predicate on the "name" field.
|
||||
func NameEQ(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldName, v))
|
||||
}
|
||||
|
||||
// NameNEQ applies the NEQ predicate on the "name" field.
|
||||
func NameNEQ(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldName, v))
|
||||
}
|
||||
|
||||
// NameIn applies the In predicate on the "name" field.
|
||||
func NameIn(vs ...string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldName, vs...))
|
||||
}
|
||||
|
||||
// NameNotIn applies the NotIn predicate on the "name" field.
|
||||
func NameNotIn(vs ...string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldName, vs...))
|
||||
}
|
||||
|
||||
// NameGT applies the GT predicate on the "name" field.
|
||||
func NameGT(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldName, v))
|
||||
}
|
||||
|
||||
// NameGTE applies the GTE predicate on the "name" field.
|
||||
func NameGTE(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldName, v))
|
||||
}
|
||||
|
||||
// NameLT applies the LT predicate on the "name" field.
|
||||
func NameLT(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldName, v))
|
||||
}
|
||||
|
||||
// NameLTE applies the LTE predicate on the "name" field.
|
||||
func NameLTE(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldName, v))
|
||||
}
|
||||
|
||||
// NameContains applies the Contains predicate on the "name" field.
|
||||
func NameContains(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldContains(FieldName, v))
|
||||
}
|
||||
|
||||
// NameHasPrefix applies the HasPrefix predicate on the "name" field.
|
||||
func NameHasPrefix(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldHasPrefix(FieldName, v))
|
||||
}
|
||||
|
||||
// NameHasSuffix applies the HasSuffix predicate on the "name" field.
|
||||
func NameHasSuffix(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldHasSuffix(FieldName, v))
|
||||
}
|
||||
|
||||
// NameEqualFold applies the EqualFold predicate on the "name" field.
|
||||
func NameEqualFold(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEqualFold(FieldName, v))
|
||||
}
|
||||
|
||||
// NameContainsFold applies the ContainsFold predicate on the "name" field.
|
||||
func NameContainsFold(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldContainsFold(FieldName, v))
|
||||
}
|
||||
|
||||
// DescriptionEQ applies the EQ predicate on the "description" field.
|
||||
func DescriptionEQ(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionNEQ applies the NEQ predicate on the "description" field.
|
||||
func DescriptionNEQ(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionIn applies the In predicate on the "description" field.
|
||||
func DescriptionIn(vs ...string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIn(FieldDescription, vs...))
|
||||
}
|
||||
|
||||
// DescriptionNotIn applies the NotIn predicate on the "description" field.
|
||||
func DescriptionNotIn(vs ...string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotIn(FieldDescription, vs...))
|
||||
}
|
||||
|
||||
// DescriptionGT applies the GT predicate on the "description" field.
|
||||
func DescriptionGT(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGT(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionGTE applies the GTE predicate on the "description" field.
|
||||
func DescriptionGTE(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldGTE(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionLT applies the LT predicate on the "description" field.
|
||||
func DescriptionLT(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLT(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionLTE applies the LTE predicate on the "description" field.
|
||||
func DescriptionLTE(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldLTE(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionContains applies the Contains predicate on the "description" field.
|
||||
func DescriptionContains(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldContains(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionHasPrefix applies the HasPrefix predicate on the "description" field.
|
||||
func DescriptionHasPrefix(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldHasPrefix(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionHasSuffix applies the HasSuffix predicate on the "description" field.
|
||||
func DescriptionHasSuffix(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldHasSuffix(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionIsNil applies the IsNil predicate on the "description" field.
|
||||
func DescriptionIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldDescription))
|
||||
}
|
||||
|
||||
// DescriptionNotNil applies the NotNil predicate on the "description" field.
|
||||
func DescriptionNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldDescription))
|
||||
}
|
||||
|
||||
// DescriptionEqualFold applies the EqualFold predicate on the "description" field.
|
||||
func DescriptionEqualFold(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEqualFold(FieldDescription, v))
|
||||
}
|
||||
|
||||
// DescriptionContainsFold applies the ContainsFold predicate on the "description" field.
|
||||
func DescriptionContainsFold(v string) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldContainsFold(FieldDescription, v))
|
||||
}
|
||||
|
||||
// EnableGreaseEQ applies the EQ predicate on the "enable_grease" field.
|
||||
func EnableGreaseEQ(v bool) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldEQ(FieldEnableGrease, v))
|
||||
}
|
||||
|
||||
// EnableGreaseNEQ applies the NEQ predicate on the "enable_grease" field.
|
||||
func EnableGreaseNEQ(v bool) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNEQ(FieldEnableGrease, v))
|
||||
}
|
||||
|
||||
// CipherSuitesIsNil applies the IsNil predicate on the "cipher_suites" field.
|
||||
func CipherSuitesIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldCipherSuites))
|
||||
}
|
||||
|
||||
// CipherSuitesNotNil applies the NotNil predicate on the "cipher_suites" field.
|
||||
func CipherSuitesNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldCipherSuites))
|
||||
}
|
||||
|
||||
// CurvesIsNil applies the IsNil predicate on the "curves" field.
|
||||
func CurvesIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldCurves))
|
||||
}
|
||||
|
||||
// CurvesNotNil applies the NotNil predicate on the "curves" field.
|
||||
func CurvesNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldCurves))
|
||||
}
|
||||
|
||||
// PointFormatsIsNil applies the IsNil predicate on the "point_formats" field.
|
||||
func PointFormatsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldPointFormats))
|
||||
}
|
||||
|
||||
// PointFormatsNotNil applies the NotNil predicate on the "point_formats" field.
|
||||
func PointFormatsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldPointFormats))
|
||||
}
|
||||
|
||||
// SignatureAlgorithmsIsNil applies the IsNil predicate on the "signature_algorithms" field.
|
||||
func SignatureAlgorithmsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldSignatureAlgorithms))
|
||||
}
|
||||
|
||||
// SignatureAlgorithmsNotNil applies the NotNil predicate on the "signature_algorithms" field.
|
||||
func SignatureAlgorithmsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldSignatureAlgorithms))
|
||||
}
|
||||
|
||||
// AlpnProtocolsIsNil applies the IsNil predicate on the "alpn_protocols" field.
|
||||
func AlpnProtocolsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldAlpnProtocols))
|
||||
}
|
||||
|
||||
// AlpnProtocolsNotNil applies the NotNil predicate on the "alpn_protocols" field.
|
||||
func AlpnProtocolsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldAlpnProtocols))
|
||||
}
|
||||
|
||||
// SupportedVersionsIsNil applies the IsNil predicate on the "supported_versions" field.
|
||||
func SupportedVersionsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldSupportedVersions))
|
||||
}
|
||||
|
||||
// SupportedVersionsNotNil applies the NotNil predicate on the "supported_versions" field.
|
||||
func SupportedVersionsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldSupportedVersions))
|
||||
}
|
||||
|
||||
// KeyShareGroupsIsNil applies the IsNil predicate on the "key_share_groups" field.
|
||||
func KeyShareGroupsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldKeyShareGroups))
|
||||
}
|
||||
|
||||
// KeyShareGroupsNotNil applies the NotNil predicate on the "key_share_groups" field.
|
||||
func KeyShareGroupsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldKeyShareGroups))
|
||||
}
|
||||
|
||||
// PskModesIsNil applies the IsNil predicate on the "psk_modes" field.
|
||||
func PskModesIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldPskModes))
|
||||
}
|
||||
|
||||
// PskModesNotNil applies the NotNil predicate on the "psk_modes" field.
|
||||
func PskModesNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldPskModes))
|
||||
}
|
||||
|
||||
// ExtensionsIsNil applies the IsNil predicate on the "extensions" field.
|
||||
func ExtensionsIsNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldIsNull(FieldExtensions))
|
||||
}
|
||||
|
||||
// ExtensionsNotNil applies the NotNil predicate on the "extensions" field.
|
||||
func ExtensionsNotNil() predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.FieldNotNull(FieldExtensions))
|
||||
}
|
||||
|
||||
// And groups predicates with the AND operator between them.
|
||||
func And(predicates ...predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.AndPredicates(predicates...))
|
||||
}
|
||||
|
||||
// Or groups predicates with the OR operator between them.
|
||||
func Or(predicates ...predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.OrPredicates(predicates...))
|
||||
}
|
||||
|
||||
// Not applies the not operator on the given predicate.
|
||||
func Not(p predicate.TLSFingerprintProfile) predicate.TLSFingerprintProfile {
|
||||
return predicate.TLSFingerprintProfile(sql.NotPredicates(p))
|
||||
}
|
||||
1341
backend/ent/tlsfingerprintprofile_create.go
Normal file
1341
backend/ent/tlsfingerprintprofile_create.go
Normal file
File diff suppressed because it is too large
Load Diff
88
backend/ent/tlsfingerprintprofile_delete.go
Normal file
88
backend/ent/tlsfingerprintprofile_delete.go
Normal file
@ -0,0 +1,88 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/dialect/sql/sqlgraph"
|
||||
"entgo.io/ent/schema/field"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileDelete is the builder for deleting a TLSFingerprintProfile entity.
|
||||
type TLSFingerprintProfileDelete struct {
|
||||
config
|
||||
hooks []Hook
|
||||
mutation *TLSFingerprintProfileMutation
|
||||
}
|
||||
|
||||
// Where appends a list predicates to the TLSFingerprintProfileDelete builder.
|
||||
func (_d *TLSFingerprintProfileDelete) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileDelete {
|
||||
_d.mutation.Where(ps...)
|
||||
return _d
|
||||
}
|
||||
|
||||
// Exec executes the deletion query and returns how many vertices were deleted.
|
||||
func (_d *TLSFingerprintProfileDelete) Exec(ctx context.Context) (int, error) {
|
||||
return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks)
|
||||
}
|
||||
|
||||
// ExecX is like Exec, but panics if an error occurs.
|
||||
func (_d *TLSFingerprintProfileDelete) ExecX(ctx context.Context) int {
|
||||
n, err := _d.Exec(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (_d *TLSFingerprintProfileDelete) sqlExec(ctx context.Context) (int, error) {
|
||||
_spec := sqlgraph.NewDeleteSpec(tlsfingerprintprofile.Table, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
|
||||
if ps := _d.mutation.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec)
|
||||
if err != nil && sqlgraph.IsConstraintError(err) {
|
||||
err = &ConstraintError{msg: err.Error(), wrap: err}
|
||||
}
|
||||
_d.mutation.done = true
|
||||
return affected, err
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileDeleteOne is the builder for deleting a single TLSFingerprintProfile entity.
|
||||
type TLSFingerprintProfileDeleteOne struct {
|
||||
_d *TLSFingerprintProfileDelete
|
||||
}
|
||||
|
||||
// Where appends a list predicates to the TLSFingerprintProfileDelete builder.
|
||||
func (_d *TLSFingerprintProfileDeleteOne) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileDeleteOne {
|
||||
_d._d.mutation.Where(ps...)
|
||||
return _d
|
||||
}
|
||||
|
||||
// Exec executes the deletion query.
|
||||
func (_d *TLSFingerprintProfileDeleteOne) Exec(ctx context.Context) error {
|
||||
n, err := _d._d.Exec(ctx)
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case n == 0:
|
||||
return &NotFoundError{tlsfingerprintprofile.Label}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExecX is like Exec, but panics if an error occurs.
|
||||
func (_d *TLSFingerprintProfileDeleteOne) ExecX(ctx context.Context) {
|
||||
if err := _d.Exec(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
564
backend/ent/tlsfingerprintprofile_query.go
Normal file
564
backend/ent/tlsfingerprintprofile_query.go
Normal file
@ -0,0 +1,564 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/dialect/sql/sqlgraph"
|
||||
"entgo.io/ent/schema/field"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileQuery is the builder for querying TLSFingerprintProfile entities.
|
||||
type TLSFingerprintProfileQuery struct {
|
||||
config
|
||||
ctx *QueryContext
|
||||
order []tlsfingerprintprofile.OrderOption
|
||||
inters []Interceptor
|
||||
predicates []predicate.TLSFingerprintProfile
|
||||
modifiers []func(*sql.Selector)
|
||||
// intermediate query (i.e. traversal path).
|
||||
sql *sql.Selector
|
||||
path func(context.Context) (*sql.Selector, error)
|
||||
}
|
||||
|
||||
// Where adds a new predicate for the TLSFingerprintProfileQuery builder.
|
||||
func (_q *TLSFingerprintProfileQuery) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileQuery {
|
||||
_q.predicates = append(_q.predicates, ps...)
|
||||
return _q
|
||||
}
|
||||
|
||||
// Limit the number of records to be returned by this query.
|
||||
func (_q *TLSFingerprintProfileQuery) Limit(limit int) *TLSFingerprintProfileQuery {
|
||||
_q.ctx.Limit = &limit
|
||||
return _q
|
||||
}
|
||||
|
||||
// Offset to start from.
|
||||
func (_q *TLSFingerprintProfileQuery) Offset(offset int) *TLSFingerprintProfileQuery {
|
||||
_q.ctx.Offset = &offset
|
||||
return _q
|
||||
}
|
||||
|
||||
// Unique configures the query builder to filter duplicate records on query.
|
||||
// By default, unique is set to true, and can be disabled using this method.
|
||||
func (_q *TLSFingerprintProfileQuery) Unique(unique bool) *TLSFingerprintProfileQuery {
|
||||
_q.ctx.Unique = &unique
|
||||
return _q
|
||||
}
|
||||
|
||||
// Order specifies how the records should be ordered.
|
||||
func (_q *TLSFingerprintProfileQuery) Order(o ...tlsfingerprintprofile.OrderOption) *TLSFingerprintProfileQuery {
|
||||
_q.order = append(_q.order, o...)
|
||||
return _q
|
||||
}
|
||||
|
||||
// First returns the first TLSFingerprintProfile entity from the query.
|
||||
// Returns a *NotFoundError when no TLSFingerprintProfile was found.
|
||||
func (_q *TLSFingerprintProfileQuery) First(ctx context.Context) (*TLSFingerprintProfile, error) {
|
||||
nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nil, &NotFoundError{tlsfingerprintprofile.Label}
|
||||
}
|
||||
return nodes[0], nil
|
||||
}
|
||||
|
||||
// FirstX is like First, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) FirstX(ctx context.Context) *TLSFingerprintProfile {
|
||||
node, err := _q.First(ctx)
|
||||
if err != nil && !IsNotFound(err) {
|
||||
panic(err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// FirstID returns the first TLSFingerprintProfile ID from the query.
|
||||
// Returns a *NotFoundError when no TLSFingerprintProfile ID was found.
|
||||
func (_q *TLSFingerprintProfileQuery) FirstID(ctx context.Context) (id int64, err error) {
|
||||
var ids []int64
|
||||
if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil {
|
||||
return
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
err = &NotFoundError{tlsfingerprintprofile.Label}
|
||||
return
|
||||
}
|
||||
return ids[0], nil
|
||||
}
|
||||
|
||||
// FirstIDX is like FirstID, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) FirstIDX(ctx context.Context) int64 {
|
||||
id, err := _q.FirstID(ctx)
|
||||
if err != nil && !IsNotFound(err) {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Only returns a single TLSFingerprintProfile entity found by the query, ensuring it only returns one.
|
||||
// Returns a *NotSingularError when more than one TLSFingerprintProfile entity is found.
|
||||
// Returns a *NotFoundError when no TLSFingerprintProfile entities are found.
|
||||
func (_q *TLSFingerprintProfileQuery) Only(ctx context.Context) (*TLSFingerprintProfile, error) {
|
||||
nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch len(nodes) {
|
||||
case 1:
|
||||
return nodes[0], nil
|
||||
case 0:
|
||||
return nil, &NotFoundError{tlsfingerprintprofile.Label}
|
||||
default:
|
||||
return nil, &NotSingularError{tlsfingerprintprofile.Label}
|
||||
}
|
||||
}
|
||||
|
||||
// OnlyX is like Only, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) OnlyX(ctx context.Context) *TLSFingerprintProfile {
|
||||
node, err := _q.Only(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// OnlyID is like Only, but returns the only TLSFingerprintProfile ID in the query.
|
||||
// Returns a *NotSingularError when more than one TLSFingerprintProfile ID is found.
|
||||
// Returns a *NotFoundError when no entities are found.
|
||||
func (_q *TLSFingerprintProfileQuery) OnlyID(ctx context.Context) (id int64, err error) {
|
||||
var ids []int64
|
||||
if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil {
|
||||
return
|
||||
}
|
||||
switch len(ids) {
|
||||
case 1:
|
||||
id = ids[0]
|
||||
case 0:
|
||||
err = &NotFoundError{tlsfingerprintprofile.Label}
|
||||
default:
|
||||
err = &NotSingularError{tlsfingerprintprofile.Label}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// OnlyIDX is like OnlyID, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) OnlyIDX(ctx context.Context) int64 {
|
||||
id, err := _q.OnlyID(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// All executes the query and returns a list of TLSFingerprintProfiles.
|
||||
func (_q *TLSFingerprintProfileQuery) All(ctx context.Context) ([]*TLSFingerprintProfile, error) {
|
||||
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll)
|
||||
if err := _q.prepareQuery(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qr := querierAll[[]*TLSFingerprintProfile, *TLSFingerprintProfileQuery]()
|
||||
return withInterceptors[[]*TLSFingerprintProfile](ctx, _q, qr, _q.inters)
|
||||
}
|
||||
|
||||
// AllX is like All, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) AllX(ctx context.Context) []*TLSFingerprintProfile {
|
||||
nodes, err := _q.All(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// IDs executes the query and returns a list of TLSFingerprintProfile IDs.
|
||||
func (_q *TLSFingerprintProfileQuery) IDs(ctx context.Context) (ids []int64, err error) {
|
||||
if _q.ctx.Unique == nil && _q.path != nil {
|
||||
_q.Unique(true)
|
||||
}
|
||||
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs)
|
||||
if err = _q.Select(tlsfingerprintprofile.FieldID).Scan(ctx, &ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// IDsX is like IDs, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) IDsX(ctx context.Context) []int64 {
|
||||
ids, err := _q.IDs(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// Count returns the count of the given query.
|
||||
func (_q *TLSFingerprintProfileQuery) Count(ctx context.Context) (int, error) {
|
||||
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount)
|
||||
if err := _q.prepareQuery(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return withInterceptors[int](ctx, _q, querierCount[*TLSFingerprintProfileQuery](), _q.inters)
|
||||
}
|
||||
|
||||
// CountX is like Count, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) CountX(ctx context.Context) int {
|
||||
count, err := _q.Count(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// Exist returns true if the query has elements in the graph.
|
||||
func (_q *TLSFingerprintProfileQuery) Exist(ctx context.Context) (bool, error) {
|
||||
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist)
|
||||
switch _, err := _q.FirstID(ctx); {
|
||||
case IsNotFound(err):
|
||||
return false, nil
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("ent: check existence: %w", err)
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExistX is like Exist, but panics if an error occurs.
|
||||
func (_q *TLSFingerprintProfileQuery) ExistX(ctx context.Context) bool {
|
||||
exist, err := _q.Exist(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return exist
|
||||
}
|
||||
|
||||
// Clone returns a duplicate of the TLSFingerprintProfileQuery builder, including all associated steps. It can be
|
||||
// used to prepare common query builders and use them differently after the clone is made.
|
||||
func (_q *TLSFingerprintProfileQuery) Clone() *TLSFingerprintProfileQuery {
|
||||
if _q == nil {
|
||||
return nil
|
||||
}
|
||||
return &TLSFingerprintProfileQuery{
|
||||
config: _q.config,
|
||||
ctx: _q.ctx.Clone(),
|
||||
order: append([]tlsfingerprintprofile.OrderOption{}, _q.order...),
|
||||
inters: append([]Interceptor{}, _q.inters...),
|
||||
predicates: append([]predicate.TLSFingerprintProfile{}, _q.predicates...),
|
||||
// clone intermediate query.
|
||||
sql: _q.sql.Clone(),
|
||||
path: _q.path,
|
||||
}
|
||||
}
|
||||
|
||||
// GroupBy is used to group vertices by one or more fields/columns.
|
||||
// It is often used with aggregate functions, like: count, max, mean, min, sum.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var v []struct {
|
||||
// CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
// Count int `json:"count,omitempty"`
|
||||
// }
|
||||
//
|
||||
// client.TLSFingerprintProfile.Query().
|
||||
// GroupBy(tlsfingerprintprofile.FieldCreatedAt).
|
||||
// Aggregate(ent.Count()).
|
||||
// Scan(ctx, &v)
|
||||
func (_q *TLSFingerprintProfileQuery) GroupBy(field string, fields ...string) *TLSFingerprintProfileGroupBy {
|
||||
_q.ctx.Fields = append([]string{field}, fields...)
|
||||
grbuild := &TLSFingerprintProfileGroupBy{build: _q}
|
||||
grbuild.flds = &_q.ctx.Fields
|
||||
grbuild.label = tlsfingerprintprofile.Label
|
||||
grbuild.scan = grbuild.Scan
|
||||
return grbuild
|
||||
}
|
||||
|
||||
// Select allows the selection one or more fields/columns for the given query,
|
||||
// instead of selecting all fields in the entity.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var v []struct {
|
||||
// CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
// }
|
||||
//
|
||||
// client.TLSFingerprintProfile.Query().
|
||||
// Select(tlsfingerprintprofile.FieldCreatedAt).
|
||||
// Scan(ctx, &v)
|
||||
func (_q *TLSFingerprintProfileQuery) Select(fields ...string) *TLSFingerprintProfileSelect {
|
||||
_q.ctx.Fields = append(_q.ctx.Fields, fields...)
|
||||
sbuild := &TLSFingerprintProfileSelect{TLSFingerprintProfileQuery: _q}
|
||||
sbuild.label = tlsfingerprintprofile.Label
|
||||
sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan
|
||||
return sbuild
|
||||
}
|
||||
|
||||
// Aggregate returns a TLSFingerprintProfileSelect configured with the given aggregations.
|
||||
func (_q *TLSFingerprintProfileQuery) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileSelect {
|
||||
return _q.Select().Aggregate(fns...)
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) prepareQuery(ctx context.Context) error {
|
||||
for _, inter := range _q.inters {
|
||||
if inter == nil {
|
||||
return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)")
|
||||
}
|
||||
if trv, ok := inter.(Traverser); ok {
|
||||
if err := trv.Traverse(ctx, _q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range _q.ctx.Fields {
|
||||
if !tlsfingerprintprofile.ValidColumn(f) {
|
||||
return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
|
||||
}
|
||||
}
|
||||
if _q.path != nil {
|
||||
prev, err := _q.path(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_q.sql = prev
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*TLSFingerprintProfile, error) {
|
||||
var (
|
||||
nodes = []*TLSFingerprintProfile{}
|
||||
_spec = _q.querySpec()
|
||||
)
|
||||
_spec.ScanValues = func(columns []string) ([]any, error) {
|
||||
return (*TLSFingerprintProfile).scanValues(nil, columns)
|
||||
}
|
||||
_spec.Assign = func(columns []string, values []any) error {
|
||||
node := &TLSFingerprintProfile{config: _q.config}
|
||||
nodes = append(nodes, node)
|
||||
return node.assignValues(columns, values)
|
||||
}
|
||||
if len(_q.modifiers) > 0 {
|
||||
_spec.Modifiers = _q.modifiers
|
||||
}
|
||||
for i := range hooks {
|
||||
hooks[i](ctx, _spec)
|
||||
}
|
||||
if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) sqlCount(ctx context.Context) (int, error) {
|
||||
_spec := _q.querySpec()
|
||||
if len(_q.modifiers) > 0 {
|
||||
_spec.Modifiers = _q.modifiers
|
||||
}
|
||||
_spec.Node.Columns = _q.ctx.Fields
|
||||
if len(_q.ctx.Fields) > 0 {
|
||||
_spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique
|
||||
}
|
||||
return sqlgraph.CountNodes(ctx, _q.driver, _spec)
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) querySpec() *sqlgraph.QuerySpec {
|
||||
_spec := sqlgraph.NewQuerySpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
|
||||
_spec.From = _q.sql
|
||||
if unique := _q.ctx.Unique; unique != nil {
|
||||
_spec.Unique = *unique
|
||||
} else if _q.path != nil {
|
||||
_spec.Unique = true
|
||||
}
|
||||
if fields := _q.ctx.Fields; len(fields) > 0 {
|
||||
_spec.Node.Columns = make([]string, 0, len(fields))
|
||||
_spec.Node.Columns = append(_spec.Node.Columns, tlsfingerprintprofile.FieldID)
|
||||
for i := range fields {
|
||||
if fields[i] != tlsfingerprintprofile.FieldID {
|
||||
_spec.Node.Columns = append(_spec.Node.Columns, fields[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
if ps := _q.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
if limit := _q.ctx.Limit; limit != nil {
|
||||
_spec.Limit = *limit
|
||||
}
|
||||
if offset := _q.ctx.Offset; offset != nil {
|
||||
_spec.Offset = *offset
|
||||
}
|
||||
if ps := _q.order; len(ps) > 0 {
|
||||
_spec.Order = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
return _spec
|
||||
}
|
||||
|
||||
func (_q *TLSFingerprintProfileQuery) sqlQuery(ctx context.Context) *sql.Selector {
|
||||
builder := sql.Dialect(_q.driver.Dialect())
|
||||
t1 := builder.Table(tlsfingerprintprofile.Table)
|
||||
columns := _q.ctx.Fields
|
||||
if len(columns) == 0 {
|
||||
columns = tlsfingerprintprofile.Columns
|
||||
}
|
||||
selector := builder.Select(t1.Columns(columns...)...).From(t1)
|
||||
if _q.sql != nil {
|
||||
selector = _q.sql
|
||||
selector.Select(selector.Columns(columns...)...)
|
||||
}
|
||||
if _q.ctx.Unique != nil && *_q.ctx.Unique {
|
||||
selector.Distinct()
|
||||
}
|
||||
for _, m := range _q.modifiers {
|
||||
m(selector)
|
||||
}
|
||||
for _, p := range _q.predicates {
|
||||
p(selector)
|
||||
}
|
||||
for _, p := range _q.order {
|
||||
p(selector)
|
||||
}
|
||||
if offset := _q.ctx.Offset; offset != nil {
|
||||
// limit is mandatory for offset clause. We start
|
||||
// with default value, and override it below if needed.
|
||||
selector.Offset(*offset).Limit(math.MaxInt32)
|
||||
}
|
||||
if limit := _q.ctx.Limit; limit != nil {
|
||||
selector.Limit(*limit)
|
||||
}
|
||||
return selector
|
||||
}
|
||||
|
||||
// ForUpdate locks the selected rows against concurrent updates, and prevent them from being
|
||||
// updated, deleted or "selected ... for update" by other sessions, until the transaction is
|
||||
// either committed or rolled-back.
|
||||
func (_q *TLSFingerprintProfileQuery) ForUpdate(opts ...sql.LockOption) *TLSFingerprintProfileQuery {
|
||||
if _q.driver.Dialect() == dialect.Postgres {
|
||||
_q.Unique(false)
|
||||
}
|
||||
_q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
|
||||
s.ForUpdate(opts...)
|
||||
})
|
||||
return _q
|
||||
}
|
||||
|
||||
// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock
|
||||
// on any rows that are read. Other sessions can read the rows, but cannot modify them
|
||||
// until your transaction commits.
|
||||
func (_q *TLSFingerprintProfileQuery) ForShare(opts ...sql.LockOption) *TLSFingerprintProfileQuery {
|
||||
if _q.driver.Dialect() == dialect.Postgres {
|
||||
_q.Unique(false)
|
||||
}
|
||||
_q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
|
||||
s.ForShare(opts...)
|
||||
})
|
||||
return _q
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileGroupBy is the group-by builder for TLSFingerprintProfile entities.
|
||||
type TLSFingerprintProfileGroupBy struct {
|
||||
selector
|
||||
build *TLSFingerprintProfileQuery
|
||||
}
|
||||
|
||||
// Aggregate adds the given aggregation functions to the group-by query.
|
||||
func (_g *TLSFingerprintProfileGroupBy) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileGroupBy {
|
||||
_g.fns = append(_g.fns, fns...)
|
||||
return _g
|
||||
}
|
||||
|
||||
// Scan applies the selector query and scans the result into the given value.
|
||||
func (_g *TLSFingerprintProfileGroupBy) Scan(ctx context.Context, v any) error {
|
||||
ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy)
|
||||
if err := _g.build.prepareQuery(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return scanWithInterceptors[*TLSFingerprintProfileQuery, *TLSFingerprintProfileGroupBy](ctx, _g.build, _g, _g.build.inters, v)
|
||||
}
|
||||
|
||||
func (_g *TLSFingerprintProfileGroupBy) sqlScan(ctx context.Context, root *TLSFingerprintProfileQuery, v any) error {
|
||||
selector := root.sqlQuery(ctx).Select()
|
||||
aggregation := make([]string, 0, len(_g.fns))
|
||||
for _, fn := range _g.fns {
|
||||
aggregation = append(aggregation, fn(selector))
|
||||
}
|
||||
if len(selector.SelectedColumns()) == 0 {
|
||||
columns := make([]string, 0, len(*_g.flds)+len(_g.fns))
|
||||
for _, f := range *_g.flds {
|
||||
columns = append(columns, selector.C(f))
|
||||
}
|
||||
columns = append(columns, aggregation...)
|
||||
selector.Select(columns...)
|
||||
}
|
||||
selector.GroupBy(selector.Columns(*_g.flds...)...)
|
||||
if err := selector.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
rows := &sql.Rows{}
|
||||
query, args := selector.Query()
|
||||
if err := _g.build.driver.Query(ctx, query, args, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
return sql.ScanSlice(rows, v)
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileSelect is the builder for selecting fields of TLSFingerprintProfile entities.
|
||||
type TLSFingerprintProfileSelect struct {
|
||||
*TLSFingerprintProfileQuery
|
||||
selector
|
||||
}
|
||||
|
||||
// Aggregate adds the given aggregation functions to the selector query.
|
||||
func (_s *TLSFingerprintProfileSelect) Aggregate(fns ...AggregateFunc) *TLSFingerprintProfileSelect {
|
||||
_s.fns = append(_s.fns, fns...)
|
||||
return _s
|
||||
}
|
||||
|
||||
// Scan applies the selector query and scans the result into the given value.
|
||||
func (_s *TLSFingerprintProfileSelect) Scan(ctx context.Context, v any) error {
|
||||
ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect)
|
||||
if err := _s.prepareQuery(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return scanWithInterceptors[*TLSFingerprintProfileQuery, *TLSFingerprintProfileSelect](ctx, _s.TLSFingerprintProfileQuery, _s, _s.inters, v)
|
||||
}
|
||||
|
||||
func (_s *TLSFingerprintProfileSelect) sqlScan(ctx context.Context, root *TLSFingerprintProfileQuery, v any) error {
|
||||
selector := root.sqlQuery(ctx)
|
||||
aggregation := make([]string, 0, len(_s.fns))
|
||||
for _, fn := range _s.fns {
|
||||
aggregation = append(aggregation, fn(selector))
|
||||
}
|
||||
switch n := len(*_s.selector.flds); {
|
||||
case n == 0 && len(aggregation) > 0:
|
||||
selector.Select(aggregation...)
|
||||
case n != 0 && len(aggregation) > 0:
|
||||
selector.AppendSelect(aggregation...)
|
||||
}
|
||||
rows := &sql.Rows{}
|
||||
query, args := selector.Query()
|
||||
if err := _s.driver.Query(ctx, query, args, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
return sql.ScanSlice(rows, v)
|
||||
}
|
||||
881
backend/ent/tlsfingerprintprofile_update.go
Normal file
881
backend/ent/tlsfingerprintprofile_update.go
Normal file
@ -0,0 +1,881 @@
|
||||
// Code generated by ent, DO NOT EDIT.
|
||||
|
||||
package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/dialect/sql/sqlgraph"
|
||||
"entgo.io/ent/dialect/sql/sqljson"
|
||||
"entgo.io/ent/schema/field"
|
||||
"github.com/Wei-Shaw/sub2api/ent/predicate"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileUpdate is the builder for updating TLSFingerprintProfile entities.
|
||||
type TLSFingerprintProfileUpdate struct {
|
||||
config
|
||||
hooks []Hook
|
||||
mutation *TLSFingerprintProfileMutation
|
||||
}
|
||||
|
||||
// Where appends a list predicates to the TLSFingerprintProfileUpdate builder.
|
||||
func (_u *TLSFingerprintProfileUpdate) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.Where(ps...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetUpdatedAt sets the "updated_at" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetUpdatedAt(v time.Time) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetUpdatedAt(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetName sets the "name" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetName(v string) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetName(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableName sets the "name" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetNillableName(v *string) *TLSFingerprintProfileUpdate {
|
||||
if v != nil {
|
||||
_u.SetName(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetDescription sets the "description" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetDescription(v string) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetDescription(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableDescription sets the "description" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetNillableDescription(v *string) *TLSFingerprintProfileUpdate {
|
||||
if v != nil {
|
||||
_u.SetDescription(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearDescription clears the value of the "description" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearDescription() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearDescription()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetEnableGrease sets the "enable_grease" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetEnableGrease(v bool) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetEnableGrease(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableEnableGrease sets the "enable_grease" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetNillableEnableGrease(v *bool) *TLSFingerprintProfileUpdate {
|
||||
if v != nil {
|
||||
_u.SetEnableGrease(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCipherSuites sets the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetCipherSuites(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetCipherSuites(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendCipherSuites appends value to the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendCipherSuites(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendCipherSuites(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearCipherSuites clears the value of the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearCipherSuites() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearCipherSuites()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCurves sets the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetCurves(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetCurves(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendCurves appends value to the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendCurves(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendCurves(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearCurves clears the value of the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearCurves() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearCurves()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPointFormats sets the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetPointFormats(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetPointFormats(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendPointFormats appends value to the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendPointFormats(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendPointFormats(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearPointFormats clears the value of the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearPointFormats() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearPointFormats()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSignatureAlgorithms sets the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetSignatureAlgorithms(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendSignatureAlgorithms appends value to the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendSignatureAlgorithms(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSignatureAlgorithms clears the value of the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearSignatureAlgorithms() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearSignatureAlgorithms()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAlpnProtocols sets the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetAlpnProtocols(v []string) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetAlpnProtocols(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendAlpnProtocols appends value to the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendAlpnProtocols(v []string) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendAlpnProtocols(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAlpnProtocols clears the value of the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearAlpnProtocols() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearAlpnProtocols()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSupportedVersions sets the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetSupportedVersions(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetSupportedVersions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendSupportedVersions appends value to the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendSupportedVersions(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendSupportedVersions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSupportedVersions clears the value of the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearSupportedVersions() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearSupportedVersions()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetKeyShareGroups sets the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetKeyShareGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendKeyShareGroups appends value to the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendKeyShareGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearKeyShareGroups clears the value of the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearKeyShareGroups() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearKeyShareGroups()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPskModes sets the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetPskModes(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetPskModes(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendPskModes appends value to the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendPskModes(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendPskModes(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearPskModes clears the value of the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearPskModes() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearPskModes()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetExtensions sets the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) SetExtensions(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.SetExtensions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendExtensions appends value to the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) AppendExtensions(v []uint16) *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.AppendExtensions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearExtensions clears the value of the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdate) ClearExtensions() *TLSFingerprintProfileUpdate {
|
||||
_u.mutation.ClearExtensions()
|
||||
return _u
|
||||
}
|
||||
|
||||
// Mutation returns the TLSFingerprintProfileMutation object of the builder.
|
||||
func (_u *TLSFingerprintProfileUpdate) Mutation() *TLSFingerprintProfileMutation {
|
||||
return _u.mutation
|
||||
}
|
||||
|
||||
// Save executes the query and returns the number of nodes affected by the update operation.
|
||||
func (_u *TLSFingerprintProfileUpdate) Save(ctx context.Context) (int, error) {
|
||||
_u.defaults()
|
||||
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
|
||||
}
|
||||
|
||||
// SaveX is like Save, but panics if an error occurs.
|
||||
func (_u *TLSFingerprintProfileUpdate) SaveX(ctx context.Context) int {
|
||||
affected, err := _u.Save(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return affected
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (_u *TLSFingerprintProfileUpdate) Exec(ctx context.Context) error {
|
||||
_, err := _u.Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecX is like Exec, but panics if an error occurs.
|
||||
func (_u *TLSFingerprintProfileUpdate) ExecX(ctx context.Context) {
|
||||
if err := _u.Exec(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaults sets the default values of the builder before save.
|
||||
func (_u *TLSFingerprintProfileUpdate) defaults() {
|
||||
if _, ok := _u.mutation.UpdatedAt(); !ok {
|
||||
v := tlsfingerprintprofile.UpdateDefaultUpdatedAt()
|
||||
_u.mutation.SetUpdatedAt(v)
|
||||
}
|
||||
}
|
||||
|
||||
// check runs all checks and user-defined validators on the builder.
|
||||
func (_u *TLSFingerprintProfileUpdate) check() error {
|
||||
if v, ok := _u.mutation.Name(); ok {
|
||||
if err := tlsfingerprintprofile.NameValidator(v); err != nil {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "TLSFingerprintProfile.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (_u *TLSFingerprintProfileUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if err := _u.check(); err != nil {
|
||||
return _node, err
|
||||
}
|
||||
_spec := sqlgraph.NewUpdateSpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
|
||||
if ps := _u.mutation.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok := _u.mutation.UpdatedAt(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldUpdatedAt, field.TypeTime, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Name(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldName, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Description(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldDescription, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.DescriptionCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldDescription, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.EnableGrease(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldEnableGrease, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CipherSuites(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedCipherSuites(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldCipherSuites, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.CipherSuitesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.Curves(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldCurves, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedCurves(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldCurves, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.CurvesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldCurves, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.PointFormats(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedPointFormats(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldPointFormats, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.PointFormatsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.SignatureAlgorithms(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedSignatureAlgorithms(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldSignatureAlgorithms, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.SignatureAlgorithmsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.AlpnProtocols(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedAlpnProtocols(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldAlpnProtocols, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.AlpnProtocolsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.SupportedVersions(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedSupportedVersions(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldSupportedVersions, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.SupportedVersionsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.KeyShareGroups(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedKeyShareGroups(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldKeyShareGroups, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.KeyShareGroupsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.PskModes(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedPskModes(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldPskModes, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.PskModesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.Extensions(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedExtensions(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldExtensions, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.ExtensionsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON)
|
||||
}
|
||||
if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
|
||||
if _, ok := err.(*sqlgraph.NotFoundError); ok {
|
||||
err = &NotFoundError{tlsfingerprintprofile.Label}
|
||||
} else if sqlgraph.IsConstraintError(err) {
|
||||
err = &ConstraintError{msg: err.Error(), wrap: err}
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
_u.mutation.done = true
|
||||
return _node, nil
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileUpdateOne is the builder for updating a single TLSFingerprintProfile entity.
|
||||
type TLSFingerprintProfileUpdateOne struct {
|
||||
config
|
||||
fields []string
|
||||
hooks []Hook
|
||||
mutation *TLSFingerprintProfileMutation
|
||||
}
|
||||
|
||||
// SetUpdatedAt sets the "updated_at" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetUpdatedAt(v time.Time) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetUpdatedAt(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetName sets the "name" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetName(v string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetName(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableName sets the "name" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetNillableName(v *string) *TLSFingerprintProfileUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetName(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetDescription sets the "description" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetDescription(v string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetDescription(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableDescription sets the "description" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetNillableDescription(v *string) *TLSFingerprintProfileUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetDescription(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearDescription clears the value of the "description" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearDescription() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearDescription()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetEnableGrease sets the "enable_grease" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetEnableGrease(v bool) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetEnableGrease(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableEnableGrease sets the "enable_grease" field if the given value is not nil.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetNillableEnableGrease(v *bool) *TLSFingerprintProfileUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetEnableGrease(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCipherSuites sets the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetCipherSuites(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetCipherSuites(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendCipherSuites appends value to the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendCipherSuites(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendCipherSuites(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearCipherSuites clears the value of the "cipher_suites" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearCipherSuites() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearCipherSuites()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCurves sets the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetCurves(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetCurves(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendCurves appends value to the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendCurves(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendCurves(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearCurves clears the value of the "curves" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearCurves() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearCurves()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPointFormats sets the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetPointFormats(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetPointFormats(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendPointFormats appends value to the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendPointFormats(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendPointFormats(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearPointFormats clears the value of the "point_formats" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearPointFormats() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearPointFormats()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSignatureAlgorithms sets the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetSignatureAlgorithms(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendSignatureAlgorithms appends value to the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendSignatureAlgorithms(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendSignatureAlgorithms(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSignatureAlgorithms clears the value of the "signature_algorithms" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearSignatureAlgorithms() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearSignatureAlgorithms()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetAlpnProtocols sets the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetAlpnProtocols(v []string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetAlpnProtocols(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendAlpnProtocols appends value to the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendAlpnProtocols(v []string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendAlpnProtocols(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearAlpnProtocols clears the value of the "alpn_protocols" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearAlpnProtocols() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearAlpnProtocols()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetSupportedVersions sets the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetSupportedVersions(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetSupportedVersions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendSupportedVersions appends value to the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendSupportedVersions(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendSupportedVersions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearSupportedVersions clears the value of the "supported_versions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearSupportedVersions() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearSupportedVersions()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetKeyShareGroups sets the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetKeyShareGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendKeyShareGroups appends value to the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendKeyShareGroups(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendKeyShareGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearKeyShareGroups clears the value of the "key_share_groups" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearKeyShareGroups() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearKeyShareGroups()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetPskModes sets the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetPskModes(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetPskModes(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendPskModes appends value to the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendPskModes(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendPskModes(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearPskModes clears the value of the "psk_modes" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearPskModes() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearPskModes()
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetExtensions sets the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SetExtensions(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.SetExtensions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AppendExtensions appends value to the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) AppendExtensions(v []uint16) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.AppendExtensions(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// ClearExtensions clears the value of the "extensions" field.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ClearExtensions() *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.ClearExtensions()
|
||||
return _u
|
||||
}
|
||||
|
||||
// Mutation returns the TLSFingerprintProfileMutation object of the builder.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Mutation() *TLSFingerprintProfileMutation {
|
||||
return _u.mutation
|
||||
}
|
||||
|
||||
// Where appends a list predicates to the TLSFingerprintProfileUpdate builder.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Where(ps ...predicate.TLSFingerprintProfile) *TLSFingerprintProfileUpdateOne {
|
||||
_u.mutation.Where(ps...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// Select allows selecting one or more fields (columns) of the returned entity.
|
||||
// The default is selecting all fields defined in the entity schema.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Select(field string, fields ...string) *TLSFingerprintProfileUpdateOne {
|
||||
_u.fields = append([]string{field}, fields...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// Save executes the query and returns the updated TLSFingerprintProfile entity.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Save(ctx context.Context) (*TLSFingerprintProfile, error) {
|
||||
_u.defaults()
|
||||
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
|
||||
}
|
||||
|
||||
// SaveX is like Save, but panics if an error occurs.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) SaveX(ctx context.Context) *TLSFingerprintProfile {
|
||||
node, err := _u.Save(ctx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// Exec executes the query on the entity.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) Exec(ctx context.Context) error {
|
||||
_, err := _u.Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// ExecX is like Exec, but panics if an error occurs.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) ExecX(ctx context.Context) {
|
||||
if err := _u.Exec(ctx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaults sets the default values of the builder before save.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) defaults() {
|
||||
if _, ok := _u.mutation.UpdatedAt(); !ok {
|
||||
v := tlsfingerprintprofile.UpdateDefaultUpdatedAt()
|
||||
_u.mutation.SetUpdatedAt(v)
|
||||
}
|
||||
}
|
||||
|
||||
// check runs all checks and user-defined validators on the builder.
|
||||
func (_u *TLSFingerprintProfileUpdateOne) check() error {
|
||||
if v, ok := _u.mutation.Name(); ok {
|
||||
if err := tlsfingerprintprofile.NameValidator(v); err != nil {
|
||||
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "TLSFingerprintProfile.name": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (_u *TLSFingerprintProfileUpdateOne) sqlSave(ctx context.Context) (_node *TLSFingerprintProfile, err error) {
|
||||
if err := _u.check(); err != nil {
|
||||
return _node, err
|
||||
}
|
||||
_spec := sqlgraph.NewUpdateSpec(tlsfingerprintprofile.Table, tlsfingerprintprofile.Columns, sqlgraph.NewFieldSpec(tlsfingerprintprofile.FieldID, field.TypeInt64))
|
||||
id, ok := _u.mutation.ID()
|
||||
if !ok {
|
||||
return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "TLSFingerprintProfile.id" for update`)}
|
||||
}
|
||||
_spec.Node.ID.Value = id
|
||||
if fields := _u.fields; len(fields) > 0 {
|
||||
_spec.Node.Columns = make([]string, 0, len(fields))
|
||||
_spec.Node.Columns = append(_spec.Node.Columns, tlsfingerprintprofile.FieldID)
|
||||
for _, f := range fields {
|
||||
if !tlsfingerprintprofile.ValidColumn(f) {
|
||||
return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
|
||||
}
|
||||
if f != tlsfingerprintprofile.FieldID {
|
||||
_spec.Node.Columns = append(_spec.Node.Columns, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ps := _u.mutation.predicates; len(ps) > 0 {
|
||||
_spec.Predicate = func(selector *sql.Selector) {
|
||||
for i := range ps {
|
||||
ps[i](selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
if value, ok := _u.mutation.UpdatedAt(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldUpdatedAt, field.TypeTime, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Name(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldName, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.Description(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldDescription, field.TypeString, value)
|
||||
}
|
||||
if _u.mutation.DescriptionCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldDescription, field.TypeString)
|
||||
}
|
||||
if value, ok := _u.mutation.EnableGrease(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldEnableGrease, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CipherSuites(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedCipherSuites(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldCipherSuites, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.CipherSuitesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldCipherSuites, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.Curves(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldCurves, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedCurves(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldCurves, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.CurvesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldCurves, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.PointFormats(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedPointFormats(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldPointFormats, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.PointFormatsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldPointFormats, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.SignatureAlgorithms(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedSignatureAlgorithms(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldSignatureAlgorithms, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.SignatureAlgorithmsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldSignatureAlgorithms, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.AlpnProtocols(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedAlpnProtocols(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldAlpnProtocols, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.AlpnProtocolsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldAlpnProtocols, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.SupportedVersions(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedSupportedVersions(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldSupportedVersions, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.SupportedVersionsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldSupportedVersions, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.KeyShareGroups(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedKeyShareGroups(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldKeyShareGroups, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.KeyShareGroupsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldKeyShareGroups, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.PskModes(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedPskModes(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldPskModes, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.PskModesCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldPskModes, field.TypeJSON)
|
||||
}
|
||||
if value, ok := _u.mutation.Extensions(); ok {
|
||||
_spec.SetField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AppendedExtensions(); ok {
|
||||
_spec.AddModifier(func(u *sql.UpdateBuilder) {
|
||||
sqljson.Append(u, tlsfingerprintprofile.FieldExtensions, value)
|
||||
})
|
||||
}
|
||||
if _u.mutation.ExtensionsCleared() {
|
||||
_spec.ClearField(tlsfingerprintprofile.FieldExtensions, field.TypeJSON)
|
||||
}
|
||||
_node = &TLSFingerprintProfile{config: _u.config}
|
||||
_spec.Assign = _node.assignValues
|
||||
_spec.ScanValues = _node.scanValues
|
||||
if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil {
|
||||
if _, ok := err.(*sqlgraph.NotFoundError); ok {
|
||||
err = &NotFoundError{tlsfingerprintprofile.Label}
|
||||
} else if sqlgraph.IsConstraintError(err) {
|
||||
err = &ConstraintError{msg: err.Error(), wrap: err}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_u.mutation.done = true
|
||||
return _node, nil
|
||||
}
|
||||
@ -42,6 +42,8 @@ type Tx struct {
|
||||
SecuritySecret *SecuritySecretClient
|
||||
// Setting is the client for interacting with the Setting builders.
|
||||
Setting *SettingClient
|
||||
// TLSFingerprintProfile is the client for interacting with the TLSFingerprintProfile builders.
|
||||
TLSFingerprintProfile *TLSFingerprintProfileClient
|
||||
// UsageCleanupTask is the client for interacting with the UsageCleanupTask builders.
|
||||
UsageCleanupTask *UsageCleanupTaskClient
|
||||
// UsageLog is the client for interacting with the UsageLog builders.
|
||||
@ -201,6 +203,7 @@ func (tx *Tx) init() {
|
||||
tx.RedeemCode = NewRedeemCodeClient(tx.config)
|
||||
tx.SecuritySecret = NewSecuritySecretClient(tx.config)
|
||||
tx.Setting = NewSettingClient(tx.config)
|
||||
tx.TLSFingerprintProfile = NewTLSFingerprintProfileClient(tx.config)
|
||||
tx.UsageCleanupTask = NewUsageCleanupTaskClient(tx.config)
|
||||
tx.UsageLog = NewUsageLogClient(tx.config)
|
||||
tx.User = NewUserClient(tx.config)
|
||||
|
||||
@ -673,17 +673,33 @@ type TLSFingerprintConfig struct {
|
||||
}
|
||||
|
||||
// TLSProfileConfig 单个TLS指纹模板的配置
|
||||
// 所有列表字段为空时使用内置默认值(Claude CLI 2.x / Node.js 20.x)
|
||||
// 建议通过 TLS 指纹采集工具 (tests/tls-fingerprint-web) 获取完整配置
|
||||
type TLSProfileConfig struct {
|
||||
// Name: 模板显示名称
|
||||
Name string `mapstructure:"name"`
|
||||
// EnableGREASE: 是否启用GREASE扩展(Chrome使用,Node.js不使用)
|
||||
EnableGREASE bool `mapstructure:"enable_grease"`
|
||||
// CipherSuites: TLS加密套件列表(空则使用内置默认值)
|
||||
// CipherSuites: TLS加密套件列表
|
||||
CipherSuites []uint16 `mapstructure:"cipher_suites"`
|
||||
// Curves: 椭圆曲线列表(空则使用内置默认值)
|
||||
// Curves: 椭圆曲线列表
|
||||
Curves []uint16 `mapstructure:"curves"`
|
||||
// PointFormats: 点格式列表(空则使用内置默认值)
|
||||
PointFormats []uint8 `mapstructure:"point_formats"`
|
||||
// PointFormats: 点格式列表
|
||||
PointFormats []uint16 `mapstructure:"point_formats"`
|
||||
// SignatureAlgorithms: 签名算法列表
|
||||
SignatureAlgorithms []uint16 `mapstructure:"signature_algorithms"`
|
||||
// ALPNProtocols: ALPN协议列表(如 ["h2", "http/1.1"])
|
||||
ALPNProtocols []string `mapstructure:"alpn_protocols"`
|
||||
// SupportedVersions: 支持的TLS版本列表(如 [0x0304, 0x0303] 即 TLS1.3, TLS1.2)
|
||||
SupportedVersions []uint16 `mapstructure:"supported_versions"`
|
||||
// KeyShareGroups: Key Share中发送的曲线组(如 [29] 即 X25519)
|
||||
KeyShareGroups []uint16 `mapstructure:"key_share_groups"`
|
||||
// PSKModes: PSK密钥交换模式(如 [1] 即 psk_dhe_ke)
|
||||
PSKModes []uint16 `mapstructure:"psk_modes"`
|
||||
// Extensions: TLS扩展类型ID列表,按发送顺序排列
|
||||
// 空则使用内置默认顺序 [0,11,10,35,16,22,23,13,43,45,51]
|
||||
// GREASE值(如0x0a0a)会自动插入GREASE扩展
|
||||
Extensions []uint16 `mapstructure:"extensions"`
|
||||
}
|
||||
|
||||
// NodeTLSProxyConfig Node.js TLS 代理配置
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -536,6 +537,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
if execErr != nil {
|
||||
return nil, execErr
|
||||
}
|
||||
// Antigravity OAuth: 新账号直接设置隐私
|
||||
h.adminService.ForceAntigravityPrivacy(ctx, account)
|
||||
// OpenAI OAuth: 新账号直接设置隐私
|
||||
h.adminService.ForceOpenAIPrivacy(ctx, account)
|
||||
return h.buildAccountResponseWithRuntime(ctx, account), nil
|
||||
})
|
||||
if err != nil {
|
||||
@ -782,6 +787,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
if account.IsOpenAI() {
|
||||
tokenInfo, err := h.openaiOAuthService.RefreshAccountToken(ctx, account)
|
||||
if err != nil {
|
||||
// 刷新失败但 access_token 可能仍有效,尝试设置隐私
|
||||
h.adminService.EnsureOpenAIPrivacy(ctx, account)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@ -883,6 +890,8 @@ func (h *AccountHandler) refreshSingleAccount(ctx context.Context, account *serv
|
||||
|
||||
// OpenAI OAuth: 刷新成功后检查并设置 privacy_mode
|
||||
h.adminService.EnsureOpenAIPrivacy(ctx, updatedAccount)
|
||||
// Antigravity OAuth: 刷新成功后检查并设置 privacy_mode
|
||||
h.adminService.EnsureAntigravityPrivacy(ctx, updatedAccount)
|
||||
|
||||
return updatedAccount, "", nil
|
||||
}
|
||||
@ -1154,6 +1163,9 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
success := 0
|
||||
failed := 0
|
||||
results := make([]gin.H, 0, len(req.Accounts))
|
||||
// 收集需要异步设置隐私的 OAuth 账号
|
||||
var antigravityPrivacyAccounts []*service.Account
|
||||
var openaiPrivacyAccounts []*service.Account
|
||||
|
||||
for _, item := range req.Accounts {
|
||||
if item.RateMultiplier != nil && *item.RateMultiplier < 0 {
|
||||
@ -1196,6 +1208,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
})
|
||||
continue
|
||||
}
|
||||
// 收集需要异步设置隐私的 OAuth 账号
|
||||
if account.Type == service.AccountTypeOAuth {
|
||||
switch account.Platform {
|
||||
case service.PlatformAntigravity:
|
||||
antigravityPrivacyAccounts = append(antigravityPrivacyAccounts, account)
|
||||
case service.PlatformOpenAI:
|
||||
openaiPrivacyAccounts = append(openaiPrivacyAccounts, account)
|
||||
}
|
||||
}
|
||||
success++
|
||||
results = append(results, gin.H{
|
||||
"name": item.Name,
|
||||
@ -1204,6 +1225,37 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// 异步设置隐私,避免批量创建时阻塞请求
|
||||
adminSvc := h.adminService
|
||||
if len(antigravityPrivacyAccounts) > 0 {
|
||||
accounts := antigravityPrivacyAccounts
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("batch_create_antigravity_privacy_panic", "recover", r)
|
||||
}
|
||||
}()
|
||||
bgCtx := context.Background()
|
||||
for _, acc := range accounts {
|
||||
adminSvc.ForceAntigravityPrivacy(bgCtx, acc)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if len(openaiPrivacyAccounts) > 0 {
|
||||
accounts := openaiPrivacyAccounts
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("batch_create_openai_privacy_panic", "recover", r)
|
||||
}
|
||||
}()
|
||||
bgCtx := context.Background()
|
||||
for _, acc := range accounts {
|
||||
adminSvc.ForceOpenAIPrivacy(bgCtx, acc)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
@ -1869,6 +1921,51 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
response.Success(c, models)
|
||||
}
|
||||
|
||||
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
|
||||
// POST /api/v1/admin/accounts/:id/set-privacy
|
||||
func (h *AccountHandler) SetPrivacy(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid account ID")
|
||||
return
|
||||
}
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "Account not found")
|
||||
return
|
||||
}
|
||||
if account.Type != service.AccountTypeOAuth {
|
||||
response.BadRequest(c, "Only OAuth accounts support privacy setting")
|
||||
return
|
||||
}
|
||||
var mode string
|
||||
switch account.Platform {
|
||||
case service.PlatformOpenAI:
|
||||
mode = h.adminService.ForceOpenAIPrivacy(c.Request.Context(), account)
|
||||
case service.PlatformAntigravity:
|
||||
mode = h.adminService.ForceAntigravityPrivacy(c.Request.Context(), account)
|
||||
default:
|
||||
response.BadRequest(c, "Only OpenAI and Antigravity OAuth accounts support privacy setting")
|
||||
return
|
||||
}
|
||||
if mode == "" {
|
||||
response.BadRequest(c, "Cannot set privacy: missing access_token")
|
||||
return
|
||||
}
|
||||
// 从 DB 重新读取以确保返回最新状态
|
||||
updated, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
// 隐私已设置成功但读取失败,回退到内存更新
|
||||
if account.Extra == nil {
|
||||
account.Extra = make(map[string]any)
|
||||
}
|
||||
account.Extra["privacy_mode"] = mode
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
|
||||
return
|
||||
}
|
||||
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updated))
|
||||
}
|
||||
|
||||
// RefreshTier handles refreshing Google One tier for a single account
|
||||
// POST /api/v1/admin/accounts/:id/refresh-tier
|
||||
func (h *AccountHandler) RefreshTier(c *gin.Context) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -0,0 +1,234 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileHandler 处理 TLS 指纹模板的 HTTP 请求
|
||||
type TLSFingerprintProfileHandler struct {
|
||||
service *service.TLSFingerprintProfileService
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileHandler 创建 TLS 指纹模板处理器
|
||||
func NewTLSFingerprintProfileHandler(service *service.TLSFingerprintProfileService) *TLSFingerprintProfileHandler {
|
||||
return &TLSFingerprintProfileHandler{service: service}
|
||||
}
|
||||
|
||||
// CreateTLSFingerprintProfileRequest 创建模板请求
|
||||
type CreateTLSFingerprintProfileRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description *string `json:"description"`
|
||||
EnableGREASE *bool `json:"enable_grease"`
|
||||
CipherSuites []uint16 `json:"cipher_suites"`
|
||||
Curves []uint16 `json:"curves"`
|
||||
PointFormats []uint16 `json:"point_formats"`
|
||||
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
|
||||
ALPNProtocols []string `json:"alpn_protocols"`
|
||||
SupportedVersions []uint16 `json:"supported_versions"`
|
||||
KeyShareGroups []uint16 `json:"key_share_groups"`
|
||||
PSKModes []uint16 `json:"psk_modes"`
|
||||
Extensions []uint16 `json:"extensions"`
|
||||
}
|
||||
|
||||
// UpdateTLSFingerprintProfileRequest 更新模板请求(部分更新)
|
||||
type UpdateTLSFingerprintProfileRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
EnableGREASE *bool `json:"enable_grease"`
|
||||
CipherSuites []uint16 `json:"cipher_suites"`
|
||||
Curves []uint16 `json:"curves"`
|
||||
PointFormats []uint16 `json:"point_formats"`
|
||||
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
|
||||
ALPNProtocols []string `json:"alpn_protocols"`
|
||||
SupportedVersions []uint16 `json:"supported_versions"`
|
||||
KeyShareGroups []uint16 `json:"key_share_groups"`
|
||||
PSKModes []uint16 `json:"psk_modes"`
|
||||
Extensions []uint16 `json:"extensions"`
|
||||
}
|
||||
|
||||
// List 获取所有模板
|
||||
// GET /api/v1/admin/tls-fingerprint-profiles
|
||||
func (h *TLSFingerprintProfileHandler) List(c *gin.Context) {
|
||||
profiles, err := h.service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, profiles)
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取模板
|
||||
// GET /api/v1/admin/tls-fingerprint-profiles/:id
|
||||
func (h *TLSFingerprintProfileHandler) GetByID(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid profile ID")
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := h.service.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if profile == nil {
|
||||
response.NotFound(c, "Profile not found")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, profile)
|
||||
}
|
||||
|
||||
// Create 创建模板
|
||||
// POST /api/v1/admin/tls-fingerprint-profiles
|
||||
func (h *TLSFingerprintProfileHandler) Create(c *gin.Context) {
|
||||
var req CreateTLSFingerprintProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
profile := &model.TLSFingerprintProfile{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CipherSuites: req.CipherSuites,
|
||||
Curves: req.Curves,
|
||||
PointFormats: req.PointFormats,
|
||||
SignatureAlgorithms: req.SignatureAlgorithms,
|
||||
ALPNProtocols: req.ALPNProtocols,
|
||||
SupportedVersions: req.SupportedVersions,
|
||||
KeyShareGroups: req.KeyShareGroups,
|
||||
PSKModes: req.PSKModes,
|
||||
Extensions: req.Extensions,
|
||||
}
|
||||
|
||||
if req.EnableGREASE != nil {
|
||||
profile.EnableGREASE = *req.EnableGREASE
|
||||
}
|
||||
|
||||
created, err := h.service.Create(c.Request.Context(), profile)
|
||||
if err != nil {
|
||||
if _, ok := err.(*model.ValidationError); ok {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, created)
|
||||
}
|
||||
|
||||
// Update 更新模板(支持部分更新)
|
||||
// PUT /api/v1/admin/tls-fingerprint-profiles/:id
|
||||
func (h *TLSFingerprintProfileHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid profile ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateTLSFingerprintProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.service.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if existing == nil {
|
||||
response.NotFound(c, "Profile not found")
|
||||
return
|
||||
}
|
||||
|
||||
// 部分更新
|
||||
profile := &model.TLSFingerprintProfile{
|
||||
ID: id,
|
||||
Name: existing.Name,
|
||||
Description: existing.Description,
|
||||
EnableGREASE: existing.EnableGREASE,
|
||||
CipherSuites: existing.CipherSuites,
|
||||
Curves: existing.Curves,
|
||||
PointFormats: existing.PointFormats,
|
||||
SignatureAlgorithms: existing.SignatureAlgorithms,
|
||||
ALPNProtocols: existing.ALPNProtocols,
|
||||
SupportedVersions: existing.SupportedVersions,
|
||||
KeyShareGroups: existing.KeyShareGroups,
|
||||
PSKModes: existing.PSKModes,
|
||||
Extensions: existing.Extensions,
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
profile.Name = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
profile.Description = req.Description
|
||||
}
|
||||
if req.EnableGREASE != nil {
|
||||
profile.EnableGREASE = *req.EnableGREASE
|
||||
}
|
||||
if req.CipherSuites != nil {
|
||||
profile.CipherSuites = req.CipherSuites
|
||||
}
|
||||
if req.Curves != nil {
|
||||
profile.Curves = req.Curves
|
||||
}
|
||||
if req.PointFormats != nil {
|
||||
profile.PointFormats = req.PointFormats
|
||||
}
|
||||
if req.SignatureAlgorithms != nil {
|
||||
profile.SignatureAlgorithms = req.SignatureAlgorithms
|
||||
}
|
||||
if req.ALPNProtocols != nil {
|
||||
profile.ALPNProtocols = req.ALPNProtocols
|
||||
}
|
||||
if req.SupportedVersions != nil {
|
||||
profile.SupportedVersions = req.SupportedVersions
|
||||
}
|
||||
if req.KeyShareGroups != nil {
|
||||
profile.KeyShareGroups = req.KeyShareGroups
|
||||
}
|
||||
if req.PSKModes != nil {
|
||||
profile.PSKModes = req.PSKModes
|
||||
}
|
||||
if req.Extensions != nil {
|
||||
profile.Extensions = req.Extensions
|
||||
}
|
||||
|
||||
updated, err := h.service.Update(c.Request.Context(), profile)
|
||||
if err != nil {
|
||||
if _, ok := err.(*model.ValidationError); ok {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, updated)
|
||||
}
|
||||
|
||||
// Delete 删除模板
|
||||
// DELETE /api/v1/admin/tls-fingerprint-profiles/:id
|
||||
func (h *TLSFingerprintProfileHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid profile ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "Profile deleted successfully"})
|
||||
}
|
||||
@ -252,6 +252,10 @@ func AccountFromServiceShallow(a *service.Account) *Account {
|
||||
enabled := true
|
||||
out.EnableTLSFingerprint = &enabled
|
||||
}
|
||||
// TLS指纹模板ID
|
||||
if profileID := a.GetTLSFingerprintProfileID(); profileID > 0 {
|
||||
out.TLSFingerprintProfileID = &profileID
|
||||
}
|
||||
// 会话ID伪装开关
|
||||
if a.IsSessionIDMaskingEnabled() {
|
||||
enabled := true
|
||||
|
||||
@ -185,7 +185,8 @@ type Account struct {
|
||||
|
||||
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 从 extra 字段提取,方便前端显示和编辑
|
||||
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
|
||||
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
|
||||
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
|
||||
|
||||
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
|
||||
|
||||
@ -75,8 +75,10 @@ func (f *fakeGroupRepo) ListActive(context.Context) ([]service.Group, error) { r
|
||||
func (f *fakeGroupRepo) ListActiveByPlatform(context.Context, string) ([]service.Group, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
|
||||
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) { return 0, 0, nil }
|
||||
func (f *fakeGroupRepo) ExistsByName(context.Context, string) (bool, error) { return false, nil }
|
||||
func (f *fakeGroupRepo) GetAccountCount(context.Context, int64) (int64, int64, error) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
func (f *fakeGroupRepo) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@ -158,6 +160,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
|
||||
nil, // rpmCache
|
||||
nil, // digestStore
|
||||
nil, // settingService
|
||||
nil, // tlsFPProfileService
|
||||
)
|
||||
|
||||
// RunModeSimple:跳过计费检查,避免引入 repo/cache 依赖。
|
||||
|
||||
@ -6,29 +6,30 @@ import (
|
||||
|
||||
// AdminHandlers contains all admin-related HTTP handlers
|
||||
type AdminHandlers struct {
|
||||
Dashboard *admin.DashboardHandler
|
||||
User *admin.UserHandler
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
Announcement *admin.AnnouncementHandler
|
||||
DataManagement *admin.DataManagementHandler
|
||||
Backup *admin.BackupHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
Setting *admin.SettingHandler
|
||||
Ops *admin.OpsHandler
|
||||
System *admin.SystemHandler
|
||||
Subscription *admin.SubscriptionHandler
|
||||
Usage *admin.UsageHandler
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
Dashboard *admin.DashboardHandler
|
||||
User *admin.UserHandler
|
||||
Group *admin.GroupHandler
|
||||
Account *admin.AccountHandler
|
||||
Announcement *admin.AnnouncementHandler
|
||||
DataManagement *admin.DataManagementHandler
|
||||
Backup *admin.BackupHandler
|
||||
OAuth *admin.OAuthHandler
|
||||
OpenAIOAuth *admin.OpenAIOAuthHandler
|
||||
GeminiOAuth *admin.GeminiOAuthHandler
|
||||
AntigravityOAuth *admin.AntigravityOAuthHandler
|
||||
Proxy *admin.ProxyHandler
|
||||
Redeem *admin.RedeemHandler
|
||||
Promo *admin.PromoHandler
|
||||
Setting *admin.SettingHandler
|
||||
Ops *admin.OpsHandler
|
||||
System *admin.SystemHandler
|
||||
Subscription *admin.SubscriptionHandler
|
||||
Usage *admin.UsageHandler
|
||||
UserAttribute *admin.UserAttributeHandler
|
||||
ErrorPassthrough *admin.ErrorPassthroughHandler
|
||||
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
|
||||
APIKey *admin.AdminAPIKeyHandler
|
||||
ScheduledTest *admin.ScheduledTestHandler
|
||||
}
|
||||
|
||||
// Handlers contains all HTTP handlers
|
||||
|
||||
@ -2224,7 +2224,7 @@ func (s *stubSoraClientForHandler) GetVideoTask(_ context.Context, _ *service.Ac
|
||||
func newMinimalGatewayService(accountRepo service.AccountRepository) *service.GatewayService {
|
||||
return service.NewGatewayService(
|
||||
accountRepo, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -464,6 +464,7 @@ func TestSoraGatewayHandler_ChatCompletions(t *testing.T) {
|
||||
nil, // rpmCache
|
||||
nil, // digestStore
|
||||
nil, // settingService
|
||||
nil, // tlsFPProfileService
|
||||
)
|
||||
|
||||
soraClient := &stubSoraClient{imageURLs: []string{"https://example.com/a.png"}}
|
||||
|
||||
@ -30,33 +30,35 @@ func ProvideAdminHandlers(
|
||||
usageHandler *admin.UsageHandler,
|
||||
userAttributeHandler *admin.UserAttributeHandler,
|
||||
errorPassthroughHandler *admin.ErrorPassthroughHandler,
|
||||
tlsFingerprintProfileHandler *admin.TLSFingerprintProfileHandler,
|
||||
apiKeyHandler *admin.AdminAPIKeyHandler,
|
||||
scheduledTestHandler *admin.ScheduledTestHandler,
|
||||
) *AdminHandlers {
|
||||
return &AdminHandlers{
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
Announcement: announcementHandler,
|
||||
DataManagement: dataManagementHandler,
|
||||
Backup: backupHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
Setting: settingHandler,
|
||||
Ops: opsHandler,
|
||||
System: systemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Usage: usageHandler,
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
Dashboard: dashboardHandler,
|
||||
User: userHandler,
|
||||
Group: groupHandler,
|
||||
Account: accountHandler,
|
||||
Announcement: announcementHandler,
|
||||
DataManagement: dataManagementHandler,
|
||||
Backup: backupHandler,
|
||||
OAuth: oauthHandler,
|
||||
OpenAIOAuth: openaiOAuthHandler,
|
||||
GeminiOAuth: geminiOAuthHandler,
|
||||
AntigravityOAuth: antigravityOAuthHandler,
|
||||
Proxy: proxyHandler,
|
||||
Redeem: redeemHandler,
|
||||
Promo: promoHandler,
|
||||
Setting: settingHandler,
|
||||
Ops: opsHandler,
|
||||
System: systemHandler,
|
||||
Subscription: subscriptionHandler,
|
||||
Usage: usageHandler,
|
||||
UserAttribute: userAttributeHandler,
|
||||
ErrorPassthrough: errorPassthroughHandler,
|
||||
TLSFingerprintProfile: tlsFingerprintProfileHandler,
|
||||
APIKey: apiKeyHandler,
|
||||
ScheduledTest: scheduledTestHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@ -145,6 +147,7 @@ var ProviderSet = wire.NewSet(
|
||||
admin.NewUsageHandler,
|
||||
admin.NewUserAttributeHandler,
|
||||
admin.NewErrorPassthroughHandler,
|
||||
admin.NewTLSFingerprintProfileHandler,
|
||||
admin.NewAdminAPIKeyHandler,
|
||||
admin.NewScheduledTestHandler,
|
||||
|
||||
|
||||
54
backend/internal/model/tls_fingerprint_profile.go
Normal file
54
backend/internal/model/tls_fingerprint_profile.go
Normal file
@ -0,0 +1,54 @@
|
||||
// Package model 定义服务层使用的数据模型。
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfile TLS 指纹配置模板
|
||||
// 包含完整的 ClientHello 参数,用于模拟特定客户端的 TLS 握手特征
|
||||
type TLSFingerprintProfile struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
EnableGREASE bool `json:"enable_grease"`
|
||||
CipherSuites []uint16 `json:"cipher_suites"`
|
||||
Curves []uint16 `json:"curves"`
|
||||
PointFormats []uint16 `json:"point_formats"`
|
||||
SignatureAlgorithms []uint16 `json:"signature_algorithms"`
|
||||
ALPNProtocols []string `json:"alpn_protocols"`
|
||||
SupportedVersions []uint16 `json:"supported_versions"`
|
||||
KeyShareGroups []uint16 `json:"key_share_groups"`
|
||||
PSKModes []uint16 `json:"psk_modes"`
|
||||
Extensions []uint16 `json:"extensions"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Validate 验证模板配置的有效性
|
||||
func (p *TLSFingerprintProfile) Validate() error {
|
||||
if p.Name == "" {
|
||||
return &ValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToTLSProfile 将领域模型转换为运行时使用的 tlsfingerprint.Profile
|
||||
// 空切片字段会在 dialer 中 fallback 到内置默认值
|
||||
func (p *TLSFingerprintProfile) ToTLSProfile() *tlsfingerprint.Profile {
|
||||
return &tlsfingerprint.Profile{
|
||||
Name: p.Name,
|
||||
EnableGREASE: p.EnableGREASE,
|
||||
CipherSuites: p.CipherSuites,
|
||||
Curves: p.Curves,
|
||||
PointFormats: p.PointFormats,
|
||||
SignatureAlgorithms: p.SignatureAlgorithms,
|
||||
ALPNProtocols: p.ALPNProtocols,
|
||||
SupportedVersions: p.SupportedVersions,
|
||||
KeyShareGroups: p.KeyShareGroups,
|
||||
PSKModes: p.PSKModes,
|
||||
Extensions: p.Extensions,
|
||||
}
|
||||
}
|
||||
@ -91,7 +91,9 @@ type UserInfo struct {
|
||||
// LoadCodeAssistRequest loadCodeAssist 请求
|
||||
type LoadCodeAssistRequest struct {
|
||||
Metadata struct {
|
||||
IDEType string `json:"ideType"`
|
||||
IDEType string `json:"ideType"`
|
||||
IDEVersion string `json:"ideVersion"`
|
||||
IDEName string `json:"ideName"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
|
||||
@ -236,6 +238,23 @@ func (r *LoadCodeAssistResponse) GetAvailableCredits() []AvailableCredit {
|
||||
return r.PaidTier.AvailableCredits
|
||||
}
|
||||
|
||||
// TierIDToPlanType 将 tier ID 映射为用户可见的套餐名。
|
||||
func TierIDToPlanType(tierID string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(tierID)) {
|
||||
case "free-tier":
|
||||
return "Free"
|
||||
case "g1-pro-tier":
|
||||
return "Pro"
|
||||
case "g1-ultra-tier":
|
||||
return "Ultra"
|
||||
default:
|
||||
if tierID == "" {
|
||||
return "Free"
|
||||
}
|
||||
return tierID
|
||||
}
|
||||
}
|
||||
|
||||
// Client Antigravity API 客户端
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
@ -449,6 +468,8 @@ func (c *Client) GetUserInfo(ctx context.Context, accessToken string) (*UserInfo
|
||||
func (c *Client) LoadCodeAssist(ctx context.Context, accessToken string) (*LoadCodeAssistResponse, map[string]any, error) {
|
||||
reqBody := LoadCodeAssistRequest{}
|
||||
reqBody.Metadata.IDEType = "ANTIGRAVITY"
|
||||
reqBody.Metadata.IDEVersion = "1.20.6"
|
||||
reqBody.Metadata.IDEName = "antigravity"
|
||||
|
||||
bodyBytes, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
@ -735,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
|
||||
}
|
||||
|
||||
@ -250,6 +250,27 @@ func TestGetTier_两者都为nil(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTierIDToPlanType(t *testing.T) {
|
||||
tests := []struct {
|
||||
tierID string
|
||||
want string
|
||||
}{
|
||||
{"free-tier", "Free"},
|
||||
{"g1-pro-tier", "Pro"},
|
||||
{"g1-ultra-tier", "Ultra"},
|
||||
{"FREE-TIER", "Free"},
|
||||
{"", "Free"},
|
||||
{"unknown-tier", "unknown-tier"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tierID, func(t *testing.T) {
|
||||
if got := TierIDToPlanType(tt.tierID); got != tt.want {
|
||||
t.Errorf("TierIDToPlanType(%q) = %q, want %q", tt.tierID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NewClient
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -800,6 +821,12 @@ type redirectRoundTripper struct {
|
||||
transport http.RoundTripper
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func (rt *redirectRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
originalURL := req.URL.String()
|
||||
for prefix, target := range rt.redirects {
|
||||
@ -1271,6 +1298,12 @@ func TestClient_LoadCodeAssist_Success_RealCall(t *testing.T) {
|
||||
if reqBody.Metadata.IDEType != "ANTIGRAVITY" {
|
||||
t.Errorf("IDEType 不匹配: got %s, want ANTIGRAVITY", reqBody.Metadata.IDEType)
|
||||
}
|
||||
if strings.TrimSpace(reqBody.Metadata.IDEVersion) == "" {
|
||||
t.Errorf("IDEVersion 不应为空")
|
||||
}
|
||||
if reqBody.Metadata.IDEName != "antigravity" {
|
||||
t.Errorf("IDEName 不匹配: got %s, want antigravity", reqBody.Metadata.IDEName)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@ -52,8 +52,9 @@ const (
|
||||
antigravityDailyBaseURL = "https://daily-cloudcode-pa.googleapis.com"
|
||||
)
|
||||
|
||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 0.2.0(匹配真实 extension 版本)
|
||||
var defaultUserAgentVersion = "0.2.0"
|
||||
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认匹配真实 extension 最新版本
|
||||
// Gemini 3.1 Pro 等新模型需要较新的版本号才允许访问(上游会检查版本返回 "not available on this version")
|
||||
var defaultUserAgentVersion = "1.21.6"
|
||||
|
||||
// defaultPlatformOS 和 defaultPlatformArch 模拟真实客户端的操作系统和架构
|
||||
// 真实 Antigravity IDE 运行在用户桌面(macOS/Windows),不是 Linux 服务器
|
||||
|
||||
@ -17,12 +17,19 @@ import (
|
||||
)
|
||||
|
||||
// Profile contains TLS fingerprint configuration.
|
||||
// All slice fields use built-in defaults when empty.
|
||||
type Profile struct {
|
||||
Name string // Profile name for identification
|
||||
CipherSuites []uint16
|
||||
Curves []uint16
|
||||
PointFormats []uint8
|
||||
EnableGREASE bool
|
||||
Name string // Profile name for identification
|
||||
CipherSuites []uint16
|
||||
Curves []uint16
|
||||
PointFormats []uint16
|
||||
EnableGREASE bool
|
||||
SignatureAlgorithms []uint16 // Empty uses defaultSignatureAlgorithms
|
||||
ALPNProtocols []string // Empty uses ["http/1.1"]
|
||||
SupportedVersions []uint16 // Empty uses [TLS1.3, TLS1.2]
|
||||
KeyShareGroups []uint16 // Empty uses [X25519]
|
||||
PSKModes []uint16 // Empty uses [psk_dhe_ke]
|
||||
Extensions []uint16 // Extension type IDs in order; empty uses default Node.js 24.x order
|
||||
}
|
||||
|
||||
// Dialer creates TLS connections with custom fingerprints.
|
||||
@ -45,154 +52,67 @@ type SOCKS5ProxyDialer struct {
|
||||
proxyURL *url.URL
|
||||
}
|
||||
|
||||
// Default TLS fingerprint values captured from Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)
|
||||
// Captured using: tshark -i lo -f "tcp port 8443" -Y "tls.handshake.type == 1" -V
|
||||
// JA3 Hash: 1a28e69016765d92e3b381168d68922c
|
||||
//
|
||||
// Note: JA3/JA4 may have slight variations due to:
|
||||
// - Session ticket presence/absence
|
||||
// - Extension negotiation state
|
||||
// Default TLS fingerprint values captured from Claude Code (Node.js 24.x)
|
||||
// Captured via tls-fingerprint-web capture server
|
||||
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
|
||||
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
var (
|
||||
// defaultCipherSuites contains all 59 cipher suites from Claude CLI
|
||||
// defaultCipherSuites contains the 17 cipher suites from Node.js 24.x
|
||||
// Order is critical for JA3 fingerprint matching
|
||||
defaultCipherSuites = []uint16{
|
||||
// TLS 1.3 cipher suites (MUST be first)
|
||||
// TLS 1.3 cipher suites
|
||||
0x1301, // TLS_AES_128_GCM_SHA256
|
||||
0x1302, // TLS_AES_256_GCM_SHA384
|
||||
0x1303, // TLS_CHACHA20_POLY1305_SHA256
|
||||
0x1301, // TLS_AES_128_GCM_SHA256
|
||||
|
||||
// ECDHE + AES-GCM
|
||||
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
0xc02b, // TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
|
||||
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
0xc02f, // TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
0xc02c, // TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
|
||||
0xc030, // TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// DHE + AES-GCM
|
||||
0x009e, // TLS_DHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
|
||||
// ECDHE/DHE + AES-CBC-SHA256/384
|
||||
0xc027, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
|
||||
0x0067, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA256
|
||||
0xc028, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
|
||||
0x006b, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA256
|
||||
|
||||
// DHE-DSS/RSA + AES-GCM
|
||||
0x00a3, // TLS_DHE_DSS_WITH_AES_256_GCM_SHA384
|
||||
0x009f, // TLS_DHE_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// ChaCha20-Poly1305
|
||||
// ECDHE + ChaCha20-Poly1305
|
||||
0xcca9, // TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
0xcca8, // TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
0xccaa, // TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256
|
||||
|
||||
// AES-CCM (256-bit)
|
||||
0xc0af, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8
|
||||
0xc0ad, // TLS_ECDHE_ECDSA_WITH_AES_256_CCM
|
||||
0xc0a3, // TLS_DHE_RSA_WITH_AES_256_CCM_8
|
||||
0xc09f, // TLS_DHE_RSA_WITH_AES_256_CCM
|
||||
|
||||
// ARIA (256-bit)
|
||||
0xc05d, // TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384
|
||||
0xc061, // TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
0xc057, // TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384
|
||||
0xc053, // TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
|
||||
// DHE-DSS + AES-GCM (128-bit)
|
||||
0x00a2, // TLS_DHE_DSS_WITH_AES_128_GCM_SHA256
|
||||
|
||||
// AES-CCM (128-bit)
|
||||
0xc0ae, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8
|
||||
0xc0ac, // TLS_ECDHE_ECDSA_WITH_AES_128_CCM
|
||||
0xc0a2, // TLS_DHE_RSA_WITH_AES_128_CCM_8
|
||||
0xc09e, // TLS_DHE_RSA_WITH_AES_128_CCM
|
||||
|
||||
// ARIA (128-bit)
|
||||
0xc05c, // TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256
|
||||
0xc060, // TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
0xc056, // TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256
|
||||
0xc052, // TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
|
||||
// ECDHE/DHE + AES-CBC-SHA384/256 (more)
|
||||
0xc024, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
|
||||
0x006a, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA256
|
||||
0xc023, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
|
||||
0x0040, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA256
|
||||
|
||||
// ECDHE/DHE + AES-CBC-SHA (legacy)
|
||||
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
|
||||
0x0039, // TLS_DHE_RSA_WITH_AES_256_CBC_SHA
|
||||
0x0038, // TLS_DHE_DSS_WITH_AES_256_CBC_SHA
|
||||
// ECDHE + AES-CBC-SHA (legacy fallback)
|
||||
0xc009, // TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA
|
||||
0xc013, // TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA
|
||||
0x0033, // TLS_DHE_RSA_WITH_AES_128_CBC_SHA
|
||||
0x0032, // TLS_DHE_DSS_WITH_AES_128_CBC_SHA
|
||||
0xc00a, // TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||
0xc014, // TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA
|
||||
|
||||
// RSA + AES-GCM/CCM/ARIA (non-PFS, 256-bit)
|
||||
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
|
||||
0xc0a1, // TLS_RSA_WITH_AES_256_CCM_8
|
||||
0xc09d, // TLS_RSA_WITH_AES_256_CCM
|
||||
0xc051, // TLS_RSA_WITH_ARIA_256_GCM_SHA384
|
||||
|
||||
// RSA + AES-GCM/CCM/ARIA (non-PFS, 128-bit)
|
||||
// RSA + AES-GCM (non-PFS)
|
||||
0x009c, // TLS_RSA_WITH_AES_128_GCM_SHA256
|
||||
0xc0a0, // TLS_RSA_WITH_AES_128_CCM_8
|
||||
0xc09c, // TLS_RSA_WITH_AES_128_CCM
|
||||
0xc050, // TLS_RSA_WITH_ARIA_128_GCM_SHA256
|
||||
0x009d, // TLS_RSA_WITH_AES_256_GCM_SHA384
|
||||
|
||||
// RSA + AES-CBC (non-PFS, legacy)
|
||||
0x003d, // TLS_RSA_WITH_AES_256_CBC_SHA256
|
||||
0x003c, // TLS_RSA_WITH_AES_128_CBC_SHA256
|
||||
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
|
||||
// RSA + AES-CBC-SHA (non-PFS, legacy)
|
||||
0x002f, // TLS_RSA_WITH_AES_128_CBC_SHA
|
||||
|
||||
// Renegotiation indication
|
||||
0x00ff, // TLS_EMPTY_RENEGOTIATION_INFO_SCSV
|
||||
0x0035, // TLS_RSA_WITH_AES_256_CBC_SHA
|
||||
}
|
||||
|
||||
// defaultCurves contains the 10 supported groups from Claude CLI (including FFDHE)
|
||||
// defaultCurves contains the 3 supported groups from Node.js 24.x
|
||||
defaultCurves = []utls.CurveID{
|
||||
utls.X25519, // 0x001d
|
||||
utls.CurveP256, // 0x0017 (secp256r1)
|
||||
utls.CurveID(0x001e), // x448
|
||||
utls.CurveP521, // 0x0019 (secp521r1)
|
||||
utls.CurveP384, // 0x0018 (secp384r1)
|
||||
utls.CurveID(0x0100), // ffdhe2048
|
||||
utls.CurveID(0x0101), // ffdhe3072
|
||||
utls.CurveID(0x0102), // ffdhe4096
|
||||
utls.CurveID(0x0103), // ffdhe6144
|
||||
utls.CurveID(0x0104), // ffdhe8192
|
||||
utls.X25519, // 0x001d
|
||||
utls.CurveP256, // 0x0017 (secp256r1)
|
||||
utls.CurveP384, // 0x0018 (secp384r1)
|
||||
}
|
||||
|
||||
// defaultPointFormats contains all 3 point formats from Claude CLI
|
||||
defaultPointFormats = []uint8{
|
||||
// defaultPointFormats contains point formats from Node.js 24.x
|
||||
defaultPointFormats = []uint16{
|
||||
0, // uncompressed
|
||||
1, // ansiX962_compressed_prime
|
||||
2, // ansiX962_compressed_char2
|
||||
}
|
||||
|
||||
// defaultSignatureAlgorithms contains the 20 signature algorithms from Claude CLI
|
||||
// defaultSignatureAlgorithms contains the 9 signature algorithms from Node.js 24.x
|
||||
defaultSignatureAlgorithms = []utls.SignatureScheme{
|
||||
0x0403, // ecdsa_secp256r1_sha256
|
||||
0x0503, // ecdsa_secp384r1_sha384
|
||||
0x0603, // ecdsa_secp521r1_sha512
|
||||
0x0807, // ed25519
|
||||
0x0808, // ed448
|
||||
0x0809, // rsa_pss_pss_sha256
|
||||
0x080a, // rsa_pss_pss_sha384
|
||||
0x080b, // rsa_pss_pss_sha512
|
||||
0x0804, // rsa_pss_rsae_sha256
|
||||
0x0805, // rsa_pss_rsae_sha384
|
||||
0x0806, // rsa_pss_rsae_sha512
|
||||
0x0401, // rsa_pkcs1_sha256
|
||||
0x0503, // ecdsa_secp384r1_sha384
|
||||
0x0805, // rsa_pss_rsae_sha384
|
||||
0x0501, // rsa_pkcs1_sha384
|
||||
0x0806, // rsa_pss_rsae_sha512
|
||||
0x0601, // rsa_pkcs1_sha512
|
||||
0x0303, // ecdsa_sha224
|
||||
0x0301, // rsa_pkcs1_sha224
|
||||
0x0302, // dsa_sha224
|
||||
0x0402, // dsa_sha256
|
||||
0x0502, // dsa_sha384
|
||||
0x0602, // dsa_sha512
|
||||
0x0201, // rsa_pkcs1_sha1
|
||||
}
|
||||
)
|
||||
|
||||
@ -256,49 +176,7 @@ func (d *SOCKS5ProxyDialer) DialTLSContext(ctx context.Context, network, addr st
|
||||
slog.Debug("tls_fingerprint_socks5_tunnel_established")
|
||||
|
||||
// Step 3: Perform TLS handshake on the tunnel with utls fingerprint
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
slog.Debug("tls_fingerprint_socks5_starting_handshake", "host", host)
|
||||
|
||||
// Build ClientHello specification from profile (Node.js/Claude CLI fingerprint)
|
||||
spec := buildClientHelloSpecFromProfile(d.profile)
|
||||
slog.Debug("tls_fingerprint_socks5_clienthello_spec",
|
||||
"cipher_suites", len(spec.CipherSuites),
|
||||
"extensions", len(spec.Extensions),
|
||||
"compression_methods", spec.CompressionMethods,
|
||||
"tls_vers_max", spec.TLSVersMax,
|
||||
"tls_vers_min", spec.TLSVersMin)
|
||||
|
||||
if d.profile != nil {
|
||||
slog.Debug("tls_fingerprint_socks5_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
|
||||
}
|
||||
|
||||
// Create uTLS connection on the tunnel
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
}, utls.HelloCustom)
|
||||
|
||||
if err := tlsConn.ApplyPreset(spec); err != nil {
|
||||
slog.Debug("tls_fingerprint_socks5_apply_preset_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("apply TLS preset: %w", err)
|
||||
}
|
||||
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
slog.Debug("tls_fingerprint_socks5_handshake_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("TLS handshake failed: %w", err)
|
||||
}
|
||||
|
||||
state := tlsConn.ConnectionState()
|
||||
slog.Debug("tls_fingerprint_socks5_handshake_success",
|
||||
"version", state.Version,
|
||||
"cipher_suite", state.CipherSuite,
|
||||
"alpn", state.NegotiatedProtocol)
|
||||
|
||||
return tlsConn, nil
|
||||
return performTLSHandshake(ctx, conn, d.profile, addr)
|
||||
}
|
||||
|
||||
// DialTLSContext establishes a TLS connection through HTTP proxy with the configured fingerprint.
|
||||
@ -358,7 +236,8 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
|
||||
slog.Debug("tls_fingerprint_http_proxy_read_response_failed", "error", err)
|
||||
return nil, fmt.Errorf("read CONNECT response: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
// CONNECT response has no body; do not defer resp.Body.Close() as it wraps the
|
||||
// same conn that will be used for the TLS handshake.
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = conn.Close()
|
||||
@ -368,47 +247,7 @@ func (d *HTTPProxyDialer) DialTLSContext(ctx context.Context, network, addr stri
|
||||
slog.Debug("tls_fingerprint_http_proxy_tunnel_established")
|
||||
|
||||
// Step 4: Perform TLS handshake on the tunnel with utls fingerprint
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
slog.Debug("tls_fingerprint_http_proxy_starting_handshake", "host", host)
|
||||
|
||||
// Build ClientHello specification (reuse the shared method)
|
||||
spec := buildClientHelloSpecFromProfile(d.profile)
|
||||
slog.Debug("tls_fingerprint_http_proxy_clienthello_spec",
|
||||
"cipher_suites", len(spec.CipherSuites),
|
||||
"extensions", len(spec.Extensions))
|
||||
|
||||
if d.profile != nil {
|
||||
slog.Debug("tls_fingerprint_http_proxy_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
|
||||
}
|
||||
|
||||
// Create uTLS connection on the tunnel
|
||||
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
}, utls.HelloCustom)
|
||||
|
||||
if err := tlsConn.ApplyPreset(spec); err != nil {
|
||||
slog.Debug("tls_fingerprint_http_proxy_apply_preset_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("apply TLS preset: %w", err)
|
||||
}
|
||||
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
slog.Debug("tls_fingerprint_http_proxy_handshake_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("TLS handshake failed: %w", err)
|
||||
}
|
||||
|
||||
state := tlsConn.ConnectionState()
|
||||
slog.Debug("tls_fingerprint_http_proxy_handshake_success",
|
||||
"version", state.Version,
|
||||
"cipher_suite", state.CipherSuite,
|
||||
"alpn", state.NegotiatedProtocol)
|
||||
|
||||
return tlsConn, nil
|
||||
return performTLSHandshake(ctx, conn, d.profile, addr)
|
||||
}
|
||||
|
||||
// DialTLSContext establishes a TLS connection with the configured fingerprint.
|
||||
@ -423,53 +262,35 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
|
||||
}
|
||||
slog.Debug("tls_fingerprint_tcp_connected", "addr", addr)
|
||||
|
||||
// Extract hostname for SNI
|
||||
// Perform TLS handshake with utls fingerprint
|
||||
return performTLSHandshake(ctx, conn, d.profile, addr)
|
||||
}
|
||||
|
||||
// performTLSHandshake performs the uTLS handshake on an established connection.
|
||||
// It builds a ClientHello spec from the profile, applies it, and completes the handshake.
|
||||
// On failure, conn is closed and an error is returned.
|
||||
func performTLSHandshake(ctx context.Context, conn net.Conn, profile *Profile, addr string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
slog.Debug("tls_fingerprint_sni_hostname", "host", host)
|
||||
|
||||
// Build ClientHello specification
|
||||
spec := d.buildClientHelloSpec()
|
||||
slog.Debug("tls_fingerprint_clienthello_spec",
|
||||
"cipher_suites", len(spec.CipherSuites),
|
||||
"extensions", len(spec.Extensions))
|
||||
spec := buildClientHelloSpecFromProfile(profile)
|
||||
tlsConn := utls.UClient(conn, &utls.Config{ServerName: host}, utls.HelloCustom)
|
||||
|
||||
// Log profile info
|
||||
if d.profile != nil {
|
||||
slog.Debug("tls_fingerprint_using_profile", "name", d.profile.Name, "grease", d.profile.EnableGREASE)
|
||||
} else {
|
||||
slog.Debug("tls_fingerprint_using_default_profile")
|
||||
}
|
||||
|
||||
// Create uTLS connection
|
||||
// Note: TLS 1.3 cipher suites are handled automatically by utls when TLS 1.3 is in SupportedVersions
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
}, utls.HelloCustom)
|
||||
|
||||
// Apply fingerprint
|
||||
if err := tlsConn.ApplyPreset(spec); err != nil {
|
||||
slog.Debug("tls_fingerprint_apply_preset_failed", "error", err)
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("apply TLS preset: %w", err)
|
||||
}
|
||||
slog.Debug("tls_fingerprint_preset_applied")
|
||||
|
||||
// Perform TLS handshake
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
slog.Debug("tls_fingerprint_handshake_failed",
|
||||
"error", err,
|
||||
"local_addr", conn.LocalAddr(),
|
||||
"remote_addr", conn.RemoteAddr())
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("TLS handshake failed: %w", err)
|
||||
}
|
||||
|
||||
// Log successful handshake details
|
||||
state := tlsConn.ConnectionState()
|
||||
slog.Debug("tls_fingerprint_handshake_success",
|
||||
"host", host,
|
||||
"version", state.Version,
|
||||
"cipher_suite", state.CipherSuite,
|
||||
"alpn", state.NegotiatedProtocol)
|
||||
@ -477,11 +298,6 @@ func (d *Dialer) DialTLSContext(ctx context.Context, network, addr string) (net.
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// buildClientHelloSpec constructs the ClientHello specification based on the profile.
|
||||
func (d *Dialer) buildClientHelloSpec() *utls.ClientHelloSpec {
|
||||
return buildClientHelloSpecFromProfile(d.profile)
|
||||
}
|
||||
|
||||
// toUTLSCurves converts uint16 slice to utls.CurveID slice.
|
||||
func toUTLSCurves(curves []uint16) []utls.CurveID {
|
||||
result := make([]utls.CurveID, len(curves))
|
||||
@ -491,70 +307,143 @@ func toUTLSCurves(curves []uint16) []utls.CurveID {
|
||||
return result
|
||||
}
|
||||
|
||||
// defaultExtensionOrder is the Node.js 24.x extension order.
|
||||
// Used when Profile.Extensions is empty.
|
||||
var defaultExtensionOrder = []uint16{
|
||||
0, // server_name
|
||||
65037, // encrypted_client_hello
|
||||
23, // extended_master_secret
|
||||
65281, // renegotiation_info
|
||||
10, // supported_groups
|
||||
11, // ec_point_formats
|
||||
35, // session_ticket
|
||||
16, // alpn
|
||||
5, // status_request
|
||||
13, // signature_algorithms
|
||||
18, // signed_certificate_timestamp
|
||||
51, // key_share
|
||||
45, // psk_key_exchange_modes
|
||||
43, // supported_versions
|
||||
}
|
||||
|
||||
// isGREASEValue checks if a uint16 value matches the TLS GREASE pattern (0x?a?a).
|
||||
func isGREASEValue(v uint16) bool {
|
||||
return v&0x0f0f == 0x0a0a && v>>8 == v&0xff
|
||||
}
|
||||
|
||||
// buildClientHelloSpecFromProfile constructs ClientHelloSpec from a Profile.
|
||||
// This is a standalone function that can be used by both Dialer and HTTPProxyDialer.
|
||||
func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
|
||||
// Get cipher suites
|
||||
var cipherSuites []uint16
|
||||
// Resolve effective values (profile overrides or built-in defaults)
|
||||
cipherSuites := defaultCipherSuites
|
||||
if profile != nil && len(profile.CipherSuites) > 0 {
|
||||
cipherSuites = profile.CipherSuites
|
||||
} else {
|
||||
cipherSuites = defaultCipherSuites
|
||||
}
|
||||
|
||||
// Get curves
|
||||
var curves []utls.CurveID
|
||||
curves := defaultCurves
|
||||
if profile != nil && len(profile.Curves) > 0 {
|
||||
curves = toUTLSCurves(profile.Curves)
|
||||
} else {
|
||||
curves = defaultCurves
|
||||
}
|
||||
|
||||
// Get point formats
|
||||
var pointFormats []uint8
|
||||
pointFormats := defaultPointFormats
|
||||
if profile != nil && len(profile.PointFormats) > 0 {
|
||||
pointFormats = profile.PointFormats
|
||||
} else {
|
||||
pointFormats = defaultPointFormats
|
||||
}
|
||||
|
||||
// Check if GREASE is enabled
|
||||
signatureAlgorithms := defaultSignatureAlgorithms
|
||||
if profile != nil && len(profile.SignatureAlgorithms) > 0 {
|
||||
signatureAlgorithms = make([]utls.SignatureScheme, len(profile.SignatureAlgorithms))
|
||||
for i, s := range profile.SignatureAlgorithms {
|
||||
signatureAlgorithms[i] = utls.SignatureScheme(s)
|
||||
}
|
||||
}
|
||||
|
||||
alpnProtocols := []string{"http/1.1"}
|
||||
if profile != nil && len(profile.ALPNProtocols) > 0 {
|
||||
alpnProtocols = profile.ALPNProtocols
|
||||
}
|
||||
|
||||
supportedVersions := []uint16{utls.VersionTLS13, utls.VersionTLS12}
|
||||
if profile != nil && len(profile.SupportedVersions) > 0 {
|
||||
supportedVersions = profile.SupportedVersions
|
||||
}
|
||||
|
||||
keyShareGroups := []utls.CurveID{utls.X25519}
|
||||
if profile != nil && len(profile.KeyShareGroups) > 0 {
|
||||
keyShareGroups = toUTLSCurves(profile.KeyShareGroups)
|
||||
}
|
||||
|
||||
pskModes := []uint16{uint16(utls.PskModeDHE)}
|
||||
if profile != nil && len(profile.PSKModes) > 0 {
|
||||
pskModes = profile.PSKModes
|
||||
}
|
||||
|
||||
enableGREASE := profile != nil && profile.EnableGREASE
|
||||
|
||||
extensions := make([]utls.TLSExtension, 0, 16)
|
||||
|
||||
if enableGREASE {
|
||||
extensions = append(extensions, &utls.UtlsGREASEExtension{})
|
||||
// Build key shares
|
||||
keyShares := make([]utls.KeyShare, len(keyShareGroups))
|
||||
for i, g := range keyShareGroups {
|
||||
keyShares[i] = utls.KeyShare{Group: g}
|
||||
}
|
||||
|
||||
// SNI extension - MUST be explicitly added for HelloCustom mode
|
||||
// utls will populate the server name from Config.ServerName
|
||||
extensions = append(extensions, &utls.SNIExtension{})
|
||||
// Determine extension order
|
||||
extOrder := defaultExtensionOrder
|
||||
if profile != nil && len(profile.Extensions) > 0 {
|
||||
extOrder = profile.Extensions
|
||||
}
|
||||
|
||||
// Claude CLI extension order (captured from tshark):
|
||||
// server_name(0), ec_point_formats(11), supported_groups(10), session_ticket(35),
|
||||
// alpn(16), encrypt_then_mac(22), extended_master_secret(23),
|
||||
// signature_algorithms(13), supported_versions(43),
|
||||
// psk_key_exchange_modes(45), key_share(51)
|
||||
extensions = append(extensions,
|
||||
&utls.SupportedPointsExtension{SupportedPoints: pointFormats},
|
||||
&utls.SupportedCurvesExtension{Curves: curves},
|
||||
&utls.SessionTicketExtension{},
|
||||
&utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}},
|
||||
&utls.GenericExtension{Id: 22},
|
||||
&utls.ExtendedMasterSecretExtension{},
|
||||
&utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: defaultSignatureAlgorithms},
|
||||
&utls.SupportedVersionsExtension{Versions: []uint16{
|
||||
utls.VersionTLS13,
|
||||
utls.VersionTLS12,
|
||||
}},
|
||||
&utls.PSKKeyExchangeModesExtension{Modes: []uint8{utls.PskModeDHE}},
|
||||
&utls.KeyShareExtension{KeyShares: []utls.KeyShare{
|
||||
{Group: utls.X25519},
|
||||
}},
|
||||
)
|
||||
// Build extensions list from the ordered IDs.
|
||||
// Parametric extensions (curves, sigalgs, etc.) are populated with resolved profile values.
|
||||
// Unknown IDs use GenericExtension (sends type ID with empty data).
|
||||
extensions := make([]utls.TLSExtension, 0, len(extOrder)+2)
|
||||
for _, id := range extOrder {
|
||||
if isGREASEValue(id) {
|
||||
extensions = append(extensions, &utls.UtlsGREASEExtension{})
|
||||
continue
|
||||
}
|
||||
switch id {
|
||||
case 0: // server_name
|
||||
extensions = append(extensions, &utls.SNIExtension{})
|
||||
case 5: // status_request (OCSP)
|
||||
extensions = append(extensions, &utls.StatusRequestExtension{})
|
||||
case 10: // supported_groups
|
||||
extensions = append(extensions, &utls.SupportedCurvesExtension{Curves: curves})
|
||||
case 11: // ec_point_formats
|
||||
extensions = append(extensions, &utls.SupportedPointsExtension{SupportedPoints: toUint8s(pointFormats)})
|
||||
case 13: // signature_algorithms
|
||||
extensions = append(extensions, &utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
|
||||
case 16: // alpn
|
||||
extensions = append(extensions, &utls.ALPNExtension{AlpnProtocols: alpnProtocols})
|
||||
case 18: // signed_certificate_timestamp
|
||||
extensions = append(extensions, &utls.SCTExtension{})
|
||||
case 23: // extended_master_secret
|
||||
extensions = append(extensions, &utls.ExtendedMasterSecretExtension{})
|
||||
case 35: // session_ticket
|
||||
extensions = append(extensions, &utls.SessionTicketExtension{})
|
||||
case 43: // supported_versions
|
||||
extensions = append(extensions, &utls.SupportedVersionsExtension{Versions: supportedVersions})
|
||||
case 45: // psk_key_exchange_modes
|
||||
extensions = append(extensions, &utls.PSKKeyExchangeModesExtension{Modes: toUint8s(pskModes)})
|
||||
case 50: // signature_algorithms_cert
|
||||
extensions = append(extensions, &utls.SignatureAlgorithmsCertExtension{SupportedSignatureAlgorithms: signatureAlgorithms})
|
||||
case 51: // key_share
|
||||
extensions = append(extensions, &utls.KeyShareExtension{KeyShares: keyShares})
|
||||
case 0xfe0d: // encrypted_client_hello (ECH, 65037)
|
||||
// Send GREASE ECH with random payload — mimics Node.js behavior when no real ECHConfig is available.
|
||||
// An empty GenericExtension causes "error decoding message" from servers that validate ECH format.
|
||||
extensions = append(extensions, &utls.GREASEEncryptedClientHelloExtension{})
|
||||
case 0xff01: // renegotiation_info
|
||||
extensions = append(extensions, &utls.RenegotiationInfoExtension{})
|
||||
default:
|
||||
// Unknown extension — send as GenericExtension (type ID + empty data).
|
||||
// This covers encrypt_then_mac(22) and any future extensions.
|
||||
extensions = append(extensions, &utls.GenericExtension{Id: id})
|
||||
}
|
||||
}
|
||||
|
||||
if enableGREASE {
|
||||
// For default extension order with EnableGREASE, wrap with GREASE bookends
|
||||
if enableGREASE && (profile == nil || len(profile.Extensions) == 0) {
|
||||
extensions = append([]utls.TLSExtension{&utls.UtlsGREASEExtension{}}, extensions...)
|
||||
extensions = append(extensions, &utls.UtlsGREASEExtension{})
|
||||
}
|
||||
|
||||
@ -566,3 +455,12 @@ func buildClientHelloSpecFromProfile(profile *Profile) *utls.ClientHelloSpec {
|
||||
TLSVersMin: utls.VersionTLS10,
|
||||
}
|
||||
}
|
||||
|
||||
// toUint8s converts []uint16 to []uint8 (for utls fields that require []uint8).
|
||||
func toUint8s(vals []uint16) []uint8 {
|
||||
out := make([]uint8, len(vals))
|
||||
for i, v := range vals {
|
||||
out[i] = uint8(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
368
backend/internal/pkg/tlsfingerprint/dialer_capture_test.go
Normal file
368
backend/internal/pkg/tlsfingerprint/dialer_capture_test.go
Normal file
@ -0,0 +1,368 @@
|
||||
//go:build integration
|
||||
|
||||
package tlsfingerprint
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
)
|
||||
|
||||
// CapturedFingerprint mirrors the Fingerprint struct from tls-fingerprint-web.
|
||||
// Used to deserialize the JSON response from the capture server.
|
||||
type CapturedFingerprint struct {
|
||||
JA3Raw string `json:"ja3_raw"`
|
||||
JA3Hash string `json:"ja3_hash"`
|
||||
JA4 string `json:"ja4"`
|
||||
HTTP2 string `json:"http2"`
|
||||
CipherSuites []int `json:"cipher_suites"`
|
||||
Curves []int `json:"curves"`
|
||||
PointFormats []int `json:"point_formats"`
|
||||
Extensions []int `json:"extensions"`
|
||||
SignatureAlgorithms []int `json:"signature_algorithms"`
|
||||
ALPNProtocols []string `json:"alpn_protocols"`
|
||||
SupportedVersions []int `json:"supported_versions"`
|
||||
KeyShareGroups []int `json:"key_share_groups"`
|
||||
PSKModes []int `json:"psk_modes"`
|
||||
CompressCertAlgos []int `json:"compress_cert_algos"`
|
||||
EnableGREASE bool `json:"enable_grease"`
|
||||
}
|
||||
|
||||
// TestDialerAgainstCaptureServer connects to the tls-fingerprint-web capture server
|
||||
// and verifies that the dialer's TLS fingerprint matches the configured Profile.
|
||||
//
|
||||
// Default capture server: https://tls.sub2api.org:8090
|
||||
// Override with env: TLSFINGERPRINT_CAPTURE_URL=https://localhost:8443
|
||||
//
|
||||
// Run: go test -v -run TestDialerAgainstCaptureServer ./internal/pkg/tlsfingerprint/...
|
||||
func TestDialerAgainstCaptureServer(t *testing.T) {
|
||||
captureURL := os.Getenv("TLSFINGERPRINT_CAPTURE_URL")
|
||||
if captureURL == "" {
|
||||
captureURL = "https://tls.sub2api.org:8090"
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
profile *Profile
|
||||
}{
|
||||
{
|
||||
name: "default_profile",
|
||||
profile: &Profile{
|
||||
Name: "default",
|
||||
EnableGREASE: false,
|
||||
// All empty → uses built-in defaults
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux_x64_node_v22171",
|
||||
profile: &Profile{
|
||||
Name: "linux_x64_node_v22171",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint16{0, 1, 2},
|
||||
SignatureAlgorithms: []uint16{0x0403, 0x0503, 0x0603, 0x0807, 0x0808, 0x0809, 0x080a, 0x080b, 0x0804, 0x0805, 0x0806, 0x0401, 0x0501, 0x0601, 0x0303, 0x0301, 0x0302, 0x0402, 0x0502, 0x0602},
|
||||
ALPNProtocols: []string{"http/1.1"},
|
||||
SupportedVersions: []uint16{0x0304, 0x0303},
|
||||
KeyShareGroups: []uint16{29},
|
||||
PSKModes: []uint16{1},
|
||||
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "macos_arm64_node_v2430",
|
||||
profile: &Profile{
|
||||
Name: "MacOS_arm64_node_v2430",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4865, 4866, 4867, 49195, 49199, 49196, 49200, 52393, 52392, 49161, 49171, 49162, 49172, 156, 157, 47, 53},
|
||||
Curves: []uint16{29, 23, 24},
|
||||
PointFormats: []uint16{0},
|
||||
SignatureAlgorithms: []uint16{0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601, 0x0201},
|
||||
ALPNProtocols: []string{"http/1.1"},
|
||||
SupportedVersions: []uint16{0x0304, 0x0303},
|
||||
KeyShareGroups: []uint16{29},
|
||||
PSKModes: []uint16{1},
|
||||
Extensions: []uint16{0, 65037, 23, 65281, 10, 11, 35, 16, 5, 13, 18, 51, 45, 43},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
captured := fetchCapturedFingerprint(t, captureURL, tc.profile)
|
||||
if captured == nil {
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("JA3 Hash: %s", captured.JA3Hash)
|
||||
t.Logf("JA4: %s", captured.JA4)
|
||||
|
||||
// Resolve effective profile values (what the dialer actually uses)
|
||||
effectiveCipherSuites := tc.profile.CipherSuites
|
||||
if len(effectiveCipherSuites) == 0 {
|
||||
effectiveCipherSuites = defaultCipherSuites
|
||||
}
|
||||
effectiveCurves := tc.profile.Curves
|
||||
if len(effectiveCurves) == 0 {
|
||||
effectiveCurves = make([]uint16, len(defaultCurves))
|
||||
for i, c := range defaultCurves {
|
||||
effectiveCurves[i] = uint16(c)
|
||||
}
|
||||
}
|
||||
effectivePointFormats := tc.profile.PointFormats
|
||||
if len(effectivePointFormats) == 0 {
|
||||
effectivePointFormats = defaultPointFormats
|
||||
}
|
||||
effectiveSigAlgs := tc.profile.SignatureAlgorithms
|
||||
if len(effectiveSigAlgs) == 0 {
|
||||
effectiveSigAlgs = make([]uint16, len(defaultSignatureAlgorithms))
|
||||
for i, s := range defaultSignatureAlgorithms {
|
||||
effectiveSigAlgs[i] = uint16(s)
|
||||
}
|
||||
}
|
||||
effectiveALPN := tc.profile.ALPNProtocols
|
||||
if len(effectiveALPN) == 0 {
|
||||
effectiveALPN = []string{"http/1.1"}
|
||||
}
|
||||
effectiveVersions := tc.profile.SupportedVersions
|
||||
if len(effectiveVersions) == 0 {
|
||||
effectiveVersions = []uint16{0x0304, 0x0303}
|
||||
}
|
||||
effectiveKeyShare := tc.profile.KeyShareGroups
|
||||
if len(effectiveKeyShare) == 0 {
|
||||
effectiveKeyShare = []uint16{29} // X25519
|
||||
}
|
||||
effectivePSKModes := tc.profile.PSKModes
|
||||
if len(effectivePSKModes) == 0 {
|
||||
effectivePSKModes = []uint16{1} // psk_dhe_ke
|
||||
}
|
||||
|
||||
// Verify each field
|
||||
assertIntSliceEqual(t, "cipher_suites", uint16sToInts(effectiveCipherSuites), captured.CipherSuites)
|
||||
assertIntSliceEqual(t, "curves", uint16sToInts(effectiveCurves), captured.Curves)
|
||||
assertIntSliceEqual(t, "point_formats", uint16sToInts(effectivePointFormats), captured.PointFormats)
|
||||
assertIntSliceEqual(t, "signature_algorithms", uint16sToInts(effectiveSigAlgs), captured.SignatureAlgorithms)
|
||||
assertStringSliceEqual(t, "alpn_protocols", effectiveALPN, captured.ALPNProtocols)
|
||||
assertIntSliceEqual(t, "supported_versions", uint16sToInts(effectiveVersions), captured.SupportedVersions)
|
||||
assertIntSliceEqual(t, "key_share_groups", uint16sToInts(effectiveKeyShare), captured.KeyShareGroups)
|
||||
assertIntSliceEqual(t, "psk_modes", uint16sToInts(effectivePSKModes), captured.PSKModes)
|
||||
|
||||
if captured.EnableGREASE != tc.profile.EnableGREASE {
|
||||
t.Errorf("enable_grease: got %v, want %v", captured.EnableGREASE, tc.profile.EnableGREASE)
|
||||
} else {
|
||||
t.Logf(" enable_grease: %v OK", captured.EnableGREASE)
|
||||
}
|
||||
|
||||
// Verify extension order
|
||||
// Use profile.Extensions if set, otherwise the default order (Node.js 24.x)
|
||||
expectedExtOrder := uint16sToInts(defaultExtensionOrder)
|
||||
if len(tc.profile.Extensions) > 0 {
|
||||
expectedExtOrder = uint16sToInts(tc.profile.Extensions)
|
||||
}
|
||||
// Strip GREASE values from both expected and captured for comparison
|
||||
var filteredExpected, filteredActual []int
|
||||
for _, e := range expectedExtOrder {
|
||||
if !isGREASEValue(uint16(e)) {
|
||||
filteredExpected = append(filteredExpected, e)
|
||||
}
|
||||
}
|
||||
for _, e := range captured.Extensions {
|
||||
if !isGREASEValue(uint16(e)) {
|
||||
filteredActual = append(filteredActual, e)
|
||||
}
|
||||
}
|
||||
assertIntSliceEqual(t, "extensions (order, non-GREASE)", filteredExpected, filteredActual)
|
||||
|
||||
// Print full captured data as JSON for debugging
|
||||
capturedJSON, _ := json.MarshalIndent(captured, " ", " ")
|
||||
t.Logf("Full captured fingerprint:\n %s", string(capturedJSON))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCapturedFingerprint(t *testing.T, captureURL string, profile *Profile) *CapturedFingerprint {
|
||||
t.Helper()
|
||||
|
||||
dialer := NewDialer(profile, nil)
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialTLSContext: dialer.DialTLSContext,
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", captureURL, strings.NewReader(`{"model":"test"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("create request: %v", err)
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var fp CapturedFingerprint
|
||||
if err := json.Unmarshal(body, &fp); err != nil {
|
||||
t.Logf("Response body: %s", string(body))
|
||||
t.Fatalf("parse response: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &fp
|
||||
}
|
||||
|
||||
func uint16sToInts(vals []uint16) []int {
|
||||
result := make([]int, len(vals))
|
||||
for i, v := range vals {
|
||||
result[i] = int(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func assertIntSliceEqual(t *testing.T, name string, expected, actual []int) {
|
||||
t.Helper()
|
||||
if len(expected) != len(actual) {
|
||||
t.Errorf("%s: length mismatch: got %d, want %d", name, len(actual), len(expected))
|
||||
if len(actual) < 20 && len(expected) < 20 {
|
||||
t.Errorf(" got: %v", actual)
|
||||
t.Errorf(" want: %v", expected)
|
||||
}
|
||||
return
|
||||
}
|
||||
mismatches := 0
|
||||
for i := range expected {
|
||||
if expected[i] != actual[i] {
|
||||
if mismatches < 5 {
|
||||
t.Errorf("%s[%d]: got %d (0x%04x), want %d (0x%04x)", name, i, actual[i], actual[i], expected[i], expected[i])
|
||||
}
|
||||
mismatches++
|
||||
}
|
||||
}
|
||||
if mismatches == 0 {
|
||||
t.Logf(" %s: %d items OK", name, len(expected))
|
||||
} else if mismatches > 5 {
|
||||
t.Errorf(" %s: %d/%d mismatches (showing first 5)", name, mismatches, len(expected))
|
||||
}
|
||||
}
|
||||
|
||||
func assertStringSliceEqual(t *testing.T, name string, expected, actual []string) {
|
||||
t.Helper()
|
||||
if len(expected) != len(actual) {
|
||||
t.Errorf("%s: length mismatch: got %d (%v), want %d (%v)", name, len(actual), actual, len(expected), expected)
|
||||
return
|
||||
}
|
||||
for i := range expected {
|
||||
if expected[i] != actual[i] {
|
||||
t.Errorf("%s[%d]: got %q, want %q", name, i, actual[i], expected[i])
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Logf(" %s: %v OK", name, expected)
|
||||
}
|
||||
|
||||
// TestBuildClientHelloSpecNewFields tests that new Profile fields are correctly applied.
|
||||
func TestBuildClientHelloSpecNewFields(t *testing.T) {
|
||||
// Test custom ALPN, versions, key shares, PSK modes
|
||||
profile := &Profile{
|
||||
Name: "custom_full",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{0x1301, 0x1302},
|
||||
Curves: []uint16{29, 23},
|
||||
PointFormats: []uint16{0},
|
||||
SignatureAlgorithms: []uint16{0x0403, 0x0804},
|
||||
ALPNProtocols: []string{"h2", "http/1.1"},
|
||||
SupportedVersions: []uint16{0x0304},
|
||||
KeyShareGroups: []uint16{29, 23},
|
||||
PSKModes: []uint16{1},
|
||||
}
|
||||
|
||||
spec := buildClientHelloSpecFromProfile(profile)
|
||||
|
||||
// Verify cipher suites
|
||||
if len(spec.CipherSuites) != 2 || spec.CipherSuites[0] != 0x1301 {
|
||||
t.Errorf("cipher suites: got %v", spec.CipherSuites)
|
||||
}
|
||||
|
||||
// Check extensions for expected values
|
||||
var foundALPN, foundVersions, foundKeyShare, foundPSK, foundSigAlgs bool
|
||||
for _, ext := range spec.Extensions {
|
||||
switch e := ext.(type) {
|
||||
case *utls.ALPNExtension:
|
||||
foundALPN = true
|
||||
if len(e.AlpnProtocols) != 2 || e.AlpnProtocols[0] != "h2" {
|
||||
t.Errorf("ALPN: got %v, want [h2, http/1.1]", e.AlpnProtocols)
|
||||
}
|
||||
case *utls.SupportedVersionsExtension:
|
||||
foundVersions = true
|
||||
if len(e.Versions) != 1 || e.Versions[0] != 0x0304 {
|
||||
t.Errorf("versions: got %v, want [0x0304]", e.Versions)
|
||||
}
|
||||
case *utls.KeyShareExtension:
|
||||
foundKeyShare = true
|
||||
if len(e.KeyShares) != 2 {
|
||||
t.Errorf("key shares: got %d entries, want 2", len(e.KeyShares))
|
||||
}
|
||||
case *utls.PSKKeyExchangeModesExtension:
|
||||
foundPSK = true
|
||||
if len(e.Modes) != 1 || e.Modes[0] != 1 {
|
||||
t.Errorf("PSK modes: got %v, want [1]", e.Modes)
|
||||
}
|
||||
case *utls.SignatureAlgorithmsExtension:
|
||||
foundSigAlgs = true
|
||||
if len(e.SupportedSignatureAlgorithms) != 2 {
|
||||
t.Errorf("sig algs: got %d, want 2", len(e.SupportedSignatureAlgorithms))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for name, found := range map[string]bool{
|
||||
"ALPN": foundALPN, "Versions": foundVersions, "KeyShare": foundKeyShare,
|
||||
"PSK": foundPSK, "SigAlgs": foundSigAlgs,
|
||||
} {
|
||||
if !found {
|
||||
t.Errorf("extension %s not found in spec", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Test nil profile uses all defaults
|
||||
specDefault := buildClientHelloSpecFromProfile(nil)
|
||||
for _, ext := range specDefault.Extensions {
|
||||
switch e := ext.(type) {
|
||||
case *utls.ALPNExtension:
|
||||
if len(e.AlpnProtocols) != 1 || e.AlpnProtocols[0] != "http/1.1" {
|
||||
t.Errorf("default ALPN: got %v, want [http/1.1]", e.AlpnProtocols)
|
||||
}
|
||||
case *utls.SupportedVersionsExtension:
|
||||
if len(e.Versions) != 2 {
|
||||
t.Errorf("default versions: got %v, want 2 entries", e.Versions)
|
||||
}
|
||||
case *utls.KeyShareExtension:
|
||||
if len(e.KeyShares) != 1 {
|
||||
t.Errorf("default key shares: got %d, want 1", len(e.KeyShares))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("TestBuildClientHelloSpecNewFields passed")
|
||||
}
|
||||
@ -40,16 +40,15 @@ func skipIfExternalServiceUnavailable(t *testing.T, err error) {
|
||||
|
||||
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
|
||||
// This test uses tls.peet.ws to verify the fingerprint.
|
||||
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
|
||||
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
|
||||
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
|
||||
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
func TestJA3Fingerprint(t *testing.T) {
|
||||
// Skip if network is unavailable or if running in short mode
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
profile := &Profile{
|
||||
Name: "Claude CLI Test",
|
||||
Name: "Default Profile Test",
|
||||
EnableGREASE: false,
|
||||
}
|
||||
dialer := NewDialer(profile, nil)
|
||||
@ -61,7 +60,6 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
// Use tls.peet.ws fingerprint detection API
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@ -69,7 +67,7 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
|
||||
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
skipIfExternalServiceUnavailable(t, err)
|
||||
@ -86,71 +84,23 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
t.Fatalf("failed to parse fingerprint response: %v", err)
|
||||
}
|
||||
|
||||
// Log all fingerprint information
|
||||
t.Logf("JA3: %s", fpResp.TLS.JA3)
|
||||
t.Logf("JA3 Hash: %s", fpResp.TLS.JA3Hash)
|
||||
t.Logf("JA4: %s", fpResp.TLS.JA4)
|
||||
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
|
||||
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
|
||||
|
||||
// Verify JA3 hash matches expected value
|
||||
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
|
||||
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
|
||||
if fpResp.TLS.JA3Hash == expectedJA3Hash {
|
||||
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
|
||||
t.Logf("✓ JA3 hash matches: %s", expectedJA3Hash)
|
||||
} else {
|
||||
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
|
||||
}
|
||||
|
||||
// Verify JA4 fingerprint
|
||||
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
|
||||
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
|
||||
// The suffix _a33745022dd6_1f22a2ca17c4 should match
|
||||
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
|
||||
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
|
||||
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
|
||||
expectedJA4CipherHash := "_5b57614c22b0_"
|
||||
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
|
||||
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
|
||||
} else {
|
||||
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
|
||||
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
|
||||
}
|
||||
|
||||
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
|
||||
// d = domain (SNI present), i = IP (no SNI)
|
||||
// Since we connect to tls.peet.ws (domain), we expect 'd'
|
||||
expectedJA4Prefix := "t13d5911h1"
|
||||
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
|
||||
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
|
||||
} else {
|
||||
// Also accept 'i' variant for IP connections
|
||||
altPrefix := "t13i5911h1"
|
||||
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
|
||||
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
|
||||
} else {
|
||||
t.Errorf("✗ JA4 prefix mismatch: got %s, expected %s or %s", fpResp.TLS.JA4, expectedJA4Prefix, altPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
|
||||
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
|
||||
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
|
||||
} else {
|
||||
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
|
||||
}
|
||||
|
||||
// Verify extension list (should be 11 extensions including SNI)
|
||||
// Expected: 0-11-10-35-16-22-23-13-43-45-51
|
||||
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
|
||||
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
|
||||
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
|
||||
} else {
|
||||
t.Logf("Warning: JA3 extension list may differ")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileExpectation defines expected fingerprint values for a profile.
|
||||
type TestProfileExpectation struct {
|
||||
Profile *Profile
|
||||
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
|
||||
ExpectedJA4 string // Expected full JA4 (empty = don't check)
|
||||
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
|
||||
}
|
||||
|
||||
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
|
||||
@ -164,30 +114,24 @@ func TestAllProfiles(t *testing.T) {
|
||||
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
|
||||
profiles := []TestProfileExpectation{
|
||||
{
|
||||
// Linux x64 Node.js v22.17.1
|
||||
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
|
||||
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
|
||||
// Default profile (Node.js 24.x)
|
||||
Profile: &Profile{
|
||||
Name: "default_node_v24",
|
||||
EnableGREASE: false,
|
||||
},
|
||||
JA4CipherHash: "5b57614c22b0",
|
||||
},
|
||||
{
|
||||
// Linux x64 Node.js v22.17.1 (explicit profile with v22 extensions)
|
||||
Profile: &Profile{
|
||||
Name: "linux_x64_node_v22171",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint8{0, 1, 2},
|
||||
PointFormats: []uint16{0, 1, 2},
|
||||
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
|
||||
},
|
||||
JA4CipherHash: "a33745022dd6", // stable part
|
||||
},
|
||||
{
|
||||
// MacOS arm64 Node.js v22.18.0
|
||||
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
|
||||
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
|
||||
Profile: &Profile{
|
||||
Name: "macos_arm64_node_v22180",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint8{0, 1, 2},
|
||||
},
|
||||
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
|
||||
JA4CipherHash: "a33745022dd6",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -55,13 +55,13 @@ func TestDialerBasicConnection(t *testing.T) {
|
||||
|
||||
// TestJA3Fingerprint verifies the JA3/JA4 fingerprint matches expected value.
|
||||
// This test uses tls.peet.ws to verify the fingerprint.
|
||||
// Expected JA3 hash: 1a28e69016765d92e3b381168d68922c (Claude CLI / Node.js 20.x)
|
||||
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4 (d=domain) or t13i5911h1_... (i=IP)
|
||||
// Expected JA3 hash: 44f88fca027f27bab4bb08d4af15f23e (Node.js 24.x)
|
||||
// Expected JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
func TestJA3Fingerprint(t *testing.T) {
|
||||
skipNetworkTest(t)
|
||||
|
||||
profile := &Profile{
|
||||
Name: "Claude CLI Test",
|
||||
Name: "Default Profile Test",
|
||||
EnableGREASE: false,
|
||||
}
|
||||
dialer := NewDialer(profile, nil)
|
||||
@ -81,7 +81,7 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/20.0.0")
|
||||
req.Header.Set("User-Agent", "Claude Code/2.0.0 Node.js/24.3.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@ -107,34 +107,28 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
t.Logf("PeetPrint: %s", fpResp.TLS.PeetPrint)
|
||||
t.Logf("PeetPrint Hash: %s", fpResp.TLS.PeetPrintHash)
|
||||
|
||||
// Verify JA3 hash matches expected value
|
||||
expectedJA3Hash := "1a28e69016765d92e3b381168d68922c"
|
||||
// Verify JA3 hash matches expected value (Node.js 24.x default)
|
||||
expectedJA3Hash := "44f88fca027f27bab4bb08d4af15f23e"
|
||||
if fpResp.TLS.JA3Hash == expectedJA3Hash {
|
||||
t.Logf("✓ JA3 hash matches expected value: %s", expectedJA3Hash)
|
||||
} else {
|
||||
t.Errorf("✗ JA3 hash mismatch: got %s, expected %s", fpResp.TLS.JA3Hash, expectedJA3Hash)
|
||||
}
|
||||
|
||||
// Verify JA4 fingerprint
|
||||
// JA4 format: t[version][sni][cipher_count][ext_count][alpn]_[cipher_hash]_[ext_hash]
|
||||
// Expected: t13d5910h1 (d=domain) or t13i5910h1 (i=IP)
|
||||
// The suffix _a33745022dd6_1f22a2ca17c4 should match
|
||||
expectedJA4Suffix := "_a33745022dd6_1f22a2ca17c4"
|
||||
if strings.HasSuffix(fpResp.TLS.JA4, expectedJA4Suffix) {
|
||||
t.Logf("✓ JA4 suffix matches expected value: %s", expectedJA4Suffix)
|
||||
// Verify JA4 cipher hash (stable middle part)
|
||||
expectedJA4CipherHash := "_5b57614c22b0_"
|
||||
if strings.Contains(fpResp.TLS.JA4, expectedJA4CipherHash) {
|
||||
t.Logf("✓ JA4 cipher hash matches: %s", expectedJA4CipherHash)
|
||||
} else {
|
||||
t.Errorf("✗ JA4 suffix mismatch: got %s, expected suffix %s", fpResp.TLS.JA4, expectedJA4Suffix)
|
||||
t.Errorf("✗ JA4 cipher hash mismatch: got %s, expected containing %s", fpResp.TLS.JA4, expectedJA4CipherHash)
|
||||
}
|
||||
|
||||
// Verify JA4 prefix (t13d5911h1 or t13i5911h1)
|
||||
// d = domain (SNI present), i = IP (no SNI)
|
||||
// Since we connect to tls.peet.ws (domain), we expect 'd'
|
||||
expectedJA4Prefix := "t13d5911h1"
|
||||
// Verify JA4 prefix (t13d1714h1 or t13i1714h1)
|
||||
expectedJA4Prefix := "t13d1714h1"
|
||||
if strings.HasPrefix(fpResp.TLS.JA4, expectedJA4Prefix) {
|
||||
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 59=ciphers, 11=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
|
||||
t.Logf("✓ JA4 prefix matches: %s (t13=TLS1.3, d=domain, 17=ciphers, 14=extensions, h1=HTTP/1.1)", expectedJA4Prefix)
|
||||
} else {
|
||||
// Also accept 'i' variant for IP connections
|
||||
altPrefix := "t13i5911h1"
|
||||
altPrefix := "t13i1714h1"
|
||||
if strings.HasPrefix(fpResp.TLS.JA4, altPrefix) {
|
||||
t.Logf("✓ JA4 prefix matches (IP variant): %s", altPrefix)
|
||||
} else {
|
||||
@ -142,16 +136,15 @@ func TestJA3Fingerprint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify JA3 contains expected cipher suites (TLS 1.3 ciphers at the beginning)
|
||||
if strings.Contains(fpResp.TLS.JA3, "4866-4867-4865") {
|
||||
// Verify JA3 contains expected TLS 1.3 cipher suites
|
||||
if strings.Contains(fpResp.TLS.JA3, "4865-4866-4867") {
|
||||
t.Logf("✓ JA3 contains expected TLS 1.3 cipher suites")
|
||||
} else {
|
||||
t.Logf("Warning: JA3 does not contain expected TLS 1.3 cipher suites")
|
||||
}
|
||||
|
||||
// Verify extension list (should be 11 extensions including SNI)
|
||||
// Expected: 0-11-10-35-16-22-23-13-43-45-51
|
||||
expectedExtensions := "0-11-10-35-16-22-23-13-43-45-51"
|
||||
// Verify extension list (14 extensions, Node.js 24.x order)
|
||||
expectedExtensions := "0-65037-23-65281-10-11-35-16-5-13-18-51-45-43"
|
||||
if strings.Contains(fpResp.TLS.JA3, expectedExtensions) {
|
||||
t.Logf("✓ JA3 contains expected extension list: %s", expectedExtensions)
|
||||
} else {
|
||||
@ -186,8 +179,8 @@ func TestDialerWithProfile(t *testing.T) {
|
||||
// Build specs and compare
|
||||
// Note: We can't directly compare JA3 without making network requests
|
||||
// but we can verify the specs are different
|
||||
spec1 := dialer1.buildClientHelloSpec()
|
||||
spec2 := dialer2.buildClientHelloSpec()
|
||||
spec1 := buildClientHelloSpecFromProfile(dialer1.profile)
|
||||
spec2 := buildClientHelloSpecFromProfile(dialer2.profile)
|
||||
|
||||
// Profile with GREASE should have more extensions
|
||||
if len(spec2.Extensions) <= len(spec1.Extensions) {
|
||||
@ -296,47 +289,33 @@ func mustParseURL(rawURL string) *url.URL {
|
||||
return u
|
||||
}
|
||||
|
||||
// TestProfileExpectation defines expected fingerprint values for a profile.
|
||||
type TestProfileExpectation struct {
|
||||
Profile *Profile
|
||||
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
|
||||
ExpectedJA4 string // Expected full JA4 (empty = don't check)
|
||||
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
|
||||
}
|
||||
|
||||
// TestAllProfiles tests multiple TLS fingerprint profiles against tls.peet.ws.
|
||||
// Run with: go test -v -run TestAllProfiles ./internal/pkg/tlsfingerprint/...
|
||||
func TestAllProfiles(t *testing.T) {
|
||||
skipNetworkTest(t)
|
||||
|
||||
// Define all profiles to test with their expected fingerprints
|
||||
// These profiles are from config.yaml gateway.tls_fingerprint.profiles
|
||||
profiles := []TestProfileExpectation{
|
||||
{
|
||||
// Linux x64 Node.js v22.17.1
|
||||
// Expected JA3 Hash: 1a28e69016765d92e3b381168d68922c
|
||||
// Expected JA4: t13d5911h1_a33745022dd6_1f22a2ca17c4
|
||||
// Default profile (Node.js 24.x)
|
||||
// JA3 Hash: 44f88fca027f27bab4bb08d4af15f23e
|
||||
// JA4: t13d1714h1_5b57614c22b0_7baf387fc6ff
|
||||
Profile: &Profile{
|
||||
Name: "default_node_v24",
|
||||
EnableGREASE: false,
|
||||
},
|
||||
JA4CipherHash: "5b57614c22b0",
|
||||
},
|
||||
{
|
||||
// Linux x64 Node.js v22.17.1 (explicit profile)
|
||||
Profile: &Profile{
|
||||
Name: "linux_x64_node_v22171",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint8{0, 1, 2},
|
||||
PointFormats: []uint16{0, 1, 2},
|
||||
Extensions: []uint16{0, 11, 10, 35, 16, 22, 23, 13, 43, 45, 51},
|
||||
},
|
||||
JA4CipherHash: "a33745022dd6", // stable part
|
||||
},
|
||||
{
|
||||
// MacOS arm64 Node.js v22.18.0
|
||||
// Expected JA3 Hash: 70cb5ca646080902703ffda87036a5ea
|
||||
// Expected JA4: t13d5912h1_a33745022dd6_dbd39dd1d406
|
||||
Profile: &Profile{
|
||||
Name: "macos_arm64_node_v22180",
|
||||
EnableGREASE: false,
|
||||
CipherSuites: []uint16{4866, 4867, 4865, 49199, 49195, 49200, 49196, 158, 49191, 103, 49192, 107, 163, 159, 52393, 52392, 52394, 49327, 49325, 49315, 49311, 49245, 49249, 49239, 49235, 162, 49326, 49324, 49314, 49310, 49244, 49248, 49238, 49234, 49188, 106, 49187, 64, 49162, 49172, 57, 56, 49161, 49171, 51, 50, 157, 49313, 49309, 49233, 156, 49312, 49308, 49232, 61, 60, 53, 47, 255},
|
||||
Curves: []uint16{29, 23, 30, 25, 24, 256, 257, 258, 259, 260},
|
||||
PointFormats: []uint8{0, 1, 2},
|
||||
},
|
||||
JA4CipherHash: "a33745022dd6", // stable part (same cipher suites)
|
||||
JA4CipherHash: "a33745022dd6",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -1,171 +0,0 @@
|
||||
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
|
||||
package tlsfingerprint
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
// DefaultProfileName is the name of the built-in Claude CLI profile.
|
||||
const DefaultProfileName = "claude_cli_v2"
|
||||
|
||||
// Registry manages TLS fingerprint profiles.
|
||||
// It holds a collection of profiles that can be used for TLS fingerprint simulation.
|
||||
// Profiles are selected based on account ID using modulo operation.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]*Profile
|
||||
profileNames []string // Sorted list of profile names for deterministic selection
|
||||
}
|
||||
|
||||
// NewRegistry creates a new TLS fingerprint profile registry.
|
||||
// It initializes with the built-in default profile.
|
||||
func NewRegistry() *Registry {
|
||||
r := &Registry{
|
||||
profiles: make(map[string]*Profile),
|
||||
profileNames: make([]string, 0),
|
||||
}
|
||||
|
||||
// Register the built-in default profile
|
||||
r.registerBuiltinProfile()
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// NewRegistryFromConfig creates a new registry and loads profiles from config.
|
||||
// If the config has custom profiles defined, they will be merged with the built-in default.
|
||||
func NewRegistryFromConfig(cfg *config.TLSFingerprintConfig) *Registry {
|
||||
r := NewRegistry()
|
||||
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
slog.Debug("tls_registry_disabled", "reason", "disabled or no config")
|
||||
return r
|
||||
}
|
||||
|
||||
// Load custom profiles from config
|
||||
for name, profileCfg := range cfg.Profiles {
|
||||
profile := &Profile{
|
||||
Name: profileCfg.Name,
|
||||
EnableGREASE: profileCfg.EnableGREASE,
|
||||
CipherSuites: profileCfg.CipherSuites,
|
||||
Curves: profileCfg.Curves,
|
||||
PointFormats: profileCfg.PointFormats,
|
||||
}
|
||||
|
||||
// If the profile has empty values, they will use defaults in dialer
|
||||
r.RegisterProfile(name, profile)
|
||||
slog.Debug("tls_registry_loaded_profile", "key", name, "name", profileCfg.Name)
|
||||
}
|
||||
|
||||
slog.Debug("tls_registry_initialized", "profile_count", len(r.profileNames), "profiles", r.profileNames)
|
||||
return r
|
||||
}
|
||||
|
||||
// registerBuiltinProfile adds the default Claude CLI profile to the registry.
|
||||
func (r *Registry) registerBuiltinProfile() {
|
||||
defaultProfile := &Profile{
|
||||
Name: "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)",
|
||||
EnableGREASE: false, // Node.js does not use GREASE
|
||||
// Empty slices will cause dialer to use built-in defaults
|
||||
CipherSuites: nil,
|
||||
Curves: nil,
|
||||
PointFormats: nil,
|
||||
}
|
||||
r.RegisterProfile(DefaultProfileName, defaultProfile)
|
||||
}
|
||||
|
||||
// RegisterProfile adds or updates a profile in the registry.
|
||||
func (r *Registry) RegisterProfile(name string, profile *Profile) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Check if this is a new profile
|
||||
_, exists := r.profiles[name]
|
||||
r.profiles[name] = profile
|
||||
|
||||
if !exists {
|
||||
r.profileNames = append(r.profileNames, name)
|
||||
// Keep names sorted for deterministic selection
|
||||
sort.Strings(r.profileNames)
|
||||
}
|
||||
}
|
||||
|
||||
// GetProfile returns a profile by name.
|
||||
// Returns nil if the profile does not exist.
|
||||
func (r *Registry) GetProfile(name string) *Profile {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return r.profiles[name]
|
||||
}
|
||||
|
||||
// GetDefaultProfile returns the built-in default profile.
|
||||
func (r *Registry) GetDefaultProfile() *Profile {
|
||||
return r.GetProfile(DefaultProfileName)
|
||||
}
|
||||
|
||||
// GetProfileByAccountID returns a profile for the given account ID.
|
||||
// The profile is selected using: profileNames[accountID % len(profiles)]
|
||||
// This ensures deterministic profile assignment for each account.
|
||||
func (r *Registry) GetProfileByAccountID(accountID int64) *Profile {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if len(r.profileNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use modulo to select profile index
|
||||
// Use absolute value to handle negative IDs (though unlikely)
|
||||
idx := accountID
|
||||
if idx < 0 {
|
||||
idx = -idx
|
||||
}
|
||||
selectedIndex := int(idx % int64(len(r.profileNames)))
|
||||
selectedName := r.profileNames[selectedIndex]
|
||||
|
||||
return r.profiles[selectedName]
|
||||
}
|
||||
|
||||
// ProfileCount returns the number of registered profiles.
|
||||
func (r *Registry) ProfileCount() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.profiles)
|
||||
}
|
||||
|
||||
// ProfileNames returns a sorted list of all registered profile names.
|
||||
func (r *Registry) ProfileNames() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
// Return a copy to prevent modification
|
||||
names := make([]string, len(r.profileNames))
|
||||
copy(names, r.profileNames)
|
||||
return names
|
||||
}
|
||||
|
||||
// Global registry instance for convenience
|
||||
var globalRegistry *Registry
|
||||
var globalRegistryOnce sync.Once
|
||||
|
||||
// GlobalRegistry returns the global TLS fingerprint registry.
|
||||
// The registry is lazily initialized with the default profile.
|
||||
func GlobalRegistry() *Registry {
|
||||
globalRegistryOnce.Do(func() {
|
||||
globalRegistry = NewRegistry()
|
||||
})
|
||||
return globalRegistry
|
||||
}
|
||||
|
||||
// InitGlobalRegistry initializes the global registry with configuration.
|
||||
// This should be called during application startup.
|
||||
// It is safe to call multiple times; subsequent calls will update the registry.
|
||||
func InitGlobalRegistry(cfg *config.TLSFingerprintConfig) *Registry {
|
||||
globalRegistryOnce.Do(func() {
|
||||
globalRegistry = NewRegistryFromConfig(cfg)
|
||||
})
|
||||
return globalRegistry
|
||||
}
|
||||
@ -1,243 +0,0 @@
|
||||
package tlsfingerprint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
)
|
||||
|
||||
func TestNewRegistry(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Should have exactly one profile (the default)
|
||||
if r.ProfileCount() != 1 {
|
||||
t.Errorf("expected 1 profile, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Should have the default profile
|
||||
profile := r.GetDefaultProfile()
|
||||
if profile == nil {
|
||||
t.Error("expected default profile to exist")
|
||||
}
|
||||
|
||||
// Default profile name should be in the list
|
||||
names := r.ProfileNames()
|
||||
if len(names) != 1 || names[0] != DefaultProfileName {
|
||||
t.Errorf("expected profile names to be [%s], got %v", DefaultProfileName, names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterProfile(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Register a new profile
|
||||
customProfile := &Profile{
|
||||
Name: "Custom Profile",
|
||||
EnableGREASE: true,
|
||||
}
|
||||
r.RegisterProfile("custom", customProfile)
|
||||
|
||||
// Should now have 2 profiles
|
||||
if r.ProfileCount() != 2 {
|
||||
t.Errorf("expected 2 profiles, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Should be able to retrieve the custom profile
|
||||
retrieved := r.GetProfile("custom")
|
||||
if retrieved == nil {
|
||||
t.Fatal("expected custom profile to exist")
|
||||
}
|
||||
if retrieved.Name != "Custom Profile" {
|
||||
t.Errorf("expected profile name 'Custom Profile', got '%s'", retrieved.Name)
|
||||
}
|
||||
if !retrieved.EnableGREASE {
|
||||
t.Error("expected EnableGREASE to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfile(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Get existing profile
|
||||
profile := r.GetProfile(DefaultProfileName)
|
||||
if profile == nil {
|
||||
t.Error("expected default profile to exist")
|
||||
}
|
||||
|
||||
// Get non-existing profile
|
||||
nonExistent := r.GetProfile("nonexistent")
|
||||
if nonExistent != nil {
|
||||
t.Error("expected nil for non-existent profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProfileByAccountID(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// With only default profile, all account IDs should return the same profile
|
||||
for i := int64(0); i < 10; i++ {
|
||||
profile := r.GetProfileByAccountID(i)
|
||||
if profile == nil {
|
||||
t.Errorf("expected profile for account %d, got nil", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Add more profiles
|
||||
r.RegisterProfile("profile_a", &Profile{Name: "Profile A"})
|
||||
r.RegisterProfile("profile_b", &Profile{Name: "Profile B"})
|
||||
|
||||
// Now we have 3 profiles: claude_cli_v2, profile_a, profile_b
|
||||
// Names are sorted, so order is: claude_cli_v2, profile_a, profile_b
|
||||
expectedOrder := []string{DefaultProfileName, "profile_a", "profile_b"}
|
||||
names := r.ProfileNames()
|
||||
for i, name := range expectedOrder {
|
||||
if names[i] != name {
|
||||
t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Test modulo selection
|
||||
// Account ID 0 % 3 = 0 -> claude_cli_v2
|
||||
// Account ID 1 % 3 = 1 -> profile_a
|
||||
// Account ID 2 % 3 = 2 -> profile_b
|
||||
// Account ID 3 % 3 = 0 -> claude_cli_v2
|
||||
testCases := []struct {
|
||||
accountID int64
|
||||
expectedName string
|
||||
}{
|
||||
{0, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"},
|
||||
{1, "Profile A"},
|
||||
{2, "Profile B"},
|
||||
{3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"},
|
||||
{4, "Profile A"},
|
||||
{5, "Profile B"},
|
||||
{100, "Profile A"}, // 100 % 3 = 1
|
||||
{-1, "Profile A"}, // |-1| % 3 = 1
|
||||
{-3, "Claude CLI 2.x (Node.js 20.x + OpenSSL 3.x)"}, // |-3| % 3 = 0
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
profile := r.GetProfileByAccountID(tc.accountID)
|
||||
if profile == nil {
|
||||
t.Errorf("expected profile for account %d, got nil", tc.accountID)
|
||||
continue
|
||||
}
|
||||
if profile.Name != tc.expectedName {
|
||||
t.Errorf("account %d: expected profile name '%s', got '%s'", tc.accountID, tc.expectedName, profile.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRegistryFromConfig(t *testing.T) {
|
||||
// Test with nil config
|
||||
r := NewRegistryFromConfig(nil)
|
||||
if r.ProfileCount() != 1 {
|
||||
t.Errorf("expected 1 profile with nil config, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Test with disabled config
|
||||
disabledCfg := &config.TLSFingerprintConfig{
|
||||
Enabled: false,
|
||||
}
|
||||
r = NewRegistryFromConfig(disabledCfg)
|
||||
if r.ProfileCount() != 1 {
|
||||
t.Errorf("expected 1 profile with disabled config, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Test with enabled config and custom profiles
|
||||
enabledCfg := &config.TLSFingerprintConfig{
|
||||
Enabled: true,
|
||||
Profiles: map[string]config.TLSProfileConfig{
|
||||
"custom1": {
|
||||
Name: "Custom Profile 1",
|
||||
EnableGREASE: true,
|
||||
},
|
||||
"custom2": {
|
||||
Name: "Custom Profile 2",
|
||||
EnableGREASE: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
r = NewRegistryFromConfig(enabledCfg)
|
||||
|
||||
// Should have 3 profiles: default + 2 custom
|
||||
if r.ProfileCount() != 3 {
|
||||
t.Errorf("expected 3 profiles, got %d", r.ProfileCount())
|
||||
}
|
||||
|
||||
// Check custom profiles exist
|
||||
custom1 := r.GetProfile("custom1")
|
||||
if custom1 == nil || custom1.Name != "Custom Profile 1" {
|
||||
t.Error("expected custom1 profile to exist with correct name")
|
||||
}
|
||||
custom2 := r.GetProfile("custom2")
|
||||
if custom2 == nil || custom2.Name != "Custom Profile 2" {
|
||||
t.Error("expected custom2 profile to exist with correct name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileNames(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Add profiles in non-alphabetical order
|
||||
r.RegisterProfile("zebra", &Profile{Name: "Zebra"})
|
||||
r.RegisterProfile("alpha", &Profile{Name: "Alpha"})
|
||||
r.RegisterProfile("beta", &Profile{Name: "Beta"})
|
||||
|
||||
names := r.ProfileNames()
|
||||
|
||||
// Should be sorted alphabetically
|
||||
expected := []string{"alpha", "beta", DefaultProfileName, "zebra"}
|
||||
if len(names) != len(expected) {
|
||||
t.Errorf("expected %d names, got %d", len(expected), len(names))
|
||||
}
|
||||
for i, name := range expected {
|
||||
if names[i] != name {
|
||||
t.Errorf("expected name at index %d to be %s, got %s", i, name, names[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Test that returned slice is a copy (modifying it shouldn't affect registry)
|
||||
names[0] = "modified"
|
||||
originalNames := r.ProfileNames()
|
||||
if originalNames[0] == "modified" {
|
||||
t.Error("modifying returned slice should not affect registry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
// Run concurrent reads and writes
|
||||
done := make(chan bool)
|
||||
|
||||
// Writers
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
r.RegisterProfile("concurrent"+string(rune('0'+id)), &Profile{Name: "Concurrent"})
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Readers
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
_ = r.ProfileCount()
|
||||
_ = r.ProfileNames()
|
||||
_ = r.GetProfileByAccountID(int64(id * j))
|
||||
_ = r.GetProfile(DefaultProfileName)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 20; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Test should pass without data races (run with -race flag)
|
||||
}
|
||||
@ -8,6 +8,14 @@ type FingerprintResponse struct {
|
||||
HTTP2 any `json:"http2"`
|
||||
}
|
||||
|
||||
// TestProfileExpectation defines expected fingerprint values for a profile.
|
||||
type TestProfileExpectation struct {
|
||||
Profile *Profile
|
||||
ExpectedJA3 string // Expected JA3 hash (empty = don't check)
|
||||
ExpectedJA4 string // Expected full JA4 (empty = don't check)
|
||||
JA4CipherHash string // Expected JA4 cipher hash - the stable middle part (empty = don't check)
|
||||
}
|
||||
|
||||
// TLSInfo contains TLS fingerprint details.
|
||||
type TLSInfo struct {
|
||||
JA3 string `json:"ja3"`
|
||||
|
||||
@ -68,10 +68,9 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
|
||||
|
||||
var resp *http.Response
|
||||
|
||||
// 如果启用 TLS 指纹且有 HTTPUpstream,使用 DoWithTLS
|
||||
if opts.EnableTLSFingerprint && s.httpUpstream != nil {
|
||||
// accountConcurrency 传 0 使用默认连接池配置,usage 请求不需要特殊的并发设置
|
||||
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, true)
|
||||
// 如果有 TLS Mode(非 off)且有 HTTPUpstream,使用 DoWithTLS
|
||||
if opts.TLSMode != service.TLSModeOff && s.httpUpstream != nil {
|
||||
resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSMode, opts.TLSProfile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err)
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"compress/flate"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -13,6 +15,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/andybalholm/brotli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
|
||||
@ -164,88 +167,59 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
|
||||
}
|
||||
|
||||
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
|
||||
// 根据 enableTLSFingerprint 参数决定是否使用 TLS 指纹
|
||||
//
|
||||
// 参数:
|
||||
// - req: HTTP 请求对象
|
||||
// - proxyURL: 代理地址,空字符串表示直连
|
||||
// - accountID: 账户 ID,用于账户级隔离和 TLS 指纹模板选择
|
||||
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
|
||||
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
|
||||
//
|
||||
// TLS 指纹说明:
|
||||
// - 优先使用 Node.js TLS 代理模式(gateway.node_tls_proxy.enabled):
|
||||
// 将请求改为 HTTP 明文发送到本地 Node.js 代理,由 Node.js 原生 TLS 栈完成上游握手,
|
||||
// JA3/JA4 指纹天然匹配 Claude CLI,无需 uTLS 模拟。
|
||||
// - 回退到 uTLS 模式(gateway.tls_fingerprint.enabled):
|
||||
// 使用 utls 库模拟 Claude CLI 的 TLS ClientHello。
|
||||
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
// 如果未启用 TLS 指纹,直接使用标准请求路径
|
||||
if !enableTLSFingerprint {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
// 优先使用 Node.js TLS 代理模式(仅 Anthropic API)
|
||||
// Antigravity (googleapis) 使用 Go 原生 TLS(更接近真实 BoringCrypto 指纹)
|
||||
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil {
|
||||
host := req.URL.Hostname()
|
||||
if host == "api.anthropic.com" {
|
||||
// mode 决定指纹策略:
|
||||
// - TLSModeOff / "": 不启用,行为与 Do 相同
|
||||
// - TLSModeNode: 走本地 Node.js TLS 代理(需 gateway.node_tls_proxy.enabled=true)
|
||||
// - TLSModeUTLS: 用 profile 模拟 TLS ClientHello(profile 为 nil 时降级为 Off)
|
||||
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, mode service.TLSMode, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
switch mode {
|
||||
case service.TLSModeNode:
|
||||
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil && s.shouldRouteViaNodeProxy(req) {
|
||||
return s.doViaNodeTLSProxy(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
}
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
|
||||
// TLS 指纹已启用,记录调试日志
|
||||
targetHost := ""
|
||||
if req != nil && req.URL != nil {
|
||||
targetHost = req.URL.Host
|
||||
}
|
||||
proxyInfo := "direct"
|
||||
if proxyURL != "" {
|
||||
proxyInfo = proxyURL
|
||||
}
|
||||
slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo)
|
||||
case service.TLSModeUTLS:
|
||||
if profile == nil {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
targetHost := ""
|
||||
if req != nil && req.URL != nil {
|
||||
targetHost = req.URL.Host
|
||||
}
|
||||
proxyInfo := "direct"
|
||||
if proxyURL != "" {
|
||||
proxyInfo = proxyURL
|
||||
}
|
||||
slog.Debug("tls_fingerprint_utls", "account_id", accountID, "target", targetHost, "proxy", proxyInfo, "profile", profile.Name)
|
||||
|
||||
if err := s.validateRequestHost(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateRequestHost(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
|
||||
if err != nil {
|
||||
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
resp, err := entry.client.Do(req)
|
||||
if err != nil {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
|
||||
decompressResponseBody(resp)
|
||||
resp.Body = wrapTrackedBody(resp.Body, func() {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
})
|
||||
return resp, nil
|
||||
|
||||
// 获取 TLS 指纹 Profile
|
||||
registry := tlsfingerprint.GlobalRegistry()
|
||||
profile := registry.GetProfileByAccountID(accountID)
|
||||
if profile == nil {
|
||||
// 如果获取不到 profile,回退到普通请求
|
||||
slog.Debug("tls_fingerprint_no_profile", "account_id", accountID, "fallback", "standard_request")
|
||||
default: // TLSModeOff 或空字符串
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
slog.Debug("tls_fingerprint_using_profile", "account_id", accountID, "profile", profile.Name, "grease", profile.EnableGREASE)
|
||||
|
||||
// 获取或创建带 TLS 指纹的客户端
|
||||
entry, err := s.acquireClientWithTLS(proxyURL, accountID, accountConcurrency, profile)
|
||||
if err != nil {
|
||||
slog.Debug("tls_fingerprint_acquire_client_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
resp, err := entry.client.Do(req)
|
||||
if err != nil {
|
||||
// 请求失败,立即减少计数
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
slog.Debug("tls_fingerprint_request_failed", "account_id", accountID, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
|
||||
|
||||
// 包装响应体,在关闭时自动减少计数并更新时间戳
|
||||
resp.Body = wrapTrackedBody(resp.Body, func() {
|
||||
atomic.AddInt64(&entry.inFlight, -1)
|
||||
atomic.StoreInt64(&entry.lastUsed, time.Now().UnixNano())
|
||||
})
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
||||
@ -908,3 +882,36 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
|
||||
}
|
||||
return &trackedBody{ReadCloser: body, onClose: onClose}
|
||||
}
|
||||
|
||||
// decompressResponseBody 根据 Content-Encoding 对响应体进行解压
|
||||
// 支持 gzip、br(brotli)、deflate;解压后更新响应头以反映明文内容
|
||||
func decompressResponseBody(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
enc := strings.ToLower(resp.Header.Get("Content-Encoding"))
|
||||
switch enc {
|
||||
case "gzip":
|
||||
gr, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Body = io.NopCloser(gr)
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
case "br":
|
||||
resp.Body = io.NopCloser(brotli.NewReader(resp.Body))
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
case "deflate":
|
||||
resp.Body = io.NopCloser(flate.NewReader(resp.Body))
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
resp.ContentLength = -1
|
||||
resp.Uncompressed = true
|
||||
}
|
||||
}
|
||||
|
||||
122
backend/internal/repository/tls_fingerprint_profile_cache.go
Normal file
122
backend/internal/repository/tls_fingerprint_profile_cache.go
Normal file
@ -0,0 +1,122 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
tlsFPProfileCacheKey = "tls_fingerprint_profiles"
|
||||
tlsFPProfilePubSubKey = "tls_fingerprint_profiles_updated"
|
||||
tlsFPProfileCacheTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
type tlsFingerprintProfileCache struct {
|
||||
rdb *redis.Client
|
||||
localCache []*model.TLSFingerprintProfile
|
||||
localMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileCache 创建 TLS 指纹模板缓存
|
||||
func NewTLSFingerprintProfileCache(rdb *redis.Client) service.TLSFingerprintProfileCache {
|
||||
return &tlsFingerprintProfileCache{
|
||||
rdb: rdb,
|
||||
}
|
||||
}
|
||||
|
||||
// Get 从缓存获取模板列表
|
||||
func (c *tlsFingerprintProfileCache) Get(ctx context.Context) ([]*model.TLSFingerprintProfile, bool) {
|
||||
c.localMu.RLock()
|
||||
if c.localCache != nil {
|
||||
profiles := c.localCache
|
||||
c.localMu.RUnlock()
|
||||
return profiles, true
|
||||
}
|
||||
c.localMu.RUnlock()
|
||||
|
||||
data, err := c.rdb.Get(ctx, tlsFPProfileCacheKey).Bytes()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
slog.Warn("tls_fp_profile_cache_get_failed", "error", err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var profiles []*model.TLSFingerprintProfile
|
||||
if err := json.Unmarshal(data, &profiles); err != nil {
|
||||
slog.Warn("tls_fp_profile_cache_unmarshal_failed", "error", err)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
c.localMu.Lock()
|
||||
c.localCache = profiles
|
||||
c.localMu.Unlock()
|
||||
|
||||
return profiles, true
|
||||
}
|
||||
|
||||
// Set 设置缓存
|
||||
func (c *tlsFingerprintProfileCache) Set(ctx context.Context, profiles []*model.TLSFingerprintProfile) error {
|
||||
data, err := json.Marshal(profiles)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.rdb.Set(ctx, tlsFPProfileCacheKey, data, tlsFPProfileCacheTTL).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.localMu.Lock()
|
||||
c.localCache = profiles
|
||||
c.localMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Invalidate 使缓存失效
|
||||
func (c *tlsFingerprintProfileCache) Invalidate(ctx context.Context) error {
|
||||
c.localMu.Lock()
|
||||
c.localCache = nil
|
||||
c.localMu.Unlock()
|
||||
|
||||
return c.rdb.Del(ctx, tlsFPProfileCacheKey).Err()
|
||||
}
|
||||
|
||||
// NotifyUpdate 通知其他实例刷新缓存
|
||||
func (c *tlsFingerprintProfileCache) NotifyUpdate(ctx context.Context) error {
|
||||
return c.rdb.Publish(ctx, tlsFPProfilePubSubKey, "refresh").Err()
|
||||
}
|
||||
|
||||
// SubscribeUpdates 订阅缓存更新通知
|
||||
func (c *tlsFingerprintProfileCache) SubscribeUpdates(ctx context.Context, handler func()) {
|
||||
go func() {
|
||||
sub := c.rdb.Subscribe(ctx, tlsFPProfilePubSubKey)
|
||||
defer func() { _ = sub.Close() }()
|
||||
|
||||
ch := sub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Debug("tls_fp_profile_cache_subscriber_stopped", "reason", "context_done")
|
||||
return
|
||||
case msg := <-ch:
|
||||
if msg == nil {
|
||||
slog.Warn("tls_fp_profile_cache_subscriber_stopped", "reason", "channel_closed")
|
||||
return
|
||||
}
|
||||
c.localMu.Lock()
|
||||
c.localCache = nil
|
||||
c.localMu.Unlock()
|
||||
|
||||
handler()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
213
backend/internal/repository/tls_fingerprint_profile_repo.go
Normal file
213
backend/internal/repository/tls_fingerprint_profile_repo.go
Normal file
@ -0,0 +1,213 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type tlsFingerprintProfileRepository struct {
|
||||
client *ent.Client
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileRepository 创建 TLS 指纹模板仓库
|
||||
func NewTLSFingerprintProfileRepository(client *ent.Client) service.TLSFingerprintProfileRepository {
|
||||
return &tlsFingerprintProfileRepository{client: client}
|
||||
}
|
||||
|
||||
// List 获取所有模板
|
||||
func (r *tlsFingerprintProfileRepository) List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) {
|
||||
profiles, err := r.client.TLSFingerprintProfile.Query().
|
||||
Order(ent.Asc(tlsfingerprintprofile.FieldName)).
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*model.TLSFingerprintProfile, len(profiles))
|
||||
for i, p := range profiles {
|
||||
result[i] = r.toModel(p)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取模板
|
||||
func (r *tlsFingerprintProfileRepository) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
|
||||
p, err := r.client.TLSFingerprintProfile.Get(ctx, id)
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return r.toModel(p), nil
|
||||
}
|
||||
|
||||
// Create 创建模板
|
||||
func (r *tlsFingerprintProfileRepository) Create(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
|
||||
builder := r.client.TLSFingerprintProfile.Create().
|
||||
SetName(p.Name).
|
||||
SetEnableGrease(p.EnableGREASE)
|
||||
|
||||
if p.Description != nil {
|
||||
builder.SetDescription(*p.Description)
|
||||
}
|
||||
if len(p.CipherSuites) > 0 {
|
||||
builder.SetCipherSuites(p.CipherSuites)
|
||||
}
|
||||
if len(p.Curves) > 0 {
|
||||
builder.SetCurves(p.Curves)
|
||||
}
|
||||
if len(p.PointFormats) > 0 {
|
||||
builder.SetPointFormats(p.PointFormats)
|
||||
}
|
||||
if len(p.SignatureAlgorithms) > 0 {
|
||||
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
|
||||
}
|
||||
if len(p.ALPNProtocols) > 0 {
|
||||
builder.SetAlpnProtocols(p.ALPNProtocols)
|
||||
}
|
||||
if len(p.SupportedVersions) > 0 {
|
||||
builder.SetSupportedVersions(p.SupportedVersions)
|
||||
}
|
||||
if len(p.KeyShareGroups) > 0 {
|
||||
builder.SetKeyShareGroups(p.KeyShareGroups)
|
||||
}
|
||||
if len(p.PSKModes) > 0 {
|
||||
builder.SetPskModes(p.PSKModes)
|
||||
}
|
||||
if len(p.Extensions) > 0 {
|
||||
builder.SetExtensions(p.Extensions)
|
||||
}
|
||||
|
||||
created, err := builder.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toModel(created), nil
|
||||
}
|
||||
|
||||
// Update 更新模板
|
||||
func (r *tlsFingerprintProfileRepository) Update(ctx context.Context, p *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
|
||||
builder := r.client.TLSFingerprintProfile.UpdateOneID(p.ID).
|
||||
SetName(p.Name).
|
||||
SetEnableGrease(p.EnableGREASE)
|
||||
|
||||
if p.Description != nil {
|
||||
builder.SetDescription(*p.Description)
|
||||
} else {
|
||||
builder.ClearDescription()
|
||||
}
|
||||
|
||||
if len(p.CipherSuites) > 0 {
|
||||
builder.SetCipherSuites(p.CipherSuites)
|
||||
} else {
|
||||
builder.ClearCipherSuites()
|
||||
}
|
||||
if len(p.Curves) > 0 {
|
||||
builder.SetCurves(p.Curves)
|
||||
} else {
|
||||
builder.ClearCurves()
|
||||
}
|
||||
if len(p.PointFormats) > 0 {
|
||||
builder.SetPointFormats(p.PointFormats)
|
||||
} else {
|
||||
builder.ClearPointFormats()
|
||||
}
|
||||
if len(p.SignatureAlgorithms) > 0 {
|
||||
builder.SetSignatureAlgorithms(p.SignatureAlgorithms)
|
||||
} else {
|
||||
builder.ClearSignatureAlgorithms()
|
||||
}
|
||||
if len(p.ALPNProtocols) > 0 {
|
||||
builder.SetAlpnProtocols(p.ALPNProtocols)
|
||||
} else {
|
||||
builder.ClearAlpnProtocols()
|
||||
}
|
||||
if len(p.SupportedVersions) > 0 {
|
||||
builder.SetSupportedVersions(p.SupportedVersions)
|
||||
} else {
|
||||
builder.ClearSupportedVersions()
|
||||
}
|
||||
if len(p.KeyShareGroups) > 0 {
|
||||
builder.SetKeyShareGroups(p.KeyShareGroups)
|
||||
} else {
|
||||
builder.ClearKeyShareGroups()
|
||||
}
|
||||
if len(p.PSKModes) > 0 {
|
||||
builder.SetPskModes(p.PSKModes)
|
||||
} else {
|
||||
builder.ClearPskModes()
|
||||
}
|
||||
if len(p.Extensions) > 0 {
|
||||
builder.SetExtensions(p.Extensions)
|
||||
} else {
|
||||
builder.ClearExtensions()
|
||||
}
|
||||
|
||||
updated, err := builder.Save(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.toModel(updated), nil
|
||||
}
|
||||
|
||||
// Delete 删除模板
|
||||
func (r *tlsFingerprintProfileRepository) Delete(ctx context.Context, id int64) error {
|
||||
return r.client.TLSFingerprintProfile.DeleteOneID(id).Exec(ctx)
|
||||
}
|
||||
|
||||
// toModel 将 Ent 实体转换为服务模型
|
||||
func (r *tlsFingerprintProfileRepository) toModel(e *ent.TLSFingerprintProfile) *model.TLSFingerprintProfile {
|
||||
p := &model.TLSFingerprintProfile{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
Description: e.Description,
|
||||
EnableGREASE: e.EnableGrease,
|
||||
CipherSuites: e.CipherSuites,
|
||||
Curves: e.Curves,
|
||||
PointFormats: e.PointFormats,
|
||||
SignatureAlgorithms: e.SignatureAlgorithms,
|
||||
ALPNProtocols: e.AlpnProtocols,
|
||||
SupportedVersions: e.SupportedVersions,
|
||||
KeyShareGroups: e.KeyShareGroups,
|
||||
PSKModes: e.PskModes,
|
||||
Extensions: e.Extensions,
|
||||
CreatedAt: e.CreatedAt,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
|
||||
// 确保切片不为 nil
|
||||
if p.CipherSuites == nil {
|
||||
p.CipherSuites = []uint16{}
|
||||
}
|
||||
if p.Curves == nil {
|
||||
p.Curves = []uint16{}
|
||||
}
|
||||
if p.PointFormats == nil {
|
||||
p.PointFormats = []uint16{}
|
||||
}
|
||||
if p.SignatureAlgorithms == nil {
|
||||
p.SignatureAlgorithms = []uint16{}
|
||||
}
|
||||
if p.ALPNProtocols == nil {
|
||||
p.ALPNProtocols = []string{}
|
||||
}
|
||||
if p.SupportedVersions == nil {
|
||||
p.SupportedVersions = []uint16{}
|
||||
}
|
||||
if p.KeyShareGroups == nil {
|
||||
p.KeyShareGroups = []uint16{}
|
||||
}
|
||||
if p.PSKModes == nil {
|
||||
p.PSKModes = []uint16{}
|
||||
}
|
||||
if p.Extensions == nil {
|
||||
p.Extensions = []uint16{}
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
@ -73,6 +73,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUserAttributeValueRepository,
|
||||
NewUserGroupRateRepository,
|
||||
NewErrorPassthroughRepository,
|
||||
NewTLSFingerprintProfileRepository,
|
||||
|
||||
// Cache implementations
|
||||
NewGatewayCache,
|
||||
@ -96,6 +97,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewTotpCache,
|
||||
NewRefreshTokenCache,
|
||||
NewErrorPassthroughCache,
|
||||
NewTLSFingerprintProfileCache,
|
||||
|
||||
// Encryptors
|
||||
NewAESEncryptor,
|
||||
|
||||
@ -79,6 +79,9 @@ func RegisterAdminRoutes(
|
||||
// 错误透传规则管理
|
||||
registerErrorPassthroughRoutes(admin, h)
|
||||
|
||||
// TLS 指纹模板管理
|
||||
registerTLSFingerprintProfileRoutes(admin, h)
|
||||
|
||||
// API Key 管理
|
||||
registerAdminAPIKeyRoutes(admin, h)
|
||||
|
||||
@ -552,3 +555,14 @@ func registerErrorPassthroughRoutes(admin *gin.RouterGroup, h *handler.Handlers)
|
||||
rules.DELETE("/:id", h.Admin.ErrorPassthrough.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
func registerTLSFingerprintProfileRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
profiles := admin.Group("/tls-fingerprint-profiles")
|
||||
{
|
||||
profiles.GET("", h.Admin.TLSFingerprintProfile.List)
|
||||
profiles.GET("/:id", h.Admin.TLSFingerprintProfile.GetByID)
|
||||
profiles.POST("", h.Admin.TLSFingerprintProfile.Create)
|
||||
profiles.PUT("/:id", h.Admin.TLSFingerprintProfile.Update)
|
||||
profiles.DELETE("/:id", h.Admin.TLSFingerprintProfile.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1166,6 +1166,48 @@ func (a *Account) IsTLSFingerprintEnabled() bool {
|
||||
return geminiTLSFingerprintEnabled(a)
|
||||
}
|
||||
|
||||
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
|
||||
// 返回 0 表示未绑定(使用内置默认 profile)
|
||||
func (a *Account) GetTLSFingerprintProfileID() int64 {
|
||||
if a.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
v, ok := a.Extra["tls_fingerprint_profile_id"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch id := v.(type) {
|
||||
case float64:
|
||||
return int64(id)
|
||||
case int64:
|
||||
return id
|
||||
case int:
|
||||
return int64(id)
|
||||
case json.Number:
|
||||
if i, err := id.Int64(); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetTLSMode 获取账号配置的 TLS 指纹模式
|
||||
// 返回值: TLSModeNode / TLSModeUTLS / TLSModeOff
|
||||
// 存储在 Extra["tls_mode"],未设置时:支持指纹的账号默认 node,其余 off
|
||||
func (a *Account) GetTLSMode() TLSMode {
|
||||
if a.Extra != nil {
|
||||
if m, ok := a.Extra["tls_mode"].(string); ok && m != "" {
|
||||
switch TLSMode(m) {
|
||||
case TLSModeNode, TLSModeUTLS, TLSModeOff:
|
||||
return TLSMode(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.IsTLSFingerprintEnabled() {
|
||||
return TLSModeNode
|
||||
}
|
||||
return TLSModeOff
|
||||
}
|
||||
|
||||
// GetUserMsgQueueMode 获取用户消息队列模式
|
||||
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
|
||||
|
||||
@ -23,6 +23,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/soraerror"
|
||||
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -69,6 +70,7 @@ type AccountTestService struct {
|
||||
antigravityGatewayService *AntigravityGatewayService
|
||||
httpUpstream HTTPUpstream
|
||||
cfg *config.Config
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
soraTestGuardMu sync.Mutex
|
||||
soraTestLastRun map[int64]time.Time
|
||||
soraTestCooldown time.Duration
|
||||
@ -83,6 +85,7 @@ func NewAccountTestService(
|
||||
antigravityGatewayService *AntigravityGatewayService,
|
||||
httpUpstream HTTPUpstream,
|
||||
cfg *config.Config,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
) *AccountTestService {
|
||||
return &AccountTestService{
|
||||
accountRepo: accountRepo,
|
||||
@ -90,6 +93,7 @@ func NewAccountTestService(
|
||||
antigravityGatewayService: antigravityGatewayService,
|
||||
httpUpstream: httpUpstream,
|
||||
cfg: cfg,
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
soraTestLastRun: make(map[int64]time.Time),
|
||||
soraTestCooldown: defaultSoraTestCooldown,
|
||||
}
|
||||
@ -300,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, account.IsTLSFingerprintEnabled())
|
||||
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()))
|
||||
}
|
||||
@ -390,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, false)
|
||||
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()))
|
||||
}
|
||||
@ -520,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, account.IsTLSFingerprintEnabled())
|
||||
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()))
|
||||
}
|
||||
@ -610,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, account.IsTLSFingerprintEnabled())
|
||||
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()))
|
||||
}
|
||||
@ -881,9 +885,9 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
if account.ProxyID != nil && account.Proxy != nil {
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
enableSoraTLSFingerprint := s.shouldEnableSoraTLSFingerprint()
|
||||
soraTLSProfile := s.resolveSoraTLSProfile()
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, enableSoraTLSFingerprint)
|
||||
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)
|
||||
@ -948,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, enableSoraTLSFingerprint)
|
||||
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())})
|
||||
@ -977,7 +981,7 @@ func (s *AccountTestService) testSoraAccountConnection(c *gin.Context, account *
|
||||
}
|
||||
|
||||
// 追加 Sora2 能力探测(对齐 sora2api 的测试思路):邀请码 + 剩余额度。
|
||||
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, enableSoraTLSFingerprint, recorder)
|
||||
s.testSora2Capabilities(c, ctx, account, authToken, proxyURL, soraTLSProfile, recorder)
|
||||
|
||||
s.emitSoraProbeSummary(c, recorder)
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
@ -990,7 +994,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
account *Account,
|
||||
authToken string,
|
||||
proxyURL string,
|
||||
enableTLSFingerprint bool,
|
||||
tlsProfile *tlsfingerprint.Profile,
|
||||
recorder *soraProbeRecorder,
|
||||
) {
|
||||
inviteStatus, inviteHeader, inviteBody, err := s.fetchSoraTestEndpoint(
|
||||
@ -999,7 +1003,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraInviteMineURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
@ -1016,7 +1020,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraBootstrapURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if bootstrapErr == nil && bootstrapStatus == http.StatusOK {
|
||||
if recorder != nil {
|
||||
@ -1029,7 +1033,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraInviteMineURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if err != nil {
|
||||
if recorder != nil {
|
||||
@ -1081,7 +1085,7 @@ func (s *AccountTestService) testSora2Capabilities(
|
||||
authToken,
|
||||
soraRemainingURL,
|
||||
proxyURL,
|
||||
enableTLSFingerprint,
|
||||
tlsProfile,
|
||||
)
|
||||
if remainingErr != nil {
|
||||
if recorder != nil {
|
||||
@ -1122,7 +1126,7 @@ func (s *AccountTestService) fetchSoraTestEndpoint(
|
||||
authToken string,
|
||||
url string,
|
||||
proxyURL string,
|
||||
enableTLSFingerprint bool,
|
||||
tlsProfile *tlsfingerprint.Profile,
|
||||
) (int, http.Header, []byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
@ -1135,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, enableTLSFingerprint)
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), tlsProfile)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
@ -1224,11 +1228,12 @@ func parseSoraRemainingSummary(body []byte) string {
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func (s *AccountTestService) shouldEnableSoraTLSFingerprint() bool {
|
||||
if s == nil || s.cfg == nil {
|
||||
return true
|
||||
func (s *AccountTestService) resolveSoraTLSProfile() *tlsfingerprint.Profile {
|
||||
if s == nil || s.cfg == nil || !s.cfg.Sora.Client.DisableTLSFingerprint {
|
||||
// Sora TLS fingerprint enabled — use built-in default profile
|
||||
return &tlsfingerprint.Profile{Name: "Built-in Default (Sora)"}
|
||||
}
|
||||
return !s.cfg.Sora.Client.DisableTLSFingerprint
|
||||
return nil // disabled
|
||||
}
|
||||
|
||||
func isCloudflareChallengeResponse(statusCode int, headers http.Header, body []byte) bool {
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -24,9 +25,9 @@ func (u *queuedHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*htt
|
||||
return nil, fmt.Errorf("unexpected Do call")
|
||||
}
|
||||
|
||||
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *queuedHTTPUpstream) DoWithTLS(req *http.Request, _ string, _ int64, _ int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
u.requests = append(u.requests, req)
|
||||
u.tlsFlags = append(u.tlsFlags, enableTLSFingerprint)
|
||||
u.tlsFlags = append(u.tlsFlags, profile != nil)
|
||||
if len(u.responses) == 0 {
|
||||
return nil, fmt.Errorf("no mocked response")
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
openaipkg "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/sync/singleflight"
|
||||
@ -241,11 +242,12 @@ type ClaudeUsageResponse struct {
|
||||
|
||||
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
|
||||
type ClaudeUsageFetchOptions struct {
|
||||
AccessToken string // OAuth access token
|
||||
ProxyURL string // 代理 URL(可选)
|
||||
AccountID int64 // 账号 ID(用于 TLS 指纹选择)
|
||||
EnableTLSFingerprint bool // 是否启用 TLS 指纹伪装
|
||||
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
||||
AccessToken string // OAuth access token
|
||||
ProxyURL string // 代理 URL(可选)
|
||||
AccountID int64 // 账号 ID(用于连接池隔离)
|
||||
TLSMode TLSMode // TLS 模式(off/node/utls)
|
||||
TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profile(nil 表示不启用)
|
||||
Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
|
||||
}
|
||||
|
||||
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
|
||||
@ -264,6 +266,7 @@ type AccountUsageService struct {
|
||||
antigravityQuotaFetcher *AntigravityQuotaFetcher
|
||||
cache *UsageCache
|
||||
identityCache IdentityCache
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
}
|
||||
|
||||
// NewAccountUsageService 创建AccountUsageService实例
|
||||
@ -275,6 +278,7 @@ func NewAccountUsageService(
|
||||
antigravityQuotaFetcher *AntigravityQuotaFetcher,
|
||||
cache *UsageCache,
|
||||
identityCache IdentityCache,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
) *AccountUsageService {
|
||||
return &AccountUsageService{
|
||||
accountRepo: accountRepo,
|
||||
@ -284,6 +288,7 @@ func NewAccountUsageService(
|
||||
antigravityQuotaFetcher: antigravityQuotaFetcher,
|
||||
cache: cache,
|
||||
identityCache: identityCache,
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1155,10 +1160,11 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
|
||||
|
||||
// 构建完整的选项
|
||||
opts := &ClaudeUsageFetchOptions{
|
||||
AccessToken: accessToken,
|
||||
ProxyURL: proxyURL,
|
||||
AccountID: account.ID,
|
||||
EnableTLSFingerprint: account.IsTLSFingerprintEnabled(),
|
||||
AccessToken: accessToken,
|
||||
ProxyURL: proxyURL,
|
||||
AccountID: account.ID,
|
||||
TLSMode: account.GetTLSMode(),
|
||||
TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
|
||||
}
|
||||
|
||||
// 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -130,7 +131,7 @@ func (s *httpUpstreamStub) Do(_ *http.Request, _ string, _ int64, _ int) (*http.
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ bool) (*http.Response, error) {
|
||||
func (s *httpUpstreamStub) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.resp, s.err
|
||||
}
|
||||
|
||||
@ -171,7 +172,7 @@ func (s *queuedHTTPUpstreamStub) Do(req *http.Request, _ string, _ int64, _ int)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ bool) (*http.Response, error) {
|
||||
func (s *queuedHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.Do(req, proxyURL, accountID, concurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -89,7 +89,8 @@ type AntigravityTokenInfo struct {
|
||||
TokenType string `json:"token_type"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ProjectIDMissing bool `json:"-"` // LoadCodeAssist 未返回 project_id
|
||||
ProjectIDMissing bool `json:"-"`
|
||||
PlanType string `json:"-"`
|
||||
}
|
||||
|
||||
// ExchangeCode 用 authorization code 交换 token
|
||||
@ -145,13 +146,17 @@ func (s *AntigravityOAuthService) ExchangeCode(ctx context.Context, input *Antig
|
||||
result.Email = userInfo.Email
|
||||
}
|
||||
|
||||
// 获取 project_id(部分账户类型可能没有),失败时重试
|
||||
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenResp.AccessToken, proxyURL, 3)
|
||||
// 获取 project_id + plan_type(部分账户类型可能没有),失败时重试
|
||||
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenResp.AccessToken, proxyURL, 3)
|
||||
if loadErr != nil {
|
||||
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
|
||||
result.ProjectIDMissing = true
|
||||
} else {
|
||||
result.ProjectID = projectID
|
||||
}
|
||||
if loadResult != nil {
|
||||
result.ProjectID = loadResult.ProjectID
|
||||
if loadResult.Subscription != nil {
|
||||
result.PlanType = loadResult.Subscription.PlanType
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@ -230,13 +235,17 @@ func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refr
|
||||
tokenInfo.Email = userInfo.Email
|
||||
}
|
||||
|
||||
// 获取 project_id(容错,失败不阻塞)
|
||||
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||
// 获取 project_id + plan_type(容错,失败不阻塞)
|
||||
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||
if loadErr != nil {
|
||||
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
|
||||
tokenInfo.ProjectIDMissing = true
|
||||
} else {
|
||||
tokenInfo.ProjectID = projectID
|
||||
}
|
||||
if loadResult != nil {
|
||||
tokenInfo.ProjectID = loadResult.ProjectID
|
||||
if loadResult.Subscription != nil {
|
||||
tokenInfo.PlanType = loadResult.Subscription.PlanType
|
||||
}
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
@ -288,33 +297,42 @@ func (s *AntigravityOAuthService) RefreshAccountToken(ctx context.Context, accou
|
||||
tokenInfo.Email = existingEmail
|
||||
}
|
||||
|
||||
// 每次刷新都调用 LoadCodeAssist 获取 project_id,失败时重试
|
||||
// 每次刷新都调用 LoadCodeAssist 获取 project_id + plan_type,失败时重试
|
||||
existingProjectID := strings.TrimSpace(account.GetCredential("project_id"))
|
||||
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||
loadResult, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
|
||||
|
||||
if loadErr != nil {
|
||||
// LoadCodeAssist 失败,保留原有 project_id
|
||||
tokenInfo.ProjectID = existingProjectID
|
||||
// 只有从未获取过 project_id 且本次也获取失败时,才标记为真正缺失
|
||||
// 如果之前有 project_id,本次只是临时故障,不应标记为错误
|
||||
if existingProjectID == "" {
|
||||
tokenInfo.ProjectIDMissing = true
|
||||
}
|
||||
} else {
|
||||
tokenInfo.ProjectID = projectID
|
||||
}
|
||||
if loadResult != nil {
|
||||
if loadResult.ProjectID != "" {
|
||||
tokenInfo.ProjectID = loadResult.ProjectID
|
||||
}
|
||||
if loadResult.Subscription != nil {
|
||||
tokenInfo.PlanType = loadResult.Subscription.PlanType
|
||||
}
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
// loadProjectIDWithRetry 带重试机制获取 project_id
|
||||
// 返回 project_id 和错误,失败时会重试指定次数
|
||||
func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, accessToken, proxyURL string, maxRetries int) (string, error) {
|
||||
// loadCodeAssistResult 封装 loadProjectIDWithRetry 的返回结果,
|
||||
// 同时携带从 LoadCodeAssist 响应中提取的 plan_type 信息。
|
||||
type loadCodeAssistResult struct {
|
||||
ProjectID string
|
||||
Subscription *AntigravitySubscriptionResult
|
||||
}
|
||||
|
||||
// loadProjectIDWithRetry 带重试机制获取 project_id,同时从响应中提取 plan_type。
|
||||
func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, accessToken, proxyURL string, maxRetries int) (*loadCodeAssistResult, error) {
|
||||
var lastErr error
|
||||
var lastSubscription *AntigravitySubscriptionResult
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// 指数退避:1s, 2s, 4s
|
||||
backoff := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
if backoff > 8*time.Second {
|
||||
backoff = 8 * time.Second
|
||||
@ -324,24 +342,34 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
|
||||
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create antigravity client failed: %w", err)
|
||||
return nil, fmt.Errorf("create antigravity client failed: %w", err)
|
||||
}
|
||||
loadResp, loadRaw, err := client.LoadCodeAssist(ctx, accessToken)
|
||||
|
||||
if loadResp != nil {
|
||||
sub := NormalizeAntigravitySubscription(loadResp)
|
||||
lastSubscription = &sub
|
||||
}
|
||||
|
||||
if err == nil && loadResp != nil && loadResp.CloudAICompanionProject != "" {
|
||||
return loadResp.CloudAICompanionProject, nil
|
||||
return &loadCodeAssistResult{
|
||||
ProjectID: loadResp.CloudAICompanionProject,
|
||||
Subscription: lastSubscription,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
if projectID, onboardErr := tryOnboardProjectID(ctx, client, accessToken, loadRaw); onboardErr == nil && projectID != "" {
|
||||
return projectID, nil
|
||||
return &loadCodeAssistResult{
|
||||
ProjectID: projectID,
|
||||
Subscription: lastSubscription,
|
||||
}, nil
|
||||
} else if onboardErr != nil {
|
||||
lastErr = onboardErr
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 记录错误
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
} else if loadResp == nil {
|
||||
@ -351,7 +379,10 @@ func (s *AntigravityOAuthService) loadProjectIDWithRetry(ctx context.Context, ac
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
|
||||
if lastSubscription != nil {
|
||||
return &loadCodeAssistResult{Subscription: lastSubscription}, fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("获取 project_id 失败 (重试 %d 次后): %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func tryOnboardProjectID(ctx context.Context, client *antigravity.Client, accessToken string, loadRaw map[string]any) (string, error) {
|
||||
@ -410,7 +441,11 @@ func (s *AntigravityOAuthService) FillProjectID(ctx context.Context, account *Ac
|
||||
proxyURL = proxy.URL()
|
||||
}
|
||||
}
|
||||
return s.loadProjectIDWithRetry(ctx, accessToken, proxyURL, 3)
|
||||
result, err := s.loadProjectIDWithRetry(ctx, accessToken, proxyURL, 3)
|
||||
if result != nil {
|
||||
return result.ProjectID, err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// BuildAccountCredentials 构建账户凭证
|
||||
@ -431,6 +466,9 @@ func (s *AntigravityOAuthService) BuildAccountCredentials(tokenInfo *Antigravity
|
||||
if tokenInfo.ProjectID != "" {
|
||||
creds["project_id"] = tokenInfo.ProjectID
|
||||
}
|
||||
if tokenInfo.PlanType != "" {
|
||||
creds["plan_type"] = tokenInfo.PlanType
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
|
||||
81
backend/internal/service/antigravity_privacy_service.go
Normal file
81
backend/internal/service/antigravity_privacy_service.go
Normal file
@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
const (
|
||||
AntigravityPrivacySet = "privacy_set"
|
||||
AntigravityPrivacyFailed = "privacy_set_failed"
|
||||
)
|
||||
|
||||
// setAntigravityPrivacy 调用 Antigravity API 设置隐私并验证结果。
|
||||
// 流程:
|
||||
// 1. setUserSettings 清空设置 → 检查返回值 {\"userSettings\":{}}
|
||||
// 2. fetchUserInfo 二次验证隐私是否已生效(需要 project_id)
|
||||
//
|
||||
// 返回 privacy_mode 值:\"privacy_set\" 成功,\"privacy_set_failed\" 失败,空串表示无法执行。
|
||||
func setAntigravityPrivacy(ctx context.Context, accessToken, projectID, proxyURL string) string {
|
||||
if accessToken == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := antigravity.NewClient(proxyURL)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_client_error", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
// 第 1 步:调用 setUserSettings,检查返回值
|
||||
setResp, err := client.SetUserSettings(ctx, accessToken)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_set_failed", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
if !setResp.IsSuccess() {
|
||||
slog.Warn("antigravity_privacy_set_response_not_empty",
|
||||
"user_settings", setResp.UserSettings,
|
||||
)
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
// 第 2 步:调用 fetchUserInfo 二次验证隐私是否已生效
|
||||
if strings.TrimSpace(projectID) == "" {
|
||||
slog.Warn("antigravity_privacy_missing_project_id")
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
userInfo, err := client.FetchUserInfo(ctx, accessToken, projectID)
|
||||
if err != nil {
|
||||
slog.Warn("antigravity_privacy_verify_failed", "error", err.Error())
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
if !userInfo.IsPrivate() {
|
||||
slog.Warn("antigravity_privacy_verify_not_private",
|
||||
"user_settings", userInfo.UserSettings,
|
||||
)
|
||||
return AntigravityPrivacyFailed
|
||||
}
|
||||
|
||||
slog.Info("antigravity_privacy_set_success")
|
||||
return AntigravityPrivacySet
|
||||
}
|
||||
|
||||
func applyAntigravityPrivacyMode(account *Account, mode string) {
|
||||
if account == nil || strings.TrimSpace(mode) == "" {
|
||||
return
|
||||
}
|
||||
extra := make(map[string]any, len(account.Extra)+1)
|
||||
for k, v := range account.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
extra["privacy_mode"] = mode
|
||||
account.Extra = extra
|
||||
}
|
||||
41
backend/internal/service/antigravity_privacy_service_test.go
Normal file
41
backend/internal/service/antigravity_privacy_service_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestApplyAntigravityPrivacyMode_SetsInMemoryExtra(t *testing.T) {
|
||||
account := &Account{}
|
||||
|
||||
applyAntigravityPrivacyMode(account, AntigravityPrivacySet)
|
||||
|
||||
if account.Extra == nil {
|
||||
t.Fatal("expected account.Extra to be initialized")
|
||||
}
|
||||
if got := account.Extra["privacy_mode"]; got != AntigravityPrivacySet {
|
||||
t.Fatalf("expected privacy_mode %q, got %v", AntigravityPrivacySet, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAntigravityPrivacyMode_PreservedBySubscriptionResult(t *testing.T) {
|
||||
account := &Account{
|
||||
Credentials: map[string]any{
|
||||
"access_token": "token",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"existing": "value",
|
||||
},
|
||||
}
|
||||
applyAntigravityPrivacyMode(account, AntigravityPrivacySet)
|
||||
|
||||
_, extra := applyAntigravitySubscriptionResult(account, AntigravitySubscriptionResult{
|
||||
PlanType: "Pro",
|
||||
})
|
||||
|
||||
if got := extra["privacy_mode"]; got != AntigravityPrivacySet {
|
||||
t.Fatalf("expected subscription writeback to keep privacy_mode %q, got %v", AntigravityPrivacySet, got)
|
||||
}
|
||||
if got := extra["existing"]; got != "value" {
|
||||
t.Fatalf("expected existing extra fields to be preserved, got %v", got)
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -40,7 +41,7 @@ func (r *recordingOKUpstream) Do(req *http.Request, proxyURL string, accountID i
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (r *recordingOKUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return r.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
@ -61,7 +62,7 @@ func (s *stubAntigravityUpstream) Do(req *http.Request, proxyURL string, account
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (s *stubAntigravityUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -93,7 +94,7 @@ func (m *mockSmartRetryUpstream) Do(req *http.Request, proxyURL string, accountI
|
||||
}, respErr
|
||||
}
|
||||
|
||||
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (m *mockSmartRetryUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return m.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
62
backend/internal/service/antigravity_subscription_service.go
Normal file
62
backend/internal/service/antigravity_subscription_service.go
Normal file
@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
)
|
||||
|
||||
const antigravitySubscriptionAbnormal = "abnormal"
|
||||
|
||||
// AntigravitySubscriptionResult 表示订阅检测后的规范化结果。
|
||||
type AntigravitySubscriptionResult struct {
|
||||
PlanType string
|
||||
SubscriptionStatus string
|
||||
SubscriptionError string
|
||||
}
|
||||
|
||||
// NormalizeAntigravitySubscription 从 LoadCodeAssistResponse 提取 plan_type + 异常状态。
|
||||
// 使用 GetTier()(返回 tier ID)+ TierIDToPlanType 映射。
|
||||
func NormalizeAntigravitySubscription(resp *antigravity.LoadCodeAssistResponse) AntigravitySubscriptionResult {
|
||||
if resp == nil {
|
||||
return AntigravitySubscriptionResult{PlanType: "Free"}
|
||||
}
|
||||
if len(resp.IneligibleTiers) > 0 {
|
||||
result := AntigravitySubscriptionResult{
|
||||
PlanType: "Abnormal",
|
||||
SubscriptionStatus: antigravitySubscriptionAbnormal,
|
||||
}
|
||||
if resp.IneligibleTiers[0] != nil {
|
||||
result.SubscriptionError = strings.TrimSpace(resp.IneligibleTiers[0].ReasonMessage)
|
||||
}
|
||||
return result
|
||||
}
|
||||
tierID := resp.GetTier()
|
||||
return AntigravitySubscriptionResult{
|
||||
PlanType: antigravity.TierIDToPlanType(tierID),
|
||||
}
|
||||
}
|
||||
|
||||
func applyAntigravitySubscriptionResult(account *Account, result AntigravitySubscriptionResult) (map[string]any, map[string]any) {
|
||||
credentials := make(map[string]any)
|
||||
for k, v := range account.Credentials {
|
||||
credentials[k] = v
|
||||
}
|
||||
credentials["plan_type"] = result.PlanType
|
||||
|
||||
extra := make(map[string]any)
|
||||
for k, v := range account.Extra {
|
||||
extra[k] = v
|
||||
}
|
||||
if result.SubscriptionStatus != "" {
|
||||
extra["subscription_status"] = result.SubscriptionStatus
|
||||
} else {
|
||||
delete(extra, "subscription_status")
|
||||
}
|
||||
if result.SubscriptionError != "" {
|
||||
extra["subscription_error"] = result.SubscriptionError
|
||||
} else {
|
||||
delete(extra, "subscription_error")
|
||||
}
|
||||
return credentials, extra
|
||||
}
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -35,7 +36,7 @@ func (u *epFixedUpstream) Do(req *http.Request, proxyURL string, accountID int64
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *epFixedUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
@ -60,7 +61,7 @@ func (u *anthropicHTTPUpstreamRecorder) Do(req *http.Request, proxyURL string, a
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
func (u *anthropicHTTPUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *anthropicHTTPUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -120,7 +120,7 @@ func (s *GatewayService) ForwardAsChatCompletions(
|
||||
}
|
||||
|
||||
// 11. Send request
|
||||
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(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@ -117,7 +117,7 @@ func (s *GatewayService) ForwardAsResponses(
|
||||
}
|
||||
|
||||
// 11. Send request
|
||||
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
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()
|
||||
|
||||
@ -124,6 +124,27 @@ func TestSystemIncludesClaudeCodePrompt(t *testing.T) {
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
// json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions)
|
||||
{
|
||||
name: "json.RawMessage string with Claude Code prompt",
|
||||
system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage string without Claude Code prompt",
|
||||
system: json.RawMessage(`"You are a helpful assistant"`),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage nil (empty)",
|
||||
system: json.RawMessage(nil),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage empty string",
|
||||
system: json.RawMessage(`""`),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@ -202,6 +223,29 @@ func TestInjectClaudeCodePrompt(t *testing.T) {
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
// json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions)
|
||||
{
|
||||
name: "json.RawMessage string system",
|
||||
body: `{"model":"claude-3","system":"Custom prompt"}`,
|
||||
system: json.RawMessage(`"Custom prompt"`),
|
||||
wantSystemLen: 2,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
wantSecondText: claudePrefix + "\n\nCustom prompt",
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage nil system",
|
||||
body: `{"model":"claude-3"}`,
|
||||
system: json.RawMessage(nil),
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
{
|
||||
name: "json.RawMessage Claude Code prompt (should not duplicate)",
|
||||
body: `{"model":"claude-3","system":"` + claudeCodeSystemPrompt + `"}`,
|
||||
system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`),
|
||||
wantSystemLen: 1,
|
||||
wantFirstText: claudeCodeSystemPrompt,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@ -40,6 +40,7 @@ func newGatewayRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -563,6 +563,8 @@ type GatewayService struct {
|
||||
responseHeaderFilter *responseheaders.CompiledHeaderFilter
|
||||
debugModelRouting atomic.Bool
|
||||
debugClaudeMimic atomic.Bool
|
||||
debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
}
|
||||
|
||||
// NewGatewayService creates a new GatewayService
|
||||
@ -589,6 +591,7 @@ func NewGatewayService(
|
||||
rpmCache RPMCache,
|
||||
digestStore *DigestSessionStore,
|
||||
settingService *SettingService,
|
||||
tlsFPProfileService *TLSFingerprintProfileService,
|
||||
) *GatewayService {
|
||||
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
|
||||
modelsListTTL := resolveModelsListCacheTTL(cfg)
|
||||
@ -620,6 +623,7 @@ func NewGatewayService(
|
||||
modelsListCache: gocache.New(modelsListTTL, time.Minute),
|
||||
modelsListCacheTTL: modelsListTTL,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
tlsFPProfileService: tlsFPProfileService,
|
||||
}
|
||||
svc.userGroupRateResolver = newUserGroupRateResolver(
|
||||
userGroupRateRepo,
|
||||
@ -3740,9 +3744,28 @@ func isClaudeCodeRequest(ctx context.Context, c *gin.Context, parsed *ParsedRequ
|
||||
return isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
|
||||
}
|
||||
|
||||
// normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil),
|
||||
// 避免 type switch 中 json.RawMessage(底层 []byte)无法匹配 case string / case []any / case nil 的问题。
|
||||
// 这是 Go 的 typed nil 陷阱:(json.RawMessage, nil) ≠ (nil, nil)。
|
||||
func normalizeSystemParam(system any) any {
|
||||
raw, ok := system.(json.RawMessage)
|
||||
if !ok {
|
||||
return system
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词
|
||||
// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等)
|
||||
func systemIncludesClaudeCodePrompt(system any) bool {
|
||||
system = normalizeSystemParam(system)
|
||||
switch v := system.(type) {
|
||||
case string:
|
||||
return hasClaudeCodePrefix(v)
|
||||
@ -3771,6 +3794,7 @@ func hasClaudeCodePrefix(text string) bool {
|
||||
// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
|
||||
// 处理 null、字符串、数组三种格式
|
||||
func injectClaudeCodePrompt(body []byte, system any) []byte {
|
||||
system = normalizeSystemParam(system)
|
||||
claudeCodeBlock, err := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true)
|
||||
if err != nil {
|
||||
logger.LegacyPrintf("service.gateway", "Warning: failed to build Claude Code prompt block: %v", err)
|
||||
@ -4116,9 +4140,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
// 解析 TLS 模式和指纹 profile(同一请求生命周期内不变,避免重试循环中重复解析)
|
||||
tlsMode := account.GetTLSMode()
|
||||
tlsProfile := s.tlsFPProfileService.ResolveTLSProfile(account)
|
||||
|
||||
// 调试日志:记录即将转发的账号信息
|
||||
logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
|
||||
account.ID, account.Name, account.Platform, account.Type, account.IsTLSFingerprintEnabled(), proxyURL)
|
||||
account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL)
|
||||
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
|
||||
body = StripEmptyTextBlocks(body)
|
||||
|
||||
@ -4138,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, account.IsTLSFingerprintEnabled())
|
||||
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()
|
||||
@ -4216,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, account.IsTLSFingerprintEnabled())
|
||||
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)
|
||||
@ -4251,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, account.IsTLSFingerprintEnabled())
|
||||
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
if retryErr2 == nil {
|
||||
resp = retryResp2
|
||||
break
|
||||
@ -4322,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, account.IsTLSFingerprintEnabled())
|
||||
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsMode, tlsProfile)
|
||||
if retryErr == nil {
|
||||
resp = budgetRetryResp
|
||||
break
|
||||
@ -4628,7 +4656,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
@ -5346,7 +5374,7 @@ func (s *GatewayService) executeBedrockUpstream(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, false)
|
||||
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()
|
||||
@ -7958,7 +7986,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
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(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
|
||||
@ -7986,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, account.IsTLSFingerprintEnabled())
|
||||
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)
|
||||
@ -8075,7 +8103,7 @@ func (s *GatewayService) forwardCountTokensAnthropicAPIKeyPassthrough(ctx contex
|
||||
proxyURL = account.Proxy.URL()
|
||||
}
|
||||
|
||||
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(), s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(err.Error()), "")
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
|
||||
@ -724,7 +724,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -1227,7 +1227,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
|
||||
c.Set(OpsUpstreamRequestBodyKey, string(body))
|
||||
}
|
||||
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err = s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
if err != nil {
|
||||
safeErr := sanitizeUpstreamErrorMessage(err.Error())
|
||||
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
|
||||
@ -2583,7 +2583,7 @@ func (s *GeminiMessagesCompatService) ForwardAIStudioGET(ctx context.Context, ac
|
||||
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
|
||||
}
|
||||
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.IsTLSFingerprintEnabled())
|
||||
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, account.GetTLSMode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@ -36,7 +37,7 @@ func (s *geminiCompatHTTPUpstreamStub) Do(req *http.Request, proxyURL string, ac
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (s *geminiCompatHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (s *geminiCompatHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -1,55 +1,36 @@
|
||||
package service
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// TLSMode 定义账号级别的 TLS 指纹模式
|
||||
type TLSMode string
|
||||
|
||||
const (
|
||||
// TLSModeOff 不启用 TLS 指纹,直接使用标准 Go HTTP 客户端
|
||||
TLSModeOff TLSMode = "off"
|
||||
// TLSModeNode 通过本地 Node.js TLS 代理发请求,天然匹配 Claude CLI 指纹
|
||||
TLSModeNode TLSMode = "node"
|
||||
// TLSModeUTLS 使用 uTLS 库模拟指定 Profile 的 TLS ClientHello
|
||||
TLSModeUTLS TLSMode = "utls"
|
||||
)
|
||||
|
||||
// HTTPUpstream 上游 HTTP 请求接口
|
||||
// 用于向上游 API(Claude、OpenAI、Gemini 等)发送请求
|
||||
// 这是一个通用接口,可用于任何基于 HTTP 的上游服务
|
||||
//
|
||||
// 设计说明:
|
||||
// - 支持可选代理配置
|
||||
// - 支持账户级连接池隔离
|
||||
// - 实现类负责连接池管理和复用
|
||||
// - 支持可选的 TLS 指纹伪装
|
||||
type HTTPUpstream interface {
|
||||
// Do 执行 HTTP 请求
|
||||
//
|
||||
// 参数:
|
||||
// - req: HTTP 请求对象,由调用方构建
|
||||
// - proxyURL: 代理服务器地址,空字符串表示直连
|
||||
// - accountID: 账户 ID,用于连接池隔离(隔离策略为 account 或 account_proxy 时生效)
|
||||
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
|
||||
//
|
||||
// 返回:
|
||||
// - *http.Response: HTTP 响应,调用方必须关闭 Body
|
||||
// - error: 请求错误(网络错误、超时等)
|
||||
//
|
||||
// 注意:
|
||||
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
|
||||
// - 响应体可能已被包装以跟踪请求生命周期
|
||||
// Do 执行 HTTP 请求(不启用 TLS 指纹)
|
||||
Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
|
||||
|
||||
// DoWithTLS 执行带 TLS 指纹伪装的 HTTP 请求
|
||||
//
|
||||
// 参数:
|
||||
// - req: HTTP 请求对象,由调用方构建
|
||||
// - proxyURL: 代理服务器地址,空字符串表示直连
|
||||
// - accountID: 账户 ID,用于连接池隔离和 TLS 指纹模板选择
|
||||
// - accountConcurrency: 账户并发限制,用于动态调整连接池大小
|
||||
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
|
||||
// mode 参数决定指纹策略:
|
||||
// - TLSModeOff / "": 不启用,行为与 Do 相同
|
||||
// - TLSModeNode: 走本地 Node.js TLS 代理(需 gateway.node_tls_proxy.enabled=true)
|
||||
// - TLSModeUTLS: 用 profile 模拟 TLS ClientHello(profile 为 nil 时降级为 Off)
|
||||
//
|
||||
// 返回:
|
||||
// - *http.Response: HTTP 响应,调用方必须关闭 Body
|
||||
// - error: 请求错误(网络错误、超时等)
|
||||
//
|
||||
// TLS 指纹说明:
|
||||
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
|
||||
// - TLS 指纹模板根据 accountID % len(profiles) 自动选择
|
||||
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
|
||||
// - 如果 enableTLSFingerprint=false,行为与 Do 方法相同
|
||||
//
|
||||
// 注意:
|
||||
// - 调用方必须关闭 resp.Body,否则会导致连接泄漏
|
||||
// - TLS 指纹客户端与普通客户端使用不同的缓存键,互不影响
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error)
|
||||
// profile 仅在 mode=TLSModeUTLS 时生效,来自数据库或内置默认值。
|
||||
DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, mode TLSMode, profile *tlsfingerprint.Profile) (*http.Response, error)
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
@ -43,7 +44,7 @@ func (u *httpUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
func (u *httpUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *httpUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -57,7 +58,7 @@ func (u *httpUpstreamSequenceRecorder) Do(req *http.Request, proxyURL string, ac
|
||||
return u.responses[len(u.responses)-1], nil
|
||||
}
|
||||
|
||||
func (u *httpUpstreamSequenceRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||
func (u *httpUpstreamSequenceRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// RateLimitService 处理限流和过载状态管理
|
||||
@ -149,6 +150,17 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
||||
}
|
||||
// 其他 400 错误(如参数问题)不处理,不禁用账号
|
||||
case 401:
|
||||
// OpenAI: token_invalidated / token_revoked 表示 token 被永久作废(非过期),直接标记 error
|
||||
openai401Code := extractUpstreamErrorCode(responseBody)
|
||||
if account.Platform == PlatformOpenAI && (openai401Code == "token_invalidated" || openai401Code == "token_revoked") {
|
||||
msg := "Token revoked (401): account authentication permanently revoked"
|
||||
if upstreamMsg != "" {
|
||||
msg = "Token revoked (401): " + upstreamMsg
|
||||
}
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
shouldDisable = true
|
||||
break
|
||||
}
|
||||
// OAuth 账号在 401 错误时临时不可调度(给 token 刷新窗口);非 OAuth 账号保持原有 SetError 行为。
|
||||
// Antigravity 除外:其 401 由 applyErrorPolicy 的 temp_unschedulable_rules 自行控制。
|
||||
if account.Type == AccountTypeOAuth && account.Platform != PlatformAntigravity {
|
||||
@ -192,6 +204,13 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
||||
shouldDisable = true
|
||||
}
|
||||
case 402:
|
||||
// OpenAI: deactivated_workspace 表示工作区已停用,直接标记 error
|
||||
if account.Platform == PlatformOpenAI && gjson.GetBytes(responseBody, "detail.code").String() == "deactivated_workspace" {
|
||||
msg := "Workspace deactivated (402): workspace has been deactivated"
|
||||
s.handleAuthError(ctx, account, msg)
|
||||
shouldDisable = true
|
||||
break
|
||||
}
|
||||
// 支付要求:余额不足或计费问题,停止调度
|
||||
msg := "Payment required (402): insufficient balance or billing issue"
|
||||
if upstreamMsg != "" {
|
||||
|
||||
259
backend/internal/service/tls_fingerprint_profile_service.go
Normal file
259
backend/internal/service/tls_fingerprint_profile_service.go
Normal file
@ -0,0 +1,259 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfileRepository 定义 TLS 指纹模板的数据访问接口
|
||||
type TLSFingerprintProfileRepository interface {
|
||||
List(ctx context.Context) ([]*model.TLSFingerprintProfile, error)
|
||||
GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error)
|
||||
Create(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error)
|
||||
Update(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error)
|
||||
Delete(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileCache 定义 TLS 指纹模板的缓存接口
|
||||
type TLSFingerprintProfileCache interface {
|
||||
Get(ctx context.Context) ([]*model.TLSFingerprintProfile, bool)
|
||||
Set(ctx context.Context, profiles []*model.TLSFingerprintProfile) error
|
||||
Invalidate(ctx context.Context) error
|
||||
NotifyUpdate(ctx context.Context) error
|
||||
SubscribeUpdates(ctx context.Context, handler func())
|
||||
}
|
||||
|
||||
// TLSFingerprintProfileService TLS 指纹模板管理服务
|
||||
type TLSFingerprintProfileService struct {
|
||||
repo TLSFingerprintProfileRepository
|
||||
cache TLSFingerprintProfileCache
|
||||
|
||||
// 本地 ID→Profile 映射缓存,用于 DoWithTLS 热路径快速查找
|
||||
localCache map[int64]*model.TLSFingerprintProfile
|
||||
localMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTLSFingerprintProfileService 创建 TLS 指纹模板服务
|
||||
func NewTLSFingerprintProfileService(
|
||||
repo TLSFingerprintProfileRepository,
|
||||
cache TLSFingerprintProfileCache,
|
||||
) *TLSFingerprintProfileService {
|
||||
svc := &TLSFingerprintProfileService{
|
||||
repo: repo,
|
||||
cache: cache,
|
||||
localCache: make(map[int64]*model.TLSFingerprintProfile),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := svc.reloadFromDB(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to load profiles from DB on startup: %v", err)
|
||||
if fallbackErr := svc.refreshLocalCache(ctx); fallbackErr != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to load profiles from cache fallback on startup: %v", fallbackErr)
|
||||
}
|
||||
}
|
||||
|
||||
if cache != nil {
|
||||
cache.SubscribeUpdates(ctx, func() {
|
||||
if err := svc.refreshLocalCache(context.Background()); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to refresh cache on notification: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
// List 获取所有模板
|
||||
func (s *TLSFingerprintProfileService) List(ctx context.Context) ([]*model.TLSFingerprintProfile, error) {
|
||||
return s.repo.List(ctx)
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取模板
|
||||
func (s *TLSFingerprintProfileService) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Create 创建模板
|
||||
func (s *TLSFingerprintProfileService) Create(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
|
||||
if err := profile.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created, err := s.repo.Create(ctx, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// Update 更新模板
|
||||
func (s *TLSFingerprintProfileService) Update(ctx context.Context, profile *model.TLSFingerprintProfile) (*model.TLSFingerprintProfile, error) {
|
||||
if err := profile.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := s.repo.Update(ctx, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// Delete 删除模板
|
||||
func (s *TLSFingerprintProfileService) Delete(ctx context.Context, id int64) error {
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
refreshCtx, cancel := s.newCacheRefreshContext()
|
||||
defer cancel()
|
||||
s.invalidateAndNotify(refreshCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- 热路径:运行时 Profile 查找 ---
|
||||
|
||||
// GetProfileByID 根据 ID 从本地缓存获取 Profile(用于 DoWithTLS 热路径)
|
||||
// 返回 nil 表示未找到,调用方应 fallback 到内置默认 Profile
|
||||
func (s *TLSFingerprintProfileService) GetProfileByID(id int64) *tlsfingerprint.Profile {
|
||||
s.localMu.RLock()
|
||||
p, ok := s.localCache[id]
|
||||
s.localMu.RUnlock()
|
||||
|
||||
if ok && p != nil {
|
||||
return p.ToTLSProfile()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRandomProfile 从本地缓存中随机选择一个 Profile
|
||||
func (s *TLSFingerprintProfileService) getRandomProfile() *tlsfingerprint.Profile {
|
||||
s.localMu.RLock()
|
||||
defer s.localMu.RUnlock()
|
||||
|
||||
if len(s.localCache) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 收集所有 profile
|
||||
profiles := make([]*model.TLSFingerprintProfile, 0, len(s.localCache))
|
||||
for _, p := range s.localCache {
|
||||
if p != nil {
|
||||
profiles = append(profiles, p)
|
||||
}
|
||||
}
|
||||
if len(profiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return profiles[rand.IntN(len(profiles))].ToTLSProfile()
|
||||
}
|
||||
|
||||
// ResolveTLSProfile 根据 Account 的配置解析出运行时 TLS Profile
|
||||
//
|
||||
// 逻辑:
|
||||
// 1. 未启用 TLS 指纹 → 返回 nil(不伪装)
|
||||
// 2. 启用 + 绑定了 profile_id → 从缓存查找对应 profile
|
||||
// 3. 启用 + 未绑定或找不到 → 返回空 Profile(使用代码内置默认值)
|
||||
func (s *TLSFingerprintProfileService) ResolveTLSProfile(account *Account) *tlsfingerprint.Profile {
|
||||
if account == nil || !account.IsTLSFingerprintEnabled() {
|
||||
return nil
|
||||
}
|
||||
id := account.GetTLSFingerprintProfileID()
|
||||
if id > 0 {
|
||||
if p := s.GetProfileByID(id); p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if id == -1 {
|
||||
// 随机选择一个 profile
|
||||
if p := s.getRandomProfile(); p != nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
// TLS 启用但无绑定 profile → 空 Profile → dialer 使用内置默认值
|
||||
return &tlsfingerprint.Profile{Name: "Built-in Default (Node.js 24.x)"}
|
||||
}
|
||||
|
||||
// --- 缓存管理 ---
|
||||
|
||||
func (s *TLSFingerprintProfileService) refreshLocalCache(ctx context.Context) error {
|
||||
if s.cache != nil {
|
||||
if profiles, ok := s.cache.Get(ctx); ok {
|
||||
s.setLocalCache(profiles)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return s.reloadFromDB(ctx)
|
||||
}
|
||||
|
||||
func (s *TLSFingerprintProfileService) reloadFromDB(ctx context.Context) error {
|
||||
profiles, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Set(ctx, profiles); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to set cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.setLocalCache(profiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TLSFingerprintProfileService) setLocalCache(profiles []*model.TLSFingerprintProfile) {
|
||||
m := make(map[int64]*model.TLSFingerprintProfile, len(profiles))
|
||||
for _, p := range profiles {
|
||||
m[p.ID] = p
|
||||
}
|
||||
|
||||
s.localMu.Lock()
|
||||
s.localCache = m
|
||||
s.localMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *TLSFingerprintProfileService) newCacheRefreshContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), 3*time.Second)
|
||||
}
|
||||
|
||||
func (s *TLSFingerprintProfileService) invalidateAndNotify(ctx context.Context) {
|
||||
if s.cache != nil {
|
||||
if err := s.cache.Invalidate(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to invalidate cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.reloadFromDB(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to refresh local cache: %v", err)
|
||||
s.localMu.Lock()
|
||||
s.localCache = make(map[int64]*model.TLSFingerprintProfile)
|
||||
s.localMu.Unlock()
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
if err := s.cache.NotifyUpdate(ctx); err != nil {
|
||||
logger.LegacyPrintf("service.tls_fp_profile", "[TLSFPProfileService] Failed to notify cache update: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -300,6 +300,8 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
|
||||
"error", setErr,
|
||||
)
|
||||
}
|
||||
// 刷新失败但 access_token 可能仍有效,尝试设置隐私
|
||||
s.ensureOpenAIPrivacy(ctx, account)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -327,6 +329,9 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
|
||||
"error", lastErr,
|
||||
)
|
||||
|
||||
// 刷新失败但 access_token 可能仍有效,尝试设置隐私
|
||||
s.ensureOpenAIPrivacy(ctx, account)
|
||||
|
||||
// 设置临时不可调度 10 分钟(不标记 error,保持 status=active 让下个刷新周期能继续尝试)
|
||||
until := time.Now().Add(tokenRefreshTempUnschedDuration)
|
||||
reason := fmt.Sprintf("token refresh retry exhausted: %v", lastErr)
|
||||
|
||||
@ -482,6 +482,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewUsageCache,
|
||||
NewTotpService,
|
||||
NewErrorPassthroughService,
|
||||
NewTLSFingerprintProfileService,
|
||||
NewDigestSessionStore,
|
||||
ProvideIdempotencyCoordinator,
|
||||
ProvideSystemOperationLockService,
|
||||
|
||||
29
backend/migrations/080_create_tls_fingerprint_profiles.sql
Normal file
29
backend/migrations/080_create_tls_fingerprint_profiles.sql
Normal file
@ -0,0 +1,29 @@
|
||||
-- Create tls_fingerprint_profiles table for managing TLS fingerprint templates.
|
||||
-- Each profile contains ClientHello parameters to simulate specific client TLS handshake characteristics.
|
||||
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
SET LOCAL statement_timeout = '10min';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tls_fingerprint_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
enable_grease BOOLEAN NOT NULL DEFAULT false,
|
||||
cipher_suites JSONB,
|
||||
curves JSONB,
|
||||
point_formats JSONB,
|
||||
signature_algorithms JSONB,
|
||||
alpn_protocols JSONB,
|
||||
supported_versions JSONB,
|
||||
key_share_groups JSONB,
|
||||
psk_modes JSONB,
|
||||
extensions JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tls_fingerprint_profiles IS 'TLS fingerprint templates for simulating specific client TLS handshake characteristics';
|
||||
COMMENT ON COLUMN tls_fingerprint_profiles.name IS 'Unique profile name, e.g. "macOS Node.js v24"';
|
||||
COMMENT ON COLUMN tls_fingerprint_profiles.enable_grease IS 'Whether to insert GREASE values in ClientHello extensions';
|
||||
COMMENT ON COLUMN tls_fingerprint_profiles.cipher_suites IS 'TLS cipher suite list as JSON array of uint16 (order-sensitive, affects JA3)';
|
||||
COMMENT ON COLUMN tls_fingerprint_profiles.extensions IS 'TLS extension type IDs in send order as JSON array of uint16';
|
||||
@ -24,6 +24,7 @@ import dataManagementAPI from './dataManagement'
|
||||
import apiKeysAPI from './apiKeys'
|
||||
import scheduledTestsAPI from './scheduledTests'
|
||||
import backupAPI from './backup'
|
||||
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
|
||||
|
||||
/**
|
||||
* Unified admin API object for convenient access
|
||||
@ -49,7 +50,8 @@ export const adminAPI = {
|
||||
dataManagement: dataManagementAPI,
|
||||
apiKeys: apiKeysAPI,
|
||||
scheduledTests: scheduledTestsAPI,
|
||||
backup: backupAPI
|
||||
backup: backupAPI,
|
||||
tlsFingerprintProfiles: tlsFingerprintProfileAPI
|
||||
}
|
||||
|
||||
export {
|
||||
@ -73,7 +75,8 @@ export {
|
||||
dataManagementAPI,
|
||||
apiKeysAPI,
|
||||
scheduledTestsAPI,
|
||||
backupAPI
|
||||
backupAPI,
|
||||
tlsFingerprintProfileAPI
|
||||
}
|
||||
|
||||
export default adminAPI
|
||||
@ -82,3 +85,4 @@ export default adminAPI
|
||||
export type { BalanceHistoryItem } from './users'
|
||||
export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough'
|
||||
export type { BackupAgentHealth, DataManagementConfig } from './dataManagement'
|
||||
export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile'
|
||||
|
||||
98
frontend/src/api/admin/tlsFingerprintProfile.ts
Normal file
98
frontend/src/api/admin/tlsFingerprintProfile.ts
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Admin TLS Fingerprint Profile API endpoints
|
||||
* Handles TLS fingerprint profile CRUD for administrators
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
|
||||
/**
|
||||
* TLS fingerprint profile interface
|
||||
*/
|
||||
export interface TLSFingerprintProfile {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
enable_grease: boolean
|
||||
cipher_suites: number[]
|
||||
curves: number[]
|
||||
point_formats: number[]
|
||||
signature_algorithms: number[]
|
||||
alpn_protocols: string[]
|
||||
supported_versions: number[]
|
||||
key_share_groups: number[]
|
||||
psk_modes: number[]
|
||||
extensions: number[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create profile request
|
||||
*/
|
||||
export interface CreateProfileRequest {
|
||||
name: string
|
||||
description?: string | null
|
||||
enable_grease?: boolean
|
||||
cipher_suites?: number[]
|
||||
curves?: number[]
|
||||
point_formats?: number[]
|
||||
signature_algorithms?: number[]
|
||||
alpn_protocols?: string[]
|
||||
supported_versions?: number[]
|
||||
key_share_groups?: number[]
|
||||
psk_modes?: number[]
|
||||
extensions?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile request
|
||||
*/
|
||||
export interface UpdateProfileRequest {
|
||||
name?: string
|
||||
description?: string | null
|
||||
enable_grease?: boolean
|
||||
cipher_suites?: number[]
|
||||
curves?: number[]
|
||||
point_formats?: number[]
|
||||
signature_algorithms?: number[]
|
||||
alpn_protocols?: string[]
|
||||
supported_versions?: number[]
|
||||
key_share_groups?: number[]
|
||||
psk_modes?: number[]
|
||||
extensions?: number[]
|
||||
}
|
||||
|
||||
export async function list(): Promise<TLSFingerprintProfile[]> {
|
||||
const { data } = await apiClient.get<TLSFingerprintProfile[]>('/admin/tls-fingerprint-profiles')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getById(id: number): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.get<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function create(profileData: CreateProfileRequest): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.post<TLSFingerprintProfile>('/admin/tls-fingerprint-profiles', profileData)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function update(id: number, updates: UpdateProfileRequest): Promise<TLSFingerprintProfile> {
|
||||
const { data } = await apiClient.put<TLSFingerprintProfile>(`/admin/tls-fingerprint-profiles/${id}`, updates)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProfile(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/tls-fingerprint-profiles/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const tlsFingerprintProfileAPI = {
|
||||
list,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteProfile
|
||||
}
|
||||
|
||||
export default tlsFingerprintProfileAPI
|
||||
@ -2169,6 +2169,14 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Profile selector -->
|
||||
<div v-if="tlsFingerprintEnabled" class="mt-3">
|
||||
<select v-model="tlsFingerprintProfileId" class="input">
|
||||
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
|
||||
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
|
||||
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session ID Masking -->
|
||||
@ -3082,6 +3090,8 @@ const umqModeOptions = computed(() => [
|
||||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||||
])
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const tlsFingerprintProfileId = ref<number | null>(null)
|
||||
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
const cacheTTLOverrideEnabled = ref(false)
|
||||
const cacheTTLOverrideTarget = ref<string>('5m')
|
||||
@ -3247,6 +3257,10 @@ watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
// Load TLS fingerprint profiles
|
||||
adminAPI.tlsFingerprintProfiles.list()
|
||||
.then(profiles => { tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name })) })
|
||||
.catch(() => { tlsFingerprintProfiles.value = [] })
|
||||
// Modal opened - fill related models
|
||||
allowedModels.value = [...getModelsByPlatform(form.platform)]
|
||||
// Antigravity: 默认使用映射模式并填充默认映射
|
||||
@ -3747,6 +3761,7 @@ const resetForm = () => {
|
||||
rpmStickyBuffer.value = null
|
||||
userMsgQueueMode.value = ''
|
||||
tlsFingerprintEnabled.value = false
|
||||
tlsFingerprintProfileId.value = null
|
||||
sessionIdMaskingEnabled.value = false
|
||||
cacheTTLOverrideEnabled.value = false
|
||||
cacheTTLOverrideTarget.value = '5m'
|
||||
@ -4825,6 +4840,9 @@ const handleAnthropicExchange = async (authCode: string) => {
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
if (tlsFingerprintProfileId.value) {
|
||||
extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
|
||||
}
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
@ -4940,6 +4958,9 @@ const handleCookieAuth = async (sessionKey: string) => {
|
||||
// Add TLS fingerprint settings
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
extra.enable_tls_fingerprint = true
|
||||
if (tlsFingerprintProfileId.value) {
|
||||
extra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
|
||||
}
|
||||
}
|
||||
|
||||
// Add session ID masking settings
|
||||
|
||||
@ -1504,6 +1504,14 @@
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Profile selector -->
|
||||
<div v-if="tlsFingerprintEnabled" class="mt-3">
|
||||
<select v-model="tlsFingerprintProfileId" class="input">
|
||||
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
|
||||
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
|
||||
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session ID Masking -->
|
||||
@ -1841,6 +1849,8 @@ const umqModeOptions = computed(() => [
|
||||
{ value: 'serialize', label: t('admin.accounts.quotaControl.rpmLimit.umqModeSerialize') },
|
||||
])
|
||||
const tlsFingerprintEnabled = ref(false)
|
||||
const tlsFingerprintProfileId = ref<number | null>(null)
|
||||
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
|
||||
const sessionIdMaskingEnabled = ref(false)
|
||||
const cacheTTLOverrideEnabled = ref(false)
|
||||
const cacheTTLOverrideTarget = ref<string>('5m')
|
||||
@ -2255,11 +2265,21 @@ watch(
|
||||
}
|
||||
if (!wasShow || newAccount !== previousAccount) {
|
||||
syncFormFromAccount(newAccount)
|
||||
loadTLSProfiles()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const loadTLSProfiles = async () => {
|
||||
try {
|
||||
const profiles = await adminAPI.tlsFingerprintProfiles.list()
|
||||
tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name }))
|
||||
} catch {
|
||||
tlsFingerprintProfiles.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Model mapping helpers
|
||||
const addModelMapping = () => {
|
||||
modelMappings.value.push({ from: '', to: '' })
|
||||
@ -2458,6 +2478,7 @@ function loadQuotaControlSettings(account: Account) {
|
||||
rpmStickyBuffer.value = null
|
||||
userMsgQueueMode.value = ''
|
||||
tlsFingerprintEnabled.value = false
|
||||
tlsFingerprintProfileId.value = null
|
||||
sessionIdMaskingEnabled.value = false
|
||||
cacheTTLOverrideEnabled.value = false
|
||||
cacheTTLOverrideTarget.value = '5m'
|
||||
@ -2495,6 +2516,7 @@ function loadQuotaControlSettings(account: Account) {
|
||||
if (account.enable_tls_fingerprint === true) {
|
||||
tlsFingerprintEnabled.value = true
|
||||
}
|
||||
tlsFingerprintProfileId.value = account.tls_fingerprint_profile_id ?? null
|
||||
|
||||
// Load session ID masking setting
|
||||
if (account.session_id_masking_enabled === true) {
|
||||
@ -2932,8 +2954,14 @@ const handleSubmit = async () => {
|
||||
// TLS fingerprint setting
|
||||
if (tlsFingerprintEnabled.value) {
|
||||
newExtra.enable_tls_fingerprint = true
|
||||
if (tlsFingerprintProfileId.value) {
|
||||
newExtra.tls_fingerprint_profile_id = tlsFingerprintProfileId.value
|
||||
} else {
|
||||
delete newExtra.tls_fingerprint_profile_id
|
||||
}
|
||||
} else {
|
||||
delete newExtra.enable_tls_fingerprint
|
||||
delete newExtra.tls_fingerprint_profile_id
|
||||
}
|
||||
|
||||
// Session ID masking setting
|
||||
|
||||
625
frontend/src/components/admin/TLSFingerprintProfilesModal.vue
Normal file
625
frontend/src/components/admin/TLSFingerprintProfilesModal.vue
Normal file
@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.tlsFingerprintProfiles.title')"
|
||||
width="wide"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.description') }}
|
||||
</p>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary btn-sm">
|
||||
<Icon name="plus" size="sm" class="mr-1" />
|
||||
{{ t('admin.tlsFingerprintProfiles.createProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Profiles Table -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<Icon name="refresh" size="lg" class="animate-spin text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="profiles.length === 0" class="py-8 text-center">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
|
||||
<Icon name="shield" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h4 class="mb-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.tlsFingerprintProfiles.noProfiles') }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.createFirstProfile') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="max-h-96 overflow-auto rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-dark-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.name') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.description') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.grease') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.alpn') }}
|
||||
</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.columns.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="profile in profiles" :key="profile.id" class="hover:bg-gray-50 dark:hover:bg-dark-700">
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white text-sm">{{ profile.name }}</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div v-if="profile.description" class="text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate">
|
||||
{{ profile.description }}
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400 dark:text-gray-600">—</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<Icon
|
||||
:name="profile.enable_grease ? 'check' : 'lock'"
|
||||
size="sm"
|
||||
:class="profile.enable_grease ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div v-if="profile.alpn_protocols?.length" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="proto in profile.alpn_protocols.slice(0, 3)"
|
||||
:key="proto"
|
||||
class="badge badge-primary text-xs"
|
||||
>
|
||||
{{ proto }}
|
||||
</span>
|
||||
<span v-if="profile.alpn_protocols.length > 3" class="text-xs text-gray-500">
|
||||
+{{ profile.alpn_protocols.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400 dark:text-gray-600">—</div>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="handleEdit(profile)"
|
||||
class="p-1 text-gray-500 hover:text-primary-600 dark:hover:text-primary-400"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(profile)"
|
||||
class="p-1 text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button @click="$emit('close')" class="btn btn-secondary">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<BaseDialog
|
||||
:show="showCreateModal || showEditModal"
|
||||
:title="showEditModal ? t('admin.tlsFingerprintProfiles.editProfile') : t('admin.tlsFingerprintProfiles.createProfile')"
|
||||
width="wide"
|
||||
:z-index="60"
|
||||
@close="closeFormModal"
|
||||
>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Paste YAML -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.pasteYaml') }}</label>
|
||||
<textarea
|
||||
v-model="yamlInput"
|
||||
rows="4"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="t('admin.tlsFingerprintProfiles.form.pasteYamlPlaceholder')"
|
||||
@paste="handleYamlPaste"
|
||||
/>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<button type="button" @click="parseYamlInput" class="btn btn-secondary btn-sm">
|
||||
{{ t('admin.tlsFingerprintProfiles.form.parseYaml') }}
|
||||
</button>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.form.pasteYamlHint') }}
|
||||
<a href="https://tls.sub2api.org" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline">{{ t('admin.tlsFingerprintProfiles.form.openCollector') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-200 dark:border-dark-600" />
|
||||
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.name') }}</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="t('admin.tlsFingerprintProfiles.form.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.tlsFingerprintProfiles.form.description') }}</label>
|
||||
<input
|
||||
v-model="form.description"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.tlsFingerprintProfiles.form.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GREASE Toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="form.enable_grease = !form.enable_grease"
|
||||
:class="[
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
form.enable_grease ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
form.enable_grease ? 'translate-x-4' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.tlsFingerprintProfiles.form.enableGrease') }}
|
||||
</span>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.tlsFingerprintProfiles.form.enableGreaseHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TLS Array Fields - 2 column grid -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.cipherSuites') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.cipher_suites"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0x1301, 0x1302, 0xc02c'"
|
||||
/>
|
||||
<p class="input-hint text-xs">{{ t('admin.tlsFingerprintProfiles.form.cipherSuitesHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.curves') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.curves"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'29, 23, 24'"
|
||||
/>
|
||||
<p class="input-hint text-xs">{{ t('admin.tlsFingerprintProfiles.form.curvesHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.signatureAlgorithms') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.signature_algorithms"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0x0403, 0x0804, 0x0401'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.supportedVersions') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.supported_versions"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0x0304, 0x0303'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.keyShareGroups') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.key_share_groups"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'29, 23'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.extensions') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.extensions"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0x0000, 0x0005, 0x000a'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.pointFormats') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.point_formats"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'0'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.pskModes') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.psk_modes"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'1'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ALPN Protocols - full width -->
|
||||
<div>
|
||||
<label class="input-label text-xs">{{ t('admin.tlsFingerprintProfiles.form.alpnProtocols') }}</label>
|
||||
<textarea
|
||||
v-model="fieldInputs.alpn_protocols"
|
||||
rows="2"
|
||||
class="input font-mono text-xs"
|
||||
:placeholder="'h2, http/1.1'"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="closeFormModal" type="button" class="btn btn-secondary">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button @click="handleSubmit" :disabled="submitting" class="btn btn-primary">
|
||||
<Icon v-if="submitting" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||
{{ showEditModal ? t('common.update') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
:title="t('admin.tlsFingerprintProfiles.deleteProfile')"
|
||||
:message="t('admin.tlsFingerprintProfiles.deleteConfirmMessage', { name: deletingProfile?.name })"
|
||||
:confirm-text="t('common.delete')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
:danger="true"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteDialog = false"
|
||||
/>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { TLSFingerprintProfile } from '@/api/admin/tlsFingerprintProfile'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
void emit // suppress unused warning - emit is used via $emit in template
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const profiles = ref<TLSFingerprintProfile[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingProfile = ref<TLSFingerprintProfile | null>(null)
|
||||
const deletingProfile = ref<TLSFingerprintProfile | null>(null)
|
||||
const yamlInput = ref('')
|
||||
|
||||
// Raw string inputs for array fields
|
||||
const fieldInputs = reactive({
|
||||
cipher_suites: '',
|
||||
curves: '',
|
||||
point_formats: '',
|
||||
signature_algorithms: '',
|
||||
alpn_protocols: '',
|
||||
supported_versions: '',
|
||||
key_share_groups: '',
|
||||
psk_modes: '',
|
||||
extensions: ''
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: null as string | null,
|
||||
enable_grease: false
|
||||
})
|
||||
|
||||
// Load profiles when dialog opens
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
loadProfiles()
|
||||
}
|
||||
})
|
||||
|
||||
const loadProfiles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
profiles.value = await adminAPI.tlsFingerprintProfiles.list()
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.tlsFingerprintProfiles.loadFailed'))
|
||||
console.error('Error loading TLS fingerprint profiles:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.description = null
|
||||
form.enable_grease = false
|
||||
fieldInputs.cipher_suites = ''
|
||||
fieldInputs.curves = ''
|
||||
fieldInputs.point_formats = ''
|
||||
fieldInputs.signature_algorithms = ''
|
||||
fieldInputs.alpn_protocols = ''
|
||||
fieldInputs.supported_versions = ''
|
||||
fieldInputs.key_share_groups = ''
|
||||
fieldInputs.psk_modes = ''
|
||||
fieldInputs.extensions = ''
|
||||
yamlInput.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML output from tls-fingerprint-web and fill form fields.
|
||||
* Expected format:
|
||||
* # comment lines
|
||||
* profile_key:
|
||||
* name: "Profile Name"
|
||||
* enable_grease: false
|
||||
* cipher_suites: [4866, 4867, ...]
|
||||
* alpn_protocols: ["h2", "http/1.1"]
|
||||
* ...
|
||||
*/
|
||||
const parseYamlInput = () => {
|
||||
const text = yamlInput.value.trim()
|
||||
if (!text) return
|
||||
|
||||
// Simple YAML parser for flat key-value structure
|
||||
// Extracts "key: value" lines, handling arrays like [1, 2, 3] and ["h2", "http/1.1"]
|
||||
const lines = text.split('\n')
|
||||
|
||||
let foundName = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
// Skip comments and empty lines
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
|
||||
// Match "key: value" pattern (must have at least 2 leading spaces to be a property)
|
||||
const match = trimmed.match(/^(\w+):\s*(.+)$/)
|
||||
if (!match) continue
|
||||
|
||||
const [, key, rawValue] = match
|
||||
const value = rawValue.trim()
|
||||
|
||||
switch (key) {
|
||||
case 'name': {
|
||||
// Remove surrounding quotes
|
||||
const unquoted = value.replace(/^["']|["']$/g, '')
|
||||
if (unquoted) {
|
||||
form.name = unquoted
|
||||
foundName = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'enable_grease':
|
||||
form.enable_grease = value === 'true'
|
||||
break
|
||||
case 'cipher_suites':
|
||||
case 'curves':
|
||||
case 'point_formats':
|
||||
case 'signature_algorithms':
|
||||
case 'supported_versions':
|
||||
case 'key_share_groups':
|
||||
case 'psk_modes':
|
||||
case 'extensions': {
|
||||
// Parse YAML array: [1, 2, 3] — values are decimal integers from tls-fingerprint-web
|
||||
const arrMatch = value.match(/^\[(.*)?\]$/)
|
||||
if (arrMatch) {
|
||||
const inner = arrMatch[1] || ''
|
||||
fieldInputs[key as keyof typeof fieldInputs] = inner
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
.join(', ')
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'alpn_protocols': {
|
||||
// Parse string array: ["h2", "http/1.1"]
|
||||
const arrMatch = value.match(/^\[(.*)?\]$/)
|
||||
if (arrMatch) {
|
||||
const inner = arrMatch[1] || ''
|
||||
fieldInputs.alpn_protocols = inner
|
||||
.split(',')
|
||||
.map(s => s.trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(s => s.length > 0)
|
||||
.join(', ')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundName) {
|
||||
appStore.showSuccess(t('admin.tlsFingerprintProfiles.form.yamlParsed'))
|
||||
} else {
|
||||
appStore.showError(t('admin.tlsFingerprintProfiles.form.yamlParseFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-parse on paste event
|
||||
const handleYamlPaste = () => {
|
||||
// Use nextTick to ensure v-model has updated
|
||||
setTimeout(() => parseYamlInput(), 50)
|
||||
}
|
||||
|
||||
const closeFormModal = () => {
|
||||
showCreateModal.value = false
|
||||
showEditModal.value = false
|
||||
editingProfile.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// Parse a comma-separated string of numbers supporting both hex (0x...) and decimal
|
||||
const parseNumericArray = (input: string): number[] => {
|
||||
if (!input.trim()) return []
|
||||
return input
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
.map(s => s.startsWith('0x') || s.startsWith('0X') ? parseInt(s, 16) : parseInt(s, 10))
|
||||
.filter(n => !isNaN(n))
|
||||
}
|
||||
|
||||
// Parse a comma-separated string of string values
|
||||
const parseStringArray = (input: string): string[] => {
|
||||
if (!input.trim()) return []
|
||||
return input
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
}
|
||||
|
||||
// Format a number as hex with 0x prefix and 4-digit padding
|
||||
const formatHex = (n: number): string => '0x' + n.toString(16).padStart(4, '0')
|
||||
|
||||
// Format numeric arrays for display in textarea (null-safe)
|
||||
const formatNumericArray = (arr: number[] | null | undefined): string => (arr ?? []).map(formatHex).join(', ')
|
||||
|
||||
// For point_formats and psk_modes (uint8), show as plain numbers (null-safe)
|
||||
const formatPlainNumericArray = (arr: number[] | null | undefined): string => (arr ?? []).join(', ')
|
||||
|
||||
const handleEdit = (profile: TLSFingerprintProfile) => {
|
||||
editingProfile.value = profile
|
||||
form.name = profile.name
|
||||
form.description = profile.description
|
||||
form.enable_grease = profile.enable_grease
|
||||
fieldInputs.cipher_suites = formatNumericArray(profile.cipher_suites)
|
||||
fieldInputs.curves = formatPlainNumericArray(profile.curves)
|
||||
fieldInputs.point_formats = formatPlainNumericArray(profile.point_formats)
|
||||
fieldInputs.signature_algorithms = formatNumericArray(profile.signature_algorithms)
|
||||
fieldInputs.alpn_protocols = (profile.alpn_protocols ?? []).join(', ')
|
||||
fieldInputs.supported_versions = formatNumericArray(profile.supported_versions)
|
||||
fieldInputs.key_share_groups = formatPlainNumericArray(profile.key_share_groups)
|
||||
fieldInputs.psk_modes = formatPlainNumericArray(profile.psk_modes)
|
||||
fieldInputs.extensions = formatNumericArray(profile.extensions)
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (profile: TLSFingerprintProfile) => {
|
||||
deletingProfile.value = profile
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
appStore.showError(t('admin.tlsFingerprintProfiles.form.name') + ' ' + t('common.required'))
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = {
|
||||
name: form.name.trim(),
|
||||
description: form.description?.trim() || null,
|
||||
enable_grease: form.enable_grease,
|
||||
cipher_suites: parseNumericArray(fieldInputs.cipher_suites),
|
||||
curves: parseNumericArray(fieldInputs.curves),
|
||||
point_formats: parseNumericArray(fieldInputs.point_formats),
|
||||
signature_algorithms: parseNumericArray(fieldInputs.signature_algorithms),
|
||||
alpn_protocols: parseStringArray(fieldInputs.alpn_protocols),
|
||||
supported_versions: parseNumericArray(fieldInputs.supported_versions),
|
||||
key_share_groups: parseNumericArray(fieldInputs.key_share_groups),
|
||||
psk_modes: parseNumericArray(fieldInputs.psk_modes),
|
||||
extensions: parseNumericArray(fieldInputs.extensions)
|
||||
}
|
||||
|
||||
if (showEditModal.value && editingProfile.value) {
|
||||
await adminAPI.tlsFingerprintProfiles.update(editingProfile.value.id, data)
|
||||
appStore.showSuccess(t('admin.tlsFingerprintProfiles.updateSuccess'))
|
||||
} else {
|
||||
await adminAPI.tlsFingerprintProfiles.create(data)
|
||||
appStore.showSuccess(t('admin.tlsFingerprintProfiles.createSuccess'))
|
||||
}
|
||||
|
||||
closeFormModal()
|
||||
loadProfiles()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.tlsFingerprintProfiles.saveFailed'))
|
||||
console.error('Error saving TLS fingerprint profile:', error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingProfile.value) return
|
||||
|
||||
try {
|
||||
await adminAPI.tlsFingerprintProfiles.delete(deletingProfile.value.id)
|
||||
appStore.showSuccess(t('admin.tlsFingerprintProfiles.deleteSuccess'))
|
||||
showDeleteDialog.value = false
|
||||
deletingProfile.value = null
|
||||
loadProfiles()
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('admin.tlsFingerprintProfiles.deleteFailed'))
|
||||
console.error('Error deleting TLS fingerprint profile:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -31,7 +31,7 @@
|
||||
</div>
|
||||
<!-- Row 2: Plan type + Privacy mode (only if either exists) -->
|
||||
<div v-if="planLabel || privacyBadge" class="inline-flex items-center overflow-hidden rounded-md">
|
||||
<span v-if="planLabel" :class="['inline-flex items-center gap-1 px-1.5 py-1', typeClass]">
|
||||
<span v-if="planLabel" :class="['inline-flex items-center gap-1 px-1.5 py-1', planBadgeClass]">
|
||||
<span>{{ planLabel }}</span>
|
||||
</span>
|
||||
<span
|
||||
@ -102,6 +102,8 @@ const planLabel = computed(() => {
|
||||
return 'Pro'
|
||||
case 'free':
|
||||
return 'Free'
|
||||
case 'abnormal':
|
||||
return t('admin.accounts.subscriptionAbnormal')
|
||||
default:
|
||||
return props.planType
|
||||
}
|
||||
@ -139,18 +141,34 @@ const typeClass = computed(() => {
|
||||
return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
})
|
||||
|
||||
// Privacy badge — shows different states for OpenAI OAuth training setting
|
||||
const planBadgeClass = computed(() => {
|
||||
if (props.planType && props.planType.toLowerCase() === 'abnormal') {
|
||||
return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
return typeClass.value
|
||||
})
|
||||
|
||||
// Privacy badge — shows different states for OpenAI/Antigravity OAuth privacy setting
|
||||
const privacyBadge = computed(() => {
|
||||
if (props.platform !== 'openai' || props.type !== 'oauth' || !props.privacyMode) return null
|
||||
if (props.type !== 'oauth' || !props.privacyMode) return null
|
||||
// 支持 OpenAI 和 Antigravity 平台
|
||||
if (props.platform !== 'openai' && props.platform !== 'antigravity') return null
|
||||
|
||||
const shieldCheck = 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z'
|
||||
const shieldX = 'M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285zM12 18h.008v.008H12V18z'
|
||||
switch (props.privacyMode) {
|
||||
// OpenAI states
|
||||
case 'training_off':
|
||||
return { label: 'Privacy', icon: shieldCheck, title: t('admin.accounts.privacyTrainingOff'), class: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' }
|
||||
return { label: 'Private', icon: shieldCheck, title: t('admin.accounts.privacyTrainingOff'), class: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' }
|
||||
case 'training_set_cf_blocked':
|
||||
return { label: 'CF', icon: shieldX, title: t('admin.accounts.privacyCfBlocked'), class: 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400' }
|
||||
case 'training_set_failed':
|
||||
return { label: 'Fail', icon: shieldX, title: t('admin.accounts.privacyFailed'), class: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' }
|
||||
// Antigravity states
|
||||
case 'privacy_set':
|
||||
return { label: 'Private', icon: shieldCheck, title: t('admin.accounts.privacyAntigravitySet'), class: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' }
|
||||
case 'privacy_set_failed':
|
||||
return { label: 'Fail', icon: shieldX, title: t('admin.accounts.privacyAntigravityFailed'), class: 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@ -1984,6 +1984,10 @@ export default {
|
||||
privacyTrainingOff: 'Training data sharing disabled',
|
||||
privacyCfBlocked: 'Blocked by Cloudflare, training may still be on',
|
||||
privacyFailed: 'Failed to disable training',
|
||||
privacyAntigravitySet: 'Telemetry and marketing emails disabled',
|
||||
privacyAntigravityFailed: 'Privacy setting failed',
|
||||
setPrivacy: 'Set Privacy',
|
||||
subscriptionAbnormal: 'Abnormal',
|
||||
// Capacity status tooltips
|
||||
capacity: {
|
||||
windowCost: {
|
||||
@ -2300,7 +2304,9 @@ export default {
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS Fingerprint Simulation',
|
||||
hint: 'Simulate Node.js/Claude Code client TLS fingerprint'
|
||||
hint: 'Simulate Node.js/Claude Code client TLS fingerprint',
|
||||
defaultProfile: 'Built-in Default',
|
||||
randomProfile: 'Random'
|
||||
},
|
||||
sessionIdMasking: {
|
||||
label: 'Session ID Masking',
|
||||
@ -4568,6 +4574,62 @@ export default {
|
||||
failedToSave: 'Failed to save rule',
|
||||
failedToDelete: 'Failed to delete rule',
|
||||
failedToToggle: 'Failed to toggle status'
|
||||
},
|
||||
|
||||
// TLS Fingerprint Profiles
|
||||
tlsFingerprintProfiles: {
|
||||
title: 'TLS Fingerprint Profiles',
|
||||
description: 'Manage TLS fingerprint profiles for simulating specific client TLS handshake characteristics',
|
||||
createProfile: 'Create Profile',
|
||||
editProfile: 'Edit Profile',
|
||||
deleteProfile: 'Delete Profile',
|
||||
noProfiles: 'No profiles configured',
|
||||
createFirstProfile: 'Create your first TLS fingerprint profile',
|
||||
|
||||
columns: {
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
grease: 'GREASE',
|
||||
alpn: 'ALPN',
|
||||
actions: 'Actions'
|
||||
},
|
||||
|
||||
form: {
|
||||
pasteYaml: 'Paste YAML Configuration',
|
||||
pasteYamlPlaceholder: 'Paste YAML output from TLS Fingerprint Collector here...',
|
||||
pasteYamlHint: 'Paste the YAML copied from TLS Fingerprint Collector to auto-fill all fields.',
|
||||
openCollector: 'Open Collector',
|
||||
parseYaml: 'Parse YAML',
|
||||
yamlParsed: 'YAML parsed successfully, fields auto-filled',
|
||||
yamlParseFailed: 'Failed to parse YAML: name field not found',
|
||||
name: 'Profile Name',
|
||||
namePlaceholder: 'e.g. macOS Node.js v24',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Optional description for this profile',
|
||||
enableGrease: 'Enable GREASE',
|
||||
enableGreaseHint: 'Insert GREASE values in TLS ClientHello extensions',
|
||||
cipherSuites: 'Cipher Suites',
|
||||
cipherSuitesHint: 'Comma-separated hex values, e.g. 0x1301, 0x1302, 0xc02c',
|
||||
curves: 'Elliptic Curves',
|
||||
curvesHint: 'Comma-separated curve IDs',
|
||||
pointFormats: 'Point Formats',
|
||||
signatureAlgorithms: 'Signature Algorithms',
|
||||
alpnProtocols: 'ALPN Protocols',
|
||||
alpnProtocolsHint: 'Comma-separated, e.g. h2, http/1.1',
|
||||
supportedVersions: 'Supported TLS Versions',
|
||||
keyShareGroups: 'Key Share Groups',
|
||||
pskModes: 'PSK Modes',
|
||||
extensions: 'Extensions'
|
||||
},
|
||||
|
||||
deleteConfirm: 'Delete Profile',
|
||||
deleteConfirmMessage: 'Are you sure you want to delete profile "{name}"? Accounts using this profile will fall back to the built-in default.',
|
||||
createSuccess: 'Profile created successfully',
|
||||
updateSuccess: 'Profile updated successfully',
|
||||
deleteSuccess: 'Profile deleted successfully',
|
||||
loadFailed: 'Failed to load profiles',
|
||||
saveFailed: 'Failed to save profile',
|
||||
deleteFailed: 'Failed to delete profile'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -2022,6 +2022,10 @@ export default {
|
||||
privacyTrainingOff: '已关闭训练数据共享',
|
||||
privacyCfBlocked: '被 Cloudflare 拦截,训练可能仍开启',
|
||||
privacyFailed: '关闭训练数据共享失败',
|
||||
privacyAntigravitySet: '已关闭遥测和营销邮件',
|
||||
privacyAntigravityFailed: '隐私设置失败',
|
||||
setPrivacy: '设置隐私',
|
||||
subscriptionAbnormal: '异常',
|
||||
// 容量状态提示
|
||||
capacity: {
|
||||
windowCost: {
|
||||
@ -2444,7 +2448,9 @@ export default {
|
||||
},
|
||||
tlsFingerprint: {
|
||||
label: 'TLS 指纹模拟',
|
||||
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹'
|
||||
hint: '模拟 Node.js/Claude Code 客户端的 TLS 指纹',
|
||||
defaultProfile: '内置默认',
|
||||
randomProfile: '随机'
|
||||
},
|
||||
sessionIdMasking: {
|
||||
label: '会话 ID 伪装',
|
||||
@ -4732,6 +4738,62 @@ export default {
|
||||
failedToSave: '保存规则失败',
|
||||
failedToDelete: '删除规则失败',
|
||||
failedToToggle: '切换状态失败'
|
||||
},
|
||||
|
||||
// TLS 指纹模板
|
||||
tlsFingerprintProfiles: {
|
||||
title: 'TLS 指纹模板',
|
||||
description: '管理 TLS 指纹模板,用于模拟特定客户端的 TLS 握手特征',
|
||||
createProfile: '创建模板',
|
||||
editProfile: '编辑模板',
|
||||
deleteProfile: '删除模板',
|
||||
noProfiles: '暂无模板',
|
||||
createFirstProfile: '创建你的第一个 TLS 指纹模板',
|
||||
|
||||
columns: {
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
grease: 'GREASE',
|
||||
alpn: 'ALPN',
|
||||
actions: '操作'
|
||||
},
|
||||
|
||||
form: {
|
||||
pasteYaml: '粘贴 YAML 配置',
|
||||
pasteYamlPlaceholder: '将 TLS 指纹采集器复制的 YAML 粘贴到这里...',
|
||||
pasteYamlHint: '粘贴从 TLS 指纹采集器复制的 YAML 配置,自动填充所有字段。',
|
||||
openCollector: '打开采集器',
|
||||
parseYaml: '解析 YAML',
|
||||
yamlParsed: 'YAML 解析成功,字段已自动填充',
|
||||
yamlParseFailed: 'YAML 解析失败:未找到 name 字段',
|
||||
name: '模板名称',
|
||||
namePlaceholder: '例如 macOS Node.js v24',
|
||||
description: '描述',
|
||||
descriptionPlaceholder: '可选的模板描述',
|
||||
enableGrease: '启用 GREASE',
|
||||
enableGreaseHint: '在 TLS ClientHello 扩展中插入 GREASE 值',
|
||||
cipherSuites: '密码套件',
|
||||
cipherSuitesHint: '逗号分隔的十六进制值,例如 0x1301, 0x1302, 0xc02c',
|
||||
curves: '椭圆曲线',
|
||||
curvesHint: '逗号分隔的曲线 ID',
|
||||
pointFormats: '点格式',
|
||||
signatureAlgorithms: '签名算法',
|
||||
alpnProtocols: 'ALPN 协议',
|
||||
alpnProtocolsHint: '逗号分隔,例如 h2, http/1.1',
|
||||
supportedVersions: '支持的 TLS 版本',
|
||||
keyShareGroups: '密钥共享组',
|
||||
pskModes: 'PSK 模式',
|
||||
extensions: '扩展'
|
||||
},
|
||||
|
||||
deleteConfirm: '删除模板',
|
||||
deleteConfirmMessage: '确定要删除模板 "{name}" 吗?使用此模板的账号将回退到内置默认值。',
|
||||
createSuccess: '模板创建成功',
|
||||
updateSuccess: '模板更新成功',
|
||||
deleteSuccess: '模板删除成功',
|
||||
loadFailed: '加载模板失败',
|
||||
saveFailed: '保存模板失败',
|
||||
deleteFailed: '删除模板失败'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -724,6 +724,7 @@ export interface Account {
|
||||
|
||||
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
enable_tls_fingerprint?: boolean | null
|
||||
tls_fingerprint_profile_id?: number | null
|
||||
|
||||
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
|
||||
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
|
||||
|
||||
@ -73,6 +73,16 @@
|
||||
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- TLS Fingerprint Profiles -->
|
||||
<button
|
||||
@click="showTLSFingerprintProfiles = true"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.tlsFingerprintProfiles.title')"
|
||||
>
|
||||
<Icon name="lock" size="md" class="mr-1.5" />
|
||||
<span class="hidden md:inline">{{ t('admin.tlsFingerprintProfiles.title') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Column Settings Dropdown -->
|
||||
<div class="relative" ref="columnDropdownRef">
|
||||
<button
|
||||
@ -289,6 +299,7 @@
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
<ErrorPassthroughRulesModal :show="showErrorPassthrough" @close="showErrorPassthrough = false" />
|
||||
<TLSFingerprintProfilesModal :show="showTLSFingerprintProfiles" @close="showTLSFingerprintProfiles = false" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@ -326,6 +337,7 @@ import AccountCapacityCell from '@/components/account/AccountCapacityCell.vue'
|
||||
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ErrorPassthroughRulesModal from '@/components/admin/ErrorPassthroughRulesModal.vue'
|
||||
import TLSFingerprintProfilesModal from '@/components/admin/TLSFingerprintProfilesModal.vue'
|
||||
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/format'
|
||||
import type { Account, AccountPlatform, AccountType, Proxy as AccountProxy, AdminGroup, WindowStats, ClaudeModel } from '@/types'
|
||||
@ -366,6 +378,7 @@ const showReAuth = ref(false)
|
||||
const showTest = ref(false)
|
||||
const showStats = ref(false)
|
||||
const showErrorPassthrough = ref(false)
|
||||
const showTLSFingerprintProfiles = ref(false)
|
||||
const edAcc = ref<Account | null>(null)
|
||||
const tempUnschedAcc = ref<Account | null>(null)
|
||||
const deletingAcc = ref<Account | null>(null)
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
#!/bin/bash
|
||||
# sub2api 指纹防泄露 iptables 规则
|
||||
# 确保只有 Node.js TLS Proxy 能直连上游 HTTPS,
|
||||
# sub2api Go 进程即使有 bug 也无法绕过。
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash setup-firewall.sh [apply|remove|status]
|
||||
#
|
||||
# 前置条件:
|
||||
# - Node.js proxy 以专用用户 "nodeproxy" 运行
|
||||
# - 创建用户: sudo useradd -r -s /usr/sbin/nologin nodeproxy
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
NODE_PROXY_USER="${MG_NODE_PROXY_USER:-nodeproxy}"
|
||||
CHAIN_NAME="MG_FINGERPRINT"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] $*"; }
|
||||
|
||||
apply_rules() {
|
||||
log "Applying fingerprint firewall rules..."
|
||||
|
||||
# 验证用户存在
|
||||
if ! id "$NODE_PROXY_USER" &>/dev/null; then
|
||||
log "ERROR: User '$NODE_PROXY_USER' does not exist."
|
||||
log "Create it: sudo useradd -r -s /usr/sbin/nologin $NODE_PROXY_USER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建自定义链(幂等)
|
||||
iptables -N "$CHAIN_NAME" 2>/dev/null || iptables -F "$CHAIN_NAME"
|
||||
|
||||
# === Rule 1: QUIC 阻断 — 丢弃所有出站 UDP 443/4433 ===
|
||||
iptables -A "$CHAIN_NAME" -p udp --dport 443 -j DROP \
|
||||
-m comment --comment "MG: block QUIC/HTTP3 UDP 443"
|
||||
iptables -A "$CHAIN_NAME" -p udp --dport 4433 -j DROP \
|
||||
-m comment --comment "MG: block QUIC alt UDP 4433"
|
||||
|
||||
# === Rule 2: 允许 Node.js proxy 出站 TCP 443 ===
|
||||
iptables -A "$CHAIN_NAME" -p tcp --dport 443 \
|
||||
-m owner --uid-owner "$NODE_PROXY_USER" -j ACCEPT \
|
||||
-m comment --comment "MG: allow nodeproxy TCP 443"
|
||||
|
||||
# === Rule 3: 阻止其他进程直连 TCP 443 ===
|
||||
iptables -A "$CHAIN_NAME" -p tcp --dport 443 -j REJECT --reject-with tcp-reset \
|
||||
-m comment --comment "MG: block non-proxy TCP 443"
|
||||
|
||||
# 将自定义链挂载到 OUTPUT(幂等)
|
||||
if ! iptables -C OUTPUT -j "$CHAIN_NAME" 2>/dev/null; then
|
||||
iptables -A OUTPUT -j "$CHAIN_NAME"
|
||||
fi
|
||||
|
||||
# === Rule 4: IPv6 全面阻断 ===
|
||||
ip6tables -N "${CHAIN_NAME}_V6" 2>/dev/null || ip6tables -F "${CHAIN_NAME}_V6"
|
||||
# 允许回环
|
||||
ip6tables -A "${CHAIN_NAME}_V6" -o lo -j ACCEPT \
|
||||
-m comment --comment "MG: allow IPv6 loopback"
|
||||
# 阻断其他 IPv6 出站
|
||||
ip6tables -A "${CHAIN_NAME}_V6" -j DROP \
|
||||
-m comment --comment "MG: block all IPv6 outbound"
|
||||
|
||||
if ! ip6tables -C OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null; then
|
||||
ip6tables -A OUTPUT -j "${CHAIN_NAME}_V6"
|
||||
fi
|
||||
|
||||
log "Firewall rules applied successfully."
|
||||
log " - UDP 443/4433: BLOCKED (QUIC)"
|
||||
log " - TCP 443: ONLY '$NODE_PROXY_USER' allowed"
|
||||
log " - IPv6 outbound: BLOCKED"
|
||||
}
|
||||
|
||||
remove_rules() {
|
||||
log "Removing fingerprint firewall rules..."
|
||||
|
||||
# 从 OUTPUT 移除引用
|
||||
iptables -D OUTPUT -j "$CHAIN_NAME" 2>/dev/null || true
|
||||
ip6tables -D OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
|
||||
# 清空并删除自定义链
|
||||
iptables -F "$CHAIN_NAME" 2>/dev/null || true
|
||||
iptables -X "$CHAIN_NAME" 2>/dev/null || true
|
||||
ip6tables -F "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
ip6tables -X "${CHAIN_NAME}_V6" 2>/dev/null || true
|
||||
|
||||
log "Firewall rules removed."
|
||||
}
|
||||
|
||||
show_status() {
|
||||
log "=== IPv4 MG_FINGERPRINT chain ==="
|
||||
iptables -L "$CHAIN_NAME" -n -v 2>/dev/null || echo "(not found)"
|
||||
echo
|
||||
log "=== IPv6 MG_FINGERPRINT_V6 chain ==="
|
||||
ip6tables -L "${CHAIN_NAME}_V6" -n -v 2>/dev/null || echo "(not found)"
|
||||
}
|
||||
|
||||
case "${1:-apply}" in
|
||||
apply) apply_rules ;;
|
||||
remove) remove_rules ;;
|
||||
status) show_status ;;
|
||||
*)
|
||||
echo "Usage: $0 [apply|remove|status]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# save-patches.sh — 将 Antigravity 自定义改动导出为 patch 文件
|
||||
# 用法: ./tools/scripts/save-patches.sh [输出目录]
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
OUTPUT_DIR="${1:-$REPO_ROOT/tools/patches}"
|
||||
UPSTREAM="origin/main"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# 检查是否有新的 upstream commits
|
||||
DIVERGED=$(git log --oneline "$UPSTREAM"..HEAD 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$DIVERGED" -eq 0 ]; then
|
||||
echo "[save-patches] 没有领先 upstream 的 commits,无需保存。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# 导出 patches
|
||||
git format-patch "$UPSTREAM"..HEAD --output-directory "$OUTPUT_DIR" --no-stat
|
||||
|
||||
COUNT=$(ls "$OUTPUT_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo "[save-patches] ✅ 已导出 $COUNT 个 patch 到 $OUTPUT_DIR/"
|
||||
echo ""
|
||||
echo "恢复方法(在全新 upstream checkout 上):"
|
||||
echo " git am $OUTPUT_DIR/*.patch"
|
||||
echo " # 或逐一应用:"
|
||||
echo " for p in $OUTPUT_DIR/*.patch; do git am \"\$p\" || git am --skip; done"
|
||||
@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# sync-upstream.sh — 从 upstream (origin/main) 同步更新,保留自定义改动
|
||||
# 用法: ./tools/scripts/sync-upstream.sh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
UPSTREAM="origin/main"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "========================================"
|
||||
echo " Antigravity Fork — Upstream Sync Tool"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Step 1: 检查工作区
|
||||
if ! git diff --quiet || ! git diff --staged --quiet; then
|
||||
echo "❌ 工作区有未提交的改动,请先 git stash 或 git commit"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Fetch
|
||||
echo "[1/4] Fetching upstream..."
|
||||
git fetch origin
|
||||
|
||||
# Step 3: 检查是否有新 commits
|
||||
NEW=$(git log --oneline HEAD.."$UPSTREAM" 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$NEW" -eq 0 ]; then
|
||||
echo "✅ 已是最新,无需同步。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "上游有 $NEW 个新 commits:"
|
||||
git log --oneline HEAD.."$UPSTREAM"
|
||||
echo ""
|
||||
|
||||
# Step 4: 备份当前 patches
|
||||
PATCH_DIR="/tmp/antigravity-patches-$(date +%Y%m%d-%H%M%S)"
|
||||
echo "[2/4] 备份自定义 patches 到 $PATCH_DIR ..."
|
||||
mkdir -p "$PATCH_DIR"
|
||||
git format-patch "$UPSTREAM"..HEAD -o "$PATCH_DIR/" --no-stat
|
||||
BACKED=$(ls "$PATCH_DIR"/*.patch 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " 已备份 $BACKED 个 patch"
|
||||
|
||||
# Step 5: Rebase
|
||||
echo ""
|
||||
echo "[3/4] 执行 rebase (git rebase $UPSTREAM)..."
|
||||
echo " 如果出现冲突,请参考 .agents/workflows/sync-upstream.md 中的冲突解决指南"
|
||||
echo ""
|
||||
if ! git rebase "$UPSTREAM"; then
|
||||
echo ""
|
||||
echo "❌ Rebase 出现冲突!"
|
||||
echo ""
|
||||
echo "请按以下步骤处理:"
|
||||
echo " 1. 查看冲突文件: git diff --name-only --diff-filter=U"
|
||||
echo " 2. 解决冲突(参考 .agents/workflows/sync-upstream.md)"
|
||||
echo " 3. git add <解决的文件>"
|
||||
echo " 4. git rebase --continue"
|
||||
echo ""
|
||||
echo "备份的 patches 在: $PATCH_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 6: 编译验证
|
||||
echo "[4/4] 编译验证..."
|
||||
if ! (cd "$REPO_ROOT/backend" && go build ./... 2>&1); then
|
||||
echo ""
|
||||
echo "❌ 编译失败!rebase 后有破坏性改动需要修复。"
|
||||
echo "备份的 patches 在: $PATCH_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 同步完成!"
|
||||
echo ""
|
||||
echo "自定义改动(我方 commits)已成功移植到最新 upstream 上。"
|
||||
echo "请运行以下命令推送:"
|
||||
echo " git push origin main --force-with-lease"
|
||||
echo ""
|
||||
echo "备份路径(可删除): $PATCH_DIR"
|
||||
@ -1,24 +0,0 @@
|
||||
FROM node:24.13.0-slim
|
||||
|
||||
LABEL maintainer="Wei-Shaw <github.com/Wei-Shaw>"
|
||||
LABEL description="Node.js TLS Forward Proxy - native JA3/JA4 fingerprint matching"
|
||||
LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY proxy.js package.json ./
|
||||
|
||||
# 零依赖,不需要 npm install
|
||||
|
||||
ENV PROXY_PORT=3456
|
||||
ENV PROXY_HOST=0.0.0.0
|
||||
ENV UPSTREAM_HOST=api.anthropic.com
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
# 健康检查:用 Node.js 内置 http 模块,不依赖 curl
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=5s \
|
||||
CMD node -e "const http=require('http');const r=http.get('http://127.0.0.1:'+(process.env.PROXY_PORT||3456)+'/__health',s=>{process.exit(s.statusCode===200?0:1)});r.on('error',()=>process.exit(1));r.setTimeout(3000,()=>{r.destroy();process.exit(1)})"
|
||||
|
||||
USER node
|
||||
CMD ["node", "proxy.js"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user