radicle_httpd/commands/
web.rs

1use std::ffi::OsString;
2use std::net::{IpAddr, Ipv4Addr, SocketAddr};
3use std::process::Command;
4use std::thread::sleep;
5use std::time::Duration;
6
7use anyhow::{anyhow, Context};
8use serde::{Deserialize, Serialize};
9use url::{Position, Url};
10
11use radicle::crypto::{PublicKey, Signature, Signer};
12
13use radicle_cli::terminal as term;
14use radicle_cli::terminal::args::{Args, Error, Help};
15
16pub const HELP: Help = Help {
17    name: "web",
18    description: "Run the HTTP daemon and connect the web explorer to it",
19    version: env!("RADICLE_VERSION"),
20    usage: r#"
21Usage
22
23    rad web [<option>...] [<explorer-url>]
24
25    Runs the Radicle HTTP Daemon and opens a Radicle web explorer to authenticate with it.
26
27Options
28
29    --listen, -l  <addr>     Address to bind the HTTP daemon to (default: 127.0.0.1:8080)
30    --connect, -c [<addr>]   Connect the explorer to an already running daemon (default: 127.0.0.1:8080)
31    --path, -p  <path>       Path to be opened in the explorer after authentication
32    --[no-]open              Open the authentication URL automatically (default: open)
33    --help                   Print help
34"#,
35};
36
37#[derive(Debug, Clone, Deserialize, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct SessionInfo {
40    pub session_id: String,
41    pub public_key: PublicKey,
42}
43
44#[derive(Debug)]
45pub struct Options {
46    pub app_url: Url,
47    pub listen: SocketAddr,
48    pub path: Option<String>,
49    pub connect: Option<SocketAddr>,
50    pub open: bool,
51}
52
53impl Args for Options {
54    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
55        use lexopt::prelude::*;
56
57        let mut parser = lexopt::Parser::from_args(args);
58        let mut listen = None;
59        let mut connect = None;
60        let mut path = None;
61        // SAFETY: This is a valid URL.
62        #[allow(clippy::unwrap_used)]
63        let mut app_url = Url::parse("https://app.radicle.xyz").unwrap();
64        let mut open = true;
65
66        while let Some(arg) = parser.next()? {
67            match arg {
68                Long("listen") | Short('l') if listen.is_none() => {
69                    let val = parser.value()?;
70                    listen = Some(term::args::socket_addr(&val)?);
71                }
72                Long("path") | Short('p') if path.is_none() => {
73                    let val = parser.value()?;
74                    path = Some(term::args::string(&val));
75                }
76                Long("connect") | Short('c') if connect.is_none() => {
77                    if let Ok(val) = parser.value() {
78                        connect = Some(term::args::socket_addr(&val)?);
79                    } else {
80                        connect = Some(SocketAddr::new(
81                            IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
82                            8080,
83                        ));
84                    }
85                }
86                Long("open") => open = true,
87                Long("no-open") => open = false,
88                Long("help") | Short('h') => {
89                    return Err(Error::Help.into());
90                }
91                Value(val) => {
92                    let val = val.to_string_lossy();
93                    app_url = Url::parse(val.as_ref()).context("invalid explorer URL supplied")?;
94                }
95                _ => {
96                    return Err(anyhow!(arg.unexpected()));
97                }
98            }
99        }
100
101        Ok((
102            Options {
103                open,
104                app_url,
105                listen: listen.unwrap_or(SocketAddr::new(
106                    IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
107                    8080,
108                )),
109                path,
110                connect,
111            },
112            vec![],
113        ))
114    }
115}
116
117pub fn sign(signer: Box<dyn Signer>, session: &SessionInfo) -> Result<Signature, anyhow::Error> {
118    signer
119        .try_sign(format!("{}:{}", session.session_id, session.public_key).as_bytes())
120        .map_err(anyhow::Error::from)
121}
122
123pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
124    let profile = ctx.profile()?;
125    let runtime_and_handle = if options.connect.is_none() {
126        tracing_subscriber::fmt::init();
127
128        let runtime = tokio::runtime::Builder::new_multi_thread()
129            .enable_all()
130            .build()
131            .expect("failed to create threaded runtime");
132        let httpd_handle = runtime.spawn(crate::run(crate::Options {
133            aliases: Default::default(),
134            listen: options.listen,
135            cache: None,
136        }));
137        Some((runtime, httpd_handle))
138    } else {
139        None
140    };
141
142    let mut retries = 30;
143    let connect = options.connect.unwrap_or(options.listen);
144    let response = loop {
145        retries -= 1;
146        sleep(Duration::from_millis(100));
147
148        match ureq::post(&format!("http://{connect}/api/v1/sessions")).call() {
149            Ok(response) => {
150                break response;
151            }
152            Err(err) => {
153                if err.kind() == ureq::ErrorKind::ConnectionFailed && retries > 0 {
154                    continue;
155                } else {
156                    anyhow::bail!(err);
157                }
158            }
159        }
160    };
161
162    let session = response.into_json::<SessionInfo>()?;
163    let signer = profile.signer()?;
164    let signature = sign(signer, &session)?;
165
166    let mut auth_url = options.app_url.clone();
167    auth_url
168        .path_segments_mut()
169        .map_err(|_| anyhow!("URL not supported"))?
170        .push("session")
171        .push(&session.session_id);
172
173    auth_url
174        .query_pairs_mut()
175        .append_pair("pk", &session.public_key.to_string())
176        .append_pair("sig", &signature.to_string())
177        .append_pair("addr", &connect.to_string());
178
179    let pathname = radicle::rad::cwd().ok().and_then(|(_, rid)| {
180        Url::parse(
181            &profile
182                .config
183                .public_explorer
184                .url(options.listen, rid)
185                .to_string(),
186        )
187        .map(|x| x[Position::BeforePath..].to_string())
188        .ok()
189    });
190    if let Some(path) = options.path.or(pathname) {
191        auth_url.query_pairs_mut().append_pair("path", &path);
192    }
193
194    if options.open {
195        #[cfg(any(target_os = "freebsd", target_os = "windows"))]
196        let cmd_name = "echo";
197        #[cfg(target_os = "macos")]
198        let cmd_name = "open";
199        #[cfg(target_os = "linux")]
200        let cmd_name = "xdg-open";
201
202        let mut cmd = Command::new(cmd_name);
203        match cmd.arg(auth_url.as_str()).spawn() {
204            Ok(mut child) => match child.wait() {
205                Ok(exit_status) => {
206                    if exit_status.success() {
207                        term::success!("Opened {auth_url}");
208                    } else {
209                        term::info!("Visit {auth_url} to connect");
210                    }
211                }
212                Err(_) => {
213                    term::info!("Visit {auth_url} to connect");
214                }
215            },
216            Err(_) => {
217                term::error(format!("Could not open web browser via `{cmd_name}`"));
218                term::hint("Use `rad web --no-open` if this continues");
219                term::info!("Visit {auth_url} to connect");
220            }
221        }
222    } else {
223        term::info!("Visit {auth_url} to connect");
224    }
225
226    if let Some((runtime, httpd_handle)) = runtime_and_handle {
227        runtime
228            .block_on(httpd_handle)?
229            .context("httpd server error")?;
230    }
231
232    Ok(())
233}