Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.
Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.
Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.
Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.
This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.
Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.
The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.
Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
//! Auth Bridge — lets browser-based OAuth logins run by CLIs *inside* a
|
||||
//! container complete against the browser on the *host*.
|
||||
//!
|
||||
//! ## The problem
|
||||
//!
|
||||
//! `claude login`, Concourse's `fly login`, `aws sso login` and friends all use
|
||||
//! the same pattern: start a throwaway HTTP listener on a random loopback port,
|
||||
//! then open a browser at a provider URL whose redirect points back to
|
||||
//! `http://localhost:<that port>/callback`. Run inside a container, the listener
|
||||
//! is on the *container's* loopback, the browser is on the *host's*, and the
|
||||
//! callback goes nowhere — the login just hangs. The ports are ephemeral and not
|
||||
//! configurable, so nothing can be pre-published at container creation time.
|
||||
//!
|
||||
//! ## The mechanism
|
||||
//!
|
||||
//! While the bridge is enabled for a running project, poll the container every
|
||||
//! [`POLL_INTERVAL`] for loopback TCP listeners (see [`proc_net`]). For each one
|
||||
//! that appears, bind the *same* port on the host's loopback and proxy each
|
||||
//! accepted connection into the container over `docker exec … socat` (see
|
||||
//! [`tunnel`]). When the in-container listener goes away, drop the host
|
||||
//! listener. The host and container therefore agree on the port number, which is
|
||||
//! the whole trick: the redirect URL the provider was given resolves correctly
|
||||
//! on both sides.
|
||||
//!
|
||||
//! ## Lifecycle and teardown
|
||||
//!
|
||||
//! One poller task per project. It is the only thing that owns
|
||||
//! [`PortForward`]s, and it always tears them down on its way out, so every way
|
||||
//! the bridge can end funnels through the same code:
|
||||
//!
|
||||
//! | Trigger | Path |
|
||||
//! |---|---|
|
||||
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
|
||||
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
|
||||
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
|
||||
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
|
||||
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
|
||||
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
|
||||
//!
|
||||
//! [`AuthBridgeManager::stop`] awaits the poller, so host ports are provably
|
||||
//! released before it returns. As a backstop for any path that skips all of the
|
||||
//! above (a panicking poller, an aborted task), `PortForward`'s [`Drop`] aborts
|
||||
//! the accept loop, which drops the socket.
|
||||
|
||||
pub mod proc_net;
|
||||
pub mod tunnel;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use proc_net::PortFamily;
|
||||
use tunnel::PortForward;
|
||||
|
||||
/// How often the container is polled for new/vanished loopback listeners.
|
||||
/// Short enough that a login redirect isn't left waiting, cheap enough to run
|
||||
/// continuously (one `cat` of two procfs files per tick).
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Emitted whenever the bridged-port set (or the conflict set) changes.
|
||||
/// Payload: `{ project_id, status: AuthBridgeStatus }`.
|
||||
const AUTH_BRIDGE_EVENT: &str = "auth-bridge-changed";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IPC response models
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A port currently bound on the host loopback and forwarded into the container.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BridgedPort {
|
||||
pub port: u16,
|
||||
pub family: PortFamily,
|
||||
/// RFC 3339 timestamp of when the host listener was bound.
|
||||
pub bridged_at: String,
|
||||
}
|
||||
|
||||
/// A loopback listener that was discovered but could not be bridged.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PortConflict {
|
||||
pub port: u16,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AuthBridgeStatus {
|
||||
pub enabled: bool,
|
||||
pub active_ports: Vec<BridgedPort>,
|
||||
pub conflicts: Vec<PortConflict>,
|
||||
}
|
||||
|
||||
impl AuthBridgeStatus {
|
||||
fn disabled() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
active_ports: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Manager
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Everything the poller owns for one project. Live ports and conflicts sit
|
||||
/// behind an `Arc<Mutex<…>>` so `get_auth_bridge_status` can read them without
|
||||
/// disturbing the poller.
|
||||
#[derive(Default)]
|
||||
struct BridgeState {
|
||||
forwards: BTreeMap<u16, PortForward>,
|
||||
conflicts: BTreeMap<u16, String>,
|
||||
}
|
||||
|
||||
impl BridgeState {
|
||||
fn snapshot(&self, enabled: bool) -> AuthBridgeStatus {
|
||||
AuthBridgeStatus {
|
||||
enabled,
|
||||
active_ports: self
|
||||
.forwards
|
||||
.values()
|
||||
.map(|f| BridgedPort {
|
||||
port: f.port,
|
||||
family: f.family,
|
||||
bridged_at: f.bridged_at.clone(),
|
||||
})
|
||||
.collect(),
|
||||
conflicts: self
|
||||
.conflicts
|
||||
.iter()
|
||||
.map(|(port, reason)| PortConflict {
|
||||
port: *port,
|
||||
reason: reason.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProjectBridge {
|
||||
/// Distinguishes this poller from a later one for the same project, so a
|
||||
/// poller that exits late can't remove its replacement's map entry.
|
||||
epoch: u64,
|
||||
cancel: watch::Sender<bool>,
|
||||
state: Arc<Mutex<BridgeState>>,
|
||||
poller: JoinHandle<()>,
|
||||
}
|
||||
|
||||
type BridgeMap = Arc<Mutex<HashMap<String, ProjectBridge>>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AuthBridgeManager {
|
||||
bridges: BridgeMap,
|
||||
next_epoch: AtomicU64,
|
||||
}
|
||||
|
||||
impl AuthBridgeManager {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Start polling for `project_id`. Idempotent: a call while a live poller
|
||||
/// already exists for the project is a no-op.
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) {
|
||||
let mut map = self.bridges.lock().await;
|
||||
|
||||
// A finished poller has already torn its ports down, so its entry is
|
||||
// just a husk and can be replaced. A live one means we're already on.
|
||||
if map
|
||||
.get(&project_id)
|
||||
.is_some_and(|b| !b.poller.is_finished())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
|
||||
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
|
||||
log::info!(
|
||||
"Auth bridge: starting for project {} (container {})",
|
||||
project_id,
|
||||
&container_id[..container_id.len().min(12)]
|
||||
);
|
||||
|
||||
let poller = tokio::spawn(poll_loop(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
epoch,
|
||||
app,
|
||||
store,
|
||||
state.clone(),
|
||||
self.bridges.clone(),
|
||||
cancel_rx,
|
||||
));
|
||||
|
||||
map.insert(
|
||||
project_id,
|
||||
ProjectBridge {
|
||||
epoch,
|
||||
cancel: cancel_tx,
|
||||
state,
|
||||
poller,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Stop the bridge for one project and wait until every host port it held
|
||||
/// has been released.
|
||||
pub async fn stop(&self, project_id: &str) {
|
||||
// Remove under the lock, then release it before awaiting: the poller
|
||||
// takes the same lock to deregister itself on exit.
|
||||
let bridge = self.bridges.lock().await.remove(project_id);
|
||||
if let Some(bridge) = bridge {
|
||||
let _ = bridge.cancel.send(true);
|
||||
let _ = bridge.poller.await;
|
||||
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop every bridge. Used on app exit.
|
||||
pub async fn stop_all(&self) {
|
||||
let bridges: Vec<(String, ProjectBridge)> =
|
||||
self.bridges.lock().await.drain().collect();
|
||||
for (project_id, bridge) in bridges {
|
||||
let _ = bridge.cancel.send(true);
|
||||
let _ = bridge.poller.await;
|
||||
log::info!("Auth bridge: stopped for project {}", project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Current status. `enabled` comes from the persisted project record, so a
|
||||
/// project whose bridge is on but whose container is stopped still reports
|
||||
/// `enabled: true` with no active ports.
|
||||
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
|
||||
let map = self.bridges.lock().await;
|
||||
match map.get(project_id) {
|
||||
Some(bridge) => bridge.state.lock().await.snapshot(enabled),
|
||||
None => AuthBridgeStatus {
|
||||
enabled,
|
||||
..AuthBridgeStatus::disabled()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Poller
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn poll_loop(
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
epoch: u64,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
state: Arc<Mutex<BridgeState>>,
|
||||
bridges: BridgeMap,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
) {
|
||||
let mut exec_failures: u32 = 0;
|
||||
|
||||
loop {
|
||||
// Stop conditions checked every tick, so the bridge winds itself down
|
||||
// even when nothing calls `stop()` (container died, project deleted
|
||||
// out from under us, flag flipped off by another path).
|
||||
let project = match store.get(&project_id) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
log::info!("Auth bridge: project {} is gone — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
if !project.auth_bridge_enabled {
|
||||
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
log::info!(
|
||||
"Auth bridge: container for project {} is no longer running — tearing down",
|
||||
project_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// One exec per tick reads both procfs files.
|
||||
let cmd = vec![
|
||||
"cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
];
|
||||
// Cancellation races the exec, not just the sleep, so disabling the
|
||||
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||
let discovery = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = exec_oneshot(&container_id, cmd) => res,
|
||||
};
|
||||
|
||||
match discovery {
|
||||
Ok(text) => {
|
||||
exec_failures = 0;
|
||||
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||
let skip = skipped_ports(&project);
|
||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||
emit_status(&app, &project_id, &state, true).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
exec_failures += 1;
|
||||
// Transient failures happen (container restarting, engine busy);
|
||||
// only complain once per streak.
|
||||
if exec_failures == 1 {
|
||||
log::warn!(
|
||||
"Auth bridge: failed to read /proc/net/tcp in container for project {}: {}",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
_ = tokio::time::sleep(POLL_INTERVAL) => {}
|
||||
}
|
||||
}
|
||||
|
||||
teardown(&project_id, &state).await;
|
||||
emit_status(
|
||||
&app,
|
||||
&project_id,
|
||||
&state,
|
||||
store
|
||||
.get(&project_id)
|
||||
.is_some_and(|p| p.auth_bridge_enabled),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Deregister, unless a newer poller has already taken this project's slot.
|
||||
let mut map = bridges.lock().await;
|
||||
if map.get(&project_id).is_some_and(|b| b.epoch == epoch) {
|
||||
map.remove(&project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports Docker already handles for this project. A container port that is
|
||||
/// explicitly published has a host-side path already, and the mapping's host
|
||||
/// port is a binding we must not fight over.
|
||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||
project
|
||||
.port_mappings
|
||||
.iter()
|
||||
.flat_map(|m| [m.container_port, m.host_port])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bring the set of host listeners in line with what the container is currently
|
||||
/// listening on. Returns whether anything the UI cares about changed.
|
||||
async fn reconcile(
|
||||
container_id: &str,
|
||||
discovered: &BTreeMap<u16, PortFamily>,
|
||||
skip: &HashSet<u16>,
|
||||
state: &Arc<Mutex<BridgeState>>,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let mut st = state.lock().await;
|
||||
|
||||
// Drop host listeners whose container-side counterpart vanished, became
|
||||
// covered by an explicit port mapping, or changed address family (a family
|
||||
// change alters the socat target, so it has to be rebound below).
|
||||
let stale: Vec<u16> = st
|
||||
.forwards
|
||||
.iter()
|
||||
.filter(|(port, forward)| match discovered.get(port) {
|
||||
None => true,
|
||||
Some(_) if skip.contains(port) => true,
|
||||
Some(family) => *family != forward.family,
|
||||
})
|
||||
.map(|(port, _)| *port)
|
||||
.collect();
|
||||
for port in stale {
|
||||
if let Some(mut forward) = st.forwards.remove(&port) {
|
||||
forward.shutdown().await;
|
||||
log::info!("Auth bridge: released host port {}", port);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Forget conflicts for ports that are no longer relevant.
|
||||
let before = st.conflicts.len();
|
||||
st.conflicts
|
||||
.retain(|port, _| discovered.contains_key(port) && !skip.contains(port));
|
||||
changed |= st.conflicts.len() != before;
|
||||
|
||||
for (&port, &family) in discovered {
|
||||
if skip.contains(&port) || st.forwards.contains_key(&port) {
|
||||
continue;
|
||||
}
|
||||
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||
Ok(forward) => {
|
||||
if st.conflicts.remove(&port).is_some() {
|
||||
log::info!("Auth bridge: host port {} became available", port);
|
||||
}
|
||||
log::info!(
|
||||
"Auth bridge: bridging 127.0.0.1:{} → container {} ({:?})",
|
||||
port,
|
||||
family.socat_target(port),
|
||||
family
|
||||
);
|
||||
st.forwards.insert(port, forward);
|
||||
changed = true;
|
||||
}
|
||||
Err(e) => {
|
||||
// Conflict policy: never fight for a port. Something else on the
|
||||
// host owns it — another project's bridge, or an unrelated
|
||||
// process. Skip it, record why so the UI can say so, and retry
|
||||
// on later ticks in case the owner releases it. Warn only on
|
||||
// the transition so a long-lived conflict doesn't spam the log.
|
||||
let reason = format!(
|
||||
"Host port {} is already in use ({}); not bridged.",
|
||||
port, e
|
||||
);
|
||||
if st.conflicts.get(&port) != Some(&reason) {
|
||||
log::warn!("Auth bridge: {}", reason);
|
||||
st.conflicts.insert(port, reason);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
/// Release every host port held for this project. Awaits each shutdown, so on
|
||||
/// return nothing is bound.
|
||||
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
|
||||
let mut st = state.lock().await;
|
||||
let forwards = std::mem::take(&mut st.forwards);
|
||||
st.conflicts.clear();
|
||||
let count = forwards.len();
|
||||
for (_, mut forward) in forwards {
|
||||
forward.shutdown().await;
|
||||
}
|
||||
if count > 0 {
|
||||
log::info!(
|
||||
"Auth bridge: released {} host port(s) for project {}",
|
||||
count,
|
||||
project_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_status(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
state: &Arc<Mutex<BridgeState>>,
|
||||
enabled: bool,
|
||||
) {
|
||||
let status = state.lock().await.snapshot(enabled);
|
||||
let _ = app.emit(
|
||||
AUTH_BRIDGE_EVENT,
|
||||
serde_json::json!({
|
||||
"project_id": project_id,
|
||||
"status": status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{PortMapping, Project, ProjectPath};
|
||||
|
||||
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||
let mut p = Project::new(
|
||||
"test".to_string(),
|
||||
vec![ProjectPath {
|
||||
host_path: "/tmp".to_string(),
|
||||
mount_name: "tmp".to_string(),
|
||||
}],
|
||||
);
|
||||
p.port_mappings = mappings
|
||||
.into_iter()
|
||||
.map(|(host_port, container_port)| PortMapping {
|
||||
host_port,
|
||||
container_port,
|
||||
protocol: "tcp".to_string(),
|
||||
})
|
||||
.collect();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_already_published_by_docker_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
assert!(skip.contains(&3000));
|
||||
// Both ends of an asymmetric mapping are off limits: the container port
|
||||
// is already reachable, and the host port is Docker's binding.
|
||||
assert!(skip.contains(&8080));
|
||||
assert!(skip.contains(&8081));
|
||||
assert!(!skip.contains(&34567));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mappings_means_nothing_is_skipped() {
|
||||
assert!(skipped_ports(&project_with_mappings(vec![])).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Discovery of loopback TCP listeners by parsing `/proc/net/tcp` and
|
||||
//! `/proc/net/tcp6` from inside the container.
|
||||
//!
|
||||
//! ## Why /proc and not `ss`
|
||||
//!
|
||||
//! The container image (`container/Dockerfile`) ships neither `iproute2` (`ss`)
|
||||
//! nor `net-tools` (`netstat`) nor `lsof`. `/proc/net/tcp{,6}` is part of procfs
|
||||
//! and needs no package at all, so discovery works in the stock image and in any
|
||||
//! snapshot derived from it.
|
||||
//!
|
||||
//! ## Wire format
|
||||
//!
|
||||
//! Both files are fixed-column text with a header line:
|
||||
//!
|
||||
//! ```text
|
||||
//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
//! 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 ...
|
||||
//! ```
|
||||
//!
|
||||
//! Only two columns matter: `local_address` (index 1) and `st` (index 3).
|
||||
//! `st == 0A` is `TCP_LISTEN`; every other state is a connection, not a listener.
|
||||
//!
|
||||
//! ## Hex and endianness
|
||||
//!
|
||||
//! `local_address` is `<address>:<port>`, both hex, but they are *not* encoded
|
||||
//! the same way:
|
||||
//!
|
||||
//! * The **port** is a plain big-endian `%04X` — `8707` is 34567.
|
||||
//! * The **address** is printed as one `%08X` per 32-bit word *in host byte
|
||||
//! order*, which is little-endian on every platform this app targets. So each
|
||||
//! 8-hex-digit group must be parsed as a `u32` and then expanded with
|
||||
//! [`u32::to_le_bytes`] to recover the address bytes in network order:
|
||||
//! `0100007F` → `0x0100007F` → `[7F, 00, 00, 01]` → `127.0.0.1`.
|
||||
//!
|
||||
//! IPv4 rows have one such group (8 hex digits); IPv6 rows have four (32 hex
|
||||
//! digits), each converted independently, in order, to fill the 16 address
|
||||
//! bytes. `::1` is therefore `00000000000000000000000001000000`, and the
|
||||
//! IPv4-mapped `::ffff:127.0.0.1` is `0000000000000000FFFF00000100007F`.
|
||||
//!
|
||||
//! ## What counts as loopback
|
||||
//!
|
||||
//! Only `127.0.0.0/8` and `::1` (plus IPv4-mapped loopback, reported as v4).
|
||||
//! A `0.0.0.0` or `::` listener is a service deliberately published to the
|
||||
//! outside world — that is the port-mappings feature's job, not the auth
|
||||
//! bridge's — so those rows are dropped.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The `st` column value for `TCP_LISTEN`.
|
||||
const TCP_LISTEN: &str = "0A";
|
||||
|
||||
/// Which loopback address family (or families) a container-side listener was
|
||||
/// found on. Determines the `socat` target address used to reach it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PortFamily {
|
||||
/// Only `127.0.0.0/8`.
|
||||
V4,
|
||||
/// Only `::1`. Common in practice: Node resolves `localhost` to IPv6 first
|
||||
/// on Linux, so `claude login` frequently binds `::1` and nothing else
|
||||
/// (anthropics/claude-code#44844).
|
||||
V6,
|
||||
/// Both — reachable either way; we use IPv4.
|
||||
Dual,
|
||||
}
|
||||
|
||||
impl PortFamily {
|
||||
fn merge(self, other: PortFamily) -> PortFamily {
|
||||
if self == other {
|
||||
self
|
||||
} else {
|
||||
PortFamily::Dual
|
||||
}
|
||||
}
|
||||
|
||||
/// The `socat` address that reaches this listener from inside the container.
|
||||
/// A `::1`-only listener genuinely cannot be reached via `127.0.0.1`
|
||||
/// (verified: connect gets ECONNREFUSED), hence the split.
|
||||
pub fn socat_target(&self, port: u16) -> String {
|
||||
match self {
|
||||
PortFamily::V4 | PortFamily::Dual => format!("TCP:127.0.0.1:{}", port),
|
||||
PortFamily::V6 => format!("TCP6:[::1]:{}", port),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed LISTEN row that survived the loopback filter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct LoopbackListener {
|
||||
pub port: u16,
|
||||
pub family: PortFamily,
|
||||
}
|
||||
|
||||
/// Parse the concatenated contents of `/proc/net/tcp` and `/proc/net/tcp6` into
|
||||
/// the set of loopback ports being listened on, keyed by port with the families
|
||||
/// merged (a port bound on both `127.0.0.1` and `::1` yields
|
||||
/// [`PortFamily::Dual`]).
|
||||
///
|
||||
/// Unparseable lines — the two header lines, `cat`'s "No such file" complaint
|
||||
/// when IPv6 is disabled, anything else that ends up interleaved in the exec's
|
||||
/// combined output — are silently ignored rather than failing the whole poll.
|
||||
pub fn parse_loopback_listeners(text: &str) -> BTreeMap<u16, PortFamily> {
|
||||
let mut ports: BTreeMap<u16, PortFamily> = BTreeMap::new();
|
||||
for listener in parse_listener_rows(text) {
|
||||
ports
|
||||
.entry(listener.port)
|
||||
.and_modify(|f| *f = f.merge(listener.family))
|
||||
.or_insert(listener.family);
|
||||
}
|
||||
ports
|
||||
}
|
||||
|
||||
/// Row-level parse, before per-port family merging. Split out so tests can
|
||||
/// assert on the individual rows.
|
||||
pub fn parse_listener_rows(text: &str) -> Vec<LoopbackListener> {
|
||||
text.lines().filter_map(parse_listener_row).collect()
|
||||
}
|
||||
|
||||
fn parse_listener_row(line: &str) -> Option<LoopbackListener> {
|
||||
let mut fields = line.split_whitespace();
|
||||
let _sl = fields.next()?;
|
||||
let local_address = fields.next()?;
|
||||
let _rem_address = fields.next()?;
|
||||
let state = fields.next()?;
|
||||
|
||||
if state != TCP_LISTEN {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (addr_hex, port_hex) = local_address.split_once(':')?;
|
||||
// The port is a straightforward big-endian hex u16 — no byte swapping.
|
||||
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||
if port == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let family = match addr_hex.len() {
|
||||
8 => {
|
||||
let addr = Ipv4Addr::from(parse_le_word(addr_hex)?);
|
||||
addr.is_loopback().then_some(PortFamily::V4)
|
||||
}
|
||||
32 => {
|
||||
let mut octets = [0u8; 16];
|
||||
for (i, group) in addr_hex.as_bytes().chunks(8).enumerate() {
|
||||
let group = std::str::from_utf8(group).ok()?;
|
||||
octets[i * 4..i * 4 + 4].copy_from_slice(&parse_le_word(group)?);
|
||||
}
|
||||
let addr = Ipv6Addr::from(octets);
|
||||
// An IPv4-mapped row describes a v4 socket, so it is reachable at
|
||||
// 127.0.0.1 and must be classified as v4, not v6.
|
||||
match addr.to_ipv4_mapped() {
|
||||
Some(v4) => v4.is_loopback().then_some(PortFamily::V4),
|
||||
None => addr.is_loopback().then_some(PortFamily::V6),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
Some(LoopbackListener { port, family })
|
||||
}
|
||||
|
||||
/// Parse one `%08X` procfs address word into its four address bytes in network
|
||||
/// order. The kernel prints the word in host byte order, so the recovered bytes
|
||||
/// are the little-endian expansion of the parsed integer.
|
||||
fn parse_le_word(hex: &str) -> Option<[u8; 4]> {
|
||||
Some(u32::from_str_radix(hex, 16).ok()?.to_le_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Verbatim `cat /proc/net/tcp` from a running `triple-c:latest` container
|
||||
/// with three listeners deliberately started:
|
||||
/// * `socat TCP4-LISTEN:34567,bind=127.0.0.1` → row 0 (`0100007F:8707`)
|
||||
/// * `socat TCP4-LISTEN:34569,bind=0.0.0.0` → row 1 (`00000000:8709`)
|
||||
/// * `node ... .listen(34568, "::1")` → appears in TCP6 only
|
||||
const REAL_PROC_NET_TCP: &str = concat!(
|
||||
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode \n",
|
||||
" 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 0000000000000000 100 0 0 10 0 \n",
|
||||
" 1: 00000000:8709 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27758875 1 0000000000000000 100 0 0 10 0 \n",
|
||||
);
|
||||
|
||||
/// Verbatim `cat /proc/net/tcp6` from the same container. The single row is
|
||||
/// the Node listener bound to `::1` only — the case that motivates the
|
||||
/// TCP6 socat target.
|
||||
const REAL_PROC_NET_TCP6: &str = concat!(
|
||||
" sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n",
|
||||
" 0: 00000000000000000000000001000000:8708 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27747129 1 0000000000000000 100 0 0 10 0\n",
|
||||
);
|
||||
|
||||
fn both_files() -> String {
|
||||
format!("{}{}", REAL_PROC_NET_TCP, REAL_PROC_NET_TCP6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ipv4_loopback_row_with_little_endian_address() {
|
||||
let rows = parse_listener_rows(REAL_PROC_NET_TCP);
|
||||
// 0100007F → 127.0.0.1 (kept), 00000000 → 0.0.0.0 (dropped).
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![LoopbackListener {
|
||||
port: 0x8707,
|
||||
family: PortFamily::V4
|
||||
}]
|
||||
);
|
||||
assert_eq!(rows[0].port, 34567);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ipv6_loopback_row() {
|
||||
let rows = parse_listener_rows(REAL_PROC_NET_TCP6);
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![LoopbackListener {
|
||||
port: 34568,
|
||||
family: PortFamily::V6
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_wildcard_bind_addresses() {
|
||||
// 0.0.0.0:34569 is in the fixture and must never be bridged — that is
|
||||
// the port-mappings feature's territory.
|
||||
let ports = parse_loopback_listeners(&both_files());
|
||||
assert!(!ports.contains_key(&34569));
|
||||
|
||||
// Same for the IPv6 wildcard and a non-loopback unicast address.
|
||||
let wildcard_v6 = " 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||
let lan_v4 = " 0: 0245A8C0:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||
assert!(parse_listener_rows(wildcard_v6).is_empty());
|
||||
assert!(parse_listener_rows(lan_v4).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_both_files_concatenated_as_one_exec_output() {
|
||||
let ports = parse_loopback_listeners(&both_files());
|
||||
assert_eq!(ports.len(), 2);
|
||||
assert_eq!(ports.get(&34567), Some(&PortFamily::V4));
|
||||
assert_eq!(ports.get(&34568), Some(&PortFamily::V6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_families_for_a_dual_stack_port() {
|
||||
let dual = format!(
|
||||
"{} 1: 00000000000000000000000001000000:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n",
|
||||
both_files()
|
||||
);
|
||||
let ports = parse_loopback_listeners(&dual);
|
||||
assert_eq!(ports.get(&34567), Some(&PortFamily::Dual));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_mapped_loopback_is_reported_as_v4() {
|
||||
// ::ffff:127.0.0.1 — a v4 socket surfacing in /proc/net/tcp6.
|
||||
let row = " 0: 0000000000000000FFFF00000100007F:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||
assert_eq!(
|
||||
parse_listener_rows(row),
|
||||
vec![LoopbackListener {
|
||||
port: 34567,
|
||||
family: PortFamily::V4
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_listen_states() {
|
||||
// Same loopback address, state 01 (ESTABLISHED) instead of 0A.
|
||||
let established = " 0: 0100007F:8707 0100007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
|
||||
assert!(parse_listener_rows(established).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_headers_and_garbage() {
|
||||
assert!(parse_listener_rows("").is_empty());
|
||||
assert!(parse_listener_rows(
|
||||
"cat: /proc/net/tcp6: No such file or directory\n\n sl local_address rem_address st\n"
|
||||
)
|
||||
.is_empty());
|
||||
// Truncated / malformed rows must not panic or be accepted.
|
||||
assert!(parse_listener_rows(" 0: 0100007F 00000000:0000 0A").is_empty());
|
||||
assert!(parse_listener_rows(" 0: ZZZZZZZZ:8707 00000000:0000 0A x").is_empty());
|
||||
assert!(parse_listener_rows(" 0: 0100007F:0000 00000000:0000 0A x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socat_target_matches_family() {
|
||||
assert_eq!(
|
||||
PortFamily::V4.socat_target(34567),
|
||||
"TCP:127.0.0.1:34567"
|
||||
);
|
||||
assert_eq!(
|
||||
PortFamily::Dual.socat_target(34567),
|
||||
"TCP:127.0.0.1:34567"
|
||||
);
|
||||
assert_eq!(PortFamily::V6.socat_target(34568), "TCP6:[::1]:34568");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! 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) {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user