diff --git a/README.md b/README.md index 6c30b84..2cc134b 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: @@ -446,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 @@ -636,6 +640,7 @@ Windows, and at ```json { + "bindAddress": "127.0.0.1", "port": 18765, "aliasProvider": "codex", "codex": { @@ -672,6 +677,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..8ed945c 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,11 @@ 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 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, @@ -116,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), @@ -229,8 +236,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 +283,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/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, 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()));