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