2026-08-09 11:35:42 -07:00
|
|
|
//! 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;
|
2026-08-09 19:35:39 -07:00
|
|
|
use crate::docker::exec::{exec_oneshot_limited, PROC_NET_OUTPUT_LIMIT};
|
2026-08-09 11:35:42 -07:00
|
|
|
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,
|
2026-08-23 08:31:39 -07:00
|
|
|
/// Set when only the IPv4 half of the host listener could be bound. The
|
|
|
|
|
/// port still works, but not for a client that insists on `::1` — see
|
|
|
|
|
/// [`tunnel::PortForward::ipv6_warning`].
|
|
|
|
|
pub ipv6_warning: Option<String>,
|
2026-08-09 11:35:42 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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(),
|
2026-08-23 08:31:39 -07:00
|
|
|
ipv6_warning: f.ipv6_warning.clone(),
|
2026-08-09 11:35:42 -07:00
|
|
|
})
|
|
|
|
|
.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 {
|
2026-08-09 19:35:39 -07:00
|
|
|
// Clone the per-project handle out and drop the map lock before taking
|
|
|
|
|
// the state lock. Holding both across the nested await is not a
|
|
|
|
|
// deadlock — the order is consistently bridges→state — but it puts a
|
|
|
|
|
// cheap UI status call behind whatever the poller is doing under
|
|
|
|
|
// `state`, and behind every other project's status call too.
|
|
|
|
|
let state = self.bridges.lock().await.get(project_id).map(|b| b.state.clone());
|
|
|
|
|
match state {
|
|
|
|
|
Some(state) => state.lock().await.snapshot(enabled),
|
2026-08-09 11:35:42 -07:00
|
|
|
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.
|
2026-08-09 19:35:39 -07:00
|
|
|
//
|
|
|
|
|
// Absolute path, deliberately: the image's `ENV PATH` puts a
|
|
|
|
|
// container-writable directory first, so a bare `cat` is a name the
|
|
|
|
|
// container can rebind to a shim that prints whatever it likes. It
|
|
|
|
|
// still could not make us bind a *non-loopback* port, but it decides
|
|
|
|
|
// how much output this loop ingests and how many host ports it is asked
|
|
|
|
|
// for, which is why the call is also length-capped and the result
|
|
|
|
|
// count is capped in `reconcile`.
|
2026-08-09 11:35:42 -07:00
|
|
|
let cmd = vec![
|
2026-08-09 19:35:39 -07:00
|
|
|
"/usr/bin/cat".to_string(),
|
2026-08-09 11:35:42 -07:00
|
|
|
"/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,
|
2026-08-09 19:35:39 -07:00
|
|
|
res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res,
|
2026-08-09 11:35:42 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match discovery {
|
|
|
|
|
Ok(text) => {
|
|
|
|
|
exec_failures = 0;
|
|
|
|
|
let discovered = proc_net::parse_loopback_listeners(&text);
|
2026-08-23 11:13:15 -07:00
|
|
|
// Re-read every tick: a project can gain a port mapping and the
|
|
|
|
|
// gateway/STT/web-terminal ports can be re-pointed while the
|
|
|
|
|
// bridge is running, and a stale reservation set is a hole.
|
|
|
|
|
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
2026-08-09 11:35:42 -07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 11:13:15 -07:00
|
|
|
/// Every port this project's bridge must not take.
|
2026-08-09 16:55:28 -07:00
|
|
|
///
|
2026-08-23 11:13:15 -07:00
|
|
|
/// The bridge's rule is "a container loopback listener on port N becomes an
|
|
|
|
|
/// **unauthenticated** host listener on port N". That is only safe for ports
|
|
|
|
|
/// nothing else on the host owns, so everything that *is* owned has to be
|
|
|
|
|
/// enumerated here. Four sources:
|
|
|
|
|
///
|
|
|
|
|
/// 1. **This project's own published ports** — a container port that Docker
|
|
|
|
|
/// already publishes has a host-side path, and the mapping's host port is a
|
|
|
|
|
/// binding we must not fight over.
|
|
|
|
|
/// 2. **Every other project's published host ports.** The container names the
|
|
|
|
|
/// *host* port, so project A's container listening on 8080 would otherwise
|
|
|
|
|
/// have the bridge bind host 8080 — the port project B publishes on. Only
|
|
|
|
|
/// the host end of another project's mapping is reserved: its container end
|
|
|
|
|
/// is a number inside a different network namespace and means nothing here.
|
|
|
|
|
/// 3. **This app's own host services** — the LiteLLM gateway, the STT sidecar
|
|
|
|
|
/// and the web terminal. All three are off by default and bind on demand, so
|
|
|
|
|
/// first-come would win: a container that binds container-loopback 4000
|
|
|
|
|
/// while the gateway is stopped gets host `127.0.0.1:4000` mirrored to it
|
|
|
|
|
/// within one [`POLL_INTERVAL`], after which the gateway cannot start and
|
|
|
|
|
/// anything on the host dialling 4000 — including *other project
|
|
|
|
|
/// containers*, which reach the gateway by host address — is talking to the
|
|
|
|
|
/// squatting container instead. The web terminal is the worst of the three,
|
|
|
|
|
/// because its access token travels in the URL query. Both the *configured*
|
|
|
|
|
/// port and the shipped default are reserved: the configured one is what the
|
|
|
|
|
/// service will bind next, and the default is what it falls back to for a
|
|
|
|
|
/// fresh profile or a settings file that failed to parse.
|
|
|
|
|
/// 4. [`RESERVED_CONTAINER_PORTS`] and [`RESERVED_HOST_PORTS`] — the
|
|
|
|
|
/// browser-view pane's two ends, which it exposes on its own authenticated
|
|
|
|
|
/// terms.
|
|
|
|
|
///
|
|
|
|
|
/// Pure on purpose: everything it needs is passed in, so the whole reservation
|
|
|
|
|
/// policy is unit-testable without a store, a container or an app handle.
|
|
|
|
|
fn skipped_ports(
|
|
|
|
|
project: &crate::models::Project,
|
|
|
|
|
all_projects: &[crate::models::Project],
|
|
|
|
|
settings: &crate::models::AppSettings,
|
|
|
|
|
) -> HashSet<u16> {
|
2026-08-09 16:55:28 -07:00
|
|
|
let mut skip: HashSet<u16> = project
|
2026-08-09 11:35:42 -07:00
|
|
|
.port_mappings
|
|
|
|
|
.iter()
|
|
|
|
|
.flat_map(|m| [m.container_port, m.host_port])
|
2026-08-09 16:55:28 -07:00
|
|
|
.collect();
|
2026-08-23 11:13:15 -07:00
|
|
|
|
|
|
|
|
// Other projects: host end only.
|
|
|
|
|
skip.extend(
|
|
|
|
|
all_projects
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|p| p.id != project.id)
|
|
|
|
|
.flat_map(|p| p.port_mappings.iter().map(|m| m.host_port)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
skip.extend(app_service_host_ports(settings));
|
2026-08-09 16:55:28 -07:00
|
|
|
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
2026-08-09 19:35:39 -07:00
|
|
|
skip.extend(RESERVED_HOST_PORTS.clone());
|
2026-08-09 16:55:28 -07:00
|
|
|
skip
|
2026-08-09 11:35:42 -07:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 11:13:15 -07:00
|
|
|
/// Current app settings, or defaults if the state is not reachable.
|
|
|
|
|
///
|
|
|
|
|
/// Falling back rather than unwrapping matters: the reservation set is a safety
|
|
|
|
|
/// rail, and a rail that panics the poller when it cannot read its input is
|
|
|
|
|
/// worse than one that falls back to the shipped port numbers — which are what
|
|
|
|
|
/// the services use anyway until someone changes them.
|
|
|
|
|
fn app_settings(app: &AppHandle) -> crate::models::AppSettings {
|
|
|
|
|
use tauri::Manager;
|
|
|
|
|
app.try_state::<crate::AppState>()
|
|
|
|
|
.map(|state| state.settings_store.get())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Host ports this app's own sibling services bind, configured value and
|
|
|
|
|
/// shipped default alike.
|
|
|
|
|
///
|
|
|
|
|
/// Read off the settings models rather than restated as literals here: a
|
|
|
|
|
/// duplicated port number is exactly the kind of constant that drifts silently,
|
|
|
|
|
/// and the failure mode of drift is a reservation that no longer covers the
|
|
|
|
|
/// service it was written for.
|
|
|
|
|
fn app_service_host_ports(settings: &crate::models::AppSettings) -> Vec<u16> {
|
|
|
|
|
use crate::models::{SttSettings, WebTerminalSettings};
|
|
|
|
|
|
|
|
|
|
vec![
|
|
|
|
|
// LiteLLM gateway (`docker/gateway.rs`).
|
|
|
|
|
settings.gateway.port,
|
|
|
|
|
crate::models::default_gateway_port(),
|
|
|
|
|
// Speech-to-text sidecar (`docker/stt.rs`).
|
|
|
|
|
settings.stt.port,
|
|
|
|
|
SttSettings::default().port,
|
|
|
|
|
// Remote web terminal (`web_terminal/server.rs`) — binds 0.0.0.0, and
|
|
|
|
|
// its access token is in the URL query.
|
|
|
|
|
settings.web_terminal.port,
|
|
|
|
|
WebTerminalSettings::default().port,
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 16:55:28 -07:00
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Reservations
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Container loopback ports another feature owns, which the bridge must leave
|
|
|
|
|
/// alone.
|
|
|
|
|
///
|
|
|
|
|
/// The bridge's contract is "mirror every container loopback listener onto the
|
|
|
|
|
/// same host port, **unauthenticated**" — correct for the throwaway OAuth
|
|
|
|
|
/// callback listeners it exists for, wrong for anything sensitive. The
|
|
|
|
|
/// browser-view pane runs Playwright's dashboard on a container loopback port
|
|
|
|
|
/// in this range and puts a token-gated listener in front of it; mirroring that
|
|
|
|
|
/// port here would quietly publish an ungated second door to full control of a
|
|
|
|
|
/// browser inside the container.
|
|
|
|
|
///
|
|
|
|
|
/// This is a constant rather than a registry the pane populates at runtime, and
|
|
|
|
|
/// that is the point: Playwright's dashboard is a detached daemon that outlives
|
|
|
|
|
/// the app, so after a crash an orphaned viewer can still be listening with
|
|
|
|
|
/// nothing in this process left to remember it. A static range is the only form
|
|
|
|
|
/// of the rule that survives a restart. It must stay in step with
|
|
|
|
|
/// `browser_view::VIEWER_PORTS`, which asserts on it.
|
|
|
|
|
pub const RESERVED_CONTAINER_PORTS: std::ops::RangeInclusive<u16> = 39321..=39328;
|
|
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
/// Host ports another feature binds on demand, which the bridge must not take
|
|
|
|
|
/// first.
|
|
|
|
|
///
|
|
|
|
|
/// These are the browser-view proxy's host ports. The bridge binds *host* ports
|
|
|
|
|
/// named by the container, so a container listening on 47820 would have the
|
|
|
|
|
/// bridge take the host side of that number — and then the browser-view pane,
|
|
|
|
|
/// which only binds when the user opens it, finds its port gone. The two ranges
|
|
|
|
|
/// are separate constants because they guard opposite ends of the same
|
|
|
|
|
/// mechanism: [`RESERVED_CONTAINER_PORTS`] is about not *publishing* something,
|
|
|
|
|
/// this one is about not *stealing* something.
|
|
|
|
|
pub const RESERVED_HOST_PORTS: std::ops::RangeInclusive<u16> =
|
|
|
|
|
crate::browser_view::proxy::PROXY_PORTS;
|
|
|
|
|
|
|
|
|
|
/// Most host ports the bridge will hold for one project at a time.
|
|
|
|
|
///
|
|
|
|
|
/// The discovery input is entirely container-controlled, and each
|
|
|
|
|
/// [`PortForward`] costs two listeners plus a task, so without a cap a
|
|
|
|
|
/// container that reports tens of thousands of fake listeners exhausts the
|
|
|
|
|
/// app's file descriptors and the host's ephemeral ports in a single tick. A
|
|
|
|
|
/// real login flow uses one or two ports at a time; anything past a couple of
|
|
|
|
|
/// dozen is not a login.
|
|
|
|
|
const MAX_FORWARDS: usize = 24;
|
|
|
|
|
|
|
|
|
|
/// Most conflicts recorded at once, so a flood of unbindable ports can't grow
|
|
|
|
|
/// the status payload (and the UI list) without bound either.
|
|
|
|
|
const MAX_CONFLICTS: usize = 32;
|
|
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
/// 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;
|
|
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
if st.forwards.len() >= MAX_FORWARDS {
|
|
|
|
|
// Don't even attempt the bind: the point of the cap is to stop the
|
|
|
|
|
// container dictating how many host resources we take.
|
|
|
|
|
changed |= note_conflict(
|
|
|
|
|
&mut st,
|
|
|
|
|
port,
|
|
|
|
|
format!(
|
|
|
|
|
"The auth bridge is already holding {} ports for this project; \
|
|
|
|
|
{} was not bridged.",
|
|
|
|
|
MAX_FORWARDS, port
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-08-09 11:35:42 -07:00
|
|
|
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);
|
|
|
|
|
}
|
2026-08-09 19:35:39 -07:00
|
|
|
changed |= note_conflict(&mut st, port, reason);
|
2026-08-09 11:35:42 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
changed
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
/// Record why a port wasn't bridged, up to [`MAX_CONFLICTS`]. Returns whether
|
|
|
|
|
/// the recorded set changed.
|
|
|
|
|
fn note_conflict(state: &mut BridgeState, port: u16, reason: String) -> bool {
|
|
|
|
|
match state.conflicts.get(&port) {
|
|
|
|
|
Some(existing) if *existing == reason => false,
|
|
|
|
|
Some(_) => {
|
|
|
|
|
state.conflicts.insert(port, reason);
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
None if state.conflicts.len() < MAX_CONFLICTS => {
|
|
|
|
|
state.conflicts.insert(port, reason);
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
None => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
/// 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::*;
|
2026-08-23 11:13:15 -07:00
|
|
|
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
2026-08-09 11:35:42 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 11:13:15 -07:00
|
|
|
/// The common case: one project, no siblings, stock settings.
|
|
|
|
|
fn skip_for(project: &Project) -> HashSet<u16> {
|
|
|
|
|
skipped_ports(project, std::slice::from_ref(project), &AppSettings::default())
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 11:35:42 -07:00
|
|
|
#[test]
|
|
|
|
|
fn ports_already_published_by_docker_are_skipped() {
|
2026-08-23 11:13:15 -07:00
|
|
|
let skip = skip_for(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
2026-08-09 11:35:42 -07:00
|
|
|
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]
|
2026-08-23 11:13:15 -07:00
|
|
|
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
|
|
|
|
let settings = AppSettings::default();
|
|
|
|
|
let project = project_with_mappings(vec![]);
|
|
|
|
|
let skip = skip_for(&project);
|
|
|
|
|
|
|
|
|
|
let mut expected: HashSet<u16> = RESERVED_CONTAINER_PORTS.collect();
|
|
|
|
|
expected.extend(RESERVED_HOST_PORTS);
|
|
|
|
|
expected.extend(app_service_host_ports(&settings));
|
|
|
|
|
assert_eq!(skip, expected);
|
|
|
|
|
|
|
|
|
|
// The ranges and the service ports are disjoint, so nothing above is
|
|
|
|
|
// accidentally counting the same port twice.
|
2026-08-09 19:35:39 -07:00
|
|
|
assert_eq!(
|
|
|
|
|
skip.len(),
|
2026-08-23 11:13:15 -07:00
|
|
|
RESERVED_CONTAINER_PORTS.clone().count()
|
|
|
|
|
+ RESERVED_HOST_PORTS.clone().count()
|
|
|
|
|
+ 3
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn this_apps_own_host_services_are_never_taken() {
|
|
|
|
|
// The bug this guards: the reserved set used to cover only the
|
|
|
|
|
// browser-view ranges and this project's own mappings, so a container
|
|
|
|
|
// binding container-loopback 4000 / 9876 / 7681 while the matching
|
|
|
|
|
// service was stopped had that port mirrored, unauthenticated, onto the
|
|
|
|
|
// host — taking the gateway's, the STT sidecar's or the web terminal's
|
|
|
|
|
// door before they could bind it.
|
|
|
|
|
let settings = AppSettings::default();
|
|
|
|
|
let skip = skip_for(&project_with_mappings(vec![]));
|
|
|
|
|
|
|
|
|
|
assert!(skip.contains(&settings.gateway.port), "LiteLLM gateway port");
|
|
|
|
|
assert!(skip.contains(&settings.stt.port), "STT sidecar port");
|
|
|
|
|
assert!(skip.contains(&settings.web_terminal.port), "web terminal port");
|
|
|
|
|
|
|
|
|
|
// The shipped defaults, spelled out once so a change to any of them is
|
|
|
|
|
// a change to this assertion and not a silent narrowing.
|
|
|
|
|
assert!(skip.contains(&4000));
|
|
|
|
|
assert!(skip.contains(&9876));
|
|
|
|
|
assert!(skip.contains(&7681));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_reconfigured_service_port_is_reserved_alongside_its_default() {
|
|
|
|
|
let mut settings = AppSettings::default();
|
|
|
|
|
settings.gateway.port = 4321;
|
|
|
|
|
settings.stt.port = 9000;
|
|
|
|
|
settings.web_terminal.port = 8443;
|
|
|
|
|
let project = project_with_mappings(vec![]);
|
|
|
|
|
let skip = skipped_ports(&project, std::slice::from_ref(&project), &settings);
|
|
|
|
|
|
|
|
|
|
for port in [4321, 9000, 8443] {
|
|
|
|
|
assert!(skip.contains(&port), "configured port {} should be reserved", port);
|
|
|
|
|
}
|
|
|
|
|
// The default stays reserved too: it is what the service falls back to
|
|
|
|
|
// for a fresh profile or an unparseable settings file, so leaving it
|
|
|
|
|
// open is leaving the same squat available one restart later.
|
|
|
|
|
for port in [4000, 9876, 7681] {
|
|
|
|
|
assert!(skip.contains(&port), "default port {} should be reserved", port);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn another_projects_published_host_port_is_not_stolen() {
|
|
|
|
|
// The container names the *host* port. Without this, project A's
|
|
|
|
|
// container listening on 8080 takes the host 8080 that project B
|
|
|
|
|
// publishes on — the bridge wins the race whenever B's container is not
|
|
|
|
|
// running yet.
|
|
|
|
|
let mine = project_with_mappings(vec![]);
|
|
|
|
|
let mut theirs = project_with_mappings(vec![(8080, 3000)]);
|
|
|
|
|
theirs.id = format!("{}-other", mine.id);
|
|
|
|
|
|
|
|
|
|
let skip = skipped_ports(
|
|
|
|
|
&mine,
|
|
|
|
|
&[mine.clone(), theirs.clone()],
|
|
|
|
|
&AppSettings::default(),
|
2026-08-09 19:35:39 -07:00
|
|
|
);
|
2026-08-23 11:13:15 -07:00
|
|
|
assert!(skip.contains(&8080), "another project's host port");
|
|
|
|
|
// …but not the other project's *container* port: that number lives in a
|
|
|
|
|
// different network namespace and means nothing on this host, and
|
|
|
|
|
// reserving it would refuse a legitimate login callback for no reason.
|
|
|
|
|
assert!(!skip.contains(&3000));
|
2026-08-09 19:35:39 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_browser_views_host_ports_are_never_taken() {
|
|
|
|
|
// The bridge binds *host* ports chosen by the container, so without
|
|
|
|
|
// this it can take the port the browser-view proxy will want later —
|
|
|
|
|
// that pane binds on demand, so first-come would win.
|
2026-08-23 11:13:15 -07:00
|
|
|
let skip = skip_for(&project_with_mappings(vec![]));
|
2026-08-09 19:35:39 -07:00
|
|
|
for port in RESERVED_HOST_PORTS {
|
|
|
|
|
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
|
|
|
|
}
|
|
|
|
|
assert!(!skip.contains(&(RESERVED_HOST_PORTS.end() + 1)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn conflicts_stop_being_recorded_past_the_cap() {
|
|
|
|
|
let mut st = BridgeState::default();
|
|
|
|
|
for port in 1000u16..1000 + MAX_CONFLICTS as u16 {
|
|
|
|
|
assert!(note_conflict(&mut st, port, "busy".to_string()));
|
|
|
|
|
}
|
|
|
|
|
// Past the cap: new ports are dropped rather than growing the status
|
|
|
|
|
// payload the UI renders.
|
|
|
|
|
assert!(!note_conflict(&mut st, 9999, "busy".to_string()));
|
|
|
|
|
assert_eq!(st.conflicts.len(), MAX_CONFLICTS);
|
|
|
|
|
// A changed reason for a port already tracked still updates.
|
|
|
|
|
assert!(!note_conflict(&mut st, 1000, "busy".to_string()));
|
|
|
|
|
assert!(note_conflict(&mut st, 1000, "different".to_string()));
|
|
|
|
|
assert_eq!(st.conflicts.len(), MAX_CONFLICTS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn the_host_ports_one_container_can_demand_are_capped() {
|
|
|
|
|
// The container fully controls the discovery input (it can shim the
|
|
|
|
|
// probe command), and each forward costs two listeners plus a task —
|
|
|
|
|
// uncapped, one tick could exhaust the app's fds and the host's
|
|
|
|
|
// ephemeral ports.
|
|
|
|
|
let discovered: BTreeMap<u16, PortFamily> =
|
|
|
|
|
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
|
|
|
|
|
let state = Arc::new(Mutex::new(BridgeState::default()));
|
|
|
|
|
|
|
|
|
|
reconcile("no-such-container", &discovered, &HashSet::new(), &state).await;
|
|
|
|
|
|
|
|
|
|
let mut st = state.lock().await;
|
|
|
|
|
assert!(
|
|
|
|
|
st.forwards.len() <= MAX_FORWARDS,
|
|
|
|
|
"bridged {} ports, cap is {}",
|
|
|
|
|
st.forwards.len(),
|
|
|
|
|
MAX_FORWARDS
|
|
|
|
|
);
|
|
|
|
|
assert!(st.conflicts.len() <= MAX_CONFLICTS);
|
|
|
|
|
// Nowhere near the 200 the "container" asked for.
|
|
|
|
|
assert!(st.forwards.len() + st.conflicts.len() < discovered.len());
|
|
|
|
|
|
|
|
|
|
for (_, mut forward) in std::mem::take(&mut st.forwards) {
|
|
|
|
|
forward.shutdown().await;
|
|
|
|
|
}
|
2026-08-09 16:55:28 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_browser_views_ports_are_never_mirrored() {
|
|
|
|
|
// Mirroring these would publish an ungated second door to the
|
|
|
|
|
// Playwright dashboard, which the pane deliberately keeps behind a
|
|
|
|
|
// token-checking listener.
|
2026-08-23 11:13:15 -07:00
|
|
|
let skip = skip_for(&project_with_mappings(vec![]));
|
2026-08-09 16:55:28 -07:00
|
|
|
for port in RESERVED_CONTAINER_PORTS {
|
|
|
|
|
assert!(skip.contains(&port), "port {} should be reserved", port);
|
|
|
|
|
}
|
|
|
|
|
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
|
|
|
|
|
|
|
|
|
// Reservations coexist with Docker's own published ports.
|
2026-08-23 11:13:15 -07:00
|
|
|
let skip = skip_for(&project_with_mappings(vec![(3000, 3000)]));
|
2026-08-09 16:55:28 -07:00
|
|
|
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
|
|
|
|
assert!(skip.contains(&3000));
|
2026-08-09 11:35:42 -07:00
|
|
|
}
|
|
|
|
|
}
|