Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -636,6 +640,7 @@ Windows, and at

```json
{
"bindAddress": "127.0.0.1",
"port": 18765,
"aliasProvider": "codex",
"codex": {
Expand Down Expand Up @@ -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` |
Expand Down
48 changes: 48 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,6 +32,8 @@ pub struct LoadedConfig {

#[derive(Deserialize)]
struct FileConfig {
#[serde(rename = "bindAddress")]
pub bind_address: Option<String>,
pub port: Option<u16>,
#[serde(rename = "aliasProvider")]
pub alias_provider: Option<String>,
Expand Down Expand Up @@ -116,13 +119,20 @@ 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,
log_stderr: false,
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;
Expand Down Expand Up @@ -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
}
Expand All @@ -188,6 +202,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
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());
}
Expand Down Expand Up @@ -228,6 +245,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
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}"));
}
Expand Down Expand Up @@ -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");
Expand All @@ -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<std::ffi::OsString>,
Expand Down
27 changes: 23 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,18 @@ 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()
.enable_all()
.build()?;
match select_serve_mode(std::io::stdout().is_terminal(), no_monitor) {
ServeMode::Plain => {
print_server_banner(effective_port, &registry);
print_server_banner(&bind_address, effective_port, &registry);
runtime
.block_on(server::serve(ServerConfig {
bind_address,
port: effective_port,
monitor: None,
}))
Expand All @@ -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,
Expand All @@ -116,6 +122,7 @@ fn main() -> Result<()> {
let ui_result = tui::run_monitor(
monitor,
MonitorUiConfig {
listen_url: monitor_listen_url,
port: effective_port,
registry: &registry,
shutdown: Some(shutdown_tx),
Expand Down Expand Up @@ -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::<std::net::IpAddr>() {
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() {
Expand Down Expand Up @@ -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");
}
}
19 changes: 14 additions & 5 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use tokio::net::TcpListener;
use uuid::Uuid;

pub struct ServerConfig {
pub bind_address: String,
pub port: u16,
pub monitor: Option<MonitorHandle>,
}
Expand All @@ -47,13 +48,16 @@ async fn serve_inner(
config: ServerConfig,
shutdown: impl Future<Output = ()> + 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<TcpListener> {
let addr = format!("127.0.0.1:{port}");
TcpListener::bind(&addr)
pub async fn bind_proxy_listener(bind_address: &str, port: u16) -> anyhow::Result<TcpListener> {
let ip = bind_address
.parse::<std::net::IpAddr>()
.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}"))
}
Expand All @@ -63,11 +67,16 @@ pub async fn serve_listener(
monitor: Option<MonitorHandle>,
shutdown: impl Future<Output = ()> + 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!(
Expand Down
39 changes: 30 additions & 9 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<oneshot::Sender<()>>,
Expand All @@ -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,
Expand Down Expand Up @@ -212,7 +213,7 @@ enum DetailView {
}

struct MonitorApp {
port: u16,
listen_url: String,
setup_text: String,
show_setup: bool,
show_help: bool,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading