Files
pi-devbox/rootfs/usr/local/bin/studio-expose
T
pi 7d8ee4cea1 feat(studio): bundle studio-expose bridge + socat (opt-in STUDIO_EXPOSE)
pi-studio binds the container's 127.0.0.1, which a published Docker port
can't reach. Add a robust, portable bridge rather than a doc-only one-liner:

- Dockerfile.base: add socat (~1 MB, generally useful TCP relay).
- rootfs/usr/local/bin/studio-expose: socat TCP relay listening on the
  container's egress IPv4 (not 0.0.0.0 — that would EADDRINUSE against
  Studio's loopback listener) forwarding to 127.0.0.1:PORT on the SAME
  port, so Studio's printed token URL works verbatim. Robust egress-IP
  detection (hostname -I, loopback-filtered; ip route get fallback),
  --help, port validation, foreground.
- entrypoint-user.sh: opt-in STUDIO_EXPOSE=1 auto-starts the bridge in the
  background (studio variant only). Default OFF — Studio stays loopback-only
  (its secure default) unless explicitly opted in.
- README: 'Using pi-studio' now documents host-networking (A) and the
  studio-expose/STUDIO_EXPOSE bridge (B) with a security note; ssh -L for
  remote, mosh caveat retained.
- smoke-test: assert socat + studio-expose present (base-level).
- CHANGELOG/AGENTS updated.

No tag — stopping for review.
2026-06-10 23:33:44 +02:00

76 lines
3.4 KiB
Bash
Executable File

#!/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:<port>, 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}"