Skip to main content

grr_cli/commands/
setup.rs

1//! `grr auth setup` — create and store an OAuth client without leaving the
2//! terminal.
3//!
4//! This replaces the old `scripts/setup-gcp.ps1`, `setup-gcp.sh` and
5//! `set-client-secret.ps1` helpers: the console links, the client-id shape
6//! check and the config write all live here now, so there is one code path
7//! instead of three that drift apart.
8//!
9//! Two audiences:
10//!   - humans following the prompts (default)
11//!   - agents and CI, which pass `--client-id`/`--client-secret` and
12//!     `--print-only` and must never be blocked on an interactive prompt
13
14use 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
23/// Each API grr can talk to: the id the embedded index uses for it
24/// (`discovery::services()` keys, which is also what `grr auth login --service`
25/// takes), the name `gcloud services enable` knows the API by, and the label a
26/// human reads.
27///
28/// One `gcloud services enable` covers them all; a method's scopes decide what
29/// actually authorises. This is the only list — `every_indexed_service_is_here`
30/// derives the id set from the index and fails the build if the two ever
31/// disagree, so a service added to the index cannot be forgotten here. The
32/// order is curated anyway (Gmail first, the surfaces people set up first)
33/// rather than the index's A–Z, so the recipe reads the way someone would type
34/// it.
35///
36/// The `gcloud` name is typed rather than derived: `rootUrl` is the shared
37/// `www.googleapis.com` for Calendar and Drive, and Calendar's API is the
38/// legacy `calendar-json.googleapis.com` — `calendar.googleapis.com` is not an
39/// API `gcloud` can enable.
40const 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    /// Google OAuth client id (…apps.googleusercontent.com). Prompted for
72    /// when omitted.
73    #[arg(long)]
74    pub client_id: Option<String>,
75
76    /// Google OAuth client secret. Prompted for when omitted.
77    #[arg(long)]
78    pub client_secret: Option<String>,
79
80    /// Print the instructions and the resolved path, but write nothing.
81    /// Useful for a first look, and for agents that only want the recipe.
82    #[arg(long)]
83    pub print_only: bool,
84
85    /// Overwrite an existing ~/.grr/config.toml instead of refusing.
86    #[arg(long)]
87    pub force: bool,
88
89    /// Try `gcloud services enable` for the APIs when gcloud is on PATH.
90    #[arg(long)]
91    pub enable_apis: bool,
92
93    /// Output format
94    #[arg(short, long, value_enum, default_value = "json")]
95    pub format: OutputFormat,
96}
97
98/// Shape check: Google's client ids always end in this suffix, and a
99/// truncated paste is the single most common setup mistake.
100fn 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    // Google desktop-app secrets are the short `GOCSPX-…` form.
120    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
133/// Escape a value for a TOML basic string.
134fn toml_escape(value: &str) -> String {
135    value.replace('\\', r"\\").replace('"', "\\\"")
136}
137
138/// The config template, taken verbatim from the shipped example so the two
139/// can never drift: `config.toml.example` is the single source of the full
140/// shape (the `[oauth]` block, the commented `[systemone]` block, the
141/// resolution-order note), and setup only fills in the client values. The
142/// example is tracked and committed, so it is inside the crate package and
143/// this `include_str!` always resolves.
144const CONFIG_TEMPLATE: &str = include_str!("../../config.toml.example");
145
146/// Fill the client id/secret into the example template.
147///
148/// This is deliberately not a hand-built TOML document: mirroring the
149/// example keeps every explanatory comment and the `[systemone]` block in
150/// the file the user actually reads, and a future example edit propagates.
151///
152/// An existing config is never rewritten in place — `handle_setup` refuses
153/// without `--force`, and nothing else touches the file. That is on purpose:
154/// silent rewrites are the surprise-write class of bug this codebase avoids,
155/// and a config missing `[systemone]` changes nothing because that section's
156/// defaults apply (see `SystemOneConfig`).
157fn 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    // Non-interactive callers get a hard error instead of a silent hang.
212    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
283/// Read one line from the terminal without pinning a runtime worker.
284///
285/// `handle_setup` is awaited from the async CLI path, and the read can block
286/// until the user hits enter. The caller has already refused non-TTY stdin
287/// (setup.rs's interactive guard), so this await cannot hang a pipe.
288async 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
298/// Best-effort API enablement. The gcloud CLI is optional; everything else
299/// in setup works without it.
300///
301/// `gcloud services enable` is a network-backed call that can take minutes;
302/// spawning it through tokio keeps the runtime free while it runs.
303async 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        // The classic copy-paste failures.
335        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        // The full current shape, comments and all.
357        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        // The empty placeholder was replaced, not duplicated.
366        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    /// The list `grr auth setup` enables is the index's own service set: same
380    /// ids, no more and no fewer. A service added to `discovery.rs` (or dropped
381    /// from it) must fail here rather than silently vanish from the recipe.
382    #[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            // Where the index carries a service-specific rootUrl, it must back
397            // the host typed here — the two `www.googleapis.com` entries
398            // (Calendar, Drive) are exactly the ones the index cannot name.
399            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}