agent_first_http/cli/cmd/
host.rs1use std::path::PathBuf;
5
6use crate::host::bootstrap::{
7 BrowserChoice, DisplayMode, HealthPublic, HostArgs, ProfileChoice, Takeover,
8};
9use crate::host::listener::{ListenAddr, parse_listen};
10use crate::shared::error::{Error, ErrorCode};
11
12#[derive(Debug)]
13pub struct Args {
14 pub listen: String,
15 pub profile: String,
16 pub display: Option<DisplayMode>,
19 pub takeover: Takeover,
20 pub takeover_quality_percent: u8,
21 pub browser: BrowserChoice,
22 pub browser_bin: Option<PathBuf>,
23 pub token: Option<String>,
24 pub no_health: bool,
25 pub health_public: HealthPublic,
26 pub engine_envs: Vec<String>,
27 pub browser_args: Vec<String>,
28 pub proxy: Option<String>,
29 pub recent_requests_cap: usize,
30}
31
32pub async fn run(args: Args) -> Result<(), Error> {
33 enforce_listen_auth(&args.listen, args.token.as_deref())?;
34 let profile_raw = args.profile.trim();
35 if profile_raw.is_empty() || profile_raw.contains(',') {
36 return Err(Error::new(
37 ErrorCode::InvalidArgument,
38 "--profile: expected one profile name or '-'",
39 ));
40 }
41 let profile = if profile_raw == "-" {
42 ProfileChoice::Ephemeral
43 } else {
44 ProfileChoice::Persistent(profile_raw.to_string())
45 };
46 let takeover_enabled = !matches!(args.takeover, Takeover::Off);
47 let display = match args.takeover {
48 Takeover::On { .. } => DisplayMode::Headful,
49 Takeover::Off => args.display.unwrap_or(DisplayMode::Headless),
50 };
51 let health_enabled: bool = !args.no_health;
52 let mut engine_envs = Vec::with_capacity(args.engine_envs.len());
53 for raw in &args.engine_envs {
54 engine_envs.push(parse_engine_env(raw)?);
55 }
56 let host_args = HostArgs {
57 listen: args.listen,
58 profile,
59 display,
60 takeover: args.takeover,
61 display_quality: args.takeover_quality_percent,
62 browser: args.browser,
63 browser_bin: args.browser_bin,
64 token: args.token,
65 takeover_enabled,
66 health_enabled,
67 health_public: args.health_public,
68 engine_envs,
69 browser_args: args.browser_args,
70 proxy: args.proxy,
71 recent_requests_cap: args.recent_requests_cap,
72 };
73 crate::host::bootstrap::run(host_args).await
74}
75
76fn enforce_listen_auth(listen: &str, token: Option<&str>) -> Result<(), Error> {
83 if token.is_some() {
84 return Ok(());
85 }
86 if let ListenAddr::Tcp(addr) = parse_listen(listen)?
87 && !addr.ip().is_loopback()
88 {
89 return Err(Error::new(
90 ErrorCode::InvalidArgument,
91 format!(
92 "--listen {listen}: refusing to bind a non-loopback address without --token-secret. \
93 A token-less TCP host exposes full browser and profile control (/cdp) to \
94 anyone who can reach the port. Pass --token-secret, or bind tcp:127.0.0.1:<port> \
95 or a unix: socket."
96 ),
97 ));
98 }
99 Ok(())
100}
101
102fn parse_engine_env(raw: &str) -> Result<(String, String), Error> {
103 let (k, v) = raw.split_once('=').ok_or_else(|| {
104 Error::new(
105 ErrorCode::InvalidArgument,
106 format!("--engine-env: expected NAME=VALUE, got {raw:?}"),
107 )
108 })?;
109 if k.is_empty() {
110 return Err(Error::new(
111 ErrorCode::InvalidArgument,
112 "--engine-env: key must not be empty",
113 ));
114 }
115 Ok((k.to_string(), v.to_string()))
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn loopback_tcp_needs_no_token() {
124 enforce_listen_auth("tcp:127.0.0.1:9222", None).unwrap();
125 enforce_listen_auth("tcp:[::1]:9222", None).unwrap();
126 }
127
128 #[test]
129 fn unix_socket_needs_no_token() {
130 enforce_listen_auth("unix:/run/afhttp.sock", None).unwrap();
131 }
132
133 #[test]
134 fn non_loopback_tcp_requires_token() {
135 for spec in ["tcp:0.0.0.0:9222", "tcp:192.168.1.10:9222", "tcp:[::]:9222"] {
136 let err = enforce_listen_auth(spec, None).err().unwrap();
137 assert_eq!(err.error_code, ErrorCode::InvalidArgument, "spec={spec}");
138 }
139 }
140
141 #[test]
142 fn token_allows_any_address() {
143 enforce_listen_auth("tcp:0.0.0.0:9222", Some("secret")).unwrap();
144 }
145}