HTTP and curl
Use curl to test HTTP endpoints, break request time into DNS, TCP, TLS and server phases, script calls safely, and read what status codes mean.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Response headers only (sends HEAD) | curl -I https://example.com |
| Headers of a real GET | curl -sS -D - -o /dev/null https://example.com |
| Follow redirects | curl -L https://example.com |
| Fail with a non-zero exit on 4xx/5xx | curl -fsS https://example.com |
| Show request and response headers, TLS details | curl -v https://example.com |
| Status code only | curl -o /dev/null -sw '%{http_code}\n' https://example.com |
| Timing breakdown | curl -o /dev/null -sw '@curl-format.txt' https://example.com |
| POST JSON | curl --json @body.json https://api.example.com/items |
| Upload a file as multipart form | curl -F file=@report.csv https://api.example.com/upload |
| Bearer token | curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com |
| Connect to a chosen IP, keep Host and SNI | curl --resolve api.example.com:443:192.0.2.10 https://api.example.com |
| Skip certificate verification | curl -k https://example.com (diagnosis only) |
| Force an HTTP version | curl --http1.1 URL, curl --http2 URL, curl --http3 URL |
| Through a proxy | curl -x http://proxy.example.com:3128 https://example.com |
| Retry transient failures | curl --retry 3 --retry-delay 2 --retry-connrefused URL |
| Bound the time | curl --connect-timeout 3 --max-time 10 URL |
| Keep cookies between calls | curl -c jar.txt -b jar.txt URL |
--json (curl 7.82.0 and later) sends the body with Content-Type: application/json and Accept: application/json. --http3 needs a curl built with HTTP/3 support; check curl --version for HTTP3 in the features line.
A request that is failing#
# Handshake, request headers (>), response headers (<), status
curl -sSv https://api.example.com/health -o /dev/null
# Where the time goes
curl -o /dev/null -sw 'code=%{http_code} dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' https://api.example.com/health
# Bypass DNS and hit one backend, keeping the Host header and TLS SNI
curl -sv --resolve api.example.com:443:192.0.2.10 https://api.example.com/health -o /dev/null| Failure | Where it happened |
|---|---|
Could not resolve host (exit 6) | DNS; see DNS |
Connection refused (exit 7) | Host reached, nothing listening on that port, or a firewall sent a reset |
Connection timed out (exit 28 with --connect-timeout) | Packets dropped: firewall, security group, routing; see iproute2 |
SSL certificate problem (exit 60) | Incomplete chain, name mismatch, expiry, or CA missing from this trust store; see TLS |
SSL_ERROR_SYSCALL or unexpected eof during handshake | Something in the path closed the connection: a proxy, a load balancer without the certificate, or a port that does not speak TLS |
Works with -k | The problem is certificate validation, not connectivity |
Long time_appconnect | Slow TLS handshake: high latency (several round trips), a large chain, or a CPU-starved terminator |
Long time_starttransfer, everything else short | The server is slow to produce the first byte |
Empty reply from server (exit 52) | The server or a middlebox closed the connection without sending a response |
Timing#
All time_* write-out values are measured from the start of the transfer, so each includes the phases before it.
cat > curl-format.txt <<'EOF'
dns %{time_namelookup}s
connect %{time_connect}s
tls %{time_appconnect}s
waiting %{time_starttransfer}s
total %{time_total}s
size %{size_download} bytes
code %{http_code}
version %{http_version}
EOF
curl -o /dev/null -s -w '@curl-format.txt' https://api.example.com/health dns 0.012345s
connect 0.030112s
tls 0.071870s
waiting 0.180553s
total 0.181204s
size 15 bytes
code 200
version 2Subtract to isolate each phase:
| Phase | Formula |
|---|---|
| DNS | time_namelookup |
| TCP handshake | time_connect - time_namelookup |
| TLS handshake | time_appconnect - time_connect (0 for plain HTTP) |
| Server processing plus one round trip | time_starttransfer - time_appconnect |
| Body transfer | time_total - time_starttransfer |
When a proxy is in use, time_connect is the connection to the proxy.
# Ten samples, sorted, to separate a slow server from a slow tail
for i in $(seq 10); do curl -o /dev/null -sw '%{time_total}\n' https://api.example.com/health; done | sort -nUseful flags#
| Flag | Effect |
|---|---|
-f / --fail | Exit 22 on HTTP 400 and above, without printing the body |
--fail-with-body | Exit 22 on HTTP errors but still output the body (7.76.0 and later) |
-s / -S | Silent; -sS stays silent but still prints errors |
-L | Follow redirects; --max-redirs bounds it (default 50) |
-i | Include response headers in the output |
-D - | Dump response headers to stdout while the body goes elsewhere |
--compressed | Ask for a compressed response and decode it |
-A, -e | Set User-Agent and Referer |
--data-binary | Send the payload exactly as given |
-G | Move -d data into the query string and send GET |
--url-query | Append a URL-encoded query parameter (7.87.0 and later) |
-w | Write-out template: timings, sizes, redirect URL, status |
-Z | Run several URLs in parallel |
--trace-ascii - | Full dump of everything sent and received |
Sending data#
# JSON body; -H is needed because -d defaults to form encoding
curl -X POST https://api.example.com/items \
-H 'Content-Type: application/json' \
-d '{"name":"widget","qty":4}'
# JSON from a file with --json (sets Content-Type and Accept)
curl --json @payload.json https://api.example.com/items
# Multipart form upload
curl -F 'file=@report.csv' -F 'note=monthly' https://api.example.com/upload
# URL-encoded query string
curl -G https://api.example.com/search --data-urlencode 'q=name with spaces' --data-urlencode 'limit=10'
# PATCH with a merge patch
curl -X PATCH -H 'Content-Type: application/merge-patch+json' -d '{"qty":5}' https://api.example.com/items/1
# PUT a file
curl -T ./artifact.tgz https://uploads.example.com/path/-d sends POST with Content-Type: application/x-www-form-urlencoded unless you override the header. -d @file strips carriage returns, newlines and null bytes from the file. --data-binary @file sends it byte for byte, which matters for signed payloads, YAML and NDJSON.
TLS checks#
# Negotiated protocol, cipher, certificate subject, issuer and expiry
curl -svI https://example.com 2>&1 | grep -E 'SSL connection|subject:|issuer:|expire date'
# Force a TLS version range
curl --tlsv1.3 -sI https://example.com
curl --tlsv1.2 --tls-max 1.2 -sI https://example.com
# Trust a private CA for this call
curl --cacert ca.pem https://internal.example.com
# Client certificate (mTLS)
curl --cert client.pem --key client.key https://mtls.example.com-k shows the problem is certificate validation and nothing more. Do not leave it in a script: it accepts any certificate, including an attacker’s. curl does not check revocation unless you ask with --cert-status (OCSP stapling). For chain repair and openssl s_client, see TLS.
Proxies#
curl -x http://proxy.example.com:3128 https://api.example.com
export https_proxy=http://proxy.example.com:3128 no_proxy=.example.com,localhost,10.0.0.0/8
curl --proxy-user "alice:$PROXY_PASSWORD" -x http://proxy.example.com:3128 https://api.example.com
curl -x socks5h://localhost:1080 https://api.example.com # socks5h: the proxy resolves DNS
curl -sv https://api.example.com -o /dev/null 2>&1 | grep -iE 'CONNECT|proxy'curl reads lowercase http_proxy only; HTTPS_PROXY and https_proxy both work. no_proxy entries match the host itself or any name inside that domain (example.com matches www.example.com). CIDR ranges in no_proxy work from curl 7.86.0; other tools (Python requests, Go, Java) each have their own rules, so test the client you actually run. A proxy in the path shows as a CONNECT in verbose output for HTTPS, and often adds Via or X-Cache headers. See Squid for the proxy side.
--proxy-user on the command line is visible in ps. For anything shared, put credentials in a .netrc file or a config file read with -K.
Redirects#
-L follows Location headers. For 301, 302 and 303 curl may switch a POST to GET on the next request; 307 and 308 keep the method and body. curl drops Authorization and Cookie headers when a redirect leaves the original origin, unless --location-trusted is set.
# Every hop with its status and Location
curl -sIL https://example.com | grep -iE '^HTTP/|^location:'
# Final URL after all redirects
curl -o /dev/null -sw '%{url_effective}\n' -L https://example.comCaching#
A cache (browser, CDN, Squid) stores a response according to its Cache-Control and reuses it while its age is below max-age; Age says how long the cache has held it. A stale copy is revalidated with a conditional request, and a 304 answer costs no body.
| Directive | Where | Effect |
|---|---|---|
max-age=N | Response | Fresh for N seconds from the origin’s Date |
s-maxage=N | Response | Same, for shared caches only; overrides max-age there |
no-cache | Response | Store, but revalidate with the origin before every reuse |
no-store | Response | Never write to disk or memory; for credentials and personal data |
private | Response | Browser may cache, shared caches must not |
stale-while-revalidate=N | Response | Serve stale for N seconds while refreshing in the background |
Vary: Accept-Encoding | Response | Cache key includes that request header; Vary: * disables caching |
no-cache | Request | Force revalidation; what a browser hard reload sends |
no-cache means “always check”, not “do not cache”; no-store keeps a response out of caches. Validation pairs ETag with If-None-Match or Last-Modified with If-Modified-Since.
# See what a cache would do with this response
curl -sSI https://example.com/app.js | grep -iE '^(cache-control|etag|last-modified|age|vary|expires|x-cache):'
# Conditional GET: 304 if the ETag still matches
curl -so /dev/null -w '%{http_code}\n' -H 'If-None-Match: "5d8c72a5edda8d6a"' https://example.com/app.js
# Download again only when the ETag changed (7.68.0 and later)
curl -sS --etag-compare etag.txt --etag-save etag.txt -o app.js https://example.com/app.jsA CDN or proxy adds Age and a hit or miss header (X-Cache, CF-Cache-Status); two requests in a row with a rising Age prove the cache holds the object.
CORS#
CORS is enforced by browsers, not servers. A script on https://app.example.com calling https://api.example.com gets the response only if the API answers with Access-Control-Allow-Origin matching the page’s origin (or *). curl ignores CORS, so “works in curl, fails in the browser” with an Access-Control console error is a missing header, not a connectivity problem.
Any method other than GET, HEAD or POST, a non-form Content-Type, or a custom header such as Authorization triggers a preflight: the browser sends OPTIONS with Origin, Access-Control-Request-Method and Access-Control-Request-Headers, and sends the real request only if the answer allows them.
# Reproduce a preflight; the server must return 2xx and the Access-Control-* headers
curl -sS -X OPTIONS https://api.example.com/items \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: PUT' \
-H 'Access-Control-Request-Headers: authorization,content-type' \
-D - -o /dev/null | grep -iE '^(HTTP/|access-control-)'| Response header | Meaning |
|---|---|
Access-Control-Allow-Origin | The one origin allowed, or *; must echo the exact origin when credentials are used |
Access-Control-Allow-Methods | Methods permitted after preflight |
Access-Control-Allow-Headers | Request headers permitted after preflight |
Access-Control-Allow-Credentials: true | Cookies and Authorization may be sent; incompatible with * |
Access-Control-Expose-Headers | Response headers script may read beyond the safe set |
Access-Control-Max-Age | Seconds the browser may cache the preflight result |
A response that varies Access-Control-Allow-Origin per request must send Vary: Origin, or a shared cache serves one origin’s answer to another. Preflights carry no cookies or Authorization, so an endpoint that rejects unauthenticated OPTIONS breaks CORS.
Authentication#
| Scheme | Request header | curl |
|---|---|---|
| Basic | Authorization: Basic base64(user:pass) | -u "alice:$PASSWORD" or --basic |
| Bearer | Authorization: Bearer <token> | --oauth2-bearer "$API_TOKEN" or -H |
| Digest | Challenge-response with a server nonce | --digest -u "alice:$PASSWORD" |
| Negotiate (Kerberos / SPNEGO) | Authorization: Negotiate <ticket> | --negotiate -u : |
| AWS Signature v4 | Signed headers and canonical request | --aws-sigv4 aws:amz:ap-southeast-2:s3 -u "$AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" |
| Mutual TLS | Client certificate in the handshake | --cert client.pem --key client.key; see TLS |
Basic credentials are base64, not encrypted; never send them over plain HTTP. With -u and no scheme flag curl sends Basic immediately; --anyauth requests first and picks the strongest scheme from WWW-Authenticate at the cost of a round trip.
# Credentials from ~/.netrc (mode 0600, one line: machine api.example.com login alice password ...)
curl -n https://api.example.com/me # --netrc-file for another path
# OAuth2 client-credentials flow, then use the token
token=$(curl -fsS -u "$CLIENT_ID:$CLIENT_SECRET" -d 'grant_type=client_credentials' https://auth.example.com/oauth/token | jq -r .access_token)
curl -fsS --oauth2-bearer "$token" https://api.example.com/items-u on the command line is visible through ps and lands in shell history. Prefer -n, a config file passed with -K, or --variable %API_TOKEN --expand-header 'Authorization: Bearer {{API_TOKEN}}' (curl 8.3.0 and later), which reads the value from the environment.
HTTP/2 and HTTP/3#
HTTP/1.1 sends one request at a time per TCP connection. HTTP/2 multiplexes streams over one TLS connection and compresses headers; HTTP/3 does the same over QUIC (UDP 443), removing transport-level head-of-line blocking. The version is chosen through ALPN (h2, http/1.1) in the TLS handshake and, for HTTP/3, by an Alt-Svc: h3=":443" header on an earlier response.
# Which version was negotiated
curl -so /dev/null -w '%{http_version}\n' https://example.com
# ALPN offer and answer
curl -sv https://example.com -o /dev/null 2>&1 | grep -iE 'ALPN|using HTTP'
# HTTP/2 without TLS, for a backend behind a terminating proxy (no upgrade, straight h2c)
curl --http2-prior-knowledge http://localhost:8080/health
# Try HTTP/3 and fall back, or insist on it
curl --http3 -sI https://example.com
curl --http3-only -so /dev/null -w '%{http_version}\n' https://example.comLoad balancers usually terminate HTTP/2 at the edge and speak HTTP/1.1 to backends, so a 421 or a header-size error may come from the edge rather than the application. HTTP/3 needs UDP 443 open on every firewall in the path; when --http3-only fails but --http3 works, the fallback masked a blocked UDP path.
Cookies#
curl has no cookie engine until you turn it on with -b or -c. -b reads cookies (a file, or a literal name=value string), -c writes every cookie the server sets to a jar in Netscape format. Using the same file for both keeps a session across calls.
# Log in, keep the session cookie, use it
curl -sS -c jar.txt -d "username=alice&password=$PASSWORD" https://app.example.com/login -o /dev/null
curl -sS -b jar.txt https://app.example.com/dashboard
# Send a literal cookie without a file
curl -b 'session=abc123; theme=dark' https://app.example.com/
# What the server set, including attributes
curl -sSI https://app.example.com/login | grep -i '^set-cookie'A Secure cookie is never sent over http://; Domain=example.com sends it to every subdomain, no Domain restricts it to the host that set it. SameSite=Lax (the browser default) withholds the cookie on cross-site POSTs, a common cause of “logged out after the identity provider redirect”; curl ignores it. -b with an empty file still enables the engine, so cookies set during a redirect chain reach later hops.
Status codes in practice#
| Code | What it usually means |
|---|---|
301 / 308 | Permanent redirect; 308 must keep the method and body |
302 / 307 | Temporary redirect; 307 must keep the method and body |
304 | Not Modified: the client’s cached copy is current |
400 | Malformed request: check Content-Type and body encoding |
401 | Missing or invalid credentials; the WWW-Authenticate header says which scheme |
403 | Authenticated but not allowed, or blocked by a WAF or IP allowlist |
404 | Wrong path, or the route exists only on another virtual host |
405 | Right path, wrong method; Allow lists the valid ones |
408 | Server timed out waiting for the request |
409 | Conflict: optimistic concurrency failure, duplicate create |
413 / 414 | Content Too Large / URI Too Long for the server or a proxy in front |
421 | Misdirected Request: a reused HTTP/2 connection reached a server that does not serve this host |
429 | Rate limited; honour Retry-After |
499 | Not standard; nginx logs it when the client closed the connection first |
500 | Unhandled application error; the application logs have the detail |
502 | A proxy could not connect to the backend or got an invalid response |
503 | No healthy backend, maintenance, or load shedding; may carry Retry-After |
504 | A proxy connected, but the backend did not answer within the proxy’s timeout |
A 502 and a 504 from the same proxy point at different problems. 502 is connection-level (refused, reset, bad response); 504 is a timeout, and the timeout that fired is set in the proxy’s configuration, not the application’s. See RFC 9110 for definitions.
Using curl in scripts#
if ! body=$(curl -fsS --max-time 10 --retry 3 --retry-connrefused https://api.example.com/health); then
printf 'health check failed\n' >&2
exit 1
fitmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
code=$(curl -sS -o "$tmp/body" -D "$tmp/headers" -w '%{http_code}' --max-time 30 \
--json @payload.json https://api.example.com/items)
case $code in
2*) ;;
429) sleep "$(awk 'tolower($1) == "retry-after:" {print $2}' "$tmp/headers" | tr -d '\r')" ;;
*) printf 'unexpected %s: %s\n' "$code" "$(cat "$tmp/body")" >&2; exit 1 ;;
esacAlways set --max-time. Without it a server that accepts the connection and never answers hangs the job indefinitely. --retry retries only transient errors: timeouts and HTTP 408, 429, 500, 502, 503, 504, 522 and 524. Add --retry-connrefused for refused connections, or --retry-all-errors to retry anything. See Bash for error handling around these calls.
Retries back off from one second and double unless --retry-delay fixes the interval; --retry-max-time 60 caps the whole sequence. -w '%{json}' prints every write-out variable as one JSON object for logging; %{exitcode}, %{errormsg} and %{num_retries} record what went wrong and how many retries it took.
curl -sS -o /dev/null -w '%{json}\n' --retry 3 --max-time 10 https://api.example.com/health \
| jq '{code: .http_code, total: .time_total, retries: .num_retries, ip: .remote_ip, err: .errormsg}'Troubleshooting#
| Symptom | Likely cause | Check |
|---|---|---|
curl -I returns 405 or different headers from GET | Server handles HEAD differently | curl -sS -D - -o /dev/null URL sends a GET |
| JSON API returns 400 or 415 | -d sent form encoding | Use --json or set Content-Type |
| Signature or checksum mismatch on upload | -d @file stripped newlines | Use --data-binary @file |
| 401 after a redirect | Authorization dropped on a cross-origin redirect | curl -v shows the second request; use the final URL directly |
| Works in a browser, fails in curl | Missing intermediate certificate, cookies, or a WAF blocking the User-Agent | curl -v TLS lines; compare headers from browser dev tools |
| Works with IP, fails with hostname (or the reverse) | Virtual host or SNI routing | --resolve keeps name and SNI while choosing the IP |
| Intermittent 502 behind a load balancer | Backend closes idle keep-alive connections before the balancer does | Backend idle timeout must exceed the balancer’s |
| Unexpected proxy use or bypass | http_proxy, https_proxy, no_proxy in the environment | env | grep -i _proxy; curl -v for CONNECT |
| Hangs with no output | No --max-time; firewall dropping packets | --connect-timeout 3 -v to see which phase stalls |
| Compressed garbage in output | Server sent compressed body without being asked, or --compressed omitted | curl -sI for Content-Encoding; add --compressed |
Browser console shows blocked by CORS policy, curl works | Missing Access-Control-Allow-Origin, or the preflight OPTIONS is rejected | Replay the OPTIONS request with Origin and Access-Control-Request-Method headers |
| Stale content after a deploy | Long max-age at a CDN or in the browser, or Vary missing | curl -sSI for Cache-Control, Age, X-Cache; purge or version the asset name |
| Login works, next request is unauthenticated | Cookie engine off, or cookie marked Secure sent over http:// | Use -c jar.txt -b jar.txt on both calls; check Set-Cookie attributes |
401 with WWW-Authenticate: Digest | Basic sent to a Digest-only server | --digest -u, or --anyauth |
--http3 works, --http3-only fails | UDP 443 blocked somewhere in the path | Test with --http3-only; open UDP 443 or accept the fallback |
--retry did not retry | Error is not on curl’s transient list (for example 404, 401 or connection refused) | Add --retry-connrefused or --retry-all-errors; check %{num_retries} |
Oneliners#
# Response headers of a GET, sorted
curl -sS -D - -o /dev/null https://example.com | sort
# Poll until a service is healthy
until curl -fsS --max-time 2 http://localhost:8080/health >/dev/null; do sleep 1; done
# 50 requests, 10 at a time, count the status codes
seq 50 | xargs -P10 -I{} curl -o /dev/null -sw '%{http_code}\n' https://api.example.com/ | sort | uniq -c
# Compare JSON config between two environments (needs jq)
diff <(curl -s https://staging.example.com/api/config | jq -S .) <(curl -s https://prod.example.com/api/config | jq -S .)
# Resume a partial download
curl -C - -O https://example.com/big.iso
# Send an HMAC-signed webhook; the secret comes from the environment
body='{"event":"test"}'; sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $NF}'); curl -X POST -H "X-Signature: sha256=$sig" -H 'Content-Type: application/json' --data-binary "$body" https://api.example.com/hook
# Negotiated HTTP version and scheme
curl -so /dev/null -w '%{http_version} %{scheme}\n' https://example.com
# Plain HTTP virtual host test against an IP
curl -H 'Host: api.example.com' http://192.0.2.10/health
# Slowest of several endpoints
for u in "${urls[@]}"; do printf '%s %s\n' "$(curl -o /dev/null -sw '%{time_total}' "$u")" "$u"; done | sort -rn | head
# Every write-out variable as JSON, for a log line
curl -sS -o /dev/null -w '%{json}\n' https://api.example.com/health
# Response headers as JSON (7.83.0 and later)
curl -sS -o /dev/null -w '%{header_json}\n' https://example.com | jq '.["cache-control"]'
# Which IP and port curl actually talked to (after DNS and redirects)
curl -so /dev/null -w '%{remote_ip}:%{remote_port} %{http_code}\n' -L https://example.com
# Talk to a service on a Unix socket (Docker, Podman, systemd-style daemons)
curl -sS --unix-socket /var/run/docker.sock http://localhost/version
# HEAD every URL in a file, 10 at a time, print code and URL
xargs -a urls.txt -P10 -I{} curl -so /dev/null -w '%{http_code} {}\n' -I {}
# Bearer token from the environment without putting it on the command line (8.3.0 and later)
curl --variable %API_TOKEN --expand-header 'Authorization: Bearer {{API_TOKEN}}' https://api.example.com/meScripts#
Sample an endpoint N times and print percentile latency per phase, so a slow DNS resolver or TLS terminator is separated from a slow application.
#!/usr/bin/env bash
# usage: http-latency.sh URL [samples]
set -euo pipefail
url=${1:?url required}; n=${2:-20}
tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT
for _ in $(seq "$n"); do
curl -sS -o /dev/null --max-time 15 \
-w '%{time_namelookup} %{time_connect} %{time_appconnect} %{time_starttransfer} %{time_total} %{http_code}\n' \
"$url" >>"$tmp" || true
done
printf '%-8s %8s %8s %8s\n' phase p50 p90 max
for col in 1:dns 2:tcp 3:tls 4:ttfb 5:total; do
i=${col%%:*}; name=${col#*:}
sort -k"$i" -n "$tmp" | awk -v i="$i" -v name="$name" \
'{v[NR]=$i} END {printf "%-8s %8.3f %8.3f %8.3f\n", name, v[int(NR*0.5)+1], v[int(NR*0.9)+1], v[NR]}'
done
printf 'codes: '; awk '{print $6}' "$tmp" | sort | uniq -c | tr '\n' ' '; echoCheck a list of URLs and report any whose status, latency or certificate expiry crosses a threshold, for a cron job that emails or pages.
#!/usr/bin/env bash
# usage: http-check.sh urls.txt (one URL per line, # comments allowed)
set -euo pipefail
list=${1:?url list required}; max_ms=${MAX_MS:-2000}; min_days=${MIN_CERT_DAYS:-14}
rc=0
while IFS= read -r url; do
[[ -z $url || $url == \#* ]] && continue
out=$(curl -sS -o /dev/null --max-time 10 -w '%{http_code} %{time_total} %{exitcode} %{errormsg}' "$url" || true)
read -r code total exit_code msg <<<"$out"
ms=$(awk -v t="$total" 'BEGIN {printf "%d", t * 1000}')
if [[ $exit_code != 0 ]]; then printf 'FAIL %s curl(%s): %s\n' "$url" "$exit_code" "$msg"; rc=1; continue; fi
[[ $code == 2* || $code == 3* ]] || { printf 'FAIL %s status %s\n' "$url" "$code"; rc=1; }
(( ms <= max_ms )) || { printf 'SLOW %s %sms\n' "$url" "$ms"; rc=1; }
if [[ $url == https://* ]]; then
host=${url#https://}; host=${host%%/*}; host=${host%%:*}
exp=$(echo | openssl s_client -servername "$host" -connect "$host:443" 2>/dev/null | openssl x509 -noout -enddate | cut -d= -f2)
days=$(( ($(date -d "$exp" +%s) - $(date +%s)) / 86400 ))
(( days >= min_days )) || { printf 'CERT %s expires in %d days\n' "$url" "$days"; rc=1; }
fi
done <"$list"
exit "$rc"