be830879c66213de693050b5e8697f5826f51ad1 braney Wed Sep 2 10:56:33 2026 -0700 ts: serve each ticket sandbox over https as well as http, refs #37867 A parked instance answered only over plain http, so anything a CGI decides from the request scheme could not be exercised in one at all. Apache sets HTTPS=on for a TLS request and cgiServerHttpsIsOn() reads it, so a CGI that branches on it, such as one deciding whether to mark a cookie Secure, always took the same branch in a park no matter what was being tested. Each instance now listens twice: plain http on its registered port, as before, and https on that port plus 1000, from a self-signed certificate generated once and shared by every park on the account. Both listeners serve the same frozen code, so hitting the pair is the comparison. Only the http port is in the registry and the https port is derived from it, so nothing about the existing layout changes and "ts conf NNNNN" adds https to an instance frozen before this. http ports are now kept below the start of the https range so the two cannot overlap. "ts list" prints both, "ts tunnel" forwards both, and "ts port NNNNN ssl" gives the https one on its own, which is how the laptop wrapper asks, rather than repeating the offset in a second file. diff --git src/utils/ts/ts src/utils/ts/ts index da18e6543b5..54daf528004 100755 --- src/utils/ts/ts +++ src/utils/ts/ts @@ -3,30 +3,38 @@ # ts - "ticket sandbox": park a UCSC Genome Browser Redmine ticket as its own # frozen browser instance on hgwdev. refs #37867 # # A per-ticket Apache instance, owned by the developer, bound to a loopback high # port, reached by an ssh tunnel (yours or a colleague's - all hgwdev users # share the loopback, so anyone with an hgwdev account can tunnel in). The DB # and /gbdb are shared (hg.conf points at hgwdev MySQL; /gbdb is shared NFS) and # trash is shared with the live CGIs, so only the *code* is frozen: a full copy # of cgi-bin-$USER + htdocs-$USER plus a rewritten hg.conf. Data is left # shared on purpose (freeze the code, share the data). The one exception is the # udc cache: each ticket gets its own subtree under the shared trash, so one # instance cannot serve another a cache entry it wrote (see setUdcDir). The # rewritten hg.conf also drops the cookie domain, or a parked instance could not # hold a cart at all (see setCookieDomain). # +# Each instance answers on two ports: plain http on its registered port, and +# https on that port plus 1000, from a self-signed certificate shared by every +# park. Both serve the same frozen code, so the pair is how you tell whether a +# CGI behaves differently under TLS - Apache sets HTTPS=on only for the https +# listener, and a CGI that reads it, such as one deciding whether to mark a +# cookie Secure, takes the other branch there. Nothing about the http side +# changed, and "ts conf NNNNN" adds https to a park frozen before it existed. +# # The parked instances and the port registry live under $TS_ROOT, by default # $HOME/ticketSandboxes. On hgwdev, point that at a large local pool (a freeze # is a few GB): mkdir /data/home/$USER/ticketSandboxes and symlink it from # $HOME, or set TS_ROOT. # # Subcommands (NNNNN = Redmine ticket number): # create NNNNN [note] freeze the live sandbox, start an httpd, register it # sync NNNNN re-freeze a parked ticket to the current live sandbox # conf NNNNN rewrite httpd.conf and refresh the ts hg.conf settings, # leaving the frozen code alone # start NNNNN start the ticket's httpd (e.g. after a reboot) # stop NNNNN stop the ticket's httpd # tunnel NNNNN open an ssh tunnel and print the browser URL # list show all parked tickets and their running status # remove NNNNN stop the httpd and delete the ticket sandbox @@ -36,65 +44,101 @@ # --- configuration ---------------------------------------------------------- ROOT="$(readlink -f "${TS_ROOT:-$HOME/ticketSandboxes}")" # parked instances live here REG="$ROOT/ports.tsv" # ticket port created note TS_USER="${USER:-$(id -un)}" # whose live sandbox we freeze LIVE_CGI=/usr/local/apache/cgi-bin-$TS_USER LIVE_HTDOCS=/usr/local/apache/htdocs-$TS_USER HTDOCS_NAME="$(basename "$LIVE_HTDOCS")" # frozen copy keeps the live name SHARED_HTDOCS=/usr/local/apache/htdocs # sibling ../htdocs the CGIs read for fonts etc. SHARED_TRASH=/usr/local/apache/trash # trash is data: shared with the live CGIs BASE_HGCONF=/usr/local/apache/cgi-bin/hg.conf HTTPD=/usr/sbin/httpd MODDIR=modules # relative to ServerRoot /etc/httpd PORT_BASE=48080 + +# Every instance listens twice: plain http on its registered port, and https on +# that port plus SSL_OFFSET. Only the http port is in the registry, so nothing +# about the old layout changes and an instance frozen before https existed picks +# it up from "ts conf". Keeping both means one park can answer the question a +# single scheme cannot: whether a CGI behaves differently under TLS. The cart +# and login cookies are the live example - Apache sets HTTPS=on for the https +# listener, cgiServerHttpsIsOn() reads it, and only then do those cookies come +# back marked Secure. http ports stay below PORT_BASE+SSL_OFFSET so the two +# ranges can never overlap. +SSL_OFFSET=1000 +SSLDIR="$ROOT/ssl" # one self-signed cert, shared by all instances HGWDEV="${HGWDEV:-hgwdev.gi.ucsc.edu}" # --- helpers ----------------------------------------------------------------- die() { echo "ts: $*" >&2; exit 1; } usage() { # print the header comment block, minus the shebang awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "${BASH_SOURCE[0]}" exit 1 } validTkt() { [[ "$1" =~ ^[0-9]+$ ]] || die "ticket must be numeric, got '$1'"; } tsDir() { echo "$ROOT/$1"; } regPort() { # regPort NNNNN -> port, or empty [[ -f "$REG" ]] || return 0 awk -F'\t' -v r="$1" '$1==r{print $2}' "$REG" } portInUse() { # portInUse PORT -> 0 if listening local ss; ss="$(command -v ss || echo /usr/sbin/ss)" "$ss" -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$1\$" } +sslPort() { echo $(( $1 + SSL_OFFSET )); } # sslPort HTTPPORT -> the https port beside it + nextPort() { local p="$PORT_BASE" + local limit=$((PORT_BASE + SSL_OFFSET)) while :; do + [[ "$p" -lt "$limit" ]] || die "no free port below $limit (the https range starts there)" if [[ -f "$REG" ]] && awk -F'\t' -v p="$p" '$2==p{f=1} END{exit !f}' "$REG"; then p=$((p+1)); continue fi - portInUse "$p" && { p=$((p+1)); continue; } + # both halves of the pair have to be free, or the instance starts on one + # scheme and silently fails to bind the other + if portInUse "$p" || portInUse "$(sslPort "$p")"; then p=$((p+1)); continue; fi echo "$p"; return 0 done } +# One self-signed cert for every parked instance, made once and kept in $SSLDIR. +# A park has no password and is reached over an ssh tunnel that is already +# encrypted, so the cert is here to turn HTTPS=on for the CGIs, not to prove +# anything about who is serving. A browser will warn that it is self-signed; +# curl needs -k. Named for localhost, with 127.0.0.1 in the SAN, because those +# are the only two ways anyone reaches a park. +ensureCert() { + [[ -s "$SSLDIR/ts.crt" && -s "$SSLDIR/ts.key" ]] && return 0 + mkdir -p "$SSLDIR" + echo "Generating the shared ticket-sandbox certificate in $SSLDIR ..." + openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ + -keyout "$SSLDIR/ts.key" -out "$SSLDIR/ts.crt" \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" >/dev/null 2>&1 \ + || die "could not generate the ticket-sandbox certificate in $SSLDIR" + chmod 600 "$SSLDIR/ts.key" +} + pidOf() { # pidOf NNNNN -> pid if httpd.pid exists and process alive local dir; dir="$(tsDir "$1")" local pf="$dir/httpd.pid" [[ -f "$pf" ]] || return 0 local pid; pid="$(cat "$pf" 2>/dev/null)" [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null && echo "$pid" } # --- freeze the live sandbox into the ticket dir (used by create and sync) --- freeze() { local tkt="$1" dir; dir="$(tsDir "$tkt")" echo "Freezing live sandbox into $dir ..." mkdir -p "$dir/cgi-bin" "$dir/$HTDOCS_NAME" "$dir/logs" # Mirror the live directory relationships so CWD-relative reads behave as on # the live sandbox. The CGIs run with CWD = $dir/cgi-bin and read two htdocs: @@ -190,47 +234,50 @@ # what a host-only cookie needs, and it works whether the instance is reached as # localhost or as 127.0.0.1. The login cookies follow it too, through # getCookieDomainString() in wikiLink.c. Appended to hg.conf, so it overrides # the value in the included base config (the last assignment of a name wins). setCookieDomain() { local tkt="$1" dir="$2" # freeze() writes hg.conf fresh, but conf() calls this on an existing one grep -q '^# ts: host-only cart cookie' "$dir/cgi-bin/hg.conf" && return 0 printf '\n# ts: host-only cart cookie (refs #37867)\ncentral.domain=\n' \ >> "$dir/cgi-bin/hg.conf" } # --- render the per-ticket httpd.conf ---------------------------------------- writeConf() { local tkt="$1" port="$2" dir; dir="$(tsDir "$1")" + local sport; sport="$(sslPort "$port")" cat > "$dir/httpd.conf" <s %b" common CustomLog "$dir/logs/access_log" common DocumentRoot "$dir/$HTDOCS_NAME" ScriptAlias /cgi-bin/ "$dir/cgi-bin/" Alias /trash/ "$SHARED_TRASH/" # CORS is scoped to the data directories only, matching the live # /usr/local/apache/conf/httpd.conf, which sets it on htdocs and trash and never @@ -243,30 +290,48 @@ AllowOverride None Options +FollowSymLinks +Includes Header set Access-Control-Allow-Origin "*" Header set Access-Control-Allow-Headers: Range Require all granted AllowOverride None Options +FollowSymLinks Header set Access-Control-Allow-Origin "*" Header set Access-Control-Allow-Headers: Range Require all granted + +# The same sandbox again, over TLS, so a CGI can be exercised under HTTPS=on. +# No session cache: shmcb wants a mutex under DefaultRuntimeDir, which a httpd +# started by a normal user cannot write, and a sandbox has nothing to gain from +# resumption anyway. DocumentRoot and the two aliases are repeated rather than +# inherited, since a vhost that names its own is the only version that is +# certain across Apache versions. The blocks above are global and +# cover both listeners. +SSLSessionCache none + + ServerName localhost:$sport + SSLEngine on + SSLCertificateFile "$SSLDIR/ts.crt" + SSLCertificateKeyFile "$SSLDIR/ts.key" + DocumentRoot "$dir/$HTDOCS_NAME" + ScriptAlias /cgi-bin/ "$dir/cgi-bin/" + Alias /trash/ "$SHARED_TRASH/" + EOF } # --- lifecycle --------------------------------------------------------------- startHttpd() { local tkt="$1" dir; dir="$(tsDir "$1")" [[ -f "$dir/httpd.conf" ]] || die "no httpd.conf for RM $tkt (create it first)" if [[ -n "$(pidOf "$tkt")" ]]; then echo "RM $tkt httpd already running (pid $(pidOf "$tkt"))."; return 0 fi "$HTTPD" -f "$dir/httpd.conf" -t >/dev/null # syntax check first "$HTTPD" -f "$dir/httpd.conf" sleep 1 local pid; pid="$(pidOf "$tkt")" [[ -n "$pid" ]] || { tail -5 "$dir/logs/error_log" >&2; die "httpd failed to start for RM $tkt"; } @@ -283,127 +348,145 @@ } # --- subcommands ------------------------------------------------------------- cmd_create() { local tkt="$1"; shift || true local note="${*:-}" # the registry is one tab-separated line per ticket, so a tab or newline in the # free-text note would append a malformed row instead of reading back as the note note="$(printf '%s' "$note" | tr '\t\n\r' ' ')" validTkt "$tkt" [[ -d "$LIVE_CGI" ]] || die "no live sandbox at $LIVE_CGI" [[ -d "$LIVE_HTDOCS" ]] || die "no live htdocs at $LIVE_HTDOCS" mkdir -p "$ROOT" [[ -z "$(regPort "$tkt")" ]] || die "RM $tkt already exists (port $(regPort "$tkt")); use sync/remove" local port; port="$(nextPort)" + local sport; sport="$(sslPort "$port")" + ensureCert freeze "$tkt" writeConf "$tkt" "$port" printf '%s\t%s\t%s\t%s\n' "$tkt" "$port" "$(date +%Y-%m-%d)" "$note" >> "$REG" startHttpd "$tkt" echo - echo "RM $tkt parked on 127.0.0.1:$port" + echo "RM $tkt parked on 127.0.0.1:$port (http) and 127.0.0.1:$sport (https)" echo " on hgwdev: curl 'http://127.0.0.1:$port/cgi-bin/hgTracks?db=hg38'" + echo " curl -k 'https://127.0.0.1:$sport/cgi-bin/hgTracks?db=hg38'" echo " you, remote: ts tunnel $tkt -> http://localhost:$port/cgi-bin/hgTracks" - echo " a colleague: ssh -N -L $port:localhost:$port @$HGWDEV -> http://localhost:$port/cgi-bin/hgTracks" + echo " a colleague: ssh -N -L $port:localhost:$port -L $sport:localhost:$sport @$HGWDEV" + echo " -> http://localhost:$port/cgi-bin/hgTracks" + echo "The certificate is self-signed, so a browser warns once on the https port." } cmd_sync() { local tkt="$1"; validTkt "$tkt" local port; port="$(regPort "$tkt")" [[ -n "$port" ]] || die "RM $tkt not found in registry" local running=""; [[ -n "$(pidOf "$tkt")" ]] && running=1 [[ -n "$running" ]] && stopHttpd "$tkt" + ensureCert freeze "$tkt" writeConf "$tkt" "$port" # regenerate in case template changed [[ -n "$running" ]] && startHttpd "$tkt" || echo "RM $tkt re-frozen (httpd was not running)." } # Rewrite httpd.conf from the current template without touching the frozen code. # "sync" also re-freezes, which is what you want after more work on the live # sandbox, but not when the freeze is the whole point of the park and only the # config template moved on. cmd_conf() { local tkt="$1"; validTkt "$tkt" local port; port="$(regPort "$tkt")" [[ -n "$port" ]] || die "RM $tkt not found in registry" [[ -d "$(tsDir "$tkt")" ]] || die "no sandbox directory for RM $tkt" local running=""; [[ -n "$(pidOf "$tkt")" ]] && running=1 [[ -n "$running" ]] && stopHttpd "$tkt" + ensureCert # retrofits https onto a pre-https park writeConf "$tkt" "$port" setUdcDir "$tkt" "$(tsDir "$tkt")" # retrofit a sandbox frozen before these existed setCookieDomain "$tkt" "$(tsDir "$tkt")" if [[ -n "$running" ]]; then startHttpd "$tkt" else echo "RM $tkt httpd.conf rewritten (httpd was not running)." fi } cmd_start() { validTkt "$1"; startHttpd "$1"; } cmd_stop() { validTkt "$1"; stopHttpd "$1"; } cmd_port() { # print the port for a ticket (used by the laptop-side ts wrapper) + # "ts port NNNNN" keeps printing the http port on its own, because a laptop + # may still hold a copy of ts.mac from before https existed. The https port + # is a second argument away rather than a second line. local tkt="$1"; validTkt "$tkt" + local want="${2:-http}" local port; port="$(regPort "$tkt")" [[ -n "$port" ]] || die "RM $tkt not found in registry" - echo "$port" + case "$want" in + http) echo "$port";; + ssl|https) sslPort "$port";; + *) die "ts port: second argument must be http or ssl, got '$want'";; + esac } cmd_tunnel() { local tkt="$1"; validTkt "$tkt" local port; port="$(regPort "$tkt")" [[ -n "$port" ]] || die "RM $tkt not found in registry" + local sport; sport="$(sslPort "$port")" cat < $HGWDEV RM $tkt sandbox. +Opening ssh tunnel: localhost:$port and localhost:$sport -> $HGWDEV RM $tkt sandbox. While this terminal stays open, point your browser at: http://localhost:$port/cgi-bin/hgTracks + https://localhost:$sport/cgi-bin/hgTracks (self-signed, your browser warns once) To let a colleague reach it from their laptop, have them run (with their own hgwdev username) and then open the same URL: - ssh -N -L $port:localhost:$port @$HGWDEV + ssh -N -L $port:localhost:$port -L $sport:localhost:$sport @$HGWDEV Leave this window running. Ctrl-C closes the tunnel. EOF - exec ssh -N -L "$port:localhost:$port" "$HGWDEV" + exec ssh -N -L "$port:localhost:$port" -L "$sport:localhost:$sport" "$HGWDEV" } cmd_list() { [[ -s "$REG" ]] || { echo "No parked tickets."; return 0; } - printf '%-8s %-6s %-11s %-8s %s\n' RM PORT CREATED STATUS NOTE + printf '%-8s %-6s %-6s %-11s %-8s %s\n' RM PORT HTTPS CREATED STATUS NOTE while IFS=$'\t' read -r tkt port created note; do [[ -z "$tkt" ]] && continue local status="stopped"; [[ -n "$(pidOf "$tkt")" ]] && status="running" - printf '%-8s %-6s %-11s %-8s %s\n' "$tkt" "$port" "$created" "$status" "$note" + printf '%-8s %-6s %-6s %-11s %-8s %s\n' \ + "$tkt" "$port" "$(sslPort "$port")" "$created" "$status" "$note" done < "$REG" } cmd_remove() { local tkt="$1"; validTkt "$tkt" local port; port="$(regPort "$tkt")" [[ -n "$port" ]] || die "RM $tkt not found in registry" stopHttpd "$tkt" || true rm -rf "$(tsDir "$tkt")" # trash is a symlink: removed, not followed rm -rf "$(udcDir "$tkt")" # the ticket's udc cache lives inside the shared trash # drop the registry row local tmp; tmp="$(mktemp)" awk -F'\t' -v r="$tkt" '$1!=r' "$REG" > "$tmp" && mv "$tmp" "$REG" echo "RM $tkt removed." } # --- dispatch ---------------------------------------------------------------- [[ $# -ge 1 ]] || usage sub="$1"; shift || true case "$sub" in create) [[ $# -ge 1 ]] || usage; cmd_create "$@";; sync) [[ $# -eq 1 ]] || usage; cmd_sync "$1";; conf) [[ $# -eq 1 ]] || usage; cmd_conf "$1";; start) [[ $# -eq 1 ]] || usage; cmd_start "$1";; stop) [[ $# -eq 1 ]] || usage; cmd_stop "$1";; tunnel) [[ $# -eq 1 ]] || usage; cmd_tunnel "$1";; - port) [[ $# -eq 1 ]] || usage; cmd_port "$1";; + port) [[ $# -ge 1 && $# -le 2 ]] || usage; cmd_port "$@";; list) cmd_list;; remove) [[ $# -eq 1 ]] || usage; cmd_remove "$1";; -h|--help|help) usage;; *) die "unknown subcommand '$sub' (try: create sync conf start stop tunnel list remove)";; esac