diff --git a/docs/FALLBACK_IP_TLS.md b/docs/FALLBACK_IP_TLS.md new file mode 100644 index 0000000..22995fc --- /dev/null +++ b/docs/FALLBACK_IP_TLS.md @@ -0,0 +1,118 @@ +# Fallback IP Routing And TLS Verification + +The app stores the server as a normal URL plus optional fallback IP addresses. +The fallback IPs are used as DNS answers for the URL host; the app does not +rewrite the request URL to an IP address. + +## Where It Is Implemented + +- `ServerProfile.baseUrl` and `ServerProfile.fallbackIpAddresses` store the URL + and fallback list in `app/src/main/java/net/rodakot/ngxhttpmonitoringclient/model/MonitorModels.kt`. +- The editor exposes those values as `Base URL` and `Fallback LAN IPs` in + `app/src/main/java/net/rodakot/ngxhttpmonitoringclient/ui/MonitorApp.kt`. +- `MonitorRepository.buildServer()` normalizes the URL, checks whether plain + HTTP is allowed, and parses fallback addresses in + `app/src/main/java/net/rodakot/ngxhttpmonitoringclient/data/MonitorRepository.kt`. +- `RouteAttemptPlanner.attempts()` tries `SystemDns` first, then `FallbackDns` + when fallback addresses exist. When a VPN is active and a LAN network is + available, it also adds `LanSystemDns` and `LanFallbackDns`. +- `MonitorHttpClient.clientFor()` installs a custom OkHttp `Dns` implementation + for fallback routes. + +## Request Flow + +For a server configured like this: + +```text +Base URL: https://monitor.example.com +Fallback LAN IPs: 192.168.1.20, 10.0.0.5 +``` + +the app builds endpoint URLs from the base URL: + +```text +https://monitor.example.com/monitor/api +https://monitor.example.com/monitor/health +https://monitor.example.com/monitor/live +``` + +The first request uses normal system DNS. If that network request fails before +a usable HTTP response is returned, the app tries the fallback route. In that +route, OkHttp still receives a request for `https://monitor.example.com/...`, +but the custom `FallbackDns` returns `192.168.1.20` and `10.0.0.5` when OkHttp +asks how to resolve `monitor.example.com`. + +The LAN variants do the same thing through Android's LAN network socket factory +when the active device state includes both VPN and Wi-Fi or Ethernet. + +## Why TLS Still Works + +TLS verification is based on the URL hostname, not the numeric socket address. +Because the app keeps the request URL as `https://monitor.example.com/...`, +OkHttp still uses `monitor.example.com` for: + +- SNI during the TLS handshake. +- Certificate hostname verification after the certificate is received. + +That means the server certificate must be valid for `monitor.example.com`. +It does not need to contain `192.168.1.20` as an IP subject alternative name +because the client is not asking TLS to verify `192.168.1.20`. + +This is different from requesting: + +```text +https://192.168.1.20/monitor/api +``` + +With that URL, the TLS hostname is `192.168.1.20`, so the certificate would +need to be valid for the IP address. Adding only an HTTP `Host` header is not +enough to fix that because TLS verification happens before the HTTP request is +processed. + +If the fallback IP reaches a server that does not present a certificate valid +for the original URL host, OkHttp throws an SSL error and the app reports it as +`TlsFailure`. + +## Bash Equivalent + +The same behavior in curl is `--resolve`: + +```bash +curl --resolve monitor.example.com:443:192.168.1.20 \ + https://monitor.example.com/monitor/api +``` + +`--resolve` says "connect to this IP for this host and port." The URL still +uses `monitor.example.com`, so curl sends SNI for `monitor.example.com` and +verifies the certificate against `monitor.example.com`. + +The sample script in this directory wraps that pattern: + +```bash +bash docs/fallback-ip-curl-example.sh \ + --base-url https://monitor.example.com \ + --fallback-ip 192.168.1.20 \ + --fallback-ip 10.0.0.5 \ + --token '' +``` + +For Basic Auth: + +```bash +bash docs/fallback-ip-curl-example.sh \ + --base-url https://monitor.example.com \ + --fallback-ips "192.168.1.20,10.0.0.5" \ + --basic-auth 'user:password' +``` + +For the health endpoint: + +```bash +bash docs/fallback-ip-curl-example.sh \ + --base-url https://monitor.example.com \ + --fallback-ip 192.168.1.20 \ + --endpoint /monitor/health +``` + +Do not use `--insecure` for this behavior. `--insecure` disables certificate +verification, while the app's fallback keeps verification enabled. diff --git a/docs/fallback-ip-curl-example.sh b/docs/fallback-ip-curl-example.sh new file mode 100644 index 0000000..bc689bf --- /dev/null +++ b/docs/fallback-ip-curl-example.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -u + +usage() { + cat <<'USAGE' +Usage: + bash docs/fallback-ip-curl-example.sh --base-url URL --fallback-ip IP [options] + +Required: + --base-url URL Server root URL, for example https://monitor.example.com + --fallback-ip IP Fallback address for the URL host. Can be repeated. + --fallback-ips "LIST" Comma or space separated fallback addresses. + +Options: + --endpoint PATH Monitor endpoint path. Default: /monitor/api + --token TOKEN Send TOKEN as X-Monitor-Token. + --basic-auth USER:PASS Send HTTP Basic Auth credentials. + --connect-timeout SEC Curl connection timeout. Default: 8 + --max-time SEC Curl total request timeout. Default: 20 + -h, --help Show this help. + +Example: + bash docs/fallback-ip-curl-example.sh \ + --base-url https://monitor.example.com \ + --fallback-ip 192.168.1.20 \ + --fallback-ip 10.0.0.5 \ + --token secret + +The fallback keeps the URL host as monitor.example.com and only overrides DNS +with curl --resolve. For HTTPS, curl still sends SNI for that host and verifies +the certificate against that host. +USAGE +} + +die() { + printf '%s\n' "$1" >&2 + printf '\n' >&2 + usage >&2 + exit 2 +} + +add_fallback_ips() { + local values ip + values="${1//,/ }" + for ip in $values; do + fallback_list+=("$ip") + done +} + +base_url="" +endpoint="/monitor/api" +token="" +basic_auth="" +connect_timeout="8" +max_time="20" +fallback_list=() + +while (($# > 0)); do + case "$1" in + --base-url) + (($# >= 2)) || die "--base-url requires a value" + base_url="$2" + shift 2 + ;; + --base-url=*) + base_url="${1#*=}" + shift + ;; + --fallback-ip) + (($# >= 2)) || die "--fallback-ip requires a value" + add_fallback_ips "$2" + shift 2 + ;; + --fallback-ip=*) + add_fallback_ips "${1#*=}" + shift + ;; + --fallback-ips) + (($# >= 2)) || die "--fallback-ips requires a value" + add_fallback_ips "$2" + shift 2 + ;; + --fallback-ips=*) + add_fallback_ips "${1#*=}" + shift + ;; + --endpoint) + (($# >= 2)) || die "--endpoint requires a value" + endpoint="$2" + shift 2 + ;; + --endpoint=*) + endpoint="${1#*=}" + shift + ;; + --token) + (($# >= 2)) || die "--token requires a value" + token="$2" + shift 2 + ;; + --token=*) + token="${1#*=}" + shift + ;; + --basic-auth) + (($# >= 2)) || die "--basic-auth requires a value" + basic_auth="$2" + shift 2 + ;; + --basic-auth=*) + basic_auth="${1#*=}" + shift + ;; + --connect-timeout) + (($# >= 2)) || die "--connect-timeout requires a value" + connect_timeout="$2" + shift 2 + ;; + --connect-timeout=*) + connect_timeout="${1#*=}" + shift + ;; + --max-time) + (($# >= 2)) || die "--max-time requires a value" + max_time="$2" + shift 2 + ;; + --max-time=*) + max_time="${1#*=}" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1" + ;; + esac +done + +[[ -n "$base_url" ]] || die "--base-url is required" +((${#fallback_list[@]} > 0)) || die "At least one --fallback-ip or --fallback-ips value is required" + +base_url="${base_url%/}" +[[ "$endpoint" == /* ]] || endpoint="/$endpoint" +url="${base_url}${endpoint}" + +if [[ "$base_url" =~ ^([A-Za-z][A-Za-z0-9+.-]*)://([^/]+) ]]; then + scheme="${BASH_REMATCH[1]}" + authority="${BASH_REMATCH[2]}" +else + die "--base-url must be an http or https URL" +fi + +if [[ "$authority" == *"@"* ]]; then + die "Put Basic Auth in --basic-auth USER:PASS, not in the URL" +fi + +if [[ "$authority" =~ ^\[([^]]+)\](:([0-9]+))?$ ]]; then + host="${BASH_REMATCH[1]}" + port="${BASH_REMATCH[3]:-}" +elif [[ "$authority" =~ ^([^:]+)(:([0-9]+))?$ ]]; then + host="${BASH_REMATCH[1]}" + port="${BASH_REMATCH[3]:-}" +else + die "Could not parse host and port from $authority" +fi + +case "$scheme" in + [Hh][Tt][Tt][Pp][Ss]) port="${port:-443}" ;; + [Hh][Tt][Tt][Pp]) port="${port:-80}" ;; + *) die "Only http and https URLs are supported" ;; +esac + +curl_args=( + --silent + --show-error + --location + --connect-timeout "$connect_timeout" + --max-time "$max_time" + --header "Accept: application/json" +) + +if [[ -n "$token" ]]; then + curl_args+=(--header "X-Monitor-Token: $token") +fi + +if [[ -n "$basic_auth" ]]; then + curl_args+=(--user "$basic_auth") +fi + +request_once() { + local body_file http_code rc + + body_file="$(mktemp)" + http_code="$(curl "${curl_args[@]}" "$@" --output "$body_file" --write-out '%{http_code}' "$url")" + rc=$? + + if ((rc != 0)); then + rm -f "$body_file" + return "$rc" + fi + + cat "$body_file" + rm -f "$body_file" + + if [[ "$http_code" == 2* ]]; then + return 0 + fi + + printf '\nHTTP %s from monitor endpoint\n' "$http_code" >&2 + return 22 +} + +printf 'Trying normal DNS for %s\n' "$host" >&2 +if request_once; then + exit 0 +fi + +rc=$? +if ((rc == 22)); then + exit "$rc" +fi + +printf 'Normal request failed before a usable HTTP response; trying fallback IPs\n' >&2 +last_rc="$rc" + +for ip in "${fallback_list[@]}"; do + [[ -n "$ip" ]] || continue + resolve_ip="$ip" + if [[ "$resolve_ip" == *:* && "$resolve_ip" != \[*\] ]]; then + resolve_ip="[$resolve_ip]" + fi + + printf 'Trying %s via --resolve %s:%s:%s\n' "$ip" "$host" "$port" "$resolve_ip" >&2 + if request_once --resolve "${host}:${port}:${resolve_ip}"; then + exit 0 + fi + last_rc=$? +done + +exit "$last_rc"