From d0005528311dfb8473700aff7c1f41c8e2b3748d Mon Sep 17 00:00:00 2001 From: mulfyx Date: Mon, 13 Jul 2026 23:08:49 +0500 Subject: [PATCH 1/2] support configurable bind addresses --- README.md | 11 ++++++++--- src/config.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 23 +++++++++++++++++++---- src/server.rs | 19 ++++++++++++++----- tests/server.rs | 21 ++++++++++++++++++++- 5 files changed, 109 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6c30b84..295c9da 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,16 @@ claude-code-proxy cursor auth status ```sh claude-code-proxy serve # listens on 127.0.0.1:18765 PORT=11435 claude-code-proxy serve # change the listen port +CCP_BIND_ADDRESS=0.0.0.0 claude-code-proxy serve # change the bind address claude-code-proxy serve --no-monitor # plain logs instead of the monitor TUI ``` -Binds to `127.0.0.1` only. One `serve` process handles all providers — the -upstream for each request is chosen from `ANTHROPIC_MODEL`. When stdout is a -terminal, `serve` opens a monitor TUI with sessions, active requests, recent +Binds to `127.0.0.1` by default. One `serve` process handles all providers — +the upstream for each request is chosen from `ANTHROPIC_MODEL`. When stdout is +a terminal, `serve` opens a monitor TUI with sessions, active requests, recent requests, and error events. Use `--no-monitor` for plain terminal output. +The proxy does not authenticate incoming clients, so protect any non-loopback +binding with a firewall or an authenticating reverse proxy. Installed via Homebrew, the proxy can also run as a background service that starts at login and restarts if it exits: @@ -636,6 +639,7 @@ Windows, and at ```json { + "bindAddress": "127.0.0.1", "port": 18765, "aliasProvider": "codex", "codex": { @@ -672,6 +676,7 @@ Windows, and at | Variable | Config key | Default | Purpose | | -------------------------------- | -------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CCP_BIND_ADDRESS` | `bindAddress` | `127.0.0.1` | Proxy listen IP address; use `0.0.0.0` only when remote access is required and protected | | `PORT` | `port` | `18765` | Proxy listen port | | `CCP_CONFIG_DIR` | unset | platform config dir | Per-process config directory; Cursor auth uses it for file storage | | `XDG_STATE_HOME` | — | `~/.local/state` | Linux/macOS base dir for `proxy.log` | diff --git a/src/config.rs b/src/config.rs index 65023ab..df1b028 100644 --- a/src/config.rs +++ b/src/config.rs @@ -22,6 +22,7 @@ impl AliasProvider { #[derive(Debug, Clone)] pub struct LoadedConfig { + pub bind_address: String, pub port: u16, pub alias_provider: AliasProvider, pub log_verbose: bool, @@ -31,6 +32,8 @@ pub struct LoadedConfig { #[derive(Deserialize)] struct FileConfig { + #[serde(rename = "bindAddress")] + pub bind_address: Option, pub port: Option, #[serde(rename = "aliasProvider")] pub alias_provider: Option, @@ -116,6 +119,7 @@ pub fn load_config() -> LoadedConfig { let env: HashMap<_, _> = std::env::vars().collect(); let mut out = LoadedConfig { + bind_address: "127.0.0.1".to_string(), port: 18765, alias_provider: AliasProvider::Codex, log_verbose: false, @@ -123,6 +127,12 @@ pub fn load_config() -> LoadedConfig { config_dir: config_dir.clone(), }; + if let Some(raw) = env.get("CCP_BIND_ADDRESS") { + out.bind_address = raw.clone(); + } else if let Some(bind_address) = file.as_ref().and_then(|f| f.bind_address.clone()) { + out.bind_address = bind_address; + } + if let Some(raw) = env.get("CCP_ALIAS_PROVIDER") { if let Some(alias) = parse_alias(raw) { out.alias_provider = alias; @@ -172,6 +182,10 @@ pub fn port() -> u16 { load_config().port } +pub fn bind_address() -> String { + load_config().bind_address +} + pub fn alias_provider() -> AliasProvider { load_config().alias_provider } @@ -188,6 +202,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { let file = read_file_config(&cfg.config_dir); let env: HashMap<_, _> = std::env::vars().collect(); let mut out = Vec::new(); + if env.contains_key("CCP_BIND_ADDRESS") { + out.push("bindAddress (env)".to_string()); + } if env.contains_key("PORT") { out.push("port (env)".to_string()); } @@ -228,6 +245,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { out.push("CCP_CODEX_REASONING_SUMMARY (env)".to_string()); } if let Some(file_cfg) = file { + if let Some(bind_address) = file_cfg.bind_address { + out.push(format!("bindAddress: {bind_address}")); + } if let Some(p) = file_cfg.port { out.push(format!("port: {p}")); } @@ -566,6 +586,7 @@ mod tests { fn clear_env() { unsafe { + std::env::remove_var("CCP_BIND_ADDRESS"); std::env::remove_var("CCP_CODEX_TRANSPORT"); std::env::remove_var("CCP_CONFIG_DIR"); std::env::remove_var("CCP_LOG_VERBOSE"); @@ -574,6 +595,33 @@ mod tests { } } + #[test] + fn bind_address_defaults_to_loopback() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + let config = tempfile::TempDir::new().unwrap(); + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + + assert_eq!(load_config().bind_address, "127.0.0.1"); + } + + #[test] + fn bind_address_reads_config_and_env_takes_precedence() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + let config = tempfile::TempDir::new().unwrap(); + std::fs::write( + config.path().join("config.json"), + r#"{"bindAddress":"192.0.2.10"}"#, + ) + .unwrap(); + let _config_env = EnvGuard::set("CCP_CONFIG_DIR", config.path()); + + assert_eq!(load_config().bind_address, "192.0.2.10"); + let _bind_env = EnvGuard::set("CCP_BIND_ADDRESS", "0.0.0.0"); + assert_eq!(load_config().bind_address, "0.0.0.0"); + } + struct EnvGuard { key: &'static str, previous: Option, diff --git a/src/main.rs b/src/main.rs index ef4b703..3db3dba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,7 @@ fn main() -> Result<()> { Ok(()) } Commands::Serve { port, no_monitor } => { + let bind_address = config::bind_address(); let effective_port = port.unwrap_or_else(config::port); let registry = Registry::with_default_alias(); let runtime = tokio::runtime::Builder::new_multi_thread() @@ -92,9 +93,10 @@ fn main() -> Result<()> { .build()?; match select_serve_mode(std::io::stdout().is_terminal(), no_monitor) { ServeMode::Plain => { - print_server_banner(effective_port, ®istry); + print_server_banner(&bind_address, effective_port, ®istry); runtime .block_on(server::serve(ServerConfig { + bind_address, port: effective_port, monitor: None, })) @@ -104,7 +106,8 @@ fn main() -> Result<()> { let _stderr_guard = logging::suppress_stderr(); let monitor = MonitorHandle::default(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let listener = runtime.block_on(server::bind_proxy_listener(effective_port))?; + let listener = runtime + .block_on(server::bind_proxy_listener(&bind_address, effective_port))?; let server_monitor = monitor.clone(); let server_task = runtime.spawn(server::serve_listener( listener, @@ -229,8 +232,15 @@ fn compact_cursor_list(models: &[String]) -> String { out } -fn print_server_banner(port: u16, registry: &Registry) { - println!("Proxy listening on http://127.0.0.1:{port}"); +fn listen_url(bind_address: &str, port: u16) -> String { + match bind_address.parse::() { + Ok(ip) => format!("http://{}", std::net::SocketAddr::new(ip, port)), + Err(_) => format!("http://{bind_address}:{port}"), + } +} + +fn print_server_banner(bind_address: &str, port: u16, registry: &Registry) { + println!("Proxy listening on {}", listen_url(bind_address, port)); println!("Logs: {}", paths::log_file().display()); let cfg = paths::config_dir(); if cfg.exists() { @@ -269,4 +279,9 @@ mod tests { fn non_tty_stdout_selects_plain_mode() { assert_eq!(select_serve_mode(false, false), ServeMode::Plain); } + + #[test] + fn listen_url_brackets_ipv6_addresses() { + assert_eq!(listen_url("::1", 18765), "http://[::1]:18765"); + } } diff --git a/src/server.rs b/src/server.rs index dce16f7..51c9ac3 100644 --- a/src/server.rs +++ b/src/server.rs @@ -28,6 +28,7 @@ use tokio::net::TcpListener; use uuid::Uuid; pub struct ServerConfig { + pub bind_address: String, pub port: u16, pub monitor: Option, } @@ -47,13 +48,16 @@ async fn serve_inner( config: ServerConfig, shutdown: impl Future + Send + 'static, ) -> anyhow::Result<()> { - let listener = bind_proxy_listener(config.port).await?; + let listener = bind_proxy_listener(&config.bind_address, config.port).await?; serve_listener(listener, config.monitor, shutdown).await } -pub async fn bind_proxy_listener(port: u16) -> anyhow::Result { - let addr = format!("127.0.0.1:{port}"); - TcpListener::bind(&addr) +pub async fn bind_proxy_listener(bind_address: &str, port: u16) -> anyhow::Result { + let ip = bind_address + .parse::() + .map_err(|err| anyhow::anyhow!("invalid proxy bind address {bind_address:?}: {err}"))?; + let addr = std::net::SocketAddr::new(ip, port); + TcpListener::bind(addr) .await .map_err(|err| anyhow::anyhow!("failed to bind proxy listener on {addr}: {err}")) } @@ -63,11 +67,16 @@ pub async fn serve_listener( monitor: Option, shutdown: impl Future + Send + 'static, ) -> anyhow::Result<()> { - let port = listener.local_addr()?.port(); + let local_addr = listener.local_addr()?; + let port = local_addr.port(); create_logger("server").info( "server listening", Some(serde_json::Map::from_iter([ ("port".to_string(), json!(port)), + ( + "bindAddress".to_string(), + json!(local_addr.ip().to_string()), + ), ( "logDir".to_string(), json!( diff --git a/tests/server.rs b/tests/server.rs index dac0597..ba37698 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -18,12 +18,31 @@ async fn bind_error_names_address_and_port() { let occupied = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = occupied.local_addr().unwrap().port(); - let err = bind_proxy_listener(port).await.unwrap_err().to_string(); + let err = bind_proxy_listener("127.0.0.1", port) + .await + .unwrap_err() + .to_string(); assert!(err.contains(&format!("127.0.0.1:{port}"))); assert!(err.contains("failed to bind proxy listener")); } +#[tokio::test] +async fn configurable_bind_address_accepts_all_interfaces() { + let listener = bind_proxy_listener("0.0.0.0", 0).await.unwrap(); + assert_eq!(listener.local_addr().unwrap().ip().to_string(), "0.0.0.0"); +} + +#[tokio::test] +async fn invalid_bind_address_is_actionable() { + let err = bind_proxy_listener("not-an-ip", 18765) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("invalid proxy bind address")); + assert!(err.contains("not-an-ip")); +} + #[tokio::test] async fn healthz_returns_ok() { let app = app(Arc::new(Registry::with_default_alias())); From ece0c1b00457146481fa86c38bb02cf45946bb82 Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 13 Jul 2026 21:30:35 +0300 Subject: [PATCH 2/2] fix configured address in monitor output The monitor header always displayed the loopback endpoint even when the proxy listened on another interface. This made the TUI misleading and could obscure network exposure from an explicit non-loopback configuration. Pass the listener's effective socket address into the monitor, render its URL in the header, and cover IPv6 formatting in the TUI test. Update the serve command reference to describe the configurable bind address. --- README.md | 9 +++++---- src/main.rs | 4 ++++ src/tui.rs | 39 ++++++++++++++++++++++++++++++--------- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 295c9da..2cc134b 100644 --- a/README.md +++ b/README.md @@ -449,10 +449,11 @@ sequenceDiagram ### `serve` -Starts the HTTP proxy and blocks. Binds to `127.0.0.1` only. When stdout is a -terminal, `serve` opens a monitor TUI showing sessions, active requests, recent -requests, token throughput, and error events. Use `--no-monitor` to run with -plain terminal output. +Starts the HTTP proxy and blocks. Binds to `127.0.0.1` by default. Set +`CCP_BIND_ADDRESS` or the `bindAddress` config key to choose another IP address. +When stdout is a terminal, `serve` opens a monitor TUI showing sessions, active +requests, recent requests, token throughput, and error events. Use `--no-monitor` +to run with plain terminal output. Logs are written to the platform state directory and rotated at 20 MiB. Set `CCP_LOG_STDERR=1` to mirror log lines to stderr while running without the diff --git a/src/main.rs b/src/main.rs index 3db3dba..8ed945c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,6 +108,9 @@ fn main() -> Result<()> { let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let listener = runtime .block_on(server::bind_proxy_listener(&bind_address, effective_port))?; + let local_addr = listener.local_addr()?; + let monitor_listen_url = + listen_url(&local_addr.ip().to_string(), local_addr.port()); let server_monitor = monitor.clone(); let server_task = runtime.spawn(server::serve_listener( listener, @@ -119,6 +122,7 @@ fn main() -> Result<()> { let ui_result = tui::run_monitor( monitor, MonitorUiConfig { + listen_url: monitor_listen_url, port: effective_port, registry: ®istry, shutdown: Some(shutdown_tx), diff --git a/src/tui.rs b/src/tui.rs index cd4f7bd..29adb81 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -102,6 +102,7 @@ const EVENTS_TABLE_HEADERS: [(&str, Alignment); 5] = [ ]; pub struct MonitorUiConfig<'a> { + pub listen_url: String, pub port: u16, pub registry: &'a Registry, pub shutdown: Option>, @@ -114,7 +115,7 @@ pub fn run_monitor( let mut terminal = setup_terminal()?; let _guard = TerminalGuard; let mut app = MonitorApp { - port: config.port, + listen_url: config.listen_url, setup_text: setup_text(config.port, config.registry), show_setup: false, show_help: false, @@ -212,7 +213,7 @@ enum DetailView { } struct MonitorApp { - port: u16, + listen_url: String, setup_text: String, show_setup: bool, show_help: bool, @@ -364,10 +365,7 @@ fn render_header( .add_modifier(Modifier::BOLD), ), Span::styled(" ", Style::default().fg(BG).bg(TEAL)), - Span::styled( - format!("http://127.0.0.1:{}", app.port), - Style::default().fg(BG).bg(TEAL), - ), + Span::styled(&app.listen_url, Style::default().fg(BG).bg(TEAL)), Span::styled(" uptime ", Style::default().fg(BG).bg(TEAL)), Span::styled( format_duration(uptime), @@ -1539,10 +1537,33 @@ mod tests { assert!(!events_text.contains("No events")); } + #[test] + fn header_renders_configured_listen_url() { + let app = MonitorApp { + listen_url: "http://[::]:18765".to_string(), + setup_text: String::new(), + show_setup: false, + show_help: false, + detail: None, + focus: FocusPane::Sessions, + selected: 0, + recent_selected: 0, + tick: 0, + shutdown: None, + }; + let state = MonitorHandle::default().snapshot(); + + let header = draw(100, 1, |frame| { + render_header(frame, frame.area(), &app, &state) + }); + + assert!(buffer_text(&header).contains("http://[::]:18765")); + } + #[test] fn clamp_selection_caps_to_available_sessions() { let mut app = MonitorApp { - port: 3000, + listen_url: "http://127.0.0.1:3000".to_string(), setup_text: String::new(), show_setup: false, show_help: false, @@ -1566,7 +1587,7 @@ mod tests { #[test] fn arrow_navigation_moves_between_focus_panes_at_edges() { let mut app = MonitorApp { - port: 3000, + listen_url: "http://127.0.0.1:3000".to_string(), setup_text: String::new(), show_setup: false, show_help: false, @@ -1590,7 +1611,7 @@ mod tests { #[test] fn vim_navigation_stays_within_focused_pane() { let mut app = MonitorApp { - port: 3000, + listen_url: "http://127.0.0.1:3000".to_string(), setup_text: String::new(), show_setup: false, show_help: false,