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:
pi
2026-06-10 23:33:44 +02:00
parent a78e59fb5b
commit 7d8ee4cea1
7 changed files with 131 additions and 12 deletions
+20 -11
View File
@@ -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
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
host's loopback:
@@ -175,28 +175,37 @@ services:
```
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
weigh it against the LAN-jump SSH feature if you use that.)
host. This is the most secure option (Studio never leaves loopback). Note:
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
bridge the container's loopback to its external interface with a one-liner
(uses the bundled `node`; binds the eth0 IP only, so it never clashes with
Studio's own `127.0.0.1:8765` listener):
**B. `studio-expose` bridge (portable — any networking mode).** Publish a
port and run the bundled `studio-expose` helper, which uses `socat` to
bridge the container's loopback to its external interface (binding the
egress IP on the same port, so the token URL Studio printed works
verbatim):
```yaml
services:
devbox:
ports:
- "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
# inside the container, after /studio --port 8765:
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"
studio-expose # bridges $STUDIO_PORT (default 8765); --help for details
```
> **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)
When the Docker host is remote, keep Studio on localhost and forward the