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.
This commit is contained in:
@@ -120,7 +120,8 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
|
|||||||
`pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
|
`pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
|
||||||
The default `:latest` image stays studio-free. Note: pi-studio binds
|
The default `:latest` image stays studio-free. Note: pi-studio binds
|
||||||
`127.0.0.1` inside the container, so browser access needs host
|
`127.0.0.1` inside the container, so browser access needs host
|
||||||
networking or a loopback bridge — see README "Using pi-studio".
|
networking or the bundled `studio-expose` bridge (socat; auto-starts
|
||||||
|
when `STUDIO_EXPOSE=1`) — see README "Using pi-studio".
|
||||||
- **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for
|
- **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for
|
||||||
Python REPLs; `apt install` other-language runtimes ad-hoc per
|
Python REPLs; `apt install` other-language runtimes ad-hoc per
|
||||||
container if needed.
|
container if needed.
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
|||||||
gate **only** the studio tags, so a studio build/smoke failure can
|
gate **only** the studio tags, so a studio build/smoke failure can
|
||||||
never block the core `:latest` / `:vX.Y.Z` release.
|
never block the core `:latest` / `:vX.Y.Z` release.
|
||||||
- `STUDIO_PORT=8765` baked as an advisory default.
|
- `STUDIO_PORT=8765` baked as an advisory default.
|
||||||
|
- **`studio-expose` helper + `socat` (base).** Because pi-studio binds the
|
||||||
|
container's loopback, a published Docker port can't reach it. The new
|
||||||
|
`studio-expose` helper (socat, added to the base) bridges the container's
|
||||||
|
loopback to its egress interface on the same port; set `STUDIO_EXPOSE=1`
|
||||||
|
in compose to auto-start it on boot (default off — Studio stays
|
||||||
|
loopback-only otherwise). `socat` is in the base for all variants.
|
||||||
- **README "Using pi-studio" section.** Documents the container access
|
- **README "Using pi-studio" section.** Documents the container access
|
||||||
reality: pi-studio hard-binds `127.0.0.1` inside the container
|
reality: pi-studio hard-binds `127.0.0.1` inside the container
|
||||||
(`.listen(port,"127.0.0.1")`, no `--host` flag), so a plain `-p`
|
(`.listen(port,"127.0.0.1")`, no `--host` flag), so a plain `-p`
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
||||||
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
||||||
# yq — YAML-aware companion to jq.
|
# yq — YAML-aware companion to jq.
|
||||||
|
# socat — TCP relay. Powers `studio-expose`, which bridges
|
||||||
|
# pi-studio's container-loopback server to the container's
|
||||||
|
# external interface so a published port can reach it.
|
||||||
|
# ~1 MB; generally useful for any port-forwarding need.
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get upgrade -y --no-install-recommends && \
|
apt-get upgrade -y --no-install-recommends && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
@@ -85,6 +89,7 @@ RUN apt-get update && \
|
|||||||
pandoc \
|
pandoc \
|
||||||
graphviz \
|
graphviz \
|
||||||
imagemagick \
|
imagemagick \
|
||||||
|
socat \
|
||||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -430,9 +435,11 @@ COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
|||||||
|
|
||||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||||
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
||||||
|
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||||
|
/usr/local/bin/studio-expose \
|
||||||
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
||||||
|
|
||||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ There is no `--host`/bind flag. This matters for a container: a plain
|
|||||||
interface, **not** its loopback, so it will not reach Studio. Two paths
|
interface, **not** its loopback, so it will not reach Studio. Two paths
|
||||||
work:
|
work:
|
||||||
|
|
||||||
**A. Host networking (simplest — recommended on OrbStack / single-host).**
|
**A. Host networking (simplest — OrbStack / single-host, no bridge).**
|
||||||
Run the container with host networking so the container's loopback is the
|
Run the container with host networking so the container's loopback is the
|
||||||
host's loopback:
|
host's loopback:
|
||||||
|
|
||||||
@@ -175,28 +175,37 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Then `http://127.0.0.1:8765/?token=…` works in a browser on the Docker
|
Then `http://127.0.0.1:8765/?token=…` works in a browser on the Docker
|
||||||
host. (Note: host networking changes `host.docker.internal` semantics, so
|
host. This is the most secure option (Studio never leaves loopback). Note:
|
||||||
weigh it against the LAN-jump SSH feature if you use that.)
|
host networking changes `host.docker.internal` semantics, so weigh it
|
||||||
|
against the LAN-jump SSH feature if you use that.
|
||||||
|
|
||||||
**B. Loopback bridge (portable — bridge networking).** Publish a port and
|
**B. `studio-expose` bridge (portable — any networking mode).** Publish a
|
||||||
bridge the container's loopback to its external interface with a one-liner
|
port and run the bundled `studio-expose` helper, which uses `socat` to
|
||||||
(uses the bundled `node`; binds the eth0 IP only, so it never clashes with
|
bridge the container's loopback to its external interface (binding the
|
||||||
Studio's own `127.0.0.1:8765` listener):
|
egress IP on the same port, so the token URL Studio printed works
|
||||||
|
verbatim):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
devbox:
|
devbox:
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8765:8765" # host-localhost only
|
- "127.0.0.1:8765:8765" # host-localhost only
|
||||||
|
environment:
|
||||||
|
- STUDIO_EXPOSE=1 # auto-start the bridge on container boot
|
||||||
```
|
```
|
||||||
|
|
||||||
|
With `STUDIO_EXPOSE=1`, the entrypoint starts the bridge for you; just run
|
||||||
|
`/studio --port 8765` in your pi session. To bridge manually instead
|
||||||
|
(leave `STUDIO_EXPOSE` unset), run `studio-expose` in a container shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# inside the container, after /studio --port 8765:
|
studio-expose # bridges $STUDIO_PORT (default 8765); --help for details
|
||||||
EXT=$(hostname -i)
|
|
||||||
node -e 'const net=require("net"),p=process.env.STUDIO_PORT||8765,h=process.argv[1];\
|
|
||||||
net.createServer(c=>{const u=net.connect(p,"127.0.0.1");c.pipe(u);u.pipe(c);u.on("error",()=>c.destroy());c.on("error",()=>u.destroy());}).listen(p,h,()=>console.log("bridge "+h+":"+p+" -> 127.0.0.1:"+p));' "$EXT"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Security:** the bridge intentionally exposes Studio beyond loopback;
|
||||||
|
> its tokenized URL is the only auth. Keep the host-side publish on
|
||||||
|
> `127.0.0.1:` and use `ssh -L` for remote access. Default is **off**.
|
||||||
|
|
||||||
### Remote host (SSH / mosh)
|
### Remote host (SSH / mosh)
|
||||||
|
|
||||||
When the Docker host is remote, keep Studio on localhost and forward the
|
When the Docker host is remote, keep Studio on localhost and forward the
|
||||||
|
|||||||
@@ -121,6 +121,25 @@ if command -v pi &>/dev/null; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── pi-studio: optional loopback bridge (opt-in) ──────────────────────
|
||||||
|
# pi-studio binds its server to 127.0.0.1 inside the container, which a
|
||||||
|
# published Docker port cannot reach. When STUDIO_EXPOSE is truthy (set in
|
||||||
|
# compose), start the `studio-expose` socat bridge in the background so a
|
||||||
|
# published port + `ssh -L` tunnel can reach Studio once the user runs
|
||||||
|
# `/studio --port "$STUDIO_PORT"`. Default OFF — Studio stays loopback-only
|
||||||
|
# (its secure default) unless explicitly opted in. Guarded on the studio
|
||||||
|
# variant (/opt/pi-studio) so it is a no-op in the plain image.
|
||||||
|
case "${STUDIO_EXPOSE:-}" in
|
||||||
|
1|true|TRUE|yes|on)
|
||||||
|
if [ -d /opt/pi-studio ] && command -v studio-expose &>/dev/null && command -v socat &>/dev/null; then
|
||||||
|
echo "STUDIO_EXPOSE set — starting studio-expose bridge on port ${STUDIO_PORT:-8765} (background)"
|
||||||
|
nohup studio-expose "${STUDIO_PORT:-8765}" >/tmp/studio-expose.log 2>&1 &
|
||||||
|
else
|
||||||
|
echo "STUDIO_EXPOSE set but studio-expose/socat/pi-studio unavailable — skipping bridge"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||||
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
||||||
# run the deploy script to create relative symlinks for skills and instructions.
|
# run the deploy script to create relative symlinks for skills and instructions.
|
||||||
|
|||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/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}"
|
||||||
@@ -78,6 +78,8 @@ run "graphviz (dot)" "dot -V"
|
|||||||
run "imagemagick" "magick --version"
|
run "imagemagick" "magick --version"
|
||||||
run "yq" "yq --version"
|
run "yq" "yq --version"
|
||||||
run "tldr (tealdeer)" "tldr --version"
|
run "tldr (tealdeer)" "tldr --version"
|
||||||
|
run "socat" "socat -V"
|
||||||
|
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
|
||||||
|
|
||||||
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
Reference in New Issue
Block a user