radicle_httpd/commands/
web.rs1use 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 #[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}