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 metrics
333    #[instrument(skip_all, err)]
334    pub async fn root_get_metrics(&self, service: String) -> anyhow::Result<String> {
335        ops::root::metrics(self, None, service).await
336    }
337
338    /// root info
339    #[instrument(skip_all, err)]
340    pub async fn root_get_info(&self) -> anyhow::Result<ApiInfo> {
341        ops::root::info(self, None).await
342    }
343
344    /// locks an account
345    #[instrument(skip_all, err)]
346    pub async fn root_lock_account(&self, account: &str) -> anyhow::Result<()> {
347        ops::root::set_lock(self, None, account, true).await
348    }
349
350    /// unlocks a locked account
351    #[instrument(skip_all, err)]
352    pub async fn root_unlock_account(&self, account: &str) -> anyhow::Result<()> {
353        ops::root::set_lock(self, None, account, false).await
354    }
355
356    /// get the local logs metadata
357    #[instrument(skip_all, err)]
358    pub fn root_logs_local_metadata(&self) -> anyhow::Result<Vec<LogFileMetadata>> {
359        ops::logs::logs_local_metadata("root", false)
360    }
361
362    /// get the remote logs metadata
363    #[instrument(skip_all, err)]
364    pub async fn root_logs_remote_metadata(&self) -> anyhow::Result<Vec<LogFileMetadata>> {
365        ops::logs::logs_remote_metadata(self, "root", None, false).await
366    }
367
368    /// sync system logs
369    #[instrument(skip_all, err)]
370    pub async fn root_logs_sync(
371        &self,
372        force: Option<bool>,
373        file: Option<&str>,
374    ) -> anyhow::Result<()> {
375        ops::logs::logs_sync(self, "root", force, file, false).await
376    }
377
378    /// search system logs
379    #[instrument(skip_all, err)]
380    pub fn root_logs_search(
381        &self,
382        query: &str,
383        format: &str,
384        limit: &Option<usize>,
385    ) -> anyhow::Result<String> {
386        ops::logs::logs_search("root", query, format, limit)
387    }
388
389    /// list system log files
390    #[instrument(skip_all, err)]
391    pub fn root_logs_file_list(&self) -> anyhow::Result<()> {
392        ops::logs::logs_file_list("root")
393    }
394
395    /// read system log file
396    #[instrument(skip_all, err)]
397    pub fn root_logs_file_read(&self, name: &str) -> anyhow::Result<()> {
398        ops::logs::logs_file_read("root", name)
399    }
400
401    /// read all system log files
402    #[instrument(skip_all, err)]
403    pub fn root_logs_file_read_all(&self) -> anyhow::Result<()> {
404        ops::logs::logs_file_read_all("root")
405    }
406
407    /// get the local logs metadata
408    #[instrument(skip_all, err)]
409    pub fn app_logs_local_metadata(&self, proj_path: &str) -> anyhow::Result<Vec<LogFileMetadata>> {
410        ops::logs::logs_local_metadata(proj_path, true)
411    }
412
413    /// get the remote logs metadata
414    #[instrument(skip_all, err)]
415    pub async fn app_logs_remote_metadata(
416        &self,
417        proj_path: &str,
418    ) -> anyhow::Result<Vec<LogFileMetadata>> {
419        ops::logs::logs_remote_metadata(self, proj_path, None, true).await
420    }
421
422    /// sync an app's logs
423    #[instrument(skip_all, err)]
424    pub async fn app_logs_sync(
425        &self,
426        proj_path: &str,
427        force: Option<bool>,
428        file: Option<&str>,
429    ) -> anyhow::Result<()> {
430        ops::logs::logs_sync(self, proj_path, force, file, true).await
431    }
432
433    /// search an app's logs
434    #[instrument(skip_all, err)]
435    pub fn app_logs_search(
436        &self,
437        proj_path: &str,
438        query: &str,
439        format: &str,
440        limit: &Option<usize>,
441    ) -> anyhow::Result<String> {
442        ops::logs::logs_search(proj_path, query, format, limit)
443    }
444
445    /// list app log files
446    #[instrument(skip_all, err)]
447    pub fn app_logs_file_list(&self, project_path: &str) -> anyhow::Result<()> {
448        ops::logs::logs_file_list(project_path)
449    }
450
451    /// read app log file
452    #[instrument(skip_all, err)]
453    pub fn app_logs_file_read(&self, project_path: &str, name: &str) -> anyhow::Result<()> {
454        ops::logs::logs_file_read(project_path, name)
455    }
456
457    /// read all app log files
458    #[instrument(skip_all, err)]
459    pub fn app_logs_file_read_all(&self, project_path: &str) -> anyhow::Result<()> {
460        ops::logs::logs_file_read_all(project_path)
461    }
462
463    /// list accounts for an app
464    #[instrument(skip(self), err)]
465    pub async fn app_accounts_list(&self, proj_path: &str) -> anyhow::Result<String> {
466        ops::app::accounts_list(self, proj_path).await
467    }
468
469    /// list app items by their model index
470    #[instrument(skip(self), err)]
471    pub async fn items_list(&self, proj_path: &str, model_name: &str) -> anyhow::Result<String> {
472        ops::models::items_list(self, proj_path, model_name).await
473    }
474
475    /// write a single asset
476    #[instrument(skip(self), err)]
477    pub async fn write(&self, proj_path: &str, asset_path: &str) -> anyhow::Result<()> {
478        ops::assets::write(self, proj_path, asset_path).await
479    }
480
481    /// write all assets in assets `dir_path`
482    #[instrument(skip(self), err)]
483    pub async fn write_all(&self, proj_path: &str) -> anyhow::Result<()> {
484        ops::assets::write_all(self, proj_path).await
485    }
486
487    /// upload a single template
488    #[instrument(skip(self), err)]
489    pub async fn upload(&self, proj_path: &str, template_name: &str) -> anyhow::Result<()> {
490        ops::templates::upload(self, proj_path, template_name).await
491    }
492
493    /// upload all templates
494    #[instrument(skip(self), err)]
495    pub async fn upload_all(&self, proj_path: &str) -> anyhow::Result<()> {
496        ops::templates::upload_all(self, proj_path).await
497    }
498
499    /// install single action
500    #[instrument(skip(self), err)]
501    pub async fn install(&self, proj_path: &str, action_name: &str) -> anyhow::Result<()> {
502        ops::actions::install(self, proj_path, action_name).await
503    }
504
505    /// install all actions
506    #[instrument(skip(self), err)]
507    pub async fn install_all(&self, proj_path: &str) -> anyhow::Result<()> {
508        ops::actions::install_all(self, proj_path).await
509    }
510
511    /// update content
512    #[instrument(skip(self), err)]
513    pub async fn update(&self, proj_path: &str) -> anyhow::Result<()> {
514        ops::content::update(self, proj_path).await
515    }
516
517    /// store secret
518    #[instrument(skip(self, secret), err)]
519    pub async fn store(&self, proj_path: &str, name: &str, secret: &[u8]) -> anyhow::Result<()> {
520        ops::secrets::store(self, proj_path, name, secret).await
521    }
522
523    fn sign_token(
524        client_dir: &Path,
525        token: &mut BytesMut,
526        signing_key: Option<SigningKey>,
527        duration_s: Option<u32>,
528    ) -> anyhow::Result<()> {
529        let client_exp = match SystemTime::now()
530            .checked_add(Duration::from_secs(u64::from(duration_s.unwrap_or(3))))
531        {
532            Some(v) => v,
533            None => bail!("failed to add"),
534        }
535        .duration_since(SystemTime::UNIX_EPOCH)?
536        .as_secs();
537
538        token.put_u64(client_exp);
539
540        let mut signing_key = if let Some(sk) = signing_key {
541            sk
542        } else {
543            let signing_key_bytes: [u8; 32] =
544                match fs_err::read(client_dir.join("signing_key"))?.try_into() {
545                    Ok(v) => v,
546                    Err(_) => bail!("failed to convert"),
547                };
548
549            let signing_key: SigningKey = SigningKey::from_bytes(&signing_key_bytes);
550            signing_key
551        };
552
553        let signature = signing_key.sign(&token[..]);
554
555        token.put(&signature.to_bytes()[..]);
556
557        Ok(())
558    }
559}
560
561pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
562    if dir.is_dir() {
563        for entry in fs_err::read_dir(dir)? {
564            let entry = entry?;
565            let path = entry.path();
566            if path.is_dir() {
567                traverse(&path, cb)?;
568            } else {
569                cb(&entry);
570            }
571        }
572    }
573    Ok(())
574}