#!/bin/bash # Compatible with macOS /bin/bash 3.2 (no Bash 4+ features) # pin-dns # # NAME # pin-dns - curl wrapper to override DNS resolution for a hostname (CDN bypass / origin pinning) # # SYNOPSIS # pin-dns [PIN_DNS_OPTS...] [CURL_OPTS...] URL [TARGET] [CURL_OPTS...] [PIN_DNS_OPTS...] # pin-dns [PIN_DNS_OPTS...] HOSTNAME [TARGET] [PATH_OR_URL] [CURL_OPTS...] # pin-dns [PIN_DNS_OPTS...] --host HOSTNAME [--target TARGET] [--path PATH_OR_URL] [--resolver DNS_SERVER_IP] [--scheme SCHEME] [--port PORT] # [--dry-run] [--no-silent] [--curlrc] [--help] [--] [CURL_OPTS...] # # DESCRIPTION # pin-dns makes it easy to send a request to SCHEME://HOSTNAME/PATH while forcing curl to connect # to a chosen IP address (usually resolved from a CDN CNAME/edge hostname), without modifying # system DNS or /etc/hosts # # Under the hood it uses: # curl --resolve "HOSTNAME:PORT:IP" "SCHEME://HOSTNAME/PATH" # # TARGET may be either: # - A hostname (e.g. commcloud.prod-xxxx-site-com.cc-ecdn.net) which pin-dns will resolve to an IP via dig; or # - An IP literal (IPv4 or IPv6), used directly # # DROP-IN CURL USAGE # The simplest workflow is: take a curl command, replace "curl" with "pin-dns", and add a target # A bare hostname or IP anywhere in the args is recognized as the target # You can also use --target explicitly. pin-dns options may appear anywhere before -- # # curl -s https://example.com/path -H "Test: Nice" # pin-dns https://example.com/path -H "Test: Nice" cdn.edge.com # pin-dns -s https://example.com/path -H "Test: Nice" --target cdn.edge.com # # POSITIONAL PARAMETERS # HOSTNAME # The hostname you want in the URL and Host/SNI (e.g. ecom-dev.somesite.com) # # TARGET # Optional. Hostname or IP to map HOSTNAME to (for curl --resolve) # If a hostname is provided, pin-dns resolves it using dig and uses the last returned IP # # PATH_OR_URL # Optional. Request path (and optional querystring), or a full URL, or HOST/PATH # Examples: # /on/demandware.store/Sites-RefArch-Site # some/path?q=stuff&q2=morestuff # ?q=stuff # https://ecom-dev.somesite.com/on/demandware.store/Sites-RefArch-Site?q=1 # ecom-dev.somesite.com/on/demandware.store/Sites-RefArch-Site?q=1 # # If PATH_OR_URL includes a scheme+host (http/https URL), or looks like HOST/PATH (e.g. contains a dot or "localhost"), # pin-dns strips the host portion and uses only the path/query # # If the extracted URL host differs from both HOSTNAME and TARGET (when TARGET is a hostname), pin-dns warns but proceeds # # If omitted, "/" is used. If a path is provided without a leading "/", pin-dns adds it # # OPTIONS # --host HOSTNAME # Explicit HOSTNAME. Overrides any host inferred from a URL argument # # --target TARGET # Hostname or IP to map HOSTNAME to (for curl --resolve). May appear anywhere before -- # Also supports: --target=VALUE # Required when using curl's --url (positional target detection cannot see inside --url) # # --path PATH_OR_URL # Path (or full URL). Also supports: --path=VALUE # # --resolver DNS_SERVER_IP # If provided, passes @DNS_SERVER_IP to dig to select a specific DNS server # # --scheme SCHEME # Default: https. URL arguments may be used to infer scheme unless --scheme is set # # --port PORT # Default: 443. URL arguments may be used to infer port unless --port is set # # --platform mac|win|linux # UA shape and Sec-CH-UA-Platform. Default: mac # # --fetch-mode navigate|cors|no-cors # Request profile: Accept + Sec-Fetch-* set. Default: navigate # navigate = page load; cors/no-cors = in-page fetch/XHR # # --client curl|impersonate|auto # auto (default) uses curl-impersonate if on PATH, else stock curl # impersonate requires it (exit 3 if absent); curl forces stock curl # curl-impersonate is accepted as an alias for impersonate # # --chrome-major N # Pin the Chrome major version; skips all version detection # # --no-impersonate # Send a bare request (DNS pin only); emit no impersonation headers # Master off-switch: overrides --client (impersonate + no-impersonate # is still a bare request, not an error) # # --dry-run # Do not execute curl. Print the resolved mapping and the curl command to stderr # # --no-silent # Do not add curl -sS by default # # -q, --quiet # Suppress pin-dns informational and warning messages (errors are still shown) # Note: curl also has -q/--disable; to pass that to curl, use --curlrc and place curl -q after -- # pin-dns --curlrc ... -- -q # # --curlrc # Allow curl to read ~/.curlrc (and other curlrc locations). By default pin-dns disables curlrc by injecting # curl's -q as the first curl argument to keep behavior deterministic across machines # # -h, --help # Show this help text # # ARGUMENT HANDLING AND -- # pin-dns does not parse curl options. It collects them and forwards them to curl unchanged # # - pin-dns options may appear anywhere before -- (including at the end for drop-in curl usage) # - Everything after -- is passed to curl verbatim, and pin-dns options after -- are not parsed # # WARNINGS THAT PREVENT MISUSE # 1) "pin-dns-looking token as curl operand" # If a token like --target appears immediately after a curl option that consumes the next token (e.g. -H), # pin-dns treats it as curl data (because curl is consuming it) and warns, since pinning would not occur # Fix: move the pin-dns option, or use --target=VALUE, or use -- # # 2) "useless -s / -S" # pin-dns adds curl -sS by default. Passing curl -s/-S/--silent/--show-error usually changes nothing # Fix: use --no-silent if you want pin-dns to stop adding -sS # # IMPERSONATION # By default pin-dns sends a full, internally consistent Chrome request: # User-Agent, client hints (Sec-CH-UA*), Sec-Fetch-* and correctly ordered # Accept / Accept-Encoding / Accept-Language. The Chrome major version is # resolved from the local Chrome install, then Google's Version History API # (cached), then a pinned fallback. Override with --chrome-major # # --platform mac|win|linux shapes the UA and Sec-CH-UA-Platform (default mac) # --fetch-mode navigate|cors|no-cors selects the Accept + Sec-Fetch-* profile # navigate (default) models a top-level page load; cors/no-cors model an # in-page fetch/XHR (Accept: */*, no Sec-Fetch-User/Upgrade-Insecure) # --no-impersonate sends a bare request (DNS pin only), no added headers. # It is the master off-switch: --client impersonate combined with # --no-impersonate still yields a bare request, not a client error # # Any header you pass (-H/-A) wins: pin-dns will not duplicate it. If you set # a non-Chrome User-Agent, the Chrome-only client hints are suppressed so the # request does not contradict itself # # TLS/JA3 note: stock curl cannot match Chrome's TLS or HTTP/2 fingerprint, # only its headers. If a curl-impersonate binary is on PATH, pin-dns routes # through it (--client auto) to also match the TLS/HTTP-2 signature. Force # with --client impersonate (or the curl-impersonate alias), or disable with # --client curl. Install the real tool from github.com/lexiforest/curl-impersonate # (prebuilt binaries) -- do NOT install the npm 'curl-impersonate' package, # which is an unrelated stub # # curl-impersonate only accepts a fixed roster of profiles (chrome131, # chrome133a, ...) that lags live Chrome and skips versions; a raw # --impersonate for a major it lacks fails hard. pin-dns discovers the roster # from the curl_chrome* wrappers installed beside the binary and maps the # detected major to the nearest installed target at or below it (warning when # it differs). A binary-only install with no wrappers passes the major through # # ENVIRONMENT # PIN_DNS_CHROME_APP # Optional override for the Chrome app path used to determine version # Default: /Applications/Google Chrome.app # # PIN_DNS_CHROME_MAJOR # Pin the Chrome major (same as --chrome-major; flag wins) # # PIN_DNS_CLIENT # Default client when --client is not given: curl|impersonate|auto # (same as --client; flag wins). auto is the built-in default, so # PIN_DNS_CLIENT=auto is a no-op. Set curl to keep stock curl (and the # live version) as the default, impersonate to require curl-impersonate # # PIN_DNS_UA_OFFLINE # Disable the network version fallback # # PIN_DNS_UA_CACHE_TTL # Version-cache freshness in seconds (default 86400) # # PIN_DNS_VERSION_API_URL # Override the Chrome Version History API base URL (advanced/testing) # # XDG_CACHE_HOME # Cache root; the version cache lives under $XDG_CACHE_HOME/pin-dns # # EXIT STATUS # 0 Success # 2 Usage / argument error # 3 Dependency error (e.g. curl missing; dig missing when TARGET is a hostname; or --client impersonate set but curl-impersonate not on PATH) # 4 Resolution error (dig returned no A/AAAA result) # _pin_dns() ( local SCRIPT_PATH="${BASH_SOURCE[0]}" local SCRIPT_NAME; SCRIPT_NAME="$(basename "$SCRIPT_PATH")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="pin-dns" ;; esac # ANSI palette for diagnostics, computed once (no per-call _color subshell): # empty unless stderr is a TTY (or CLICOLOR_FORCE is set) and NO_COLOR is # unset, so pipes/redirects and the test harness stay plain by default while # CLICOLOR_FORCE=1 lets a capture keep color. NO_COLOR wins when both are set # https://no-color.org/ local C_ERR="" # ERR severity: red local C_WRN="" # WRN severity: yellow local C_INF="" # INF severity: dim local C_RST="" if { [ -t 2 ] || [ -n "${CLICOLOR_FORCE:-}" ]; } && [ -z "${NO_COLOR:-}" ]; then C_ERR=$'\033[31m' C_WRN=$'\033[33m' C_INF=$'\033[2m' C_RST=$'\033[0m' fi _error() { printf '%s[ERR][%s] %s%s\n' "$C_ERR" "$SCRIPT_NAME" "$*" "$C_RST" >&2; } _warn() { [ -n "$quiet" ] && return 0; printf '%s[WRN][%s] %s%s\n' "$C_WRN" "$SCRIPT_NAME" "$*" "$C_RST" >&2; } _info() { [ -n "$quiet" ] && return 0; printf '%s[INF][%s] %s%s\n' "$C_INF" "$SCRIPT_NAME" "$*" "$C_RST" >&2; } # Temp-file registry cleaned by a single EXIT trap. Set early so any temp # registered later (the capture/decode body + header files) is removed when # the subshell returns, on every exit path local _tmpfiles="" # _cleanup_tmp reads _tmpfiles at exit time (deliberately NOT captured at # trap-set time), so temps registered later via _tmpfiles="$_tmpfiles $new" # are still cleaned. Word-splitting of $_tmpfiles in rm is intentional. # SC2329: invoked indirectly via the trap below, not by name # shellcheck disable=SC2064,SC2086,SC2329 _cleanup_tmp() { [ -n "$_tmpfiles" ] && rm -f $_tmpfiles; } trap _cleanup_tmp EXIT _print_help() { # Keep this closely aligned with the header comment cat >&2 </dev/null 2>&1; } # Resolve the client binary. Honors the `client` global: # curl -> stock curl (prints nothing, returns 1) # impersonate -> require curl-impersonate (returns 1 if absent) # auto -> use curl-impersonate if present, else stock curl # Prints the impersonate binary name on success; empty + return 1 otherwise _detect_impersonate_engine() { case "$client" in curl) return 1 ;; impersonate|auto) if _cmd_exists curl-impersonate; then printf '%s\n' "curl-impersonate" return 0 fi return 1 ;; esac return 1 } # Map a detected Chrome major onto an installed curl-impersonate target. # curl-impersonate's --impersonate accepts only a discrete, curated roster of # profiles (chrome116, chrome131, chrome133a, ...) that lags live Chrome and # skips versions; a raw --impersonate chrome fails hard ("Unknown # impersonation target", exit 43). The roster is discovered from the curl_chrome* # wrapper scripts shipped alongside the binary ($1 = its dir), so it reflects # what THIS install supports rather than a hardcoded list that would drift. # $1 = directory holding curl-impersonate (and its curl_chrome* wrappers) # $2 = requested Chrome major (integer) # Prints the chosen target NAME (e.g. chrome131, chrome133a) and returns 0 when # a roster is found; prints nothing and returns 1 when none is discoverable # (binary-only install), leaving the caller to pass the raw major through. # Selection is floor-map: highest target whose numeric major is <= requested; # if the request is below the roster floor, the lowest target; ties keep the # first seen. Comparison is on the integer, but the emitted name keeps any # suffix (chrome133a is a valid target; chrome133 is NOT) _map_impersonate_target() { local dir="$1" local want="$2" local best_name="" local best_int="" local min_name="" local min_int="" local f="" local base="" local name="" local int="" for f in "$dir"/curl_chrome*; do [ -e "$f" ] || continue base="${f##*/}" case "$base" in *_android|*_ios) continue ;; esac name="${base#curl_}" # chrome133a (full emittable target) int="${name#chrome}" # 133a int="${int%%[!0-9]*}" # 133 case "$int" in ''|*[!0-9]*) continue ;; esac if [ -z "$min_int" ] || [ "$int" -lt "$min_int" ]; then min_int="$int" min_name="$name" fi if [ "$int" -le "$want" ] && { [ -z "$best_int" ] || [ "$int" -gt "$best_int" ]; }; then best_int="$int" best_name="$name" fi done [ -z "$min_name" ] && return 1 # no roster discovered if [ -n "$best_name" ]; then printf '%s\n' "$best_name" else printf '%s\n' "$min_name" # request below floor -> lowest target fi return 0 } _is_ipv4() { local ip="$1" local a="" local b="" local c="" local d="" case "$ip" in *[!0-9.]*|'') return 1 ;; esac local IFS="." read -r a b c d </dev/null && [ "$a" -le 255 ] || return 1 [ "$b" -ge 0 ] 2>/dev/null && [ "$b" -le 255 ] || return 1 [ "$c" -ge 0 ] 2>/dev/null && [ "$c" -le 255 ] || return 1 [ "$d" -ge 0 ] 2>/dev/null && [ "$d" -le 255 ] || return 1 return 0 } _is_ipv6() { local ip="$1" case "$ip" in \[*\]) ip="${ip#[}"; ip="${ip%]}";; esac case "$ip" in *:*) ;; *) return 1 ;; esac case "$ip" in *[!0-9a-fA-F:]*|'') return 1 ;; esac return 0 } _is_ip() { _is_ipv4 "$1" && return 0 _is_ipv6 "$1" && return 0 return 1 } _looks_like_host() { local h="$1" local inner="" if [ -z "$h" ]; then return 1 fi case "$h" in \[*\]) inner="${h#[}" inner="${inner%]}" [ -n "$inner" ] || return 1 _is_ipv6 "$inner" && return 0 return 1 ;; esac case "$h" in *"@"*) h="${h##*@}" ;; esac case "$h" in *:*) case "$h" in *:*:*) ;; *) local left="${h%%:*}" local right="${h#*:}" case "$right" in *[!0-9]*|'') ;; *) h="$left" ;; esac ;; esac ;; esac [ "$h" = "localhost" ] && return 0 case "$h" in *.*) return 0 ;; esac _is_ipv4 "$h" && return 0 return 1 } _host_for_compare() { local h="$1" local inner="" if [ -z "$h" ]; then return 0 fi case "$h" in \[*\]) inner="${h#[}" inner="${inner%]}" h="$inner" ;; esac case "$h" in *:*) case "$h" in *:*:*) ;; *) h="${h%%:*}" ;; esac ;; esac LC_ALL=C printf '%s' "$h" | tr '[:upper:]' '[:lower:]' } _is_http_url() { case "$1" in http://*|https://*) return 0 ;; esac return 1 } _parse_http_url() { # Outputs: scheme host port path local in="$1" local __out_scheme="$2" local __out_host="$3" local __out_port="$4" local __out_path="$5" local scheme="" local rest="" local hostport="" local path="" local host="" local port="" case "$in" in http://*) scheme="http" rest="${in#http://}" ;; https://*) scheme="https" rest="${in#https://}" ;; *) scheme="" rest="$in" ;; esac hostport="$rest" path="" case "$rest" in */*) hostport="${rest%%/*}" path="/${rest#*/}" ;; *\?*) hostport="${rest%%\?*}" path="/?${rest#*\?}" ;; *\#*) hostport="${rest%%\#*}" path="/#${rest#*\#}" ;; *) hostport="$rest" path="/" ;; esac case "$hostport" in *"@"*) hostport="${hostport##*@}" ;; esac case "$hostport" in \[*\]*) host="${hostport#\[}" host="${host%%\]*}" port="" case "$hostport" in \[*\]:*) port="${hostport##*:}" ;; esac ;; *) host="$hostport" port="" case "$hostport" in *:*) local left="${hostport%%:*}" local right="${hostport#*:}" case "$right" in *[!0-9]*|'') host="$hostport" port="" ;; *) host="$left" port="$right" ;; esac ;; esac ;; esac if [ -z "$port" ]; then if [ "$scheme" = "http" ]; then port="80" elif [ "$scheme" = "https" ]; then port="443" else port="" fi fi printf -v "$__out_scheme" '%s' "$scheme" printf -v "$__out_host" '%s' "$host" printf -v "$__out_port" '%s' "$port" printf -v "$__out_path" '%s' "$path" } _normalize_path_or_url() { # Accepts: # - /path, path, ?q=1 # - http(s)://host/path # - host/path (host-like prefix) # Outputs: # out_host (maybe empty), out_path (always begins with /) local in="$1" local __out_host_var="$2" local __out_path_var="$3" local out_host="" local out_path="" local tmp="" local pre="" local rest="" local scheme_removed="" local hostpart="" local pathpart="" if [ -z "$in" ]; then in="/" fi case "$in" in http://*|https://*) tmp="$in" scheme_removed="${tmp#http://}" if [ "$scheme_removed" = "$tmp" ]; then scheme_removed="${tmp#https://}" fi hostpart="${scheme_removed%%/*}" pathpart="" if [ "$hostpart" = "$scheme_removed" ]; then case "$scheme_removed" in *\?*) hostpart="${scheme_removed%%\?*}" pathpart="/?${scheme_removed#*\?}" ;; *\#*) hostpart="${scheme_removed%%\#*}" pathpart="/#${scheme_removed#*\#}" ;; *) pathpart="/" ;; esac else pathpart="/${scheme_removed#*/}" fi out_host="$hostpart" out_path="$pathpart" ;; /*|\?*|\#*) out_host="" out_path="$in" ;; *) case "$in" in */*) pre="${in%%/*}" rest="${in#*/}" if _looks_like_host "$pre"; then out_host="$pre" out_path="/$rest" else out_host="" out_path="/$in" fi ;; *) out_host="" out_path="/$in" ;; esac ;; esac case "$out_host" in *"@"*) out_host="${out_host##*@}" ;; esac if [ -z "$out_path" ]; then out_path="/" fi case "$out_path" in /*) ;; \?*|\#*) out_path="/$out_path" ;; *) out_path="/$out_path" ;; esac printf -v "$__out_host_var" '%s' "$out_host" printf -v "$__out_path_var" '%s' "$out_path" } # MAINTENANCE NOTE: # This list is used to protect curl operands from being misinterpreted as pin-dns options # If you find a curl option missing here, pin-dns may wrongly interpret the next token as a pin-dns option _curl_opt_consumes_next() { local a="$1" case "$a" in # meta:not-options -- recognizes curl's flags, not pin-dns's own -H|--header|-A|--user-agent|-d|--data|--data-raw|--data-binary|--data-urlencode|-F|--form|-e|--referer|-u|--user|-x|--proxy|-b|--cookie|-c|--cookie-jar|-o|--output|-D|--dump-header|-T|--upload-file|-X|--request|--url|--resolve|--connect-to|-K|--config|--cacert|--capath|--cert|--cert-type|--key|--key-type|--pass|--ciphers|--interface|--dns-servers|--range|-r|--max-time|-m|--connect-timeout|--retry|--retry-delay|--retry-max-time|--limit-rate|--local-port|--unix-socket|--stderr|--proxy-user|--proxy-password|--mail-from|--mail-rcpt|--request-target|--engine) return 0 ;; --header=*|--user-agent=*|--data=*|--data-raw=*|--data-binary=*|--data-urlencode=*|--form=*|--referer=*|--user=*|--proxy=*|--cookie=*|--cookie-jar=*|--output=*|--dump-header=*|--upload-file=*|--request=*|--url=*|--resolve=*|--connect-to=*|--config=*|--cacert=*|--capath=*|--cert=*|--cert-type=*|--key=*|--key-type=*|--pass=*|--ciphers=*|--interface=*|--dns-servers=*|--range=*|--max-time=*|--connect-timeout=*|--retry=*|--retry-delay=*|--retry-max-time=*|--limit-rate=*|--local-port=*|--unix-socket=*|--stderr=*|--proxy-user=*|--proxy-password=*|--mail-from=*|--mail-rcpt=*|--request-target=*|--engine=*) return 1 ;; -H*|-A*|-d*|-F*|-e*|-u*|-x*|-b*|-c*|-o*|-D*|-T*|-X*|-K*|-m*|-r*) return 1 ;; esac return 1 } # MAINTENANCE NOTE: # This is used ONLY for the "pin-dns-looking token as curl operand" warning # If you add a new pin-dns option, update BOTH: # - the pin-dns option parser (search for "PIN-DNS OPTION PARSER") # - this helper (so warnings stay accurate) _looks_like_pin_dns_option_token() { local a="$1" case "$a" in -h|--help|-q|--quiet|--curlrc|--dry-run|--no-silent|--no-impersonate) return 0 ;; --host|--host=*|--target|--target=*|--path|--path=*|--resolver|--resolver=*|--scheme|--scheme=*|--port|--port=*|--platform|--platform=*|--fetch-mode|--fetch-mode=*|--client|--client=*|--chrome-major|--chrome-major=*) return 0 ;; esac return 1 } _short_opts_contains_silent() { # Detects curl -s/-S inside combined short options (e.g. -sS, -skS) local a="$1" case "$a" in --*) return 1 ;; -*) ;; *) return 1 ;; esac case "$a" in -*s*|-*S*) return 0 ;; esac return 1 } _curl_args_has_silent_controls() { # Detect curl flags that try to control silence; used for the "useless -s / -S" warning local a="" for a in "$@"; do case "$a" in # meta:not-options -- recognizes curl's flags, not pin-dns's own --silent|--show-error) return 0 ;; esac if _short_opts_contains_silent "$a"; then return 0 fi done return 1 } # Pinned fallback major -- used when local read and network fetch both fail # Update periodically; keep roughly in step with chrome-ua's FALLBACK_MAJOR # Last set: 2026-04, Chrome stable 148 local PIN_DNS_FALLBACK_MAJOR="148" local PIN_DNS_CACHE_VERSION="1" local MAX_STALE_SECS="7776000" # 90 days -- past this a cached major is not credibly "current" # Frozen reduced-UA shape per platform (Chrome reduced-UA policy) # https://developer.chrome.com/docs/privacy-security/user-agent-client-hints#user-agent-reduction _ua_for_platform() { local plat="$1" local major="$2" case "$plat" in mac) printf 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36\n' "$major" ;; win) printf 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36\n' "$major" ;; linux) printf 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s.0.0.0 Safari/537.36\n' "$major" ;; esac } # Fetch latest stable Chrome major from Google's Version History API # Prints major to stdout; returns non-zero on any failure (missing tool, # network error, empty result). Base URL overridable for tests _fetch_latest_major() { local plat="$1" _cmd_exists curl || return 1 _cmd_exists jq || return 1 local base="${PIN_DNS_VERSION_API_URL:-https://versionhistory.googleapis.com/v1/chrome/platforms}" local url="$base/$plat/channels/stable/versions?pageSize=1" local body; body="$(curl -sS --max-time 5 "$url" 2>/dev/null)" || return 1 local ver; ver="$(printf '%s' "$body" | jq -r '.versions[0].version // empty' 2>/dev/null)" [ -n "$ver" ] || return 1 printf '%s\n' "${ver%%.*}" } _now_epoch() { date +%s; } _cache_path() { local root="${XDG_CACHE_HOME:-$HOME/.cache}/pin-dns" printf '%s/chrome-major.%s\n' "$root" "$platform" } # Read one field from a key=value cache file. $1=file $2=key _cache_field() { local f="$1" local k="$2" local line while IFS= read -r line; do case "$line" in "$k="*) printf '%s\n' "${line#*=}"; return 0 ;; esac done < "$f" return 1 } # Validate a cache file's structural integrity (version + platform match) # Returns 0 and prints the major on structural validity; 1 otherwise # Freshness is the caller's concern (fresh vs stale-if-error) _cache_valid_major() { local f="$1" [ -f "$f" ] || return 1 local cv; cv="$(_cache_field "$f" cacheVersion)" || return 1 [ "$cv" = "$PIN_DNS_CACHE_VERSION" ] || return 1 local cp; cp="$(_cache_field "$f" platform)" || return 1 [ "$cp" = "$platform" ] || return 1 local mj; mj="$(_cache_field "$f" major)" || return 1 case "$mj" in ''|*[!0-9]*) return 1 ;; esac printf '%s\n' "$mj" return 0 } # Fresh cache: structurally valid AND now < expiresAt _cache_read() { local f; f="$(_cache_path)" local mj; mj="$(_cache_valid_major "$f")" || return 1 local exp; exp="$(_cache_field "$f" expiresAt)" || return 1 case "$exp" in ''|*[!0-9]*) return 1 ;; esac local now; now="$(_now_epoch)" [ "$now" -lt "$exp" ] || return 1 printf '%s\n' "$mj" return 0 } # Stale-if-error cache: structurally valid AND (now - fetchedAt) <= MAX_STALE_SECS _cache_read_stale() { local f; f="$(_cache_path)" local mj; mj="$(_cache_valid_major "$f")" || return 1 local fetched; fetched="$(_cache_field "$f" fetchedAt)" || return 1 case "$fetched" in ''|*[!0-9]*) return 1 ;; esac local now; now="$(_now_epoch)" local age=$((now - fetched)) [ "$age" -le "$MAX_STALE_SECS" ] || return 1 printf '%s\n' "$mj" return 0 } _cache_write() { local major="$1" local f; f="$(_cache_path)" local dir="${f%/*}" mkdir -p "$dir" 2>/dev/null || return 1 local ttl="${PIN_DNS_UA_CACHE_TTL:-86400}" case "$ttl" in ''|*[!0-9]*) ttl="86400" ;; esac local now; now="$(_now_epoch)" local exp=$((now + ttl)) local tmp; tmp="$(mktemp "$dir/.chrome-major.XXXXXX" 2>/dev/null)" || return 1 { printf 'cacheVersion=%s\n' "$PIN_DNS_CACHE_VERSION" printf 'source=versionhistory-api\n' printf 'platform=%s\n' "$platform" printf 'major=%s\n' "$major" printf 'fetchedAt=%s\n' "$now" printf 'ttl=%s\n' "$ttl" printf 'expiresAt=%s\n' "$exp" } > "$tmp" mv -f "$tmp" "$f" 2>/dev/null || { rm -f "$tmp"; return 1; } return 0 } # Read the local Chrome major via defaults (macOS). Prints major or returns 1 _local_chrome_major() { local app_default="/Applications/Google Chrome.app" local app="${PIN_DNS_CHROME_APP:-$app_default}" [ -d "$app" ] || return 1 local plist="$app/Contents/Info.plist" local version; version="$(defaults read "$plist" CFBundleShortVersionString 2>/dev/null)" [ -n "$version" ] || return 1 printf '%s\n' "${version%%.*}" } # Resolve the Chrome major to impersonate. Precedence: # 1-2. --chrome-major flag (chrome_major_override), else PIN_DNS_CHROME_MAJOR env # 3. local Chrome read (authoritative, uncached) # 4. fresh cache (within TTL) # 5. Version History API (writes cache), unless PIN_DNS_UA_OFFLINE # 6. stale-if-error cache (within MAX_STALE_SECS) # 7. pinned fallback # Only the API result is cached, never the local read or an override # Reads global `platform`. Always prints a major and returns 0 _resolve_chrome_major() { # 1-2: explicit override (flag beats env) local override="${chrome_major_override:-${PIN_DNS_CHROME_MAJOR:-}}" if [ -n "$override" ]; then printf '%s\n' "$override" return 0 fi # 3: local read (authoritative, uncached) local m if m="$(_local_chrome_major)" && [ -n "$m" ]; then printf '%s\n' "$m" return 0 fi # 4: fresh cache if m="$(_cache_read)" && [ -n "$m" ]; then printf '%s\n' "$m" return 0 fi # 5: network fetch (writes cache), unless offline if [ -z "${PIN_DNS_UA_OFFLINE:-}" ]; then if m="$(_fetch_latest_major "$platform")" && [ -n "$m" ]; then _cache_write "$m" printf '%s\n' "$m" return 0 fi fi # 6: stale-if-error cache (within max-stale) if m="$(_cache_read_stale)" && [ -n "$m" ]; then printf '%s\n' "$m" return 0 fi # 7: pinned fallback printf '%s\n' "$PIN_DNS_FALLBACK_MAJOR" return 0 } _curl_args_has_user_agent() { local a="" local h="" while [ "$#" -gt 0 ]; do a="$1" shift case "$a" in # meta:not-options -- recognizes curl's flags, not pin-dns's own -A|--user-agent) return 0 ;; -A*) return 0 ;; -H|--header) [ "$#" -gt 0 ] || return 1 h="$1" shift case "$h" in [Uu]ser-[Aa]gent:*) return 0 ;; esac ;; --header=*) h="${a#*=}" case "$h" in [Uu]ser-[Aa]gent:*) return 0 ;; esac ;; -H*) h="${a#-H}" case "$h" in [Uu]ser-[Aa]gent:*) return 0 ;; esac ;; esac done return 1 } # Return 0 if the user already supplied header $1 (case-insensitive, matched # with trailing colon) via -H/--header/--header=/-Hx. Args after -- are the # curl args to scan. Used so pin-dns never double-sends a managed header _user_supplied_header() { local want; want="$(LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" shift [ "$1" = "--" ] && shift local a local h local lh while [ "$#" -gt 0 ]; do a="$1" shift h="" case "$a" in # meta:not-options -- recognizes curl's flags, not pin-dns's own -H|--header) h="$1" ;; --header=*) h="${a#*=}" ;; -H?*) h="${a#-H}" ;; esac [ -n "$h" ] || continue lh="$(LC_ALL=C printf '%s' "$h" | tr '[:upper:]' '[:lower:]')" case "$lh" in "$want":*) return 0 ;; esac done return 1 } # Return 0 if the user supplied a User-Agent (via -A/--user-agent or # -H User-Agent:) whose value does NOT contain "Chrome/". Drives the # backoff that suppresses Chrome client hints against a non-Chrome UA _ua_is_non_chrome() { [ "$1" = "--" ] && shift local a local v while [ "$#" -gt 0 ]; do a="$1" shift v="" case "$a" in # meta:not-options -- recognizes curl's flags, not pin-dns's own -A|--user-agent) v="$1" ;; --user-agent=*) v="${a#*=}" ;; -A?*) v="${a#-A}" ;; -H|--header) v="$1"; case "$(LC_ALL=C printf '%s' "$v" | tr '[:upper:]' '[:lower:]')" in user-agent:*) v="${v#*:}" ;; *) v="" ;; esac ;; --header=*) v="${a#*=}"; case "$(LC_ALL=C printf '%s' "$v" | tr '[:upper:]' '[:lower:]')" in user-agent:*) v="${v#*:}" ;; *) v="" ;; esac ;; -H?*) v="${a#-H}"; case "$(LC_ALL=C printf '%s' "$v" | tr '[:upper:]' '[:lower:]')" in user-agent:*) v="${v#*:}" ;; *) v="" ;; esac ;; esac [ -n "$v" ] || continue case "$v" in *Chrome/*) return 1 ;; *) return 0 ;; esac done return 1 } # Return 0 if the user directed the body to a file via # -o/--output/-O/--remote-name/-J/--remote-header-name (including the # attached forms -o/--output= and the valueless -O.../-J...). # Drives the file-output --compressed swap and the capture/decode-vs-direct # exec decision. Mirrors -o*/--output=* handling in _curl_opt_consumes_next; # -o takes a value (match -o?* for the attached value), -O/-J take none _curl_args_write_to_file() { [ "$1" = "--" ] && shift local a while [ "$#" -gt 0 ]; do a="$1" shift case "$a" in # meta:not-options -- recognizes curl's flags, not pin-dns's own -o|--output|-O|--remote-name|-J|--remote-header-name) return 0 ;; -o?*|--output=*|-O*|-J*) return 0 ;; esac done return 1 } # Return 0 if the user asked for headers only via -I/--head. Such a request # emits no body, so it execs curl directly (no capture/decode) _curl_args_headers_only() { [ "$1" = "--" ] && shift local a for a in "$@"; do case "$a" in # meta:not-options -- recognizes curl's flags, not pin-dns's own -I|--head) return 0 ;; esac done return 1 } # Return 0 if the user asked for headers inline with the body via -i/--include, # or for a header dump via -D/--dump-header (including the attached -D and # --dump-header= forms). Both idioms break the capture/decode path: -i # prepends the status line + headers to the body file (the decoder would run # over the header block and emit garbage on a compressed response), and the # capture path's own -D temp would clobber the user's -D (curl honors only the # last one). Route both to direct exec + --compressed so curl owns decoding and # the user's dump behaves normally, at the same AE-fingerprint tradeoff as -o. # -I/--head is handled by _curl_args_headers_only, not here _curl_args_wants_raw_headers() { [ "$1" = "--" ] && shift local a while [ "$#" -gt 0 ]; do a="$1" shift case "$a" in # meta:not-options -- recognizes curl's flags, not pin-dns's own -i|--include|-D|--dump-header) return 0 ;; -D?*|--dump-header=*) return 0 ;; esac done return 1 } # Decode $1 (body file) according to $2 (content-encoding token) to stdout. # Missing decoder -> warn on stderr, emit raw. Unknown/identity -> emit raw _decode_stdout_body() { local body="$1" local enc; enc="$(LC_ALL=C printf '%s' "$2" | tr '[:upper:]' '[:lower:]')" case "$enc" in gzip|x-gzip) if _cmd_exists gzip; then gzip -dc < "$body" || cat "$body"; else _warn "gzip decoder not found; emitting raw body"; cat "$body"; fi ;; deflate) # gzip -d handles zlib/deflate on most builds; fall back to raw if _cmd_exists gzip; then gzip -dc < "$body" 2>/dev/null || cat "$body"; else _warn "deflate decoder not found; emitting raw body"; cat "$body"; fi ;; br) if _cmd_exists brotli; then brotli -dc < "$body" || cat "$body"; else _warn "brotli decoder not found (install brotli); emitting raw body"; cat "$body"; fi ;; zstd) if _cmd_exists zstd; then zstd -dc < "$body" || cat "$body"; else _warn "zstd decoder not found (install zstd); emitting raw body"; cat "$body"; fi ;; *) cat "$body" ;; esac } # Append the full Chrome header set to the cmd array as -H pairs, in Chrome's # real order. Skips any header the user already supplied (per-header override) # and, if the user's UA is non-Chrome, suppresses the Chrome-only client hints. # Reads globals: platform, fetch_mode, chrome_major, curl_args, cmd _emit_impersonation_headers() { local major="$chrome_major" local plat_hint case "$platform" in mac) plat_hint="macOS" ;; win) plat_hint="Windows" ;; linux) plat_hint="Linux" ;; *) plat_hint="macOS" ;; esac local accept local sf_site local sf_mode local sf_dest local want_sf_user_and_uir case "$fetch_mode" in cors) accept="*/*" sf_site="same-origin" sf_mode="cors" sf_dest="empty" want_sf_user_and_uir="" ;; no-cors) accept="*/*" sf_site="same-origin" sf_mode="no-cors" sf_dest="empty" want_sf_user_and_uir="" ;; *) accept="text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" sf_site="none" sf_mode="navigate" sf_dest="document" want_sf_user_and_uir="1" ;; esac local ua_backoff="" _ua_is_non_chrome -- "${curl_args[@]}" && ua_backoff="1" # Chrome client hints (suppressed under UA backoff) if [ -z "$ua_backoff" ]; then _user_supplied_header "sec-ch-ua" -- "${curl_args[@]}" || \ cmd+=("-H" "sec-ch-ua: \"Chromium\";v=\"$major\", \"Not?A_Brand\";v=\"24\", \"Google Chrome\";v=\"$major\"") _user_supplied_header "sec-ch-ua-mobile" -- "${curl_args[@]}" || \ cmd+=("-H" "sec-ch-ua-mobile: ?0") _user_supplied_header "sec-ch-ua-platform" -- "${curl_args[@]}" || \ cmd+=("-H" "sec-ch-ua-platform: \"$plat_hint\"") fi if [ -n "$want_sf_user_and_uir" ]; then _user_supplied_header "upgrade-insecure-requests" -- "${curl_args[@]}" || \ cmd+=("-H" "Upgrade-Insecure-Requests: 1") fi # User-Agent emitted here as -H so it lands in Chrome's slot: after # Upgrade-Insecure-Requests for navigate, after the client hints for # cors/no-cors, and before Accept either way. curl -A always sends the # UA first on the wire, so -A cannot produce this order. Skipped when # the user supplied their own UA (via -A or -H, which also covers the # non-Chrome backoff case) so we never double-send if ! _curl_args_has_user_agent "${curl_args[@]}"; then local ua; ua="$(_ua_for_platform "$platform" "$major")" cmd+=("-H" "User-Agent: $ua") fi _user_supplied_header "accept" -- "${curl_args[@]}" || \ cmd+=("-H" "Accept: $accept") _user_supplied_header "sec-fetch-site" -- "${curl_args[@]}" || \ cmd+=("-H" "Sec-Fetch-Site: $sf_site") _user_supplied_header "sec-fetch-mode" -- "${curl_args[@]}" || \ cmd+=("-H" "Sec-Fetch-Mode: $sf_mode") if [ -n "$want_sf_user_and_uir" ]; then _user_supplied_header "sec-fetch-user" -- "${curl_args[@]}" || \ cmd+=("-H" "Sec-Fetch-User: ?1") fi _user_supplied_header "sec-fetch-dest" -- "${curl_args[@]}" || \ cmd+=("-H" "Sec-Fetch-Dest: $sf_dest") # Accept-Encoding: honest full set here so a decode-capable stdout path # advertises what Chrome does. Skipped for file output and for -i/-D # (raw-header idioms), where the exec block adds --compressed instead so # curl writes a usable decoded body rather than pin-dns decoding it if ! _curl_args_write_to_file -- "${curl_args[@]}" \ && ! _curl_args_wants_raw_headers -- "${curl_args[@]}"; then _user_supplied_header "accept-encoding" -- "${curl_args[@]}" || \ cmd+=("-H" "Accept-Encoding: gzip, deflate, br, zstd") fi _user_supplied_header "accept-language" -- "${curl_args[@]}" || \ cmd+=("-H" "Accept-Language: en-US,en;q=0.9") } _resolve_target_to_ip() { local target="$1" local resolver="$2" local dig_out="" local ip="" local dig_rc=0 if _is_ip "$target"; then printf '%s\n' "$target" return 0 fi if ! _cmd_exists dig; then _error "dig is required" return 3 fi if [ -n "$resolver" ]; then dig_out="$(dig @"$resolver" +short "$target" 2>/dev/null)" dig_rc=$? else dig_out="$(dig +short "$target" 2>/dev/null)" dig_rc=$? fi if [ "$dig_rc" -ne 0 ]; then _error "dig failed (exit $dig_rc) while resolving TARGET '$target'" return 3 fi ip="$(printf '%s\n' "$dig_out" | tail -n 1)" if [ -z "$ip" ]; then _error "dig returned no results for TARGET '$target'" return 4 fi printf '%s\n' "$ip" return 0 } _extract_first_curl_disable_flag() { # Extracts the first occurrence of curl -q/--disable from curl_args and removes ALL occurrences # Prints the extracted token to stdout on success local token="" local found="" local -a new=() local a="" for a in "${curl_args[@]}"; do if [ "$a" = "-q" ] || [ "$a" = "--disable" ]; then if [ -z "$found" ]; then token="$a" found="1" fi continue fi new+=("$a") done curl_args=("${new[@]}") if [ -n "$found" ]; then printf '%s\n' "$token" return 0 fi return 1 } _strip_curl_disable_flags() { local -a new=() local a="" for a in "${curl_args[@]}"; do if [ "$a" = "-q" ] || [ "$a" = "--disable" ]; then continue fi new+=("$a") done curl_args=("${new[@]}") return 0 } local host="" local host_set_explicit="" local target="" local path="" local resolver="" local scheme="https" local port="443" local platform="mac" local fetch_mode="navigate" local no_impersonate="" local client="" # empty until set by --client; resolved to flag:-env:-auto post-parse local chrome_major_override="" local dry_run="" local no_silent="" local quiet="" local allow_curlrc="" local scheme_set_explicit="" local port_set_explicit="" local -a pre_args=() local -a post_args=() local -a rest_pre_args=() local -a curl_args=() if ! _cmd_exists curl; then _error "curl is required" return 3 fi # Split args on explicit -- (everything after is curl-only; pin-dns options after -- are not parsed) while [ "$#" -gt 0 ]; do if [ "$1" = "--" ]; then shift post_args+=("$@") break fi pre_args+=("$1") shift done # PIN-DNS OPTION PARSER (may appear anywhere before --) # MAINTENANCE NOTE: # If you add a new pin-dns option, update BOTH: # - this case statement # - _looks_like_pin_dns_option_token (for accurate operand warnings) local expect_curl_operand="" local expect_curl_operand_for="" local i=0 while [ "$i" -lt "${#pre_args[@]}" ]; do local arg="${pre_args[$i]}" if [ -n "$expect_curl_operand" ]; then if _looks_like_pin_dns_option_token "$arg"; then _warn "Token '$arg' looks like a $SCRIPT_NAME option but is being used as the operand to curl option '$expect_curl_operand_for'; it will be passed to curl unchanged. If you intended $SCRIPT_NAME, move it, use --target=VALUE (or similar), or use --" fi rest_pre_args+=("$arg") expect_curl_operand="" expect_curl_operand_for="" i=$((i + 1)) continue fi case "$arg" in -h|--help) _print_help return 0 ;; -q|--quiet) quiet="1" i=$((i + 1)) continue ;; --curlrc) allow_curlrc="1" i=$((i + 1)) continue ;; --dry-run) # meta:canonical-exempt: reserved namespace -- -n is curl's --netrc, so --dry-run is long-only (see CONVENTIONS "Canonical short options") dry_run="1" i=$((i + 1)) continue ;; --no-silent) no_silent="1" i=$((i + 1)) continue ;; --no-impersonate) no_impersonate="1" i=$((i + 1)) continue ;; --host=*) host="${arg#*=}" host_set_explicit="1" i=$((i + 1)) continue ;; --target=*) target="${arg#*=}" i=$((i + 1)) continue ;; --path=*) path="${arg#*=}" i=$((i + 1)) continue ;; --resolver=*) resolver="${arg#*=}" i=$((i + 1)) continue ;; --scheme=*) scheme="${arg#*=}" scheme_set_explicit="1" i=$((i + 1)) continue ;; --port=*) port="${arg#*=}" port_set_explicit="1" i=$((i + 1)) continue ;; --platform=*) platform="${arg#*=}" i=$((i + 1)) continue ;; --fetch-mode=*) fetch_mode="${arg#*=}" i=$((i + 1)) continue ;; --client=*) client="${arg#*=}" i=$((i + 1)) continue ;; --chrome-major=*) chrome_major_override="${arg#*=}" i=$((i + 1)) continue ;; --host|--target|--path|--resolver|--scheme|--port|--platform|--fetch-mode|--client|--chrome-major) local opt="$arg" i=$((i + 1)) if [ "$i" -ge "${#pre_args[@]}" ]; then _error "$opt requires a value. Run \`$SCRIPT_NAME -h\` for usage" return 2 fi local val="${pre_args[$i]}" # long-only: reserved namespace (pin-dns gives its whole short space to curl passthrough) case "$opt" in --host) host="$val"; host_set_explicit="1" ;; --target) target="$val" ;; --path) path="$val" ;; --resolver) resolver="$val" ;; --scheme) scheme="$val"; scheme_set_explicit="1" ;; --port) port="$val"; port_set_explicit="1" ;; --platform) platform="$val" ;; --fetch-mode) fetch_mode="$val" ;; --client) client="$val" ;; --chrome-major) chrome_major_override="$val" ;; esac i=$((i + 1)) continue ;; esac rest_pre_args+=("$arg") if _curl_opt_consumes_next "$arg"; then expect_curl_operand="1" expect_curl_operand_for="$arg" fi i=$((i + 1)) done # Combine pre and post curl args curl_args=("${rest_pre_args[@]}" "${post_args[@]}") # Validate impersonation enum flags (bad value -> usage error) case "$platform" in mac|win|linux) ;; *) _error "Invalid --platform '$platform' (valid: mac|win|linux). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac case "$fetch_mode" in navigate|cors|no-cors) ;; *) _error "Invalid --fetch-mode '$fetch_mode' (valid: navigate|cors|no-cors). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac # Resolve client precedence: --client flag beats PIN_DNS_CLIENT env beats the # auto default (same flag>env>default shape as chrome_major_effective). The # enum check below then validates the resolved value, so a bad flag OR a bad # env value hits one message -- they are the same knob client="${client:-${PIN_DNS_CLIENT:-auto}}" # curl-impersonate is an alias for impersonate; normalize immediately so # every downstream check (the enum validator, _detect_impersonate_engine, # the missing-binary error) only ever sees the single canonical value case "$client" in curl-impersonate) client="impersonate" ;; esac case "$client" in curl|impersonate|auto) ;; *) _error "Invalid --client '$client' (valid: curl|impersonate|auto). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac # Validate the effective Chrome major (flag beats env, same precedence as # _resolve_chrome_major). It is interpolated verbatim into the UA as # Chrome/.0.0.0, so a set-but-non-numeric value is a usage error; empty # (e.g. --chrome-major=) falls through to env/local/network/pinned detection. # --chrome-major and PIN_DNS_CHROME_MAJOR are the same knob, so one message covers both local chrome_major_effective="${chrome_major_override:-${PIN_DNS_CHROME_MAJOR:-}}" if [ -n "$chrome_major_effective" ]; then case "$chrome_major_effective" in *[!0-9]*) _error "Invalid --chrome-major '$chrome_major_effective' (must be numeric). Run \`$SCRIPT_NAME -h\` for usage"; return 2 ;; esac fi # Resolve the request client. Empty -> stock curl (inject UA + header suite); # non-empty -> curl-impersonate (it sets headers + TLS together, so pin-dns # injects no header suite). --client impersonate with no binary is fatal local client_bin="" if [ -z "$no_impersonate" ]; then client_bin="$(_detect_impersonate_engine)" || client_bin="" if [ "$client" = "impersonate" ] && [ -z "$client_bin" ]; then _error "curl-impersonate not found on PATH (required by --client impersonate). Install lexiforest/curl-impersonate or use --client auto/curl" return 3 fi fi # WARNING: useless -s / -S (only warn when pin-dns is still adding -sS) if [ -z "$no_silent" ] && _curl_args_has_silent_controls "${curl_args[@]}"; then _warn "You passed curl -s/-S/--silent/--show-error, but $SCRIPT_NAME already adds curl -sS by default; use --no-silent if you want $SCRIPT_NAME to stop adding -sS" fi # Determine whether we are in positional mode (HOSTNAME [TARGET] [PATH_OR_URL]) or URL/drop-in mode # Rule: if the first non-option token (excluding curl option operands) looks like a hostname (not a URL), treat as positional local positional_mode="" if [ -z "$host" ]; then local expect_op="" local first_free="" local a="" for a in "${rest_pre_args[@]}"; do if [ -n "$expect_op" ]; then expect_op="" continue fi case "$a" in -*) if _curl_opt_consumes_next "$a"; then expect_op="1" fi continue ;; esac first_free="$a" break done if [ -n "$first_free" ] && ! _is_http_url "$first_free" && _looks_like_host "$first_free"; then positional_mode="1" fi fi # If positional mode, consume HOSTNAME [TARGET] [PATH_OR_URL] from rest_pre_args (not from post_args) if [ -n "$positional_mode" ]; then local host_pos="" local target_pos="" local path_pos="" local pos_i=0 local expect_op="" local -a keep_pre=() local a="" for a in "${rest_pre_args[@]}"; do if [ -n "$expect_op" ]; then keep_pre+=("$a") expect_op="" continue fi case "$a" in -*) keep_pre+=("$a") if _curl_opt_consumes_next "$a"; then expect_op="1" fi continue ;; esac if [ "$pos_i" -eq 0 ]; then host_pos="$a" pos_i=$((pos_i + 1)) continue fi if [ "$pos_i" -eq 1 ]; then target_pos="$a" pos_i=$((pos_i + 1)) continue fi if [ "$pos_i" -eq 2 ]; then path_pos="$a" pos_i=$((pos_i + 1)) continue fi keep_pre+=("$a") done if [ -z "$host" ] && [ -n "$host_pos" ]; then host="$host_pos" fi if [ -z "$target" ] && [ -n "$target_pos" ]; then target="$target_pos" fi if [ -z "$path" ] && [ -n "$path_pos" ]; then path="$path_pos" fi rest_pre_args=("${keep_pre[@]}") curl_args=("${rest_pre_args[@]}" "${post_args[@]}") else # URL/drop-in mode: if no --target was set, look for a lone free token that looks like # a hostname or IP (not an HTTP URL). This supports: pin-dns https://example.com cdn.edge.com # If multiple host-like free tokens exist, it's ambiguous -- require explicit --target if [ -z "$target" ]; then local _utc="" # candidate value local _uti="-1" # candidate index in rest_pre_args local _utn="0" # count of host-like free tokens local expect_op="" local idx=0 local a="" for a in "${rest_pre_args[@]}"; do if [ -n "$expect_op" ]; then expect_op="" idx=$((idx + 1)) continue fi case "$a" in -*) if _curl_opt_consumes_next "$a"; then expect_op="1" fi idx=$((idx + 1)) continue ;; esac if ! _is_http_url "$a" && _looks_like_host "$a"; then _utn=$((_utn + 1)) if [ "$_utn" -eq 1 ]; then _utc="$a" _uti="$idx" fi if [ "$_utn" -gt 1 ]; then break fi fi idx=$((idx + 1)) done if [ "$_utn" -eq 1 ]; then target="$_utc" local -a new_rest=() idx=0 for a in "${rest_pre_args[@]}"; do if [ "$idx" -ne "$_uti" ]; then new_rest+=("$a") fi idx=$((idx + 1)) done rest_pre_args=("${new_rest[@]}") fi fi curl_args=("${rest_pre_args[@]}" "${post_args[@]}") fi # Infer host/scheme/port/path from the first http(s) URL argument, if present local url_token="" local url_scheme="" local url_host="" local url_port="" local url_path="" local url_index="-1" for i in "${!curl_args[@]}"; do if _is_http_url "${curl_args[$i]}"; then url_index="$i" url_token="${curl_args[$i]}" break fi done local host_set_from_url="" if [ "$url_index" -ne -1 ]; then _parse_http_url "$url_token" url_scheme url_host url_port url_path if [ -n "$url_host" ]; then if [ -z "$host" ]; then host="$url_host" host_set_from_url="1" else if [ -n "$host_set_explicit" ]; then local hc1; hc1="$(_host_for_compare "$host")" local hc2; hc2="$(_host_for_compare "$url_host")" if [ "$hc1" != "$hc2" ]; then _warn "URL host '$url_host' does not match --host '$host'; using --host '$host' anyway" fi fi fi fi if [ -z "$scheme_set_explicit" ] && [ -n "$url_scheme" ]; then if [ -n "$host_set_from_url" ] || [ -z "$host" ] || [ "$(_host_for_compare "$host")" = "$(_host_for_compare "$url_host")" ]; then scheme="$url_scheme" fi fi if [ -z "$port_set_explicit" ] && [ -n "$url_port" ]; then if [ -n "$host_set_from_url" ] || [ -z "$host" ] || [ "$(_host_for_compare "$host")" = "$(_host_for_compare "$url_host")" ]; then port="$url_port" fi fi if [ -z "$path" ] || [ "$path" = "/" ]; then if [ -n "$url_path" ]; then path="$url_path" fi fi fi if [ -z "$path" ]; then path="/" fi local path_url_host="" _normalize_path_or_url "$path" path_url_host path if [ -n "$path_url_host" ]; then local pu; pu="$(_host_for_compare "$path_url_host")" local hh; hh="$(_host_for_compare "$host")" local tt="" if [ -n "$target" ] && ! _is_ip "$target"; then tt="$(_host_for_compare "$target")" else tt="" fi if [ -n "$hh" ] && [ "$pu" != "$hh" ] && { [ -z "$tt" ] || [ "$pu" != "$tt" ]; }; then if [ -n "$tt" ]; then _warn "PATH_OR_URL host '$path_url_host' does not match HOSTNAME '$host' or TARGET '$target'; using HOSTNAME '$host' anyway" else _warn "PATH_OR_URL host '$path_url_host' does not match HOSTNAME '$host'; using HOSTNAME '$host' anyway" fi fi fi if [ -z "$host" ]; then _error "Unable to determine HOSTNAME (provide a URL or use --host HOSTNAME). Run \`$SCRIPT_NAME -h\` for usage" _print_help return 2 fi if [ -z "$scheme" ]; then scheme="https" fi if [ -z "$port" ]; then if [ "$scheme" = "http" ]; then port="80" else port="443" fi fi local url="${scheme}://${host}${path}" local ip="" if [ -n "$target" ]; then local resolved; resolved="$(_resolve_target_to_ip "$target" "$resolver")" local rc=$? if [ "$rc" -ne 0 ]; then return "$rc"; fi ip="$resolved" fi # Resolve the Chrome major whenever impersonation is on; the emitter needs it # for both the sec-ch-ua brand string and the -H User-Agent (emitted in slot) local chrome_major="" if [ -z "$no_impersonate" ]; then chrome_major="$(_resolve_chrome_major)" fi # curl -q/--disable must be the very first curl argument to reliably disable curlrc # Default: pin-dns disables curlrc by injecting curl -q first # If --curlrc is set, we do NOT inject curl -q; however, if the user provided curl -q/--disable, we promote it to the first curl arg local curl_disable_token="" if [ -n "$allow_curlrc" ]; then curl_disable_token="$(_extract_first_curl_disable_flag 2>/dev/null)" || curl_disable_token="" else _strip_curl_disable_flags fi local -a cmd=() if [ -n "$client_bin" ]; then cmd+=("$client_bin") else cmd+=("curl") fi if [ -z "$allow_curlrc" ]; then cmd+=("-q") else if [ -n "$curl_disable_token" ]; then cmd+=("$curl_disable_token") fi fi if [ -z "$no_silent" ]; then cmd+=("-sS") fi # Impersonate path: curl-impersonate's wrapper sets --compressed and the # matching browser fingerprint via --impersonate chrome, which also # applies the Chrome header set (why the suite above is skipped). Placed # before --resolve and user args so those pass through last, as curl expects. # The detected major is mapped onto an installed target (see # _map_impersonate_target): --impersonate rejects a major it has no profile # for, and live Chrome routinely outruns the roster. When no roster is # discoverable (binary-only install), the raw major passes through unchanged if [ -n "$client_bin" ]; then local impersonate_target="chrome${chrome_major}" local client_dir; client_dir="$(command -v "$client_bin" 2>/dev/null)" client_dir="${client_dir%/*}" if [ -n "$client_dir" ]; then local mapped if mapped="$(_map_impersonate_target "$client_dir" "$chrome_major")"; then if [ "$mapped" != "$impersonate_target" ]; then _warn "curl-impersonate has no chrome${chrome_major} profile; using nearest installed target '$mapped'" fi impersonate_target="$mapped" fi fi cmd+=("--compressed" "--impersonate" "$impersonate_target") fi # The pin-dns-generated User-Agent is emitted inside _emit_impersonation_headers # as -H at Chrome's canonical position, not via -A (curl -A always sends the UA # first on the wire). A user-supplied -A/-H User-Agent still flows via curl_args # Only for stock curl: curl-impersonate sets the browser header set itself, so # the suite is skipped on that path (gated on empty client_bin) if [ -z "$no_impersonate" ] && [ -z "$client_bin" ]; then _emit_impersonation_headers fi # File output or a raw-header idiom (-i/-D) while impersonating on stock curl: # let curl own encoding (usable saved file / decoded inline body); the honest # full Accept-Encoding is already skipped for these inside # _emit_impersonation_headers. Single append so --compressed is added at most # once even when both predicates match (e.g. -i together with -o file) if [ -z "$no_impersonate" ] && [ -z "$client_bin" ] \ && { _curl_args_write_to_file -- "${curl_args[@]}" \ || _curl_args_wants_raw_headers -- "${curl_args[@]}"; }; then cmd+=("--compressed") fi if [ -n "$ip" ]; then cmd+=("--resolve" "${host}:${port}:${ip}") fi cmd+=("${curl_args[@]}") local has_url="" local ca="" for ca in "${curl_args[@]}"; do case "$ca" in http://*|https://*) has_url="1" ;; esac done if [ -z "$has_url" ]; then cmd+=("$url") fi if [ -n "$ip" ]; then _info "Mapping ${host}:${port} -> ${ip} (from TARGET: ${target})" else _info "No TARGET provided; running curl against ${url}" fi if [ -n "$resolver" ]; then _info "dig resolver: ${resolver}" fi if [ -n "$allow_curlrc" ]; then _info "curlrc enabled (--curlrc)" else _info "curlrc disabled (curl -q)" fi if [ -n "$dry_run" ]; then _info "Dry run: curl command follows" printf '%s' "[CMD] " >&2 local x="" for x in "${cmd[@]}"; do case "$x" in *[[:space:]]*|*\"*|*\'*) printf '%s ' "'$x'" >&2 ;; *) printf '%s ' "$x" >&2 ;; esac done printf '\n' >&2 return 0 fi # Decide execution mode: # - impersonate client, file output, headers-only, or raw-header idioms # (-i/-D) -> exec directly (curl owns any decoding) # - stock curl + stdout + impersonating -> capture + decode if [ -n "$client_bin" ] || [ -n "$no_impersonate" ] \ || _curl_args_write_to_file -- "${curl_args[@]}" \ || _curl_args_headers_only -- "${curl_args[@]}" \ || _curl_args_wants_raw_headers -- "${curl_args[@]}"; then "${cmd[@]}" local curl_rc=$? if [ "$curl_rc" -eq 126 ] || [ "$curl_rc" -eq 127 ]; then _error "curl failed to execute (exit $curl_rc)" return 3 fi return "$curl_rc" fi # Capture-and-decode path: add -o bodyfile -D headerfile, run, then decode local _body; _body="$(mktemp "${TMPDIR:-/tmp}/pin-dns-body.XXXXXX")" || { _error "mktemp failed"; return 3; } local _hdr; _hdr="$(mktemp "${TMPDIR:-/tmp}/pin-dns-hdr.XXXXXX")" || { rm -f "$_body"; _error "mktemp failed"; return 3; } _tmpfiles="$_tmpfiles $_body $_hdr" cmd+=("-o" "$_body" "-D" "$_hdr") "${cmd[@]}" local curl_rc=$? if [ "$curl_rc" -eq 126 ] || [ "$curl_rc" -eq 127 ]; then _error "curl failed to execute (exit $curl_rc)" return 3 fi # Extract the LAST Content-Encoding from the header dump (handles redirects) local _ce; _ce="$(LC_ALL=C tr -d '\r' < "$_hdr" | awk -F': ' 'tolower($1)=="content-encoding"{v=$2} END{print v}')" _decode_stdout_body "$_body" "$_ce" return "$curl_rc" ) _pin_dns "$@" __pin_dns_rc=$? unset -f _pin_dns # Propagate exit status whether executed or sourced # Use BASH_SOURCE vs $0 instead of a `return || exit` chain so we don't lose $__pin_dns_rc if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __pin_dns_rc; return $__pin_dns_rc" fi eval "unset __pin_dns_rc; exit $__pin_dns_rc"