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