Skip to main content

ordinary_api/client/
mod.rs

1// Copyright (C) 2026 Ordinary Labs, LLC.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4
5mod ops;
6
7use anyhow::bail;
8use bytes::{BufMut, BytesMut};
9use fs_err::DirEntry;
10use ordinary_auth::token::{extract_hmac_no_check, get_exp};
11use std::env::home_dir;
12use std::path::Path;
13use std::path::PathBuf;
14use std::time::{Duration, SystemTime};
15use tracing::instrument;
16
17use crate::ApiInfo;
18use ed25519_dalek::{SigningKey, ed25519::signature::SignerMut};
19use ordinary_monitor::LogFileMetadata;
20use reqwest::Client;
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use totp_rs::TOTP;
24use uuid::Uuid;
25
26/// client for interaction with Ordinary API Server
27pub struct OrdinaryApiClient<'a> {
28    pub(crate) addr: &'a str,
29    pub(crate) account: &'a str,
30    pub(crate) api_domain: Option<&'a str>,
31    pub(crate) client: Client,
32    pub(crate) correlation_id: Option<Uuid>,
33}
34
35fn compress_zstd(val: &[u8]) -> std::io::Result<Vec<u8>> {
36    zstd::stream::encode_all(std::io::Cursor::new(val), 17)
37}
38
39fn strip_http(addr: &str) -> &str {
40    if let Some(stripped) = addr.strip_prefix("https://") {
41        return stripped;
42    }
43    if let Some(stripped) = addr.strip_prefix("http://") {
44        return stripped;
45    }
46
47    addr
48}
49
50fn get_client_dir(domain: &str, account: &str) -> PathBuf {
51    home_dir()
52        .expect("failed to get home dir")
53        .join(".ordinary")
54        .join("clients")
55        .join(domain)
56        .join(account)
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize)]
60pub struct AccountMeta {
61    pub id: String,
62    pub host: String,
63    pub domain: String,
64    pub name: String,
65    pub project: String,
66    pub permissions: Vec<u8>,
67    pub refresh_exp: u64,
68}
69
70impl<'a> OrdinaryApiClient<'a> {
71    pub fn new(
72        addr: &'a str,
73        account: &'a str,
74        api_domain: Option<&'a str>,
75        danger_accept_invalid_certs: bool,
76        user_agent: &str,
77        correlation_id: bool,
78    ) -> anyhow::Result<OrdinaryApiClient<'a>> {
79        tracing::debug!("initializing Ordinary API client");
80
81        let mut client_builder = Client::builder().use_rustls_tls().zstd(true);
82        client_builder = client_builder.user_agent(user_agent);
83
84        if danger_accept_invalid_certs {
85            client_builder = client_builder.danger_accept_invalid_certs(true);
86        }
87
88        let client = client_builder.build()?;
89
90        Ok(OrdinaryApiClient {
91            addr,
92            account,
93            api_domain,
94            client,
95            correlation_id: correlation_id.then(Uuid::new_v4),
96        })
97    }
98
99    pub(crate) fn get_password_hash_and_domain(&self, password: &str) -> (Vec<u8>, &'a str) {
100        let (mut input, domain) = match self.api_domain {
101            Some(domain) => (domain.as_bytes().to_vec(), domain),
102            None => (
103                strip_http(self.addr).as_bytes().to_vec(),
104                strip_http(self.addr),
105            ),
106        };
107
108        input.extend_from_slice(self.account.as_bytes());
109        input.extend_from_slice(password.as_bytes());
110
111        let mut hasher = Sha256::new();
112        hasher.update(&input);
113        let password = hasher.finalize().to_vec();
114
115        (password, domain)
116    }
117
118    #[allow(clippy::missing_panics_doc)]
119    pub fn get_host(domain: &str, account: &str) -> anyhow::Result<String> {
120        let clients = home_dir()
121            .expect("failed to get home dir")
122            .join(".ordinary")
123            .join("clients");
124
125        let host = fs_err::read_to_string(clients.join(domain).join(account).join("host"))?;
126
127        Ok(host)
128    }
129
130    #[allow(clippy::missing_panics_doc)]
131    pub fn list_accounts() -> anyhow::Result<Vec<AccountMeta>> {
132        let clients = home_dir()
133            .expect("failed to get home dir")
134            .join(".ordinary")
135            .join("clients");
136
137        let mut out = vec![];
138
139        for entry in fs_err::read_dir(&clients)? {
140            let path = entry?.path();
141
142            if path.is_dir()
143                && let Some(domain) = &path.strip_prefix(&clients)?.to_str()
144            {
145                for entry in fs_err::read_dir(&path)? {
146                    let path = entry?.path();
147
148                    if path.is_dir()
149                        && let Some(account) = path.strip_prefix(clients.join(domain))?.to_str()
150                    {
151                        let host = fs_err::read_to_string(
152                            clients.join(domain).join(account).join("host"),
153                        )?;
154                        out.push(Self::get_account(&host, domain, account)?);
155                    }
156                }
157            }
158        }
159
160        Ok(out)
161    }
162
163    pub fn get_account(host: &str, domain: &str, account: &str) -> anyhow::Result<AccountMeta> {
164        tracing::debug!("getting account");
165
166        let path = get_client_dir(domain, account);
167        tracing::debug!(path = %path.display());
168
169        let refresh_token = fs_err::read(path.join("refresh_token"))?;
170        let access_token = fs_err::read(path.join("access_token"))?;
171
172        tracing::debug!("extracting claims");
173        let claims = extract_hmac_no_check(&access_token)?;
174
175        let claims_vec = match flexbuffers::Reader::get_root(
176            &claims[..claims.len().checked_sub(8 + 64).unwrap_or(claims.len())],
177        ) {
178            Ok(v) => v.as_vector(),
179            Err(_) => flexbuffers::Reader::get_root(claims)?.as_vector(),
180        };
181
182        let system_claims = claims_vec.idx(0).as_vector();
183        let token_uuid_bytes: [u8; 16] = system_claims.idx(0).as_blob().0.try_into()?;
184
185        let token_uuid_str = Uuid::from_bytes(token_uuid_bytes).to_string();
186
187        let project = claims_vec.idx(1).as_str();
188        let permissions = claims_vec
189            .idx(2)
190            .as_vector()
191            .iter()
192            .map(|r| r.as_u8())
193            .collect::<Vec<u8>>();
194
195        Ok(AccountMeta {
196            id: token_uuid_str,
197            host: (*host).to_owned(),
198            domain: (*domain).to_owned(),
199            name: (*account).to_owned(),
200            project: project.to_owned(),
201            permissions,
202            refresh_exp: get_exp(&refresh_token)?,
203        })
204    }
205
206    /// register with API server
207    #[instrument(skip_all, err)]
208    pub async fn register(
209        &self,
210        password: &str,
211        invite_code: &str,
212    ) -> anyhow::Result<(TOTP, String)> {
213        ops::account::register(self, password, invite_code).await
214    }
215
216    /// log in to API server
217    #[instrument(skip_all, err)]
218    pub async fn login(&self, password: &str, mfa_code: &str) -> anyhow::Result<()> {
219        ops::account::login(self, password, mfa_code).await
220    }
221
222    /// get access token for API server
223    pub async fn get_access(
224        &self,
225        duration_s: Option<u32>,
226        correlation_id: Option<String>,
227    ) -> anyhow::Result<Vec<u8>> {
228        ops::account::get_access(self, duration_s, correlation_id).await
229    }
230
231    /// reset API account password
232    #[instrument(skip_all, err)]
233    pub async fn reset_password(
234        &self,
235        old_password: &str,
236        mfa_code: &str,
237        new_password: &str,
238    ) -> anyhow::Result<()> {
239        ops::account::reset_password(self, old_password, mfa_code, new_password).await
240    }
241
242    /// recover forgotten API account password
243    #[instrument(skip_all, err)]
244    pub async fn forgot_password(
245        &self,
246        new_password: &str,
247        recovery_code: &str,
248    ) -> anyhow::Result<()> {
249        // todo: forgot password should include MFA code?
250        ops::account::forgot_password(self, new_password, recovery_code).await
251    }
252
253    /// reset MFA TOTP secret for API account
254    #[instrument(skip_all, err)]
255    pub async fn mfa_totp_reset(&self, password: &str, mfa_code: &str) -> anyhow::Result<TOTP> {
256        ops::account::mfa_totp_reset(self, password, mfa_code).await
257    }
258
259    /// recover lost MFA TOTP secret for API account
260    #[instrument(skip_all, err)]
261    pub async fn mfa_totp_lost(&self, password: &str, recovery_code: &str) -> anyhow::Result<TOTP> {
262        ops::account::mfa_totp_lost(self, password, recovery_code).await
263    }
264
265    /// reset API account recovery codes
266    #[instrument(skip_all, err)]
267    pub async fn recovery_codes_reset(
268        &self,
269        password: &str,
270        mfa_code: &str,
271    ) -> anyhow::Result<String> {
272        ops::account::recovery_codes_reset(self, password, mfa_code).await
273    }
274
275    /// delete API account
276    #[instrument(skip_all, err)]
277    pub async fn delete_account(&self, password: &str, mfa_code: &str) -> anyhow::Result<()> {
278        ops::account::delete(self, password, mfa_code).await
279    }
280
281    /// invite API account
282    #[instrument(skip(self), err)]
283    pub async fn invite_api_account(
284        &self,
285        app_domain: &str,
286        account_name: &str,
287        permissions: Vec<u8>,
288    ) -> anyhow::Result<String> {
289        ops::account::invite_account(self, app_domain, account_name, permissions).await
290    }
291
292    /// list accounts for the API server
293    #[instrument(skip(self), err)]
294    pub async fn api_accounts_list(&self, proj_path: Option<&str>) -> anyhow::Result<String> {
295        ops::account::accounts_list(self, proj_path).await
296    }
297
298    /// deploy app to a API server
299    #[instrument(skip(self), err)]
300    pub async fn deploy(&self, proj_path: &str) -> anyhow::Result<u16> {
301        ops::app::deploy(self, proj_path).await
302    }
303
304    /// kill app on API server
305    #[instrument(skip(self), err)]
306    pub async fn kill(&self, proj_path: &str) -> anyhow::Result<()> {
307        ops::app::kill(self, proj_path).await
308    }
309
310    /// restart app on API server
311    #[instrument(skip(self), err)]
312    pub async fn restart(&self, proj_path: &str) -> anyhow::Result<u16> {
313        ops::app::restart(self, proj_path).await
314    }
315
316    /// erase app on API server
317    #[instrument(skip(self), err)]
318    pub async fn erase(&self, proj_path: &str) -> anyhow::Result<()> {
319        ops::app::erase(self, proj_path).await
320    }
321
322    /// root info
323    #[instrument(skip_all, err)]
324    pub async fn root_get_info(&self) -> anyhow::Result<ApiInfo> {
325        ops::root::info(self, None).await
326    }
327
328    /// locks an account
329    #[instrument(skip_all, err)]
330    pub async fn root_lock_account(&self, account: &str) -> anyhow::Result<()> {
331        ops::root::set_lock(self, None, account, true).await
332    }
333
334    /// unlocks a locked account
335    #[instrument(skip_all, err)]
336    pub async fn root_unlock_account(&self, account: &str) -> anyhow::Result<()> {
337        ops::root::set_lock(self, None, account, false).await
338    }
339
340    /// get the local logs metadata
341    #[instrument(skip_all, err)]
342    pub fn root_logs_local_metadata(&self) -> anyhow::Result<Vec<LogFileMetadata>> {
343        ops::logs::logs_local_metadata("root", false)
344    }
345
346    /// get the remote logs metadata
347    #[instrument(skip_all, err)]
348    pub async fn root_logs_remote_metadata(&self) -> anyhow::Result<Vec<LogFileMetadata>> {
349        ops::logs::logs_remote_metadata(self, "root", None, false).await
350    }
351
352    #[instrument(skip_all, err)]
353    pub async fn root_logs_sync(
354        &self,
355        force: Option<bool>,
356        file: Option<&str>,
357    ) -> anyhow::Result<()> {
358        ops::logs::logs_sync(self, "root", force, file, false).await
359    }
360
361    /// query an app's logs
362    #[instrument(skip_all, err)]
363    pub fn root_logs_search(
364        &self,
365        query: &str,
366        format: &str,
367        limit: &Option<usize>,
368    ) -> anyhow::Result<String> {
369        ops::app::logs_search("root", query, format, limit)
370    }
371
372    /// get the local logs metadata
373    #[instrument(skip_all, err)]
374    pub fn app_logs_local_metadata(&self, proj_path: &str) -> anyhow::Result<Vec<LogFileMetadata>> {
375        ops::logs::logs_local_metadata(proj_path, true)
376    }
377
378    /// get the remote logs metadata
379    #[instrument(skip_all, err)]
380    pub async fn app_logs_remote_metadata(
381        &self,
382        proj_path: &str,
383    ) -> anyhow::Result<Vec<LogFileMetadata>> {
384        ops::logs::logs_remote_metadata(self, proj_path, None, true).await
385    }
386
387    #[instrument(skip_all, err)]
388    pub async fn app_logs_sync(
389        &self,
390        proj_path: &str,
391        force: Option<bool>,
392        file: Option<&str>,
393    ) -> anyhow::Result<()> {
394        ops::logs::logs_sync(self, proj_path, force, file, true).await
395    }
396
397    /// query an app's logs
398    #[instrument(skip_all, err)]
399    pub fn app_logs_search(
400        &self,
401        proj_path: &str,
402        query: &str,
403        format: &str,
404        limit: &Option<usize>,
405    ) -> anyhow::Result<String> {
406        ops::app::logs_search(proj_path, query, format, limit)
407    }
408
409    /// list accounts for an app
410    #[instrument(skip(self), err)]
411    pub async fn app_accounts_list(&self, proj_path: &str) -> anyhow::Result<String> {
412        ops::app::accounts_list(self, proj_path).await
413    }
414
415    /// list app items by their model index
416    #[instrument(skip(self), err)]
417    pub async fn items_list(&self, proj_path: &str, model_name: &str) -> anyhow::Result<String> {
418        ops::models::items_list(self, proj_path, model_name).await
419    }
420
421    /// write a single asset
422    #[instrument(skip(self), err)]
423    pub async fn write(&self, proj_path: &str, asset_path: &str) -> anyhow::Result<()> {
424        ops::assets::write(self, proj_path, asset_path).await
425    }
426
427    /// write all assets in assets `dir_path`
428    #[instrument(skip(self), err)]
429    pub async fn write_all(&self, proj_path: &str) -> anyhow::Result<()> {
430        ops::assets::write_all(self, proj_path).await
431    }
432
433    /// upload a single template
434    #[instrument(skip(self), err)]
435    pub async fn upload(&self, proj_path: &str, template_name: &str) -> anyhow::Result<()> {
436        ops::templates::upload(self, proj_path, template_name).await
437    }
438
439    /// upload all templates
440    #[instrument(skip(self), err)]
441    pub async fn upload_all(&self, proj_path: &str) -> anyhow::Result<()> {
442        ops::templates::upload_all(self, proj_path).await
443    }
444
445    /// install single action
446    #[instrument(skip(self), err)]
447    pub async fn install(&self, proj_path: &str, action_name: &str) -> anyhow::Result<()> {
448        ops::actions::install(self, proj_path, action_name).await
449    }
450
451    /// install all actions
452    #[instrument(skip(self), err)]
453    pub async fn install_all(&self, proj_path: &str) -> anyhow::Result<()> {
454        ops::actions::install_all(self, proj_path).await
455    }
456
457    /// update content
458    #[instrument(skip(self), err)]
459    pub async fn update(&self, proj_path: &str) -> anyhow::Result<()> {
460        ops::content::update(self, proj_path).await
461    }
462
463    /// store secret
464    #[instrument(skip(self, secret), err)]
465    pub async fn store(&self, proj_path: &str, name: &str, secret: &[u8]) -> anyhow::Result<()> {
466        ops::secrets::store(self, proj_path, name, secret).await
467    }
468
469    fn sign_token(
470        client_dir: &Path,
471        token: &mut BytesMut,
472        signing_key: Option<SigningKey>,
473        duration_s: Option<u32>,
474    ) -> anyhow::Result<()> {
475        let client_exp = match SystemTime::now()
476            .checked_add(Duration::from_secs(u64::from(duration_s.unwrap_or(3))))
477        {
478            Some(v) => v,
479            None => bail!("failed to add"),
480        }
481        .duration_since(SystemTime::UNIX_EPOCH)?
482        .as_secs();
483
484        token.put_u64(client_exp);
485
486        let mut signing_key = if let Some(sk) = signing_key {
487            sk
488        } else {
489            let signing_key_bytes: [u8; 32] =
490                match fs_err::read(client_dir.join("signing_key"))?.try_into() {
491                    Ok(v) => v,
492                    Err(_) => bail!("failed to convert"),
493                };
494
495            let signing_key: SigningKey = SigningKey::from_bytes(&signing_key_bytes);
496            signing_key
497        };
498
499        let signature = signing_key.sign(&token[..]);
500
501        token.put(&signature.to_bytes()[..]);
502
503        Ok(())
504    }
505}
506
507pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
508    if dir.is_dir() {
509        for entry in fs_err::read_dir(dir)? {
510            let entry = entry?;
511            let path = entry.path();
512            if path.is_dir() {
513                traverse(&path, cb)?;
514            } else {
515                cb(&entry);
516            }
517        }
518    }
519    Ok(())
520}