#!/usr/bin/env bash # studio-expose — make a container-loopback pi-studio server reachable # through a published Docker port. # # WHY THIS EXISTS # pi-studio hard-binds its HTTP/WebSocket server to 127.0.0.1 inside the # container (index.ts: `.listen(port, "127.0.0.1")`) and there is no # --host / bind flag. A plain `docker run -p 8765:8765` forwards to the # container's EXTERNAL interface (eth0), not its loopback, so it cannot # reach Studio. This helper runs a socat TCP relay that listens on the # container's egress IP and forwards to 127.0.0.1:, so a published # port (and an `ssh -L` tunnel from your laptop) can reach Studio. # # SECURITY # This intentionally exposes Studio beyond loopback — anything that can # reach the container's network interface (and the host port you publish) # can connect. Studio's tokenized URL is the only auth. Mitigate by # publishing the host port on localhost only: # ports: ["127.0.0.1:${STUDIO_PORT}:${STUDIO_PORT}"] # and use `ssh -L` for remote access. Bridge nothing you don't intend to. # # USAGE # studio-expose [PORT] # bridge PORT (default: $STUDIO_PORT or 8765) # studio-expose --help # # Typically: inside a pi session run `/studio --no-browser --port 8765`, # then in a container shell run `studio-expose` (or set STUDIO_EXPOSE=1 in # compose to auto-start it on container boot — see entrypoint-user.sh). # # Runs in the foreground; Ctrl-C to stop. The entrypoint auto-start path # runs it backgrounded. set -euo pipefail PORT="${1:-${STUDIO_PORT:-8765}}" if [ "$PORT" = "--help" ] || [ "$PORT" = "-h" ]; then sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//' exit 0 fi case "$PORT" in ''|*[!0-9]*) echo "studio-expose: invalid port '$PORT'" >&2; exit 2 ;; esac if ! command -v socat >/dev/null 2>&1; then echo "studio-expose: socat not found in PATH" >&2 exit 1 fi # Container's primary egress IPv4. In Docker the container hostname resolves # to its eth0 address, so `hostname -I` lists it; we take the first # non-loopback IPv4. We must bind this specific address rather than 0.0.0.0 # — binding 0.0.0.0 would collide with Studio's own 127.0.0.1:PORT listener # (0.0.0.0 includes loopback) and fail with EADDRINUSE. `ip route get` is a # fallback only when iproute2 happens to be present (not in the base image). BIND_IP="$(hostname -I 2>/dev/null | tr ' ' '\n' \ | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -vE '^127\.' | head -n1)" if [ -z "${BIND_IP:-}" ] && command -v ip >/dev/null 2>&1; then BIND_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')" fi [ -n "${BIND_IP:-}" ] || BIND_IP="$(hostname -i 2>/dev/null | awk '{print $1}')" if [ -z "${BIND_IP:-}" ]; then echo "studio-expose: could not determine container egress IP" >&2 exit 1 fi echo "studio-expose: bridging ${BIND_IP}:${PORT} -> 127.0.0.1:${PORT}" echo "studio-expose: open the tokenized URL pi-studio printed; if the host" echo "studio-expose: publishes ${PORT}, reach it at http://127.0.0.1:${PORT}/?token=..." echo "studio-expose: (remote host: ssh -L ${PORT}:127.0.0.1:${PORT} user@host)" # fork: one child per connection (handles concurrent + long-lived WebSocket # connections). reuseaddr: survive quick restarts. Studio need not be up yet # — connections simply fail until `/studio --port ${PORT}` is running. exec socat "TCP-LISTEN:${PORT},bind=${BIND_IP},fork,reuseaddr" "TCP:127.0.0.1:${PORT}"