Four features, plus a latent bug fix.
llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.
Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.
Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.
URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.
Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.
188 frontend tests, 107 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
271 lines
10 KiB
Rust
271 lines
10 KiB
Rust
//! Host-side loopback listener for one bridged port, and the per-connection
|
|
//! tunnel that carries its bytes into the container.
|
|
//!
|
|
//! ## Why not connect to the container's IP
|
|
//!
|
|
//! Container IPs are not routable from the host on Docker Desktop (macOS and
|
|
//! Windows run the engine in a VM), so a host→`172.17.x.x` dial cannot be the
|
|
//! transport. The Docker API is the only channel guaranteed to reach the
|
|
//! container from the host, so each accepted connection is carried by a
|
|
//! `docker exec` running `socat - TCP:127.0.0.1:<port>`, with the exec's stdin
|
|
//! and stdout wired to the TCP socket. `socat` ships in the container image.
|
|
//!
|
|
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
|
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
|
//! interactive terminal sessions are built on.
|
|
|
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
|
|
|
use bollard::container::LogOutput;
|
|
use futures_util::StreamExt;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::{TcpListener, TcpStream};
|
|
use tokio::task::{JoinHandle, JoinSet};
|
|
|
|
use crate::docker::exec::{create_attached_exec, AttachedExec};
|
|
|
|
use super::proc_net::PortFamily;
|
|
|
|
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
|
|
/// only needs to not be pathological.
|
|
const PUMP_BUF: usize = 16 * 1024;
|
|
|
|
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
|
/// child running.
|
|
struct AbortOnDrop(JoinHandle<()>);
|
|
|
|
impl Drop for AbortOnDrop {
|
|
fn drop(&mut self) {
|
|
self.0.abort();
|
|
}
|
|
}
|
|
|
|
/// One host loopback port bound and proxied into the container.
|
|
///
|
|
/// The accept loop owns the [`TcpListener`](tokio::net::TcpListener)s and the
|
|
/// [`JoinSet`] of live connection tasks, so aborting the single task handle
|
|
/// releases the port *and* tears down every connection under it. [`Drop`] does
|
|
/// that as a backstop; [`PortForward::shutdown`] does it deterministically by
|
|
/// also awaiting the aborted task, which guarantees the socket is closed before
|
|
/// the caller proceeds (important when a port is rebound right after).
|
|
pub struct PortForward {
|
|
pub port: u16,
|
|
pub family: PortFamily,
|
|
pub bridged_at: String,
|
|
task: JoinHandle<()>,
|
|
}
|
|
|
|
impl Drop for PortForward {
|
|
fn drop(&mut self) {
|
|
self.task.abort();
|
|
}
|
|
}
|
|
|
|
impl PortForward {
|
|
/// Bind `port` on the host loopback and start proxying into `container_id`.
|
|
///
|
|
/// The bind happens before the task is spawned, so an already-taken port is
|
|
/// reported to the caller as an error rather than disappearing into a
|
|
/// background task.
|
|
pub async fn bind(
|
|
container_id: String,
|
|
port: u16,
|
|
family: PortFamily,
|
|
) -> Result<Self, std::io::Error> {
|
|
// SECURITY BOUNDARY: the host side binds loopback ONLY — 127.0.0.1 and
|
|
// ::1, never 0.0.0.0 / ::. Everything reachable through this socket is
|
|
// an unauthenticated service inside the container that deliberately
|
|
// bound loopback because it expected to be reachable from nowhere else.
|
|
// Binding a wildcard address here would publish container internals to
|
|
// every host on the LAN. Do not "fix" a connectivity problem by
|
|
// widening these addresses.
|
|
let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?;
|
|
|
|
// Also take ::1 when it is available. Browsers and CLIs resolve
|
|
// `localhost` to either family, and the IPv6 answer is often tried
|
|
// first, so a v4-only host listener would miss those callbacks. This is
|
|
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
|
// the v4 listener alone still works, so it is not treated as a conflict.
|
|
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
|
Ok(l) => Some(l),
|
|
Err(e) => {
|
|
log::debug!(
|
|
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
|
port,
|
|
port,
|
|
e
|
|
);
|
|
None
|
|
}
|
|
};
|
|
|
|
let target = family.socat_target(port);
|
|
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
|
|
|
Ok(Self {
|
|
port,
|
|
family,
|
|
bridged_at: chrono::Utc::now().to_rfc3339(),
|
|
task,
|
|
})
|
|
}
|
|
|
|
/// Stop accepting, drop the host socket, and abort every in-flight
|
|
/// connection. Awaits the aborted task so the port is provably released
|
|
/// when this returns.
|
|
pub async fn shutdown(&mut self) {
|
|
self.task.abort();
|
|
let _ = (&mut self.task).await;
|
|
}
|
|
}
|
|
|
|
/// Accept on both loopback listeners until aborted. Dropping this future drops
|
|
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
|
|
async fn accept_loop(
|
|
container_id: String,
|
|
port: u16,
|
|
target: String,
|
|
v4: TcpListener,
|
|
v6: Option<TcpListener>,
|
|
) {
|
|
let mut conns: JoinSet<()> = JoinSet::new();
|
|
|
|
loop {
|
|
let accepted = tokio::select! {
|
|
r = v4.accept() => r,
|
|
r = accept_optional(v6.as_ref()) => r,
|
|
// Reap finished tunnels so the JoinSet doesn't grow without bound.
|
|
// When the set is empty `join_next()` yields None, the pattern fails
|
|
// to match, and the branch simply drops out of the select.
|
|
Some(_) = conns.join_next() => continue,
|
|
};
|
|
|
|
match accepted {
|
|
Ok((stream, peer)) => {
|
|
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
|
let _ = stream.set_nodelay(true);
|
|
conns.spawn(tunnel_connection(
|
|
container_id.clone(),
|
|
target.clone(),
|
|
stream,
|
|
port,
|
|
));
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Auth bridge: accept failed on port {}: {} — stopping listener", port, e);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `accept()` on an optional listener; never completes when there is none, so it
|
|
/// can sit in a `select!` arm unconditionally.
|
|
async fn accept_optional(
|
|
listener: Option<&TcpListener>,
|
|
) -> std::io::Result<(TcpStream, SocketAddr)> {
|
|
match listener {
|
|
Some(l) => l.accept().await,
|
|
None => std::future::pending().await,
|
|
}
|
|
}
|
|
|
|
/// Carry one accepted host connection into the container over `socat`.
|
|
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
|
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
|
}
|
|
|
|
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
|
/// ahead of anything further read from `stream`.
|
|
///
|
|
/// This exists for callers that must *inspect* the beginning of a connection
|
|
/// before deciding to forward it — the browser-view proxy reads the HTTP request
|
|
/// head off the socket to check a token, and then has to put those same bytes
|
|
/// back on the wire. Passing them here keeps the byte stream exact, rather than
|
|
/// re-serialising a parsed request.
|
|
pub async fn tunnel_connection_with_prelude(
|
|
container_id: String,
|
|
target: String,
|
|
stream: TcpStream,
|
|
port: u16,
|
|
prelude: Vec<u8>,
|
|
) {
|
|
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
|
|
|
|
let AttachedExec {
|
|
mut output,
|
|
mut input,
|
|
..
|
|
} = match create_attached_exec(&container_id, cmd, false).await {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
log::warn!(
|
|
"Auth bridge: failed to open tunnel exec for port {} ({}): {}",
|
|
port,
|
|
target,
|
|
e
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let (mut host_rx, mut host_tx) = stream.into_split();
|
|
|
|
// Host → container. Runs as its own task so the container→host direction is
|
|
// never blocked behind a client that has stopped sending. Finishing this
|
|
// direction drops `input`, which closes the exec's stdin and lets socat see
|
|
// a clean EOF (a half-close, not a teardown of the whole connection).
|
|
let upstream = AbortOnDrop(tokio::spawn(async move {
|
|
// Bytes the caller already consumed from the socket go first, so the
|
|
// container sees the connection exactly as the client sent it.
|
|
if !prelude.is_empty()
|
|
&& (input.write_all(&prelude).await.is_err() || input.flush().await.is_err())
|
|
{
|
|
return;
|
|
}
|
|
let mut buf = vec![0u8; PUMP_BUF];
|
|
loop {
|
|
match host_rx.read(&mut buf).await {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}));
|
|
|
|
// Container → host. This direction is authoritative: when the exec's output
|
|
// stream ends, socat has exited and the connection is over.
|
|
while let Some(chunk) = output.next().await {
|
|
match chunk {
|
|
// Only stdout is payload. The exec is created with tty = false
|
|
// precisely so Docker demultiplexes these, keeping socat's stderr
|
|
// diagnostics out of the proxied byte stream.
|
|
Ok(LogOutput::StdOut { message }) => {
|
|
if host_tx.write_all(&message).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Ok(LogOutput::StdErr { message }) => {
|
|
log::debug!(
|
|
"Auth bridge: socat stderr for port {}: {}",
|
|
port,
|
|
String::from_utf8_lossy(&message).trim()
|
|
);
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
log::debug!("Auth bridge: tunnel stream error on port {}: {}", port, e);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let _ = host_tx.shutdown().await;
|
|
// Explicit: stop reading from the host now that the container side is gone.
|
|
drop(upstream);
|
|
}
|