1use crate::core::config_loader::ConfigLoader;
15use crate::output::{OutputFormat, print_output};
16use anyhow::{Context, Result, bail};
17use clap::Args;
18use std::io::IsTerminal;
19
20const CONSOLE: &str = "https://console.cloud.google.com";
21const SETUP_DOCS: &str = "https://github.com/debanjanbasu/grr-cli/blob/main/docs/gcp-setup.md";
22
23const SERVICE_APIS: &[(&str, &str, &str)] = &[
41 ("gmail", "gmail.googleapis.com", "Gmail"),
42 ("calendar", "calendar-json.googleapis.com", "Calendar"),
43 ("drive", "drive.googleapis.com", "Drive"),
44 ("people", "people.googleapis.com", "Contacts"),
45 ("chat", "chat.googleapis.com", "Chat"),
46 ("forms", "forms.googleapis.com", "Forms"),
47 ("tasks", "tasks.googleapis.com", "Tasks"),
48 ("docs", "docs.googleapis.com", "Docs"),
49 ("sheets", "sheets.googleapis.com", "Sheets"),
50 ("slides", "slides.googleapis.com", "Slides"),
51 ("script", "script.googleapis.com", "Apps Script"),
52 (
53 "analyticsadmin",
54 "analyticsadmin.googleapis.com",
55 "Analytics Admin",
56 ),
57 (
58 "analyticsdata",
59 "analyticsdata.googleapis.com",
60 "Analytics Data",
61 ),
62 (
63 "searchconsole",
64 "searchconsole.googleapis.com",
65 "Search Console",
66 ),
67];
68
69#[derive(Args, Debug, Clone)]
70pub struct SetupArgs {
71 #[arg(long)]
74 pub client_id: Option<String>,
75
76 #[arg(long)]
78 pub client_secret: Option<String>,
79
80 #[arg(long)]
83 pub print_only: bool,
84
85 #[arg(long)]
87 pub force: bool,
88
89 #[arg(long)]
91 pub enable_apis: bool,
92
93 #[arg(short, long, value_enum, default_value = "json")]
95 pub format: OutputFormat,
96}
97
98fn validate_client_id(id: &str) -> Result<()> {
101 let id = id.trim();
102 if id.is_empty() {
103 bail!("client id is empty");
104 }
105 if !id.ends_with(".apps.googleusercontent.com") {
106 bail!(
107 "that does not look like a Google OAuth client id.\n\
108 Expected something ending in .apps.googleusercontent.com, got:\n {id}\n\
109 Copy it from Google Cloud console -> Google Auth Platform -> Clients."
110 );
111 }
112 if !id.contains('.') {
113 bail!("client id looks truncated: {id}");
114 }
115 Ok(())
116}
117
118fn validate_client_secret(secret: &str) -> Result<()> {
119 if secret.trim().is_empty() {
121 bail!("client secret is empty");
122 }
123 if secret.trim().len() < 10 {
124 bail!(
125 "that client secret looks truncated ({} chars).\n\
126 Copy the whole value from the OAuth client page.",
127 secret.trim().len()
128 );
129 }
130 Ok(())
131}
132
133fn toml_escape(value: &str) -> String {
135 value.replace('\\', r"\\").replace('"', "\\\"")
136}
137
138const CONFIG_TEMPLATE: &str = include_str!("../../config.toml.example");
145
146fn render_config(client_id: &str, client_secret: &str) -> String {
158 CONFIG_TEMPLATE
159 .replacen(
160 "client_id = \"\"",
161 &format!("client_id = \"{}\"", toml_escape(client_id)),
162 1,
163 )
164 .replacen(
165 "# client_secret = \"\"",
166 &format!("client_secret = \"{}\"", toml_escape(client_secret)),
167 1,
168 )
169}
170
171fn instructions() -> String {
172 let mut out = String::new();
173 out.push_str("grr needs one Google OAuth client. Three steps, about five minutes:\n\n");
174 out.push_str(&format!(
175 "1. Create a project\n {CONSOLE}/projectcreate\n\n"
176 ));
177 out.push_str(&format!(
178 "2. Enable the APIs (one command if you have gcloud):\n gcloud services enable {}\n\n",
179 SERVICE_APIS
180 .iter()
181 .map(|(_, host, _)| *host)
182 .collect::<Vec<_>>()
183 .join(" ")
184 ));
185 out.push_str(&format!(
186 "3. Create the client\n {CONSOLE}/auth/clients/create\n Application type: Desktop app\n Then copy the Client ID and Client secret.\n\n"
187 ));
188 out.push_str("Full walkthrough: ");
189 out.push_str(SETUP_DOCS);
190 out.push('\n');
191 out
192}
193
194pub async fn handle_setup(args: SetupArgs) -> Result<()> {
195 let path = ConfigLoader::config_path()?;
196 let recipe = instructions();
197
198 if args.print_only {
199 print_output(
200 &serde_json::json!({
201 "instructions": recipe,
202 "config_path": path.display().to_string(),
203 "wrote_config": false,
204 "apis": SERVICE_APIS.iter().map(|(_, host, label)| serde_json::json!({"service": host, "label": label})).collect::<Vec<_>>(),
205 }),
206 args.format,
207 )?;
208 return Ok(());
209 }
210
211 let interactive = std::io::stdin().is_terminal();
213
214 let client_id = match args.client_id {
215 Some(id) => id,
216 None => {
217 if !interactive {
218 bail!(
219 "--client-id is required when stdin is not a terminal (CI, agents, pipes).\n\n{recipe}"
220 );
221 }
222 prompt("Client ID (…apps.googleusercontent.com): ").await?
223 }
224 };
225 validate_client_id(&client_id)?;
226
227 let client_secret = match args.client_secret {
228 Some(secret) => secret,
229 None => {
230 if !interactive {
231 bail!(
232 "--client-secret is required when stdin is not a terminal (CI, agents, pipes).\n\n{recipe}"
233 );
234 }
235 prompt("Client secret: ").await?
236 }
237 };
238 validate_client_secret(&client_secret)?;
239
240 if path.exists() && !args.force {
241 bail!(
242 "{} already exists; it will not be overwritten.\n\
243 Rerun with --force to replace it.",
244 path.display()
245 );
246 }
247
248 if let Some(parent) = path.parent() {
249 std::fs::create_dir_all(parent)
250 .with_context(|| format!("creating {}", parent.display()))?;
251 }
252 let contents = render_config(client_id.trim(), client_secret.trim());
253 std::fs::write(&path, contents).with_context(|| format!("writing {}", path.display()))?;
254
255 #[cfg(unix)]
256 {
257 use std::os::unix::fs::PermissionsExt;
258 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
259 }
260
261 let mut apis_enabled = false;
262 if args.enable_apis {
263 match enable_apis_via_gcloud().await {
264 Ok(()) => apis_enabled = true,
265 Err(e) => eprintln!("warning: could not enable APIs automatically: {e}"),
266 }
267 }
268
269 print_output(
270 &serde_json::json!({
271 "wrote_config": true,
272 "config_path": path.display().to_string(),
273 "client_id": client_id.trim(),
274 "apis_enabled": apis_enabled,
275 "next_step": "grr auth login",
276 }),
277 args.format,
278 )?;
279
280 Ok(())
281}
282
283async fn prompt(label: &str) -> Result<String> {
289 use std::io::Write;
290 eprint!("{label}");
291 std::io::stderr().flush().ok();
292 let mut line = String::new();
293 let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
294 tokio::io::AsyncBufReadExt::read_line(&mut stdin, &mut line).await?;
295 Ok(line.trim().to_owned())
296}
297
298async fn enable_apis_via_gcloud() -> Result<()> {
304 let services = SERVICE_APIS
305 .iter()
306 .map(|(_, host, _)| *host)
307 .collect::<Vec<_>>()
308 .join(" ");
309
310 let status = tokio::process::Command::new("gcloud")
311 .args(["services", "enable", &services])
312 .status()
313 .await
314 .context("gcloud is not on PATH (see https://cloud.google.com/sdk/docs/install)")?;
315
316 if !status.success() {
317 bail!("`gcloud services enable` exited with {status}");
318 }
319 Ok(())
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn accepts_a_real_looking_client_id() {
328 assert!(validate_client_id("123-abc.apps.googleusercontent.com").is_ok());
329 assert!(validate_client_id(" 123-abc.apps.googleusercontent.com ").is_ok());
330 }
331
332 #[test]
333 fn rejects_a_wrong_or_truncated_client_id() {
334 assert!(validate_client_id("123-abc").is_err());
336 assert!(validate_client_id("123-abc.googleusercontent.com").is_err());
337 assert!(validate_client_id("").is_err());
338 assert!(validate_client_id(" ").is_err());
339 }
340
341 #[test]
342 fn rejects_a_truncated_client_secret() {
343 assert!(validate_client_secret("GOCSPX-abc123def456").is_ok());
344 assert!(validate_client_secret("short").is_err());
345 assert!(validate_client_secret(" ").is_err());
346 }
347
348 #[test]
349 fn escapes_quotes_when_writing_toml() {
350 assert_eq!(toml_escape(r#"a"b\c"#), r#"a\"b\\c"#);
351 }
352
353 #[test]
354 fn rendered_config_mirrors_the_example_with_the_client_filled_in() {
355 let rendered = render_config("123-abc.apps.googleusercontent.com", "GOCSPX-secret");
356 assert!(rendered.contains("[oauth]"));
358 assert!(rendered.contains("client_id = \"123-abc.apps.googleusercontent.com\""));
359 assert!(rendered.contains("client_secret = \"GOCSPX-secret\""));
360 assert!(
361 rendered.contains("[systemone]"),
362 "the commented systemone block must ride along"
363 );
364 assert!(rendered.contains("GRR_CONFIG_PATH"));
365 assert!(!rendered.contains("client_id = \"\""));
367 assert!(!rendered.contains("# client_secret = \"\""));
368 }
369
370 #[test]
371 fn instructions_cover_every_service() {
372 let text = instructions();
373 for (_, host, _) in SERVICE_APIS {
374 assert!(text.contains(*host), "missing {host}");
375 }
376 assert!(text.contains("Desktop app"));
377 }
378
379 #[test]
383 fn every_indexed_service_is_here() {
384 let indexed: std::collections::BTreeSet<&str> =
385 crate::discovery::services().keys().copied().collect();
386 let listed: std::collections::BTreeSet<&str> =
387 SERVICE_APIS.iter().map(|(id, _, _)| *id).collect();
388 assert_eq!(
389 listed, indexed,
390 "SERVICE_APIS and the embedded index disagree; a service must not be \
391 enabled by hand or left out of `gcloud services enable`"
392 );
393
394 for (id, host, label) in SERVICE_APIS {
395 assert!(!label.is_empty(), "{id} has no human label");
396 let root = crate::discovery::services()[*id]
400 .root_url
401 .as_deref()
402 .unwrap_or_default()
403 .trim_start_matches("https://")
404 .trim_end_matches('/')
405 .to_owned();
406 if root != "www.googleapis.com" {
407 assert_eq!(*host, root, "{id}'s gcloud name disagrees with the index");
408 }
409 }
410 }
411}