diff --git a/testing/stun_dispatcher.sh b/testing/stun_dispatcher.sh new file mode 100755 index 0000000..b321d75 --- /dev/null +++ b/testing/stun_dispatcher.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# stun_dispatcher.sh — long-running STUN send/receive handler. +# +# Run as: socat "UDP4:REMOTE:PORT,bind=LOCAL:PORT" "EXEC:./stun_dispatcher.sh,nofork" +# +# With nofork, socat wires this script directly to the connected UDP socket: +# stdin — one complete datagram per socat read() from the network +# stdout — each write() call becomes one UDP sendmsg() to the peer +# +# The script first sends our Binding Request (proactive check, controlling +# agent with USE-CANDIDATE), then enters a loop reading packets from stdin. +# STUN's own length field (bytes 2-3) is used to frame datagrams out of the +# byte-stream pipe, so no external framing is needed. +# +# Required env vars (exported by sft_call.sh): +# LOCAL_ICE_UFRAG, LOCAL_ICE_PWD — our ICE credentials +# REMOTE_UFRAG, REMOTE_PWD — SFT's ICE credentials +# REMOTE_IP, REMOTE_PORT — SFT's candidate address +# WORK — temp dir for the ice_success flag + +set -euo pipefail + +log() { echo "[$(date -u +%T)] stun: $*" >&2; } + +# ── read exactly N bytes from stdin as a lowercase hex string ───────────────── +# Uses dd with iflag=fullblock (GNU coreutils) which retries short reads until +# the full count is satisfied or EOF is reached. + +read_hex() { + dd bs="$1" count=1 iflag=fullblock 2>/dev/null | hexdump -v -e '1/1 "%02x"' | tr -d '\n' +} + +hex2bin() { perl -e 'local $/; my $h = ; $h =~ s/\s//g; print pack "H*", $h'; } +bin2hex() { perl -e 'local $/; $d=; print unpack("H*", $d)'; } + +# ── build a STUN attribute block and append MESSAGE-INTEGRITY + FINGERPRINT ─── +# Args: $1 = msg_type_hex (4 chars), $2 = txn_id_hex (24 chars), +# $3 = attrs_hex (variable), $4 = hmac_key (text string) +# Writes the complete packet hex to stdout (as a string, not binary). + +build_stun_packet() { + local msg_type="$1" txn_id="$2" attrs="$3" hmac_key="$4" + local attrs_bytes mi_total fp_total len_with_mi len_with_fp + local msg_for_mi hmac mi_attr msg_pre_fp crc_le crc_be fp_val + + attrs_bytes=$(( ${#attrs} / 2 )) + mi_total=24 # 4-byte header + 20-byte HMAC-SHA1 value + fp_total=8 # 4-byte header + 4-byte CRC32 value + + # MESSAGE-INTEGRITY: HMAC input uses length that includes MI but not FP + len_with_mi=$(( attrs_bytes + mi_total )) + msg_for_mi="${msg_type}$(printf '%04x' $len_with_mi)2112a442${txn_id}${attrs}" + + hmac=$(printf '%s' "$msg_for_mi" | hex2bin \ + | openssl dgst -sha1 -hmac "$hmac_key" -binary \ + | bin2hex | tr -d '\n') + mi_attr="00080014${hmac}" + # FINGERPRINT: CRC32 of message up to (not including) FP, with FP in length + len_with_fp=$(( attrs_bytes + mi_total + fp_total )) + msg_pre_fp="${msg_type}$(printf '%04x' $len_with_fp)2112a442${txn_id}${attrs}${mi_attr}" + + # gzip embeds standard CRC32 (little-endian) in its last 8 bytes + crc_le=$(printf '%s' "$msg_pre_fp" | hex2bin \ + | gzip -c | tail -c 8 | head -c 4 | hexdump -v -e '1/1 "%02x"' | tr -d '\n') + crc_be="${crc_le:6:2}${crc_le:4:2}${crc_le:2:2}${crc_le:0:2}" + fp_val=$(printf '%08x' $(( 16#$crc_be ^ 0x5354554e ))) + + printf '%s' "${msg_pre_fp}80280004${fp_val}" +} + +# ── 1. Send our Binding Request (controlling agent, USE-CANDIDATE) ───────────── +# Writing to stdout before reading stdin is safe: socat's pipe buffers the +# outbound datagram and sends it as soon as the socket is writable, while we +# then block on stdin awaiting the SFT's response and its own checks. + +TXN=$(openssl rand -hex 12) + +pad_hex() { + local n="$1" + [ "$n" -eq 0 ] && return + printf '%*s' "$((n * 2))" '' | tr ' ' 0 +} + +USERNAME="${REMOTE_UFRAG}:${LOCAL_ICE_UFRAG}" +UN_HEX=$(printf '%s' "$USERNAME" | bin2hex | tr -d '\n') +UN_LEN=${#USERNAME} +UN_PAD=$(( (4 - (UN_LEN % 4)) % 4 )) +UN_ATTR="0006$(printf '%04x' $UN_LEN)${UN_HEX}$(pad_hex "$UN_PAD")" + +PRIORITY_ATTR="002400040000d401" # standard host priority +ICE_ROLE_ATTR="80290008$(openssl rand -hex 8)" # ICE-CONTROLED + +ATTRS="${UN_ATTR}${PRIORITY_ATTR}${ICE_ROLE_ATTR}" + +REQUEST_HEX=$(build_stun_packet "0001" "$TXN" "$ATTRS" "$REMOTE_PWD") +log "→ Binding Request txn=${TXN}" +printf '%s' "$REQUEST_HEX" | hex2bin # one write() = one UDP datagram + +# ── 2. Packet read loop ──────────────────────────────────────────────────────── +# STUN fixed header is always 20 bytes: +# [0-1] message type +# [2-3] attribute section length ← used for framing +# [4-7] magic cookie (0x2112a442) +# [8-19] transaction ID +# We read the header first, then read exactly that many more bytes for attributes. + +# XOR-MAPPED-ADDRESS values are constant for this session: we always +# respond to the SFT with its own address (REMOTE_IP:REMOTE_PORT). +IFS='.' read -r -a octs <<< "$REMOTE_IP" +PEER_IP_HEX=$(printf '%02x%02x%02x%02x' "${octs[@]}") +PEER_XOR_PORT=$(printf '%04x' $(( REMOTE_PORT ^ 0x2112 ))) +PEER_XOR_IP=$( printf '%08x' $(( 16#$PEER_IP_HEX ^ 0x2112a442 ))) +# type=0020 len=0008 reserved=00 family=01 xor_port xor_ip +XMA="002000080001${PEER_XOR_PORT}${PEER_XOR_IP}" + +while true; do + # ── read fixed header ────────────────────────────────────────────────────── + header_hex=$(read_hex 20) + [ ${#header_hex} -eq 40 ] || { log "short header or EOF — exiting"; break; } + + msg_type="${header_hex:0:4}" + attr_bytes=$(( 16#${header_hex:4:4} )) + magic="${header_hex:8:8}" + rxn_id="${header_hex:16:24}" + + # ── read attribute section ───────────────────────────────────────────────── + if [ "$attr_bytes" -gt 0 ]; then + attrs_hex=$(read_hex "$attr_bytes") + else + attrs_hex="" + fi + + [ "$magic" = "2112a442" ] || { log "bad magic $magic — dropping"; continue; } + + # ── dispatch ─────────────────────────────────────────────────────────────── + case "$msg_type" in + + 0001) # Binding Request from SFT — send Success Response + log "← Binding Request txn=${rxn_id}" + RESP_HEX=$(build_stun_packet "0101" "$rxn_id" "$XMA" "$LOCAL_ICE_PWD") + log "→ Binding Success Resp txn=${rxn_id}" + printf '%s' "$RESP_HEX" | hex2bin + ## This is the last packet we want to handle. + touch "${WORK}/ice_success" + ;; + + 0101) # Binding Success Response — our outbound check was acknowledged + log "← Binding Success Resp txn=${rxn_id} (matches our request: $([ "$rxn_id" = "$TXN" ] && echo yes || echo no))" + if [ "$rxn_id" != "$TXN" ]; then + log "ignoring success response for unknown transaction: $rxn_id" + fi + # Stay in the loop: the SFT may send more checks, and we should keep + # responding so it maintains the nominated candidate pair. + ;; + + 0111) # Binding Error Response + err_code="${attrs_hex:0:16}" # first attribute likely contains the error code + log "← Binding Error Resp txn=${rxn_id} attrs=${err_code}..." + ;; + + *) + log "← unknown type=${msg_type} txn=${rxn_id} — dropping" + ;; + esac +done + diff --git a/testing/test_coturn.sh b/testing/test_coturn.sh new file mode 100755 index 0000000..b15c270 --- /dev/null +++ b/testing/test_coturn.sh @@ -0,0 +1,522 @@ +#!/usr/bin/env bash +# test_coturn.sh — coturn connectivity tests for Wire/Kubernetes deployments. +# +# Tests the subset of the coturn remote test plan that is valid against the +# Wire Helm chart configuration: +# +# Always run (plain transports): +# 2.1 TURN 401 challenge / UDP — unauthenticated Allocate → 401 + REALM + NONCE +# 2.2 TURN 401 challenge / TCP +# +# With --tls (requires tls.enabled=true in Helm values): +# 2.3 TURN 401 challenge / TLS +# 3.1 TLS certificate validity — not expired, chains to expected CA +# 3.2 TLS version negotiation — TLS 1.2/1.3 accepted; 1.0/1.1 rejected +# 3.3 Cipher suite enforcement — strong ciphers accepted; NULL/EXPORT rejected +# 3.5 ALPN negotiation — stun.turn / stun.nat-discovery tokens +# +# With --metrics (requires metrics to be exposed!): +# 5.1 Prometheus health — GET / on metrics port → HTTP 200 +# 5.2 Prometheus metrics scrape — GET /metrics → valid exposition format +# 5.4 Metrics reflect challenge — allocation counter unchanged after 2.1+2.2 +# +# Omitted (invalid against our config): +# 1.x STUN Binding — excluded by secure-stun (requires MESSAGE-INTEGRITY) +# 1.4 STUN/DTLS — excluded by no-dtls +# 1.5 RFC 5780 — excluded by no-rfc5780 +# 2.4 TURN/DTLS — excluded by no-dtls +# 4.1 ACME — not configured in Helm chart template +# +# DEFAULTS: +# Defaults to not performing TLS checks, and no metrics tests. +# +# USAGE: +# export TURN_HOST= +# export TURN_PORT=3478 # default: 3478 +# export TURN_TLS_PORT=5349 # default: 5349 +# export PROM_HOST= # default: TURN_HOST +# export PROM_PORT=9641 # default: 9641 +# ./test_coturn.sh [--tls|--no-tls|--metrics|--no-metrics] +# +# DEPENDENCIES: bash, socat, openssl, curl, perl, gzip + +set -euo pipefail + +# ── argument parsing ─────────────────────────────────────────────────────────── + +# TLS and metrics testing are off by default. +TLS=0 +METRICS=0 +VERBOSE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --tls) TLS=1; shift ;; + --no-tls) TLS=0; shift ;; + --metrics) METRICS=1; shift;; + --no-metrics) METRICS=0; shift;; + --verbose) VERBOSE=1; shift;; + *) echo "Usage: $0 [--tls|--no-tls|--metrics|--no-metrics]" >&2; exit 1 ;; + esac +done + +# ── configuration ────────────────────────────────────────────────────────────── + +HOST="${TURN_HOST:-coturn-0.coturn.calling-staging-v01.zinfra.io}" +PORT="${TURN_PORT:-3478}" +TLS_PORT="${TURN_TLS_PORT:-5349}" +PROM_HOST="${PROM_HOST:-$HOST}" +PROM_PORT="${PROM_PORT:-9641}" + +export WORK +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +PASS=0 +FAIL=0 +SKIP=0 + +# ── helpers ──────────────────────────────────────────────────────────────────── + +log() { echo "[$(date -u +%T)] $*" >&2; } +die() { echo "ERROR: $2" >&2; exit "$1"; } +pass() { printf '\033[32m✓\033[0m %s\n' "$1"; PASS=$(( PASS + 1 )); } +fail() { printf '\033[31m✗\033[0m %s — %s\n' "$1" "$2"; FAIL=$(( FAIL + 1 )); } +skipTLS() { [ "$VERBOSE" -eq 1 ] && printf '\033[33m–\033[0m %s\n' "$1 (skipped: needs --tls)"; SKIP=$(( SKIP + 1 )); } +skipMetrics() { [ "$VERBOSE" -eq 1 ] && printf '\033[33m–\033[0m %s\n' "$1 (skipped: needs --metrics)"; SKIP=$(( SKIP + 1 )); } +now_ms() { perl -MTime::HiRes -e 'printf "%.3f\n", Time::HiRes::time()' | tr -d '.'; } + +hex2bin() { perl -e 'local $/; my $h = ; $h =~ s/\s//g; print pack "H*", $h'; } +bin2hex() { perl -e 'local $/; $d=; print unpack("H*", $d)'; } + +# ── STUN packet builder ──────────────────────────────────────────────────────── +# +# Builds a STUN packet with FINGERPRINT but without MESSAGE-INTEGRITY. +# MESSAGE-INTEGRITY requires a shared password, which is not available in +# unauthenticated flows. FINGERPRINT is always included so that coturn's +# built-in check does not discard the packet before processing it. +# +# RFC 5389 §15.5: FINGERPRINT = CRC32(message excluding FP attr) XOR 0x5354554e. +# The Message Length field counts all attributes including FINGERPRINT. +# +# Args: $1=msg_type (4 hex), $2=txn_id (24 hex), $3=attrs_hex (may be empty) + +build_stun_fp() { + local msg_type="$1" txn_id="$2" attrs="${3:-}" + + local attrs_bytes=$(( ${#attrs} / 2 )) + local fp_size=8 # 4-byte attr header + 4-byte CRC32 + local total_len=$(( attrs_bytes + fp_size )) + + local msg_pre_fp="${msg_type}$(printf '%04x' "$total_len")2112a442${txn_id}${attrs}" + + # CRC32 via the gzip trailer: bytes -8..-5 of a gzip stream are the CRC32 + # of the payload, stored little-endian. + local crc_le + crc_le=$(printf '%s' "$msg_pre_fp" \ + | hex2bin | gzip -c | tail -c 8 | head -c 4 | bin2hex | tr -d '\n') + + local crc_be="${crc_le:6:2}${crc_le:4:2}${crc_le:2:2}${crc_le:0:2}" + local fp_val + fp_val=$(printf '%08x' $(( 16#$crc_be ^ 0x5354554e ))) + + printf '%s' "${msg_pre_fp}80280004${fp_val}" +} + +# ── STUN attribute helpers ───────────────────────────────────────────────────── + +# Walk a STUN attribute section and return the value hex of the first attribute +# matching $target_type (4 hex chars). Exits 1 if not found. +find_attr() { + local attrs_hex="$1" target="$2" + local i=0 + while [ $(( i + 8 )) -le ${#attrs_hex} ]; do + local atype="${attrs_hex:$i:4}" + local alen=$(( 16#${attrs_hex:$(( i + 4 )):4} )) + local aval="${attrs_hex:$(( i + 8 )):$(( alen * 2 ))}" + if [ "$atype" = "$target" ]; then printf '%s' "$aval"; return 0; fi + local padded=$(( ( (alen + 3) / 4 ) * 4 )) + i=$(( i + 8 + padded * 2 )) + done + return 1 +} + +# Decode ERROR-CODE attribute value hex to an integer (e.g. 401). +# Layout: 2 reserved bytes | class (×100) | number. +decode_error_code() { + local v="$1" + echo $(( 16#${v:4:2} * 100 + 16#${v:6:2} )) +} + +# ── transport: send and capture first response ───────────────────────────────── +# +# Returns the response as a hex string on stdout; returns empty string on +# timeout or connection failure (never exits non-zero). + +send_udp() { + local host="$1" port="$2" req_hex="$3" + printf '%s' "$req_hex" | hex2bin \ + | socat -T 3 - "UDP4:${host}:${port}" 2>/dev/null \ + | bin2hex || true +} + +send_tcp() { + local host="$1" port="$2" req_hex="$3" + printf '%s' "$req_hex" | hex2bin \ + | socat -T 3 - "TCP4:${host}:${port}" 2>/dev/null \ + | bin2hex || true +} + +# Feed the request to openssl s_client, sleep 2 s so the response arrives +# before stdin closes and the session tears down. +send_tls() { + local host="$1" port="$2" req_hex="$3" + ( printf '%s' "$req_hex" | hex2bin; sleep 2 ) \ + | openssl s_client -connect "${host}:${port}" -quiet 2>/dev/null \ + | bin2hex || true +} + +# ── assertion: TURN 401 challenge ───────────────────────────────────────────── +# +# Validates a STUN Error Response for an Allocate (type 0113). +# Checks magic cookie, message type, error code == 401, REALM present, +# NONCE present. All three are required for a well-formed 401 challenge +# (RFC 5766 §6.2). + +check_401_response() { + local name="$1" resp_hex="$2" elapsed_ms="$3" + + if [ -z "$resp_hex" ] || [ ${#resp_hex} -lt 40 ]; then + fail "$name" "no response received"; return + fi + + local rtype="${resp_hex:0:4}" + local alen=$(( 16#${resp_hex:4:4} )) + local magic="${resp_hex:8:8}" + local attrs="${resp_hex:40:$(( alen * 2 ))}" + + if [ "$magic" != "2112a442" ]; then + fail "$name" "bad magic cookie: expected 2112a442 got $magic"; return + fi + if [ "$rtype" != "0113" ]; then + fail "$name" "expected Allocate Error (0113), got $rtype"; return + fi + + local ec_hex + if ! ec_hex=$(find_attr "$attrs" "0009"); then + fail "$name" "no ERROR-CODE attribute"; return + fi + local code; code=$(decode_error_code "$ec_hex") + if [ "$code" != "401" ]; then + fail "$name" "expected 401, got $code"; return + fi + + local realm="" nonce="" realm_hex nonce_hex + realm_hex=$(find_attr "$attrs" "0014") && realm=$(printf '%s' "$realm_hex" | hex2bin) || true + nonce_hex=$(find_attr "$attrs" "0015") && nonce=$(printf '%s' "$nonce_hex" | hex2bin | tr -dc '[:print:]') || true + + [ -n "$realm" ] || { fail "$name" "401 but REALM absent"; return; } + [ -n "$nonce" ] || { fail "$name" "401 but NONCE absent"; return; } + + pass "${name} (${elapsed_ms} ms realm=\"${realm}\" nonce=${nonce:0:16}…)" +} + +# ── assertion: TLS transport ─────────────────────────────────────────────────── + +# 3.1 Certificate not expired, SAN/CN matches host. +check_tls_cert() { + local T0; T0=$(now_ms) + + local cert_pem + cert_pem=$(echo \ + | timeout 5 openssl s_client -connect "${HOST}:${TLS_PORT}" \ + -servername "$HOST" 2>/dev/null \ + | openssl x509 2>/dev/null) || { + fail "3.1 TLS cert validity" "could not retrieve certificate"; return + } + + # -checkend 0: exits 1 if cert has already expired. + echo "$cert_pem" | openssl x509 -noout -checkend 0 2>/dev/null || { + local expiry; expiry=$(echo "$cert_pem" | openssl x509 -noout -enddate | cut -d= -f2) + fail "3.1 TLS cert validity" "certificate expired: $expiry"; return + } + + local days_left enddate + enddate=$(echo "$cert_pem" | openssl x509 -noout -enddate | cut -d= -f2) + # -checkend N: exits 1 if cert expires within N seconds. + # Binary search isn't needed; just report days remaining via the 1-day boundary. + local secs_left=0 + # Use perl for portable epoch arithmetic (avoids Linux vs macOS date divergence). + secs_left=$(echo "$cert_pem" \ + | openssl x509 -noout -enddate \ + | perl -ne 'use POSIX; /=(.+)/ and print int((POSIX::mktime(strptime($1, "%b %d %T %Y %Z") ? @_ : ()) - time()) / 86400)' 2>/dev/null) || secs_left="?" + + local elapsed=$(( $(now_ms) - T0 )) + pass "3.1 TLS cert validity (${elapsed} ms expires in ${secs_left} days: ${enddate})" +} + +# 3.2 TLS 1.2 and 1.3 accepted; 1.0 and 1.1 rejected. +check_tls_versions() { + local name="3.2 TLS version negotiation" + local ok=1 + + for good_ver in tls1_2 tls1_3; do + if echo | timeout 5 openssl s_client \ + -connect "${HOST}:${TLS_PORT}" \ + -servername "$HOST" \ + "-${good_ver}" 2>/dev/null \ + | grep -q "Cipher is\|Cipher :"; then + log "${name}: ${good_ver} accepted (expected)" + else + fail "$name" "${good_ver} not accepted" + ok=0 + fi + done + + # Older versions may not be offered by the local openssl binary at all; + # either a handshake failure or "unknown option" is a passing result. + for bad_ver in tls1 tls1_1; do + local out + out=$(echo | timeout 5 openssl s_client \ + -connect "${HOST}:${TLS_PORT}" \ + -servername "$HOST" \ + "-${bad_ver}" 2>&1 || true) + if echo "$out" | grep -qE "handshake failure|alert|unknown option|no protocols"; then + log "${name}: ${bad_ver} rejected (expected)" + elif echo "$out" | grep -q "Cipher is\|Cipher :"; then + fail "$name" "${bad_ver} was accepted — server should reject it" + ok=0 + else + log "${name}: ${bad_ver} — inconclusive response (may be unsupported by local openssl)" + fi + done + + [ "$ok" -eq 1 ] && pass "${name}" +} + +# 3.3 Strong cipher accepted; NULL/EXPORT ciphers rejected. +check_tls_ciphers() { + local name="3.3 Cipher suite enforcement" + local ok=1 + + # A representative strong cipher: should always be accepted. + if echo | timeout 5 openssl s_client \ + -connect "${HOST}:${TLS_PORT}" \ + -servername "$HOST" \ + -cipher "ECDHE-RSA-AES128-GCM-SHA256" 2>/dev/null \ + | grep -q "Cipher is\|Cipher :"; then + log "${name}: strong cipher (ECDHE-RSA-AES128-GCM-SHA256) accepted" + else + fail "$name" "strong cipher ECDHE-RSA-AES128-GCM-SHA256 not accepted" + ok=0 + fi + + # NULL / EXPORT suites: modern openssl may not even build these strings, + # so an "unknown option" or empty-cipher-list error is also a pass. + local weak_out + weak_out=$(echo | timeout 5 openssl s_client \ + -connect "${HOST}:${TLS_PORT}" \ + -servername "$HOST" \ + -cipher "NULL,LOW,EXPORT" 2>&1 || true) + if echo "$weak_out" | grep -q "Cipher is\|Cipher :"; then + fail "$name" "NULL/EXPORT cipher was accepted — server should reject it" + ok=0 + else + log "${name}: NULL/EXPORT ciphers rejected (expected)" + fi + + [ "$ok" -eq 1 ] && pass "${name}" +} + +# 3.5 ALPN: stun.turn and stun.nat-discovery tokens (RFC 7443). +check_tls_alpn() { + local name="3.5 ALPN negotiation" + local ok=1 + + for token in "stun.turn" "stun.nat-discovery"; do + local out + out=$(echo | timeout 5 openssl s_client \ + -connect "${HOST}:${TLS_PORT}" \ + -servername "$HOST" \ + -alpn "$token" 2>/dev/null || true) + + if echo "$out" | grep -q "ALPN protocol.*${token}"; then + log "${name}: token '${token}' negotiated" + elif echo "$out" | grep -q "Cipher is\|Cipher :"; then + # Connected but ALPN not echoed back — server does not advertise + # ALPN for this token, which is permitted by RFC 7443. + log "${name}: token '${token}' — TLS handshake succeeded, ALPN not echoed (acceptable)" + else + fail "$name" "TLS handshake failed for token '${token}'" + ok=0 + fi + done + + [ "$ok" -eq 1 ] && pass "${name}" +} +# ── dependency checks ────────────────────────────────────────────────────────── + +for cmd in socat openssl curl perl gzip; do + command -v "$cmd" &>/dev/null || die 1 "'$cmd' required but not found" +done + +log "deps ok socat=$(socat -V 2>&1 | awk '/socat version/{print $3}') openssl=$(openssl version | awk '{print $2}')" +log "target turn=${HOST}:${PORT} tls=${HOST}:${TLS_PORT} prom=${PROM_HOST}:${PROM_PORT} tls-tests=$([ "$TLS" -eq 1 ] && echo on || echo off) metrics-tests=$([ "$METRICS" -eq 1 ] && echo on || echo off)" + +# REQUESTED-TRANSPORT attribute: type=0019, length=4, value=0x11000000 +# (protocol=UDP=17, 3 padding bytes). +ALLOC_ATTRS="0019000411000000" + +# ── prometheus baseline (captured before the TURN tests that 5.4 observes) ──── + +if [ "$METRICS" -eq 1 ] ; then + { + log "capturing prometheus baseline for 5.4…" + PROM_BASELINE=$(curl -sf --max-time 5 "http://${PROM_HOST}:${PROM_PORT}/metrics" 2>/dev/null) || PROM_BASELINE="" + ALLOC_BEFORE=$(printf '%s' "$PROM_BASELINE" \ + | awk '/^turn_new_allocation_total /{print $2; exit}') + ALLOC_BEFORE="${ALLOC_BEFORE:-0}" + log "baseline turn_new_allocation_total=${ALLOC_BEFORE}" + } +else + { + ALLOC_BEFORE=0 + skipMetrics "prometheus baseline (used in 5.4)" + } +fi + +# ── 2.1 TURN 401 challenge / UDP ────────────────────────────────────────────── + +TXN=$(openssl rand -hex 12) +REQ=$(build_stun_fp "0003" "$TXN" "$ALLOC_ATTRS") +log "2.1 → Allocate (no credentials) UDP txn=$TXN" +T0=$(now_ms) +RESP=$(send_udp "$HOST" "$PORT" "$REQ") +T1=$(now_ms) +check_401_response "2.1 TURN 401 challenge / UDP" "$RESP" "$(( T1 - T0 ))" + +# ── 2.2 TURN 401 challenge / TCP ────────────────────────────────────────────── + +TXN=$(openssl rand -hex 12) +REQ=$(build_stun_fp "0003" "$TXN" "$ALLOC_ATTRS") +log "2.2 → Allocate (no credentials) TCP txn=$TXN" +T0=$(now_ms) +RESP=$(send_tcp "$HOST" "$PORT" "$REQ") +T1=$(now_ms) +check_401_response "2.2 TURN 401 challenge / TCP" "$RESP" "$(( T1 - T0 ))" + +# ── 2.3 / 3.1 / 3.2 / 3.3 / 3.5 TLS tests (--tls only) ───────────────────── + +if [ "$TLS" -eq 1 ]; then + + TXN=$(openssl rand -hex 12) + REQ=$(build_stun_fp "0003" "$TXN" "$ALLOC_ATTRS") + log "2.3 → Allocate (no credentials) TLS txn=$TXN" + T0=$(now_ms) + RESP=$(send_tls "$HOST" "$TLS_PORT" "$REQ") + T1=$(now_ms) + check_401_response "2.3 TURN 401 challenge / TLS" "$RESP" "$(( T1 - T0 ))" + + check_tls_cert + check_tls_versions + check_tls_ciphers + check_tls_alpn + +else + for label in \ + "2.3 TURN 401 challenge / TLS" \ + "3.1 TLS cert validity" \ + "3.2 TLS version negotiation" \ + "3.3 Cipher suite enforcement" \ + "3.5 ALPN negotiation" + do + skipTLS "$label" + done +fi + +# ── 5.1 Prometheus health ───────────────────────────────────────────────────── + +if [ "$METRICS" -eq 1 ]; then + { + log "5.1 → GET http://${PROM_HOST}:${PROM_PORT}/" + T0=$(now_ms) + HTTP_CODE=$(curl -so /dev/null -w '%{http_code}' --max-time 5 \ + "http://${PROM_HOST}:${PROM_PORT}/" 2>/dev/null) || HTTP_CODE=0 + T1=$(now_ms) + if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "301" ] || [ "$HTTP_CODE" = "302" ]; then + pass "5.1 Prometheus health ($(( T1 - T0 )) ms HTTP $HTTP_CODE)" + else + fail "5.1 Prometheus health" "expected 200/301/302, got HTTP $HTTP_CODE" + fi + } +else + skipMetrics "5.1 Prometheus health" +fi + +# ── 5.2 Prometheus metrics scrape ──────────────────────────────────────────── + +if [ "$METRICS" -eq 1 ]; then + { + log "5.2 → GET http://${PROM_HOST}:${PROM_PORT}/metrics" + T0=$(now_ms) + FOUNDMETRICS=$(curl -sf --max-time 5 "http://${PROM_HOST}:${PROM_PORT}/metrics" 2>/dev/null) || FOUNDMETRICS="" + T1=$(now_ms) + + if [ -z "$FOUNDMETRICS" ]; then + fail "5.2 Prometheus metrics scrape" "empty or no response" + else + MISSING="" + for key in \ + turn_new_allocation_total \ + turn_total_allocations_number \ + turn_traffic_rcvp \ + turn_traffic_sentp + do + printf '%s' "$FOUNDMETRICS" | grep -q "^${key}" || MISSING="${MISSING} ${key}" + done + + if [ -n "$MISSING" ]; then + fail "5.2 Prometheus metrics scrape" "missing keys:${MISSING}" + else + pass "5.2 Prometheus metrics scrape ($(( T1 - T0 )) ms)" + fi + fi + } +else + skipMetrics "5.2 Prometheus metrics scrape" +fi + +# ── 5.4 Metrics reflect TURN 401 activity ──────────────────────────────────── +# +# After tests 2.1 and 2.2 have run, the allocation counter must be unchanged: +# a 401 challenge must not create an allocation. A change here indicates that +# the server granted an allocation to a request with no credentials, which is +# a serious misconfiguration. + +if [ "$METRICS" -eq 1 ]; then + { + log "5.4 → checking allocation counter did not increment" + PROM_AFTER=$(curl -sf --max-time 5 "http://${PROM_HOST}:${PROM_PORT}/metrics" 2>/dev/null) || PROM_AFTER="" + ALLOC_AFTER=$(printf '%s' "$PROM_AFTER" \ + | awk '/^turn_new_allocation_total /{print $2; exit}') + ALLOC_AFTER="${ALLOC_AFTER:-0}" + log "after turn_new_allocation_total=${ALLOC_AFTER}" + + if [ -z "$PROM_AFTER" ]; then + fail "5.4 Metrics / TURN challenge" "could not scrape metrics after TURN tests" + elif [ "$ALLOC_BEFORE" = "$ALLOC_AFTER" ]; then + pass "5.4 Metrics / TURN challenge (allocation counter stable at ${ALLOC_AFTER})" + else + fail "5.4 Metrics / TURN challenge" \ + "turn_new_allocation_total changed: ${ALLOC_BEFORE} → ${ALLOC_AFTER} — server may have granted an unauthenticated allocation" + fi + } +else + skipMetrics "5.4 Metrics / TURN challenge" +fi + +# ── summary ──────────────────────────────────────────────────────────────────── + +echo +echo "Results: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped" +[ "$FAIL" -eq 0 ] diff --git a/testing/test_notifications.sh b/testing/test_notifications.sh new file mode 100644 index 0000000..5869652 --- /dev/null +++ b/testing/test_notifications.sh @@ -0,0 +1,633 @@ +#!/usr/bin/env bash +# test_notifications.sh — Wire notification stack connectivity + protocol probe. +# +# Beyond basic TCP reachability, this script exercises each service at the +# *protocol layer* using deliberately-invalid unauthenticated requests, and +# verifies that the responses match what the real service returns. This lets +# you distinguish: +# +# • A correctly routed connection (real service JSON error response) ✓ +# • A firewall black-hole / RST ✗ timeout/error +# • A TLS-intercepting proxy (wrong certificate issuer, wrong body) ✗ +# • A DNS override pointing at the wrong host (wrong certificate) ✗ +# +# Architecture note: cannon and gundeck are not directly reachable externally. +# All access is proxied through nginz: +# +# nginz-https. — REST API (HTTPS/443); routes include gundeck's +# /push/tokens, /notifications, etc. +# nginz-ssl. — WebSocket endpoint (WSS/443); proxies to cannon +# at /await. May share the same LB as nginz-https, +# or be a separate LoadBalancer in dedicated WS setups. +# +# See: https://github.com/wireapp/wire-server/blob/develop/docs/src/how-to/install/infrastructure-configuration.md +# +# Tests performed: +# §1 APNs — TLS cert validity+issuer, ALPN h2, HTTP/2 protocol probe +# §2 FCM — TLS cert validity+issuer, v1 API protocol probe +# §3 AWS — SNS + SQS HTTPS reachability + AWS request-ID header +# §4 nginz-https — /status health check, gundeck /push/tokens 401 probe +# §5 nginz-ssl — WebSocket upgrade handshake to /await (--ws flag) +# +# USAGE: +# ./test_notifications.sh [OPTIONS] +# +# --push / --no-push APNs + FCM checks (default: on) +# --ws / --no-ws nginz-ssl WebSocket checks (default: off) +# --verbose Print skipped tests too +# +# ENVIRONMENT (all optional, but §4/§5 require at least one of these): +# WIRE_DOMAIN Base domain, e.g. "wire.example.com" +# Used to derive nginz-https/nginz-ssl hostnames if they +# are not set individually. +# NGINZ_HTTPS_HOST Override the REST API hostname +# (default: nginz-https.$WIRE_DOMAIN) +# NGINZ_SSL_HOST Override the WebSocket hostname +# (default: nginz-ssl.$WIRE_DOMAIN) +# APNS_HOST Production APNs gateway (default: api.push.apple.com) +# APNS_SANDBOX_HOST Sandbox APNs gateway (default: api.sandbox.push.apple.com) +# FCM_HOST FCM hostname (default: fcm.googleapis.com) +# AWS_REGION Region for SNS/SQS URLs (default: eu-central-1) +# SNS_ENDPOINT Override SNS endpoint URL +# SQS_ENDPOINT Override SQS endpoint URL +# +# DEPENDENCIES: bash ≥4, curl (with HTTP/2), openssl, socat, perl +# GNU timeout (macOS: brew install coreutils) + +set -euo pipefail + +# ── default flags ───────────────────────────────────────────────────────────── +DO_PUSH=1 +DO_WS=0 +VERBOSE=0 + +# ── argument parsing ────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --push) DO_PUSH=1; shift ;; + --no-push) DO_PUSH=0; shift ;; + --ws) DO_WS=1; shift ;; + --no-ws) DO_WS=0; shift ;; + --verbose) VERBOSE=1; shift ;; + *) + echo "Usage: $0 [--push|--no-push] [--ws|--no-ws] [--verbose]" >&2 + exit 1 + ;; + esac +done + +# ── environment defaults ────────────────────────────────────────────────────── +APNS_HOST="${APNS_HOST:-api.push.apple.com}" +APNS_SANDBOX_HOST="${APNS_SANDBOX_HOST:-api.sandbox.push.apple.com}" +FCM_HOST="${FCM_HOST:-fcm.googleapis.com}" + +SNS_ENDPOINT="${SNS_ENDPOINT:-https://sns.${AWS_REGION:-eu-central-1}.amazonaws.com}" +SQS_ENDPOINT="${SQS_ENDPOINT:-https://sqs.${AWS_REGION:-eu-central-1}.amazonaws.com}" + +# Derive nginz hostnames from WIRE_DOMAIN if not set individually. +WIRE_DOMAIN="${WIRE_DOMAIN:-wiab-dev-b.zinfradev.com}" +NGINZ_HTTPS_HOST="${NGINZ_HTTPS_HOST:-${WIRE_DOMAIN:+nginz-https.${WIRE_DOMAIN}}}" +NGINZ_SSL_HOST="${NGINZ_SSL_HOST:-${WIRE_DOMAIN:+nginz-ssl.${WIRE_DOMAIN}}}" + +# A well-formed (64 lowercase hex chars) but deliberately invalid device token. +# APNs validates token format before checking auth, so we get a clean +# "BadDeviceToken"(400) or "MissingProviderToken"(403) JSON response that +# fingerprints a genuine APNs connection. +APNS_PROBE_TOKEN="0000000000000000000000000000000000000000000000000000000000000000" + +# FCM HTTP v1 send endpoint. Project ID is intentionally fake so the request +# fails immediately with a Google-format 401/403 JSON body. +FCM_V1_URL="https://${FCM_HOST}/v1/projects/wire-notification-probe/messages:send" + +# ── temp dir ────────────────────────────────────────────────────────────────── +export WORK +WORK=$(mktemp -d) +trap 'rm -rf "$WORK" 2>/dev/null || true' EXIT + +# ── counters ────────────────────────────────────────────────────────────────── +PASS=0; FAIL=0; SKIP=0 + +# ── helpers ─────────────────────────────────────────────────────────────────── +log() { echo "[$(date -u +%T)] $*" >&2; } +die() { echo "ERROR: $2" >&2; exit "$1"; } +now_ms() { perl -MTime::HiRes -e 'printf "%d\n", Time::HiRes::time() * 1000'; } + +pass() { printf '\033[32m✓\033[0m %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf '\033[31m✗\033[0m %s — %s\n' "$1" "$2"; FAIL=$((FAIL + 1)); } +warn() { printf '\033[33m⚠\033[0m %s\n' "$1"; } +skip() { + [[ "$VERBOSE" -eq 1 ]] && printf '\033[90m–\033[0m %s (skipped)\n' "$1" + SKIP=$((SKIP + 1)) +} + +# ── TLS / certificate helpers ───────────────────────────────────────────────── + +# Return 0 if the server certificate on host:port is still valid (not expired). +cert_not_expired() { + local host=$1 port=${2:-443} + timeout 8 openssl s_client \ + -connect "${host}:${port}" -servername "$host" \ + &1 \ + | openssl x509 -noout -checkend 0 2>/dev/null +} + +# Print the approximate days until the certificate on host:port expires. +cert_days_left() { + local host=$1 port=${2:-443} + local not_after=$(timeout 8 openssl s_client -connect "${host}:${port}" -servername "$host" /dev/null \ + | openssl x509 -noout -enddate \ + | cut -d= -f2) + + # openssl -enddate emits: "Jun 15 12:00:00 2026 GMT" + # Single-digit days are space-padded: "Jun 5 12:00:00 2026 GMT" + # \s+ in the regex absorbs both forms. + perl -MTime::Local -e ' + my %mon = (Jan=>0, Feb=>1, Mar=>2, Apr=>3, May=>4, Jun=>5, + Jul=>6, Aug=>7, Sep=>8, Oct=>9, Nov=>10, Dec=>11); + if ($ARGV[0] =~ /(\w{3})\s+(\d+)\s+(\d+):(\d+):(\d+)\s+(\d+)/) { + my $exp = Time::Local::timegm($5, $4, $3, $2, $mon{$1}, $6 - 1900); + printf "%d\n", ($exp - time) / 86400; + } else { + print "unknown\n"; + } + ' "$not_after" +} + +# Return 0 if the leaf cert's issuer contains needle (case-insensitive). +cert_issuer_contains() { + local host=$1 port=${2:-443} needle=$3 + local issuer + issuer=$(timeout 8 openssl s_client \ + -connect "${host}:${port}" -servername "$host" \ + &1 \ + | openssl x509 -noout -issuer 2>/dev/null || echo "") + grep -qi "$needle" <<<"$issuer" +} + +# Return 0 if ALPN negotiated h2 on host:port. +alpn_h2_ok() { + local host=$1 port=${2:-443} + timeout 8 openssl s_client \ + -alpn h2 \ + -connect "${host}:${port}" -servername "$host" \ + &1 \ + | grep -q "ALPN protocol: h2" +} + +# ── JSON helpers ────────────────────────────────────────────────────────────── + +# Return 0 if string $1 contains the literal text $2 (used to fingerprint JSON bodies). +body_contains() { grep -qF "$2" <<<"$1"; } + +# ── §0 Dependency and feature detection ───────────────────────────────────── +for cmd in curl openssl socat perl; do + command -v "$cmd" &>/dev/null || die 1 "'$cmd' is required but not found in PATH" +done + +CURL_VER=$(curl --version | head -n1 | awk '{print $2}') +SSL_VER=$(openssl version | awk '{print $2}') +log "deps ok curl=${CURL_VER} openssl=${SSL_VER}" + +# HTTP/2 support in curl is required for the APNs protocol probe. +if curl --version 2>&1 | grep -qiE "HTTP2|nghttp2"; then + CURL_H2=1 + log "curl HTTP/2 support: yes" +else + CURL_H2=0 + warn "curl was not built with HTTP/2 support; APNs protocol probe (1.5) will be skipped" +fi + +# use gtimeout if it's available. +if command -v gtimeout >/dev/null; then + alias timeout='gtimeout' +elif ! command -v timeout >/dev/null; then + die 1 "'timeout' is required but not installed. Install coreutils (brew install coreutils)." +fi + +# ── §1 APNs ────────────────────────────────────────────────────────────────── +# Wire's Gundeck routes Apple push notifications through AWS SNS, which makes +# outbound TLS connections to api.push.apple.com on behalf of Gundeck. +# Run this script from a network position with the same egress path as Gundeck. +# +# APNs HTTP/2 API: POST /3/device/ +# Without auth: 403 {"reason":"MissingProviderToken"} +# Auth OK, bad token: 400 {"reason":"BadDeviceToken"} +# The "reason" key is the fingerprint of a genuine APNs connection. + +if [[ "$DO_PUSH" -eq 1 ]]; then + log "── §1 APNs ──────────────────────────────────────────────────────────────────" + + # 1.1 Basic HTTPS reachability + log "▶ 1.1 APNs basic reachability (${APNS_HOST}:443)" + T0=$(now_ms) + if curl -s --max-time 6 "https://${APNS_HOST}/3/device" -o /dev/null 2>&1; then + T1=$(now_ms) + pass "1.1 APNs basic HTTPS reachability ($((T1 - T0)) ms)" + else + fail "1.1 APNs basic HTTPS reachability" \ + "no response — check firewall egress rules and DNS for ${APNS_HOST}" + fi + + # 1.2 TLS certificate not expired + log "▶ 1.2 APNs TLS certificate expiry" + days=$(cert_days_left "$APNS_HOST" 443) + if cert_not_expired "$APNS_HOST" 443; then + pass "1.2 APNs TLS certificate valid (expires in ~${days} days)" + else + fail "1.2 APNs TLS certificate valid" \ + "certificate has expired or could not be retrieved" + fi + + # 1.3 TLS certificate issuer — must be Apple + # A TLS-intercepting proxy (corporate DLP, misconfigured middlebox) would + # present its own certificate here instead of Apple's. + log "▶ 1.3 APNs TLS certificate issuer" + if cert_issuer_contains "$APNS_HOST" 443 "Apple"; then + pass "1.3 APNs TLS certificate issued by Apple (no TLS intercept detected)" + else + raw_issuer=$(timeout 8 openssl s_client \ + -connect "${APNS_HOST}:443" -servername "$APNS_HOST" \ + &1 \ + | openssl x509 -noout -issuer 2>/dev/null || echo "unavailable") + fail "1.3 APNs TLS certificate issued by Apple" \ + "unexpected issuer: ${raw_issuer} — possible TLS intercept proxy or DNS override" + fi + + # 1.4 ALPN HTTP/2 negotiation + # APNs mandates HTTP/2; if h2 is not negotiated, push delivery will fail + # even if basic connectivity looks fine. + log "▶ 1.4 APNs ALPN h2 negotiation" + if alpn_h2_ok "$APNS_HOST" 443; then + pass "1.4 APNs ALPN h2 negotiated" + else + fail "1.4 APNs ALPN h2 negotiated" \ + "server did not accept h2 in ALPN — APNs HTTP/2 API unavailable; check for a middlebox" + fi + + # 1.5 HTTP/2 protocol probe (unauthenticated) + # We POST a valid-format APNs request without auth credentials. A genuine + # APNs server returns a JSON body containing a "reason" field. A firewall + # drop, a TLS proxy, or a wrong endpoint produces something entirely different. + if [[ "$CURL_H2" -eq 1 ]]; then + log "▶ 1.5 APNs HTTP/2 protocol probe (unauthenticated → expect Apple JSON error)" + apns_body="$WORK/apns_probe.json" + apns_meta=$(curl -s --max-time 10 \ + --http2 \ + -H "apns-topic: com.wire.notifications" \ + -H "apns-push-type: alert" \ + -H "content-type: application/json" \ + -d '{"aps":{"alert":"wire-notification-probe"}}' \ + -o "$apns_body" \ + -w "%{http_code} %{http_version}" \ + "https://${APNS_HOST}/3/device/${APNS_PROBE_TOKEN}" 2>/dev/null) || apns_meta="000 ?" + apns_code=$(awk '{print $1}' <<<"$apns_meta") + apns_hv=$(awk '{print $2}' <<<"$apns_meta") + apns_resp=$(cat "$apns_body" 2>/dev/null || echo "") + + if body_contains "$apns_resp" '"reason"'; then + reason=$(grep -oE '"reason"\s*:\s*"[^"]+"' <<<"$apns_resp" | head -1 | tr -d ' ') + if [[ "$apns_hv" == "2" ]]; then + pass "1.5 APNs HTTP/2 protocol probe — HTTP/${apns_hv} ${apns_code} ${reason}" + else + warn "1.5 APNs responded with correct JSON but over HTTP/${apns_hv} not h2" + pass "1.5 APNs HTTP/2 protocol probe — HTTP/${apns_hv} ${apns_code} ${reason}" + fi + else + fail "1.5 APNs HTTP/2 protocol probe" \ + "unexpected response: HTTP/${apns_hv} ${apns_code} body='${apns_resp:0:160}'" + fi + else + skip "1.5 APNs HTTP/2 protocol probe (curl lacks HTTP/2 support)" + fi + + # 1.6 APNs sandbox endpoint + # Development and staging Wire clients register against the sandbox APNs + # endpoint; missing connectivity here causes missed notifications for those. + log "▶ 1.6 APNs sandbox reachability (${APNS_SANDBOX_HOST}:443)" + T0=$(now_ms) + if curl -s --max-time 6 "https://${APNS_SANDBOX_HOST}/3/device" -o /dev/null 2>&1; then + T1=$(now_ms) + pass "1.6 APNs sandbox reachable ($((T1 - T0)) ms)" + else + fail "1.6 APNs sandbox reachable" \ + "no response — development/staging Wire clients will not receive notifications" + fi + + # 1.7 APNs legacy port 2197 + # Some corporate firewalls block outbound 443 for non-HTTP traffic; port + # 2197 is Apple's alternative for APNs connections. + log "▶ 1.7 APNs legacy port 2197 (${APNS_HOST}:2197)" + T0=$(now_ms) + if timeout 6 openssl s_client -quiet \ + -connect "${APNS_HOST}:2197" -servername "$APNS_HOST" \ + /dev/null; then + T1=$(now_ms) + pass "1.7 APNs legacy port 2197 reachable ($((T1 - T0)) ms)" + else + warn "1.7 APNs port 2197 unreachable (not fatal if port 443 works)" + SKIP=$((SKIP + 1)) + fi + +else + for n in "1.1" "1.2" "1.3" "1.4" "1.5" "1.6" "1.7"; do + skip "${n} APNs (--no-push)" + done +fi + +# ── §2 FCM ─────────────────────────────────────────────────────────────────── +# Firebase Cloud Messaging (FCM) is used for Android push notifications. +# Wire's Gundeck routes FCM pushes through AWS SNS → fcm.googleapis.com. +# +# FCM HTTP v1 API: POST /v1/projects//messages:send +# Without auth: 401 {"error":{"code":401,"status":"UNAUTHENTICATED",...}} +# The nested "error" + "code" structure is the Google API fingerprint. + +if [[ "$DO_PUSH" -eq 1 ]]; then + log "── §2 FCM ───────────────────────────────────────────────────────────────────" + + # 2.1 Basic HTTPS reachability + log "▶ 2.1 FCM basic reachability (${FCM_HOST}:443)" + T0=$(now_ms) + if curl -s --max-time 6 "https://${FCM_HOST}/" -o /dev/null 2>&1; then + T1=$(now_ms) + pass "2.1 FCM basic HTTPS reachability ($((T1 - T0)) ms)" + else + fail "2.1 FCM basic HTTPS reachability" \ + "no response — check firewall egress rules and DNS for ${FCM_HOST}" + fi + + # 2.2 TLS certificate not expired + log "▶ 2.2 FCM TLS certificate expiry" + days=$(cert_days_left "$FCM_HOST" 443) + if cert_not_expired "$FCM_HOST" 443; then + pass "2.2 FCM TLS certificate valid (expires in ~${days} days)" + else + fail "2.2 FCM TLS certificate valid" \ + "certificate has expired or could not be retrieved" + fi + + # 2.3 TLS certificate issuer — must be Google (Trust Services / GTS) + log "▶ 2.3 FCM TLS certificate issuer" + if cert_issuer_contains "$FCM_HOST" 443 "Google"; then + pass "2.3 FCM TLS certificate issued by Google (no TLS intercept detected)" + else + raw_issuer=$(timeout 8 openssl s_client \ + -connect "${FCM_HOST}:443" -servername "$FCM_HOST" \ + &1 \ + | openssl x509 -noout -issuer 2>/dev/null || echo "unavailable") + fail "2.3 FCM TLS certificate issued by Google" \ + "unexpected issuer: ${raw_issuer} — possible TLS intercept proxy or DNS override" + fi + + # 2.4 FCM v1 API protocol probe (unauthenticated) + # POST to the send endpoint without auth. Google's API gateway returns a + # machine-readable 401 JSON body. A firewall block or TLS proxy produces + # something different (timeout, HTML, no nested "error.code" structure). + log "▶ 2.4 FCM v1 API protocol probe (unauthenticated → expect 401 UNAUTHENTICATED)" + fcm_body="$WORK/fcm_probe.json" + fcm_code=$(curl -s --max-time 10 \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"message":{"token":"wire-notification-probe"}}' \ + -o "$fcm_body" \ + -w "%{http_code}" \ + "$FCM_V1_URL" 2>/dev/null) || fcm_code="000" + fcm_resp=$(cat "$fcm_body" 2>/dev/null || echo "") + + if [[ "$fcm_code" == "401" ]] && body_contains "$fcm_resp" "UNAUTHENTICATED"; then + pass "2.4 FCM v1 API probe — HTTP ${fcm_code} UNAUTHENTICATED (real FCM endpoint confirmed)" + elif [[ "$fcm_code" =~ ^(400|401|403)$ ]] && body_contains "$fcm_resp" '"error"'; then + pass "2.4 FCM v1 API probe — HTTP ${fcm_code} with Google API error JSON (endpoint confirmed)" + else + fail "2.4 FCM v1 API probe" \ + "unexpected response: HTTP ${fcm_code} body='${fcm_resp:0:160}'" + fi + +else + for n in "2.1" "2.2" "2.3" "2.4"; do + skip "${n} FCM (--no-push)" + done +fi + +# ── §3 AWS SNS / SQS ───────────────────────────────────────────────────────── +# Gundeck requires both SNS (to register/send push) and SQS (to receive +# APNs/FCM delivery-failure feedback and to process internal user events). +# Even in websocket-only setups a real or fake-aws SQS endpoint is mandatory. +# +# Beyond HTTPS reachability we check for the x-amzn-requestid response header, +# which AWS emits on every response. Its absence means the request likely hit +# a proxy or a misconfigured endpoint rather than real (or LocalStack) AWS. + +log "── §3 AWS SNS/SQS ───────────────────────────────────────────────────────────" + +check_aws_endpoint() { + local label=$1 url=$2 + log "▶ ${label} (${url})" + local hdr_file="$WORK/aws_${label//[^a-z0-9]/_}.headers" + local http_code + + http_code=$(curl -Is --max-time 8 "$url" \ + -D "$hdr_file" \ + -o /dev/null \ + -w "%{http_code}" 2>/dev/null) || http_code="000" + local hdrs + hdrs=$(cat "$hdr_file" 2>/dev/null || echo "") + + if grep -qi "x-amzn-requestid\|x-amz-request-id" <<<"$hdrs"; then + pass "${label} HTTP ${http_code} + AWS request-ID header (real AWS / LocalStack endpoint)" + elif [[ "$http_code" =~ ^[2345][0-9][0-9]$ ]]; then + warn "${label} HTTP ${http_code} but no AWS request-ID header — confirm this is the intended endpoint" + PASS=$((PASS + 1)) + else + fail "${label}" "HTTP ${http_code} — cannot reach ${url}" + fi +} + +check_aws_endpoint "3.1 SNS endpoint" "$SNS_ENDPOINT" +check_aws_endpoint "3.2 SQS endpoint" "$SQS_ENDPOINT" + +# ── §4 nginz-https — gundeck route probe ──────────────────────────────────── +# Gundeck is not directly reachable; all REST access goes via nginz-https. +# We probe two things: +# +# /status — Wire's unauthenticated health endpoint; always returns +# 200 OK with an empty body. Confirms nginz itself is up. +# +# /push/tokens — Gundeck's push-token registration endpoint (requires +# auth). An unauthenticated request gets rejected by +# nginz's libzauth module with a Wire-format 401 JSON body. +# This response confirms both that the gundeck upstream is +# configured in nginz AND that libzauth is running correctly. +# A 502 would mean gundeck is down; a 404 would mean the +# upstream route is missing from the nginz config. + +log "── §4 nginz-https (gundeck route) ─────────────────────────────────────────" + +if [[ -z "$NGINZ_HTTPS_HOST" ]]; then + warn "NGINZ_HTTPS_HOST is not set — set WIRE_DOMAIN or NGINZ_HTTPS_HOST to enable §4" + for n in "4.1" "4.2" "4.3"; do skip "${n} nginz-https (no host configured)"; done +else + # 4.1 TLS certificate validity and issuer + log "▶ 4.1 nginz-https TLS certificate (${NGINZ_HTTPS_HOST}:443)" + days=$(cert_days_left "$NGINZ_HTTPS_HOST" 443) + if cert_not_expired "$NGINZ_HTTPS_HOST" 443; then + pass "4.1 nginz-https TLS certificate valid (expires in ~${days} days)" + else + fail "4.1 nginz-https TLS certificate valid" \ + "certificate has expired or could not be retrieved from ${NGINZ_HTTPS_HOST}" + fi + + # 4.2 /status health check — must return exactly HTTP 200 + # This endpoint is unauthenticated and always returns 200 OK with an empty + # body when nginz (and its upstream brig health check) is healthy. + log "▶ 4.2 nginz-https /status health check" + T0=$(now_ms) + status_code=$(curl -s --max-time 8 \ + -o /dev/null -w "%{http_code}" \ + "https://${NGINZ_HTTPS_HOST}/status" 2>/dev/null) || status_code="000" + T1=$(now_ms) + + if [[ "$status_code" == "200" ]]; then + pass "4.2 nginz-https /status → HTTP 200 OK ($((T1 - T0)) ms)" + else + fail "4.2 nginz-https /status" \ + "HTTP ${status_code} (expected 200 — nginz or brig may be unhealthy)" + fi + + # 4.3 /push/tokens gundeck route probe (unauthenticated) + # A 401 with a Wire-format JSON body confirms: + # • nginz is routing /push/tokens to the gundeck upstream + # • libzauth is running and rejecting unauthenticated requests correctly + # A 502 means the gundeck upstream is unreachable. + # A 404 means the gundeck upstream route is absent from the nginz config. + log "▶ 4.3 nginz-https /push/tokens gundeck route probe (unauthenticated → expect 401)" + push_body="$WORK/push_tokens_probe.json" + push_code=$(curl -s --max-time 8 \ + -H "Accept: application/json" \ + -o "$push_body" -w "%{http_code}" \ + "https://${NGINZ_HTTPS_HOST}/push/tokens" 2>/dev/null) || push_code="000" + push_resp=$(cat "$push_body" 2>/dev/null || echo "") + + if [[ "$push_code" == "401" ]]; then + # Wire reurns {"code":401,"message":"...","label":"..."} on auth failure + if body_contains "$push_resp" '"code"' || body_contains "$push_resp" "Unauthorized"; then + pass "4.3 nginz-https /push/tokens → HTTP 401 Wire auth rejection (gundeck upstream reachable)" + else + pass "4.3 nginz-https /push/tokens → HTTP 401 (gundeck upstream reachable)" + fi + elif [[ "$push_code" == "403" ]]; then + pass "4.3 nginz-https /push/tokens → HTTP 403 (gundeck upstream reachable, auth enforced)" + elif [[ "$push_code" == "502" ]]; then + fail "4.3 nginz-https /push/tokens → HTTP 502" \ + "nginz route exists but gundeck upstream is unreachable (pod down or not ready?)" + elif [[ "$push_code" == "404" ]]; then + fail "4.3 nginz-https /push/tokens → HTTP 404" \ + "route missing from nginz config — check nginx_conf.ignored_upstreams does not include gundeck" + else + fail "4.3 nginz-https /push/tokens" \ + "unexpected HTTP ${push_code} body='${push_resp:0:120}'" + fi +fi + +# ── §5 nginz-ssl — cannon WebSocket probe ──────────────────────────────────── +# Cannon (Wire's WebSocket hub) is accessed via nginz-ssl. Clients connect to +# wss://nginz-ssl./await after authenticating. In some deployments +# nginz-ssl shares the same LoadBalancer as nginz-https; in others it has a +# dedicated LB (see separate-websocket-traffic in infrastructure-configuration.md). +# +# We perform two protocol-level checks: +# TCP connect — confirms basic network path +# HTTP Upgrade handshake — sends a real WebSocket upgrade request and verifies +# an HTTP response line is returned, proving a real HTTP server is present. +# Without a valid Wire auth token we expect 101 (if pre-auth WS accepted), +# 401 (auth enforced by nginz/libzauth), or 400 (bad request but server alive). + +if [[ "$DO_WS" -eq 1 ]]; then + log "── §5 nginz-ssl (cannon WebSocket) ─────────────────────────────────────────" + + if [[ -z "$NGINZ_SSL_HOST" ]]; then + warn "NGINZ_SSL_HOST is not set — set WIRE_DOMAIN or NGINZ_SSL_HOST to enable §5" + for n in "5.1" "5.2" "5.3"; do skip "${n} nginz-ssl (no host configured)"; done + else + # 5.1 TLS certificate validity + log "▶ 5.1 nginz-ssl TLS certificate (${NGINZ_SSL_HOST}:443)" + days=$(cert_days_left "$NGINZ_SSL_HOST" 443) + if cert_not_expired "$NGINZ_SSL_HOST" 443; then + pass "5.1 nginz-ssl TLS certificate valid (expires in ~${days} days)" + else + fail "5.1 nginz-ssl TLS certificate valid" \ + "certificate has expired or could not be retrieved from ${NGINZ_SSL_HOST}" + fi + + # 5.2 TCP reachability + log "▶ 5.2 nginz-ssl TCP connect (${NGINZ_SSL_HOST}:443)" + T0=$(now_ms) + if timeout 6 socat -T5 - "TCP4:${NGINZ_SSL_HOST}:443" \ + /dev/null 2>&1; then + T1=$(now_ms) + pass "5.2 nginz-ssl TCP reachable ($((T1 - T0)) ms)" + else + fail "5.2 nginz-ssl TCP reachable" \ + "cannot open TCP connection to ${NGINZ_SSL_HOST}:443" + fi + + # 5.3 WebSocket HTTP Upgrade handshake probe + # curl handles TLS automatically; --http1.1 forces the HTTP/1.1 Upgrade + # flow that WebSocket requires (HTTP/2 uses a different mechanism). + # We check both the HTTP status code and for a Sec-WebSocket-Accept + # header to confirm a full WS handshake is possible. + log "▶ 5.3 nginz-ssl WebSocket upgrade probe (wss://${NGINZ_SSL_HOST}/await)" + ws_body="$WORK/ws_probe.txt" + ws_hdrs_file="$WORK/ws_headers.txt" + ws_code=$(curl -s --max-time 10 \ + --http1.1 \ + -H "Upgrade: websocket" \ + -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" \ + -o "$ws_body" \ + -w "%{http_code}" \ + -D "$ws_hdrs_file" \ + "https://${NGINZ_SSL_HOST}/await" 2>/dev/null) || ws_code="000" + ws_hdrs=$(cat "$ws_hdrs_file" 2>/dev/null || echo "") + + if [[ "$ws_code" == "101" ]]; then + if grep -qi "Sec-WebSocket-Accept" <<<"$ws_hdrs"; then + pass "5.3 nginz-ssl WebSocket upgrade → HTTP 101 + Sec-WebSocket-Accept (full WS handshake)" + else + pass "5.3 nginz-ssl WebSocket upgrade → HTTP 101 Switching Protocols" + fi + elif [[ "$ws_code" =~ ^(400|401|403)$ ]]; then + # Auth or request rejection — server is alive and handled the WS upgrade request. + pass "5.3 nginz-ssl WebSocket upgrade → HTTP ${ws_code} (cannon upstream reachable, auth enforced)" + elif [[ "$ws_code" == "502" ]]; then + fail "5.3 nginz-ssl WebSocket upgrade → HTTP 502" \ + "nginz route exists but cannon upstream is unreachable (pod down or being drained?)" + elif [[ "$ws_code" == "000" ]]; then + fail "5.3 nginz-ssl WebSocket upgrade" \ + "no response (timeout or TLS handshake failure)" + else + fail "5.3 nginz-ssl WebSocket upgrade" \ + "unexpected HTTP ${ws_code}" + fi + fi +else + skip "5.1 nginz-ssl TLS certificate (--ws not enabled)" + skip "5.2 nginz-ssl TCP reachable (--ws not enabled)" + skip "5.3 nginz-ssl WebSocket upgrade probe (--ws not enabled)" +fi + +# ── summary ─────────────────────────────────────────────────────────────────── +echo +echo "════════════════════════════════════════" +printf " \033[32m✓\033[0m passed : %d\n" "$PASS" +printf " \033[31m✗\033[0m failed : %d\n" "$FAIL" +printf " \033[90m–\033[0m skipped: %d\n" "$SKIP" +echo "════════════════════════════════════════" + +if [[ "$FAIL" -gt 0 ]]; then + echo + echo "§4/§5 require WIRE_DOMAIN (or NGINZ_HTTPS_HOST / NGINZ_SSL_HOST) to be set." +fi + +[[ "$FAIL" -eq 0 ]] diff --git a/testing/test_sft.sh b/testing/test_sft.sh new file mode 100755 index 0000000..3b9c8ac --- /dev/null +++ b/testing/test_sft.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# sft_call.sh — Wire SFT client: signaling + ICE via a single UDP socket. +# +# USAGE: +# export SFT_URL="https://sft.example.com" +# ./sft_call.sh +# +# ARCHITECTURE: +# After SETUP, socat opens one connected UDP socket: +# bind → LOCAL_IP:LOCAL_UDP_PORT (our declared candidate) +# peer → REMOTE_IP:REMOTE_PORT (SFT's candidate from SDP answer) +# stun_dispatcher.sh runs as the socat EXEC,nofork co-process. +# Its stdin reads datagrams arriving from the SFT; its stdout writes +# datagrams sent to the SFT. Both directions share the same socket, +# so every packet — responses to SFT's checks AND our own outbound +# Binding Request — carries source port LOCAL_UDP_PORT. +# +# NOTE: accepts a conversation ID on the command line, which is not (yet) useful. +# +# NOTE: If socat is not available, will skip the UDP portion. +# +# MACOS: This supports MacOS, but requires jq to be installed. +# I installed this via: +# ``` +# sudo mkdir -p /usr/local/bin +# curl -Lo /usr/local/bin/jq https://github.com/jqlang/jq/releases/latest/download/jq-macos-amd64 +# chmod +x /usr/local/bin/jq +# ``` +# +# DEPENDENCIES: curl, jq, uuidgen, openssl, gzip, (ip || ipconfig), dd + +set -euo pipefail + +SFT_URL="${SFT_URL:-https://sftd.wiab-dev-b.zinfradev.com}" +CONV_ID="${1:-$(uuidgen | tr '[:upper:]' '[:lower:]')}" + +export WORK +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"; kill "${SOCAT_PID:-}" 2>/dev/null || true' EXIT + +# ── helpers ──────────────────────────────────────────────────────────────────── + +log() { echo "[$(date -u +%T)] $*" >&2; } +die() { echo "ERROR: $2" >&2; exit $1; } +now_ms() { perl -MTime::HiRes -e 'printf "%.3f\n", Time::HiRes::time()' | tr -d '.'; } +http_post_json_timed() { + local name="$1" + local url="$2" + local json="$3" + local diecode="$4" + + local body_file headers_file meta_file curl_rc http_code + local time_namelookup time_connect time_appconnect time_pretransfer + local time_starttransfer time_total effective_url + + body_file="$WORK/${name}.body" + headers_file="$WORK/${name}.headers" + meta_file="$WORK/${name}.meta" + + log "$name → $json → $url" + + set +e + curl -sS -L --max-time 30 \ + -H "Content-Type: application/json" \ + -D "$headers_file" \ + -o "$body_file" \ + -w $'http_code=%{http_code}\nurl_effective=%{url_effective}\ntime_namelookup=%{time_namelookup}\ntime_connect=%{time_connect}\n \ + time_appconnect=%{time_appconnect}\ntime_pretransfer=%{time_pretransfer}\ntime_starttransfer=%{time_starttransfer}\ntime_total=%{time_total}\n' \ + -d "$json" \ + "$url" > "$meta_file" + curl_rc=$? + set -e + + http_code=$(awk -F= '/^http_code=/{print $2}' "$meta_file") + effective_url=$(awk -F= '/^url_effective=/{print $2}' "$meta_file") + time_namelookup=$(awk -F= '/^time_namelookup=/{print $2}' "$meta_file") + time_connect=$(awk -F= '/^time_connect=/{print $2}' "$meta_file") + time_appconnect=$(awk -F= '/^time_appconnect=/{print $2}' "$meta_file") + time_pretransfer=$(awk -F= '/^time_pretransfer=/{print $2}' "$meta_file") + time_starttransfer=$(awk -F= '/^time_starttransfer=/{print $2}' "$meta_file") + time_total=$(awk -F= '/^time_total=/{print $2}' "$meta_file") + + log "$name HTTP=$http_code curl_rc=$curl_rc total=${time_total}s ttfb=${time_starttransfer}s url=$effective_url" + log "$name timing: total=${time_total}s dns=${time_namelookup}s tcp=${time_connect}s tls=${time_appconnect}s pretransfer=${time_pretransfer}s" + + if [ "$curl_rc" -ne 0 ] || [ "${http_code:-0}" -lt 200 ] || [ "${http_code:-0}" -ge 300 ]; then + log "$name failed response headers:" + sed 's/^/ /' "$headers_file" >&2 || true + + log "$name failed response body:" + if jq . "$body_file" >&2 2>/dev/null; then + : + else + sed 's/^/ /' "$body_file" >&2 || true + fi + + die $diecode "$name failed: curl_rc=$curl_rc http_code=$http_code total=${time_total}s" + fi + + cat "$body_file" +} + +# ── dependency checks ────────────────────────────────────────────────────────── + +# Generic dependencies. +for cmd in curl jq uuidgen openssl xxd gzip dd; do + command -v "$cmd" &>/dev/null || die -1 "'$cmd' required but not found." +done + +# things we can work around. +if command -v "ip" &>/dev/null || command -v "ipconfig" &>/dev/null ; then + log 'deps ok (ip or ipconfig available)' +else + die -1 "neither IP or IPCONFIG is available." +fi + +log "deps ok curl=$(curl -V | awk 'NR==1{print $2}')" + +# ── ICE credentials ──────────────────────────────────────────────────────────── + +export LOCAL_ICE_UFRAG LOCAL_ICE_PWD +LOCAL_ICE_UFRAG=$(openssl rand -hex 4) +LOCAL_ICE_PWD=$( openssl rand -hex 12) + +# ── DTLS certificate fingerprint ─────────────────────────────────────────────── + +openssl req -newkey rsa:2048 -nodes -x509 -days 1 \ + -out "$WORK/dtls.pem" -keyout "$WORK/dtls.key" \ + -subj "/CN=sft-client" 2>/dev/null +FINGERPRINT=$(openssl x509 -in "$WORK/dtls.pem" -fingerprint -sha256 -noout \ + | sed 's/.*=//' | tr '[:upper:]' '[:lower:]') + +# ── local address ────────────────────────────────────────────────────────────── + +export LOCAL_IP LOCAL_UDP_PORT +# tries IP first, from linux, thes falls back to ipconfig for MacOS. +LOCAL_IP=$( + if command -v ip >/dev/null 2>&1; then + ip -4 addr show $(ip route show default | awk '/default/ {print $5; exit}') | awk '/inet /{print $2}' | cut -d/ -f1 + else + ipconfig getifaddr "$(route get 1 | awk '/interface:/ {print $2}')" + fi +) + +# Random high‑port (49152–65535) without shuf +LOCAL_UDP_PORT=$(( (0x$(openssl rand -hex 2) % (65535-49152+1)) + 49152 )) + +# ── step 1: CONFCONN ─────────────────────────────────────────────────────────── + +USER_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') +# NOTE: webapp's SFT uses '_' here. +CLIENT_ID=$(openssl rand -hex 8) +SESSION_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') + +## Note: src_userid and src_clientid must not be quoted. +CONFCONN_JSON=$(jq -cn \ + --arg sessid "$SESSION_ID" \ + --arg src_userid "$USER_ID" \ + --arg src_clientid "$CLIENT_ID" \ + '{"version":"3.0","type":"CONFCONN", "sessid":$sessid, + "src_userid":$src_userid, "src_clientid":$src_clientid, + "resp":false, + "toolver":"0.0.0", "selective_audio":false, "selective_video":false}') + +CONFCONN_RESP=$(http_post_json_timed "CONFCONN" "${SFT_URL%/}/sft/${CONV_ID}" "$CONFCONN_JSON" -3) + +REMOTE_SFT=$(echo "$CONFCONN_RESP" | jq -r '.url // empty') +[ -n "$REMOTE_SFT" ] || die -4 "No SFT in CONFCONN response." +log "found remote SFT server: $REMOTE_SFT" + +# ── step 2: SETUP ────────────────────────────────────────────────────────────── + +SSRC=$(openssl rand -hex 8) +CNAME=$(openssl rand -hex 8) + +SDP="v=0 +o=- $(date +%s) 2 IN IP4 $LOCAL_IP +s=- +t=0 0 +a=group:BUNDLE audio video data +m=audio $LOCAL_UDP_PORT UDP/TLS/RTP/SAVPF 111 +c=IN IP4 $LOCAL_IP +a=rtpmap:111 opus/48000/2 +a=sendrecv +a=rtcp-mux +a=ice-ufrag:$LOCAL_ICE_UFRAG +a=ice-pwd:$LOCAL_ICE_PWD +a=fingerprint:sha-256 $FINGERPRINT +a=setup:active +a=mid:audio +a=candidate:1 1 udp 2122260223 $LOCAL_IP $LOCAL_UDP_PORT typ host +m=video 0 UDP/TLS/RTP/SAVPF 100 +c=IN IP4 0.0.0.0 +a=mid:video +m=application 0 DTLS/SCTP 5000 +c=IN IP4 0.0.0.0 +a=mid:data +" + +SETUP_JSON=$(jq -cn \ + --arg sessid "$SESSION_ID" \ + --arg src_userid "$USER_ID" \ + --arg src_clientid "$CLIENT_ID" \ + --arg sdp "$SDP" \ + '{"version":"3.0",type:"SETUP", "sessid":$sessid, + "src_userid":$src_userid, "src_clientid":$src_clientid, + "resp":true, + "sdp":$sdp, "props":{"videosend":"false","screensend":"false","audiocbr":"false","muted":"true"}}') + +# Test form: constructs the URL using the original URL, not the returned one. works on WIAB, unreliable in prod? +# SETUP_RESP=$(http_post_json_timed "SETUP" "${SFT_URL%/}/sft/${CONV_ID}" "$SETUP_JSON" -5) + +# Real form: take the URL handed to us, and use it properly. +SETUP_RESP=$(http_post_json_timed "SETUP" "${REMOTE_SFT}/sft/${CONV_ID}" "$SETUP_JSON" -5) + +# ── parse remote ICE from SDP answer ────────────────────────────────────────── + +REMOTE_SDP=$(echo "$CONFCONN_RESP" | jq -r '.sdp // .sdp_msg // empty') +[ -n "$REMOTE_SDP" ] || die -6 "No SDP in SETUP response." + +export REMOTE_UFRAG REMOTE_PWD REMOTE_IP REMOTE_PORT +REMOTE_UFRAG=$(echo "$REMOTE_SDP" | awk -F: '/^a=ice-ufrag:/{print $2; exit}' | tr -d '[:space:]') +REMOTE_PWD=$( echo "$REMOTE_SDP" | awk -F: '/^a=ice-pwd:/{print $2; exit}' | tr -d '[:space:]') +read -r REMOTE_IP REMOTE_PORT < <(echo "$REMOTE_SDP" \ + | awk '/^a=candidate:/{print $5, $6; exit}') + +[ -n "$REMOTE_IP" ] || die -7 "No candidate in SDP answer." +[ -n "$REMOTE_UFRAG" ] || die -8 "No ice-ufrag in SDP answer." +log "Remote ICE: ufrag=$REMOTE_UFRAG $REMOTE_IP:$REMOTE_PORT" + +# skip the rest of this file if we do not have socat. +if command -v "socat" &>/dev/null; then + +DISPATCHER="$(cd "$(dirname "$0")" && pwd)/stun_dispatcher.sh" + +log "deps ok socat=$(socat -V 2>&1 | awk '/socat version/{print $3}')" +[ -x "$DISPATCHER" ] || die -2 "stun_dispatcher.sh not found or not executable at: $DISPATCHER" + +# ── step 3: single connected UDP socket + dispatcher ────────────────────────── +# +# UDP4:REMOTE_IP:REMOTE_PORT — connect() to the SFT's candidate. +# bind=LOCAL_IP:LOCAL_UDP_PORT — source address matches our SDP candidate. +# +# With connect() in effect the kernel only delivers datagrams FROM the SFT's +# candidate to this socket, and all writes go TO that candidate. +# +# EXEC:stun_dispatcher.sh,nofork — dispatcher replaces socat's I/O loop with: +# stdin ← datagrams received from SFT (one datagram per socat read) +# stdout → datagrams sent to SFT (one datagram per write call) +# +# The dispatcher sends our Binding Request immediately on startup (before +# blocking on stdin), then processes whatever arrives. + +log "Opening UDP $LOCAL_IP:$LOCAL_UDP_PORT → $REMOTE_IP:$REMOTE_PORT" + +ICE_START_MS=$(now_ms) + +socat \ + "UDP4:${REMOTE_IP}:${REMOTE_PORT},bind=${LOCAL_IP}:${LOCAL_UDP_PORT},reuseaddr" \ + "EXEC:${DISPATCHER},nofork" & +SOCAT_PID=$! + +log "Waiting for ICE success (up to 30 s)..." + +ICE_OK=0 +for _ in $(seq 1 300); do + sleep 0.1 + if [ -f "$WORK/ice_success" ]; then + ICE_END_MS=$(now_ms) + ICE_MS=$((ICE_END_MS - ICE_START_MS)) + printf '\033[32m✓\033[0m %s' + echo " ICE connectivity confirmed in ${ICE_MS} ms." + ICE_OK=1 + break + fi +done + +if [ "$ICE_OK" -ne 1 ]; then + ICE_END_MS=$(now_ms) + ICE_MS=$((ICE_END_MS - ICE_START_MS)) + log "Terminating UDP Listener..." + kill "$SOCAT_PID" + die -9 "ICE did not complete within ${ICE_MS} ms." +fi +log "Terminating UDP Listener..." +kill "$SOCAT_PID" + +else + log 'socat binary not found; UDP tests were not performed.' +fi