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:
2026-08-09 11:35:42 -07:00
co-authored by Claude Opus 5
parent f68d10d5c2
commit 01a2f6aec8
83 changed files with 8042 additions and 3064 deletions
+525
View File
@@ -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());
}
}
+302
View File
@@ -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");
}
}
+245
View File
@@ -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);
}