Skip to main content

ryra_core/system/
account.rs

1//! The ryra account: talking to the control plane (app.ryra.dev) and
2//! persisting the account API key locally.
3//!
4//! Auth is a bearer API key (`sk_ryra_orc_...`) minted in the dashboard,
5//! not an OAuth flow, so "login" is really "store and validate a key" the
6//! way `gh auth login --with-token` does. The key is the same credential
7//! that unlocks ryra-managed backups (a later step vends short-lived R2
8//! storage creds against it).
9//!
10//! System-touching (network + a 0600 credential file), so it lives under
11//! `system` rather than in the pure planner. HTTP goes through `curl` to
12//! match the rest of the codebase (ryra carries no HTTP-client crate).
13
14use std::path::PathBuf;
15use std::process::Command;
16
17use anyhow::{Context, Result, bail};
18use serde::{Deserialize, Serialize};
19
20use crate::config::ConfigPaths;
21
22/// Default control-plane base URL. `RYRA_API_URL` overrides it (local dev
23/// and E2E point at a throwaway orchestrator), mirroring how `RYRA_DATA_DIR`
24/// / `RYRA_CONFIG_DIR` redirect the rest of ryra in tests.
25const DEFAULT_API_URL: &str = "https://app.ryra.dev";
26
27/// The control-plane base URL, with no trailing slash.
28pub fn api_base_url() -> String {
29    match std::env::var("RYRA_API_URL") {
30        Ok(v) if !v.trim().is_empty() => v.trim().trim_end_matches('/').to_string(),
31        _ => DEFAULT_API_URL.to_string(),
32    }
33}
34
35/// The stored account credential. Persisted to `credentials.toml` next to
36/// `preferences.toml`, 0600 (it is a bearer secret).
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Credentials {
39    /// Bearer API key (`sk_ryra_orc_...`).
40    pub token: String,
41}
42
43fn credentials_path() -> Result<PathBuf> {
44    Ok(ConfigPaths::resolve()?.config_dir.join("credentials.toml"))
45}
46
47/// Load the stored credentials, or `None` if the user has not logged in.
48pub fn load_credentials() -> Result<Option<Credentials>> {
49    let path = credentials_path()?;
50    match std::fs::read_to_string(&path) {
51        Ok(s) => {
52            let creds =
53                toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))?;
54            Ok(Some(creds))
55        }
56        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
57        Err(e) => Err(anyhow::Error::new(e).context(format!("reading {}", path.display()))),
58    }
59}
60
61/// Where the active token came from. A managed box is provisioned with
62/// `RYRA_TOKEN` in its env; a self-hoster stores one via `ryra account login`.
63pub enum TokenSource {
64    /// From the `RYRA_TOKEN` environment variable (managed box / CI).
65    Env(String),
66    /// From the stored credentials file (`ryra account login`).
67    Stored(String),
68}
69
70impl TokenSource {
71    pub fn token(&self) -> &str {
72        match self {
73            TokenSource::Env(t) | TokenSource::Stored(t) => t,
74        }
75    }
76}
77
78/// The token ryra should authenticate with. `RYRA_TOKEN` in the environment
79/// (how a managed box is provisioned) wins over the stored credentials file
80/// (how a self-hoster logs in). `None` if neither is set.
81pub fn effective_token() -> Result<Option<TokenSource>> {
82    if let Ok(t) = std::env::var("RYRA_TOKEN") {
83        let t = t.trim().to_string();
84        if !t.is_empty() {
85            return Ok(Some(TokenSource::Env(t)));
86        }
87    }
88    Ok(load_credentials()?.map(|c| TokenSource::Stored(c.token)))
89}
90
91/// Persist credentials at 0600. The directory is created if missing.
92pub fn save_credentials(creds: &Credentials) -> Result<()> {
93    let paths = ConfigPaths::resolve()?;
94    paths.ensure_dirs()?;
95    let path = paths.config_dir.join("credentials.toml");
96    let body = toml::to_string(creds).context("serializing credentials")?;
97    crate::system::atomic_write::atomic_write(&path, body.as_bytes(), 0o600)?;
98    Ok(())
99}
100
101/// Delete the stored credentials. Returns whether a file was actually removed
102/// (so `logout` can tell the user "nothing to do" vs "done").
103pub fn delete_credentials() -> Result<bool> {
104    let path = credentials_path()?;
105    match std::fs::remove_file(&path) {
106        Ok(()) => Ok(true),
107        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
108        Err(e) => Err(anyhow::Error::new(e).context(format!("removing {}", path.display()))),
109    }
110}
111
112/// Revoke the stored key on the control plane as part of logout, so the box
113/// stops appearing under "Connected boxes" and the key stops granting access.
114/// Returns whether a request was sent (`false` when there's no stored key).
115///
116/// Acts only on the credentials-file token: a `RYRA_TOKEN` env key belongs to a
117/// managed box's provisioning, not something `logout` should revoke. Transport
118/// or HTTP errors bubble up so the caller can downgrade to a warning -- local
119/// logout must still succeed offline.
120pub fn revoke_stored_key() -> Result<bool> {
121    let Some(creds) = load_credentials()? else {
122        return Ok(false);
123    };
124    let resp = curl("POST", "/api/v1/account/logout", &creds.token, None)?;
125    match resp.status {
126        // Revoked now, or the key was already gone / rejected: either way it no
127        // longer grants access, which is exactly what logout wants.
128        200 | 204 | 401 | 404 => Ok(true),
129        other => bail!("the control plane returned HTTP {other} revoking the key"),
130    }
131}
132
133/// One HTTP response: status code + body. Body may be empty.
134struct ApiResponse {
135    status: u16,
136    body: String,
137}
138
139/// `curl` to the control plane with the bearer key. Distinguishes a transport
140/// failure (DNS/TLS/offline: curl exits non-zero) from an HTTP error code
141/// (curl succeeds, we read the status off `-w`).
142fn curl(method: &str, path: &str, token: &str, body: Option<&str>) -> Result<ApiResponse> {
143    curl_inner(method, path, Some(token), body)
144}
145
146/// Like [`curl`] but without an `Authorization` header, for the device-auth
147/// endpoints that a box hits before it has any key.
148fn curl_unauthed(method: &str, path: &str, body: Option<&str>) -> Result<ApiResponse> {
149    curl_inner(method, path, None, body)
150}
151
152fn curl_inner(
153    method: &str,
154    path: &str,
155    token: Option<&str>,
156    body: Option<&str>,
157) -> Result<ApiResponse> {
158    let url = format!("{}{}", api_base_url(), path);
159    let mut cmd = Command::new("curl");
160    cmd.args(["-sS", "-X", method])
161        .arg("-H")
162        .arg("Accept: application/json")
163        .arg("-w")
164        .arg("\n%{http_code}");
165    if let Some(t) = token {
166        cmd.arg("-H").arg(format!("Authorization: Bearer {t}"));
167    }
168    if let Some(b) = body {
169        cmd.args(["-H", "Content-Type: application/json", "--data-binary", b]);
170    }
171    cmd.arg(&url);
172    let out = cmd
173        .output()
174        .with_context(|| format!("curl {method} {url}"))?;
175    if !out.status.success() {
176        let err = String::from_utf8_lossy(&out.stderr);
177        bail!("could not reach {url}: {}", err.trim());
178    }
179    let combined = String::from_utf8_lossy(&out.stdout).into_owned();
180    let (body, code) = combined
181        .rsplit_once('\n')
182        .ok_or_else(|| anyhow::anyhow!("malformed curl response from {url} (no status code)"))?;
183    let status: u16 = code
184        .trim()
185        .parse()
186        .with_context(|| format!("parsing HTTP status from {code:?}"))?;
187    Ok(ApiResponse {
188        status,
189        body: body.to_string(),
190    })
191}
192
193/// Validate a key against the control plane. `Ok(())` means the key is live
194/// and accepted; errors name the likely cause (rejected vs unreachable).
195///
196/// Uses `GET /api/v1/auth/whoami`: a scope-agnostic liveness probe that any
197/// live key passes (operator, customer session, or a box's backups-only key).
198/// A capability endpoint like `/plans` would 403 a valid box key for lacking a
199/// scope it was never meant to have, so it can't stand in for "is this key ok".
200pub fn verify_token(token: &str) -> Result<()> {
201    let resp = curl("GET", "/api/v1/auth/whoami", token, None)?;
202    match resp.status {
203        200 => Ok(()),
204        401 | 403 => bail!(
205            "the control plane rejected this API key (HTTP {}). \
206             Generate a fresh key at {}/account.",
207            resp.status,
208            api_base_url()
209        ),
210        other => {
211            let detail = resp.body.trim();
212            if detail.is_empty() {
213                bail!("unexpected response from the control plane: HTTP {other}");
214            }
215            bail!("unexpected response from the control plane: HTTP {other}: {detail}");
216        }
217    }
218}
219
220/// A started device-authorization request. The box shows `user_code` /
221/// `verification_uri` to a human, then polls with `device_code` (the secret)
222/// until the request is approved or dies.
223#[derive(Deserialize)]
224pub struct DeviceStart {
225    /// Secret the box polls with; never shown to the user.
226    pub device_code: String,
227    /// Short human-readable code the user confirms in the browser.
228    pub user_code: String,
229    /// Where the user goes to approve (enter `user_code` there).
230    pub verification_uri: String,
231    /// One-click URL with the code pre-filled.
232    pub verification_uri_complete: String,
233    /// Seconds until the request expires (bounds the poll loop).
234    pub expires_in: u64,
235    /// Seconds the box should wait between polls.
236    pub interval: u64,
237}
238
239/// One terminal-or-not outcome of a device-auth poll.
240pub enum DevicePoll {
241    /// Not approved yet; keep polling.
242    Pending,
243    /// Approved: here is the minted API key.
244    Approved(String),
245    /// The user rejected the request in the browser.
246    Denied,
247    /// The request expired before anyone approved it.
248    Expired,
249}
250
251/// Begin a device-authorization flow (`POST /api/v1/device/start`). This is an
252/// unauthenticated endpoint: the box has no key yet. `label` identifies the box
253/// in the approval UI (typically its hostname).
254pub fn device_start(label: &str) -> Result<DeviceStart> {
255    #[derive(Serialize)]
256    struct Req<'a> {
257        label: &'a str,
258    }
259    let body = serde_json::to_string(&Req { label }).context("encoding device/start request")?;
260    let resp = curl_unauthed("POST", "/api/v1/device/start", Some(&body))?;
261    match resp.status {
262        200 => serde_json::from_str(&resp.body).context("parsing device/start response"),
263        other => {
264            let detail = resp.body.trim();
265            if detail.is_empty() {
266                bail!("could not start device login: HTTP {other}");
267            }
268            bail!("could not start device login: HTTP {other}: {detail}");
269        }
270    }
271}
272
273/// Poll a device-authorization request once (`POST /api/v1/device/poll`).
274/// Unauthenticated; `device_code` is the secret from [`device_start`].
275pub fn device_poll(device_code: &str) -> Result<DevicePoll> {
276    #[derive(Serialize)]
277    struct Req<'a> {
278        device_code: &'a str,
279    }
280    let body =
281        serde_json::to_string(&Req { device_code }).context("encoding device/poll request")?;
282    let resp = curl_unauthed("POST", "/api/v1/device/poll", Some(&body))?;
283    match resp.status {
284        200 => {
285            #[derive(Deserialize)]
286            struct Body {
287                status: String,
288                key: Option<String>,
289            }
290            let b: Body =
291                serde_json::from_str(&resp.body).context("parsing device/poll response")?;
292            match b.status.as_str() {
293                "pending" => Ok(DevicePoll::Pending),
294                "approved" => {
295                    let key = b.key.filter(|k| !k.trim().is_empty()).ok_or_else(|| {
296                        anyhow::anyhow!("control plane approved the login but returned no key")
297                    })?;
298                    Ok(DevicePoll::Approved(key))
299                }
300                "denied" => Ok(DevicePoll::Denied),
301                "expired" => Ok(DevicePoll::Expired),
302                other => bail!("unexpected device login status from the control plane: {other:?}"),
303            }
304        }
305        other => {
306            let detail = resp.body.trim();
307            if detail.is_empty() {
308                bail!("could not check device login: HTTP {other}");
309            }
310            bail!("could not check device login: HTTP {other}: {detail}");
311        }
312    }
313}
314
315/// The account's managed-backup state, as the CLI needs it to decide what to do.
316pub enum BackupState {
317    /// No backup plan yet (the control plane returned 404).
318    None,
319    /// An active, paid plan.
320    Active { used_bytes: i64, quota_bytes: i64 },
321    /// A plan row exists but isn't active (e.g. `canceled`, `past_due`).
322    Inactive(String),
323}
324
325/// Fetch the calling account's managed-backup state (`GET /api/v1/backup`).
326pub fn backup_status(token: &str) -> Result<BackupState> {
327    let resp = curl("GET", "/api/v1/backup", token, None)?;
328    match resp.status {
329        200 => {
330            #[derive(Deserialize)]
331            struct Body {
332                status: String,
333                used_bytes: i64,
334                quota_bytes: i64,
335            }
336            let b: Body = serde_json::from_str(&resp.body).context("parsing backup status")?;
337            if b.status == "active" {
338                Ok(BackupState::Active {
339                    used_bytes: b.used_bytes,
340                    quota_bytes: b.quota_bytes,
341                })
342            } else {
343                Ok(BackupState::Inactive(b.status))
344            }
345        }
346        404 => Ok(BackupState::None),
347        401 | 403 => bail!(
348            "the control plane rejected this key (HTTP {}). Re-run `ryra account login`.",
349            resp.status
350        ),
351        other => bail!("unexpected response from the control plane: HTTP {other}"),
352    }
353}
354
355/// Short-lived storage credentials vended for a managed backup.
356#[derive(Deserialize)]
357struct VendedCredentials {
358    access_key_id: String,
359    secret_access_key: String,
360    session_token: String,
361    endpoint: String,
362    bucket: String,
363    prefix: String,
364}
365
366/// Vend short-lived, prefix-scoped storage credentials for the calling
367/// account's managed backup (`POST /api/v1/backup/credentials`).
368fn vend_credentials(token: &str) -> Result<VendedCredentials> {
369    let resp = curl("POST", "/api/v1/backup/credentials", token, None)?;
370    match resp.status {
371        200 => serde_json::from_str(&resp.body).context("parsing vended backup credentials"),
372        401 | 403 => {
373            bail!("this key can't vend backup credentials; it needs the backups.write scope")
374        }
375        404 => {
376            bail!("no managed backup plan for this account; run `ryra backup config` to set one up")
377        }
378        409 => bail!("managed backup is not available: {}", resp.body.trim()),
379        other => bail!(
380            "unexpected response vending backup credentials: HTTP {other}: {}",
381            resp.body.trim()
382        ),
383    }
384}
385
386/// Resolve a managed backup into concrete, short-lived S3 credentials by vending
387/// them from the user's account. Called at backup/restore time so a box never
388/// stores long-lived storage keys; the restic password stays client-side.
389pub fn resolve_managed_backend() -> Result<crate::config::schema::BackupBackend> {
390    let src = effective_token()?.ok_or_else(|| {
391        anyhow::anyhow!("managed backups need a ryra account; run `ryra account login`")
392    })?;
393    let c = vend_credentials(src.token())?;
394    Ok(crate::config::schema::BackupBackend::S3 {
395        endpoint: c.endpoint,
396        bucket: c.bucket,
397        access_key_id: c.access_key_id,
398        secret_access_key: c.secret_access_key,
399        // Omit an empty token (static creds, e.g. a local MinIO): an empty
400        // AWS_SESSION_TOKEN is rejected by S3/MinIO.
401        session_token: (!c.session_token.is_empty()).then_some(c.session_token),
402        prefix: Some(c.prefix),
403    })
404}