Skip to main content

k_ruoka_mcp/
login.rs

1//! The `login` subcommand: a visible browser the user signs into by hand.
2//!
3//! Credentials and MFA are never automated and never touched by this program.
4//! All it does is put a real browser in front of the user, wait until K-Ruoka
5//! starts reporting an account, and shut Chrome down cleanly so the cookies land
6//! in the profile.
7
8use std::time::{Duration, Instant};
9
10use anyhow::{Context, Result};
11
12use crate::browser::basket::Cart;
13use crate::browser::session::{SHOP_URL, default_profile_dir, evaluate};
14use crate::browser::{LaunchMode, Session};
15
16/// Long enough for a password manager, an OIDC hop and an MFA prompt.
17const LOGIN_TIMEOUT: Duration = Duration::from_secs(15 * 60);
18const POLL_EVERY: Duration = Duration::from_secs(3);
19
20/// Any store works -- `basket/active` reports `userInfo` regardless of which.
21pub const DEFAULT_PROBE_STORE: &str = "N137";
22
23/// Cookies are browser-wide, so the poller can sit in its own tab. Re-stamped on
24/// every poll, both so React cannot overwrite it and so the tab is unmistakable
25/// in `chrome://inspect`, where the user has to pick the right one.
26const POLLER_TITLE: &str = "[k-ruoka-mcp] poller - do NOT use this tab";
27const USER_TAB_TITLE: &str = "Tuotteet | K-Ruoka Verkkokauppa";
28
29/// Set by the xvfb re-exec on its own child. Not the same thing as
30/// `K_RUOKA_NO_XVFB`, which is the user's opt-out.
31const UNDER_XVFB_ENV: &str = "K_RUOKA_UNDER_XVFB";
32
33pub async fn run(debug_port: u16, store_id: &str) -> Result<()> {
34    let display = Display::detect();
35    reexec_under_xvfb_if_headless()?;
36    ensure_port_free(debug_port)?;
37
38    let profile = default_profile_dir()?;
39    let session = Session::new(&profile, LaunchMode::Headful { debug_port })?;
40
41    println!("Opening a browser against {}", profile.display());
42
43    // The session's own page becomes the poller. The user gets a separate tab, so
44    // that nothing this process does ever navigates the page they are typing into.
45    let _user_page = session
46        .open_extra_page(SHOP_URL)
47        .await
48        .context("launching the login browser")?;
49
50    print_instructions(display, debug_port);
51
52    let cart = Cart::new(&session);
53
54    // Probe once before telling the user to go and sign in. Without this, a typo'd
55    // --store-id (or a Cloudflare block, or a broken Chrome) waits the full 15
56    // minutes and then reports "no signed-in account", which misattributes the cause.
57    // "Not signed in yet" is the expected answer here and is not an error.
58    if let Err(e) = cart.active(store_id).await {
59        anyhow::bail!("cannot reach K-Ruoka, so signing in would not be detected: {e}");
60    }
61
62    let deadline = Instant::now() + LOGIN_TIMEOUT;
63    let mut result = Err(anyhow::anyhow!(
64        "timed out after {} minutes without seeing a signed-in account",
65        LOGIN_TIMEOUT.as_secs() / 60
66    ));
67
68    let mut signalled = false;
69    while Instant::now() < deadline {
70        // Signals matter here as much as in `serve`: without this, terminating a `login`
71        // kills this process and leaves Chrome running, holding the profile's
72        // SingletonLock so nothing can launch against it afterwards. That is also how
73        // `start_login`'s cancel path stops it.
74        tokio::select! {
75            _ = tokio::time::sleep(POLL_EVERY) => {}
76            name = terminate_signal() => {
77                println!("\n{name}, closing the browser.");
78                signalled = true;
79                break;
80            }
81        }
82        mark_poller_tab(&session).await;
83
84        // A failure here is expected and uninteresting while the user is still
85        // mid-login (they are off on login.kesko.fi), so keep polling.
86        if let Ok(basket) = cart.active(store_id).await
87            && let Some(who) = basket.user_info.display()
88        {
89            println!("\nSigned in as {who}.");
90            result = Ok(());
91            break;
92        }
93    }
94    if signalled {
95        result = Err(anyhow::anyhow!("cancelled before signing in"));
96    }
97
98    // Graceful close, so Chrome flushes cookies into the profile. Without this
99    // the login appears to work and then silently isn't there next time.
100    session.close().await.ok();
101
102    match result {
103        Ok(()) => {
104            println!("Session saved to {}.", profile.display());
105            println!("`k-ruoka-mcp serve` will now use it. Re-run `login` if it expires.");
106            Ok(())
107        }
108        Err(e) => Err(e),
109    }
110}
111
112/// Resolves on SIGTERM or SIGINT, naming which arrived. Never resolves where those do
113/// not exist, which leaves the poll loop behaving exactly as it did before.
114async fn terminate_signal() -> &'static str {
115    #[cfg(unix)]
116    {
117        use tokio::signal::unix::{SignalKind, signal};
118        let mut term = match signal(SignalKind::terminate()) {
119            Ok(s) => s,
120            Err(_) => return std::future::pending().await,
121        };
122        let mut int = match signal(SignalKind::interrupt()) {
123            Ok(s) => s,
124            Err(_) => return std::future::pending().await,
125        };
126        tokio::select! {
127            _ = term.recv() => "SIGTERM",
128            _ = int.recv() => "SIGINT",
129        }
130    }
131    #[cfg(not(unix))]
132    {
133        match tokio::signal::ctrl_c().await {
134            Ok(()) => "Ctrl-C",
135            Err(_) => std::future::pending().await,
136        }
137    }
138}
139
140/// Best-effort; a failure here only costs the tab its label.
141async fn mark_poller_tab(session: &Session) {
142    let js = format!("document.title = {}", serde_json::json!(POLLER_TITLE));
143    let _ = session
144        .with_page(|page| async move {
145            evaluate(&page, &js).await?;
146            Ok(())
147        })
148        .await;
149}
150
151/// Whether the user can see the browser we just opened. It decides which set of
152/// instructions is true, and the two are completely different -- telling someone
153/// with a window in front of them to set up an SSH tunnel is worse than useless.
154#[derive(Clone, Copy)]
155enum Display {
156    /// A real X/Wayland session: the window is on screen.
157    Real,
158    /// Xvfb, so the only way in is CDP over the debug port.
159    Virtual,
160}
161
162impl Display {
163    /// Called *before* [`reexec_under_xvfb_if_headless`], which replaces the
164    /// process; the re-exec'd child detects again and sees the marker.
165    fn detect() -> Self {
166        if std::env::var_os(UNDER_XVFB_ENV).is_some() {
167            Self::Virtual
168        } else if std::env::var_os("DISPLAY").is_some() {
169            Self::Real
170        } else {
171            // About to re-exec under xvfb-run.
172            Self::Virtual
173        }
174    }
175}
176
177fn print_instructions(display: Display, port: u16) {
178    let steps = match display {
179        Display::Real => real_display_steps(),
180        Display::Virtual => virtual_display_steps(port),
181    };
182    println!(
183        "\n{steps}\n\
184         Nothing here types your credentials for you, and this process never sees\n\
185         them -- it only watches for K-Ruoka to start reporting an account.\n\
186         Waiting (Ctrl-C to give up)...\n"
187    );
188}
189
190fn real_display_steps() -> String {
191    format!(
192        "┌─ Sign in by hand ────────────────────────────────────────────────────┐\n\
193         │ A Chrome window has just opened on this machine.                     │\n\
194         └──────────────────────────────────────────────────────────────────────┘\n\
195         \n\
196         1. Switch to it and pick the tab titled\n\
197         \n         {USER_TAB_TITLE}\n\
198         \n   \
199            and NOT the one marked \"{POLLER_TITLE}\" -- that one is this process\n   \
200            checking whether you are signed in yet, and it gets navigated out from\n   \
201            under you every {poll} seconds.\n\
202         \n\
203         2. Click \"Kirjaudu\" and sign in to K-Plussa as you normally would. It\n   \
204            hands off to login.kesko.fi; that is expected.\n",
205        poll = POLL_EVERY.as_secs()
206    )
207}
208
209fn virtual_display_steps(port: u16) -> String {
210    let host = hostname();
211    format!(
212        "┌─ Sign in by hand ────────────────────────────────────────────────────┐\n\
213         │ The browser is running on this machine with no screen attached, so   │\n\
214         │ reach it over an SSH tunnel and drive it from your own Chrome.       │\n\
215         └──────────────────────────────────────────────────────────────────────┘\n\
216         \n\
217         1. On your laptop:\n\
218         \n    ssh -N -L {port}:localhost:{port} {host}\n\
219         \n\
220         2. Open chrome://inspect in your local Chrome. Under \"Discover network\n   \
221            targets\", click Configure and add   localhost:{port}\n\
222         \n\
223         3. Under \"Remote Target\" there will be two k-ruoka.fi tabs. Click\n   \
224            \"inspect\" on the one titled\n\
225         \n         {USER_TAB_TITLE}\n\
226         \n   \
227            and NOT the one marked \"{POLLER_TITLE}\" -- that one is this process\n   \
228            checking whether you are signed in yet.\n\
229         \n\
230         4. In the inspector's screencast view, click \"Kirjaudu\" and sign in to\n   \
231            K-Plussa as you normally would. It hands off to login.kesko.fi; that\n   \
232            is expected.\n"
233    )
234}
235
236/// Only used to print a copy-pasteable `ssh` command, so a fallback is harmless.
237fn hostname() -> String {
238    std::fs::read_to_string("/etc/hostname")
239        .map(|h| h.trim().to_string())
240        .ok()
241        .filter(|h| !h.is_empty())
242        .or_else(|| std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()))
243        .or_else(|| std::env::var("COMPUTERNAME").ok().filter(|h| !h.is_empty()))
244        .unwrap_or_else(|| "<this-host>".to_string())
245}
246
247/// A leftover Chrome holding the debug port makes `Browser::launch` fail in a way
248/// that says nothing useful, so check first and name the real problem.
249fn ensure_port_free(port: u16) -> Result<()> {
250    use std::net::{Ipv4Addr, SocketAddr, TcpStream};
251    let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
252    if TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok() {
253        anyhow::bail!(
254            "something is already listening on port {port} -- most likely a Chrome left \
255             over from an earlier `login`. Close it (pkill -f remote-debugging-port={port}) \
256             or pass --port with a different one."
257        );
258    }
259    Ok(())
260}
261
262/// Windows and macOS always have a window server, so there is nothing to arrange.
263///
264/// `xvfb-run` is a Linux answer to a Linux problem (a server with no display). A Mac or a
265/// Windows box running this has a desktop by definition, so `login` just opens a window.
266#[cfg(not(target_os = "linux"))]
267fn reexec_under_xvfb_if_headless() -> Result<()> {
268    Ok(())
269}
270
271/// A headful Chrome needs an X display. On a headless VM there isn't one, so
272/// re-exec the whole process under `xvfb-run`, which is already the standard
273/// tool for exactly this and saves managing an Xvfb child by hand.
274#[cfg(target_os = "linux")]
275fn reexec_under_xvfb_if_headless() -> Result<()> {
276    use std::os::unix::process::CommandExt;
277
278    if std::env::var_os("DISPLAY").is_some()
279        || std::env::var_os("WAYLAND_DISPLAY").is_some()
280        || std::env::var_os("K_RUOKA_NO_XVFB").is_some()
281    {
282        return Ok(());
283    }
284    let xvfb = which("xvfb-run").context(
285        "no DISPLAY and `xvfb-run` is not installed. Install it (apt install xvfb) or run \
286         `login` somewhere with a display.",
287    )?;
288
289    let exe = std::env::current_exe()?;
290    let args: Vec<String> = std::env::args().skip(1).collect();
291    println!("No DISPLAY set; re-running under xvfb-run.");
292
293    // execve: replace this process rather than supervising a child, so signals
294    // and the exit status pass through untouched.
295    let err = std::process::Command::new(xvfb)
296        .arg("-a")
297        .arg(exe)
298        .args(args)
299        .env("K_RUOKA_NO_XVFB", "1")
300        // xvfb-run sets DISPLAY, so the child would otherwise be indistinguishable
301        // from a laptop and print the wrong half of the instructions.
302        .env(UNDER_XVFB_ENV, "1")
303        .exec();
304    Err(anyhow::anyhow!("failed to exec xvfb-run: {err}"))
305}
306
307/// Only the xvfb re-exec needs this, and that is Linux-only.
308#[cfg(target_os = "linux")]
309fn which(bin: &str) -> Option<std::path::PathBuf> {
310    std::env::var_os("PATH").and_then(|paths| {
311        std::env::split_paths(&paths)
312            .map(|p| p.join(bin))
313            .find(|p| p.is_file())
314    })
315}