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