1mod 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
26pub 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 #[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 #[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 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 #[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 #[instrument(skip_all, err)]
244 pub async fn forgot_password(
245 &self,
246 new_password: &str,
247 recovery_code: &str,
248 ) -> anyhow::Result<()> {
249 ops::account::forgot_password(self, new_password, recovery_code).await
251 }
252
253 #[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 #[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 #[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 #[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 #[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 #[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 #[instrument(skip(self), err)]
300 pub async fn deploy(
301 &self,
302 proj_path: &str,
303 tls_staging: bool,
304 ) -> anyhow::Result<PortsResponse> {
305 ops::app::deploy(self, proj_path, tls_staging).await
306 }
307
308 #[instrument(skip(self), err)]
310 pub async fn kill(&self, proj_path: &str) -> anyhow::Result<()> {
311 ops::app::kill(self, proj_path).await
312 }
313
314 #[instrument(skip(self), err)]
316 pub async fn restart(
317 &self,
318 proj_path: &str,
319 tls_staging: bool,
320 ) -> anyhow::Result<PortsResponse> {
321 ops::app::restart(self, proj_path, tls_staging).await
322 }
323
324 #[instrument(skip(self), err)]
326 pub async fn erase(&self, proj_path: &str) -> anyhow::Result<()> {
327 ops::app::erase(self, proj_path).await
328 }
329
330 #[instrument(skip_all, err)]
332 pub async fn root_get_info(&self) -> anyhow::Result<ApiInfo> {
333 ops::root::info(self, None).await
334 }
335
336 #[instrument(skip_all, err)]
338 pub async fn root_lock_account(&self, account: &str) -> anyhow::Result<()> {
339 ops::root::set_lock(self, None, account, true).await
340 }
341
342 #[instrument(skip_all, err)]
344 pub async fn root_unlock_account(&self, account: &str) -> anyhow::Result<()> {
345 ops::root::set_lock(self, None, account, false).await
346 }
347
348 #[instrument(skip_all, err)]
350 pub fn root_logs_local_metadata(&self) -> anyhow::Result<Vec<LogFileMetadata>> {
351 ops::logs::logs_local_metadata("root", false)
352 }
353
354 #[instrument(skip_all, err)]
356 pub async fn root_logs_remote_metadata(&self) -> anyhow::Result<Vec<LogFileMetadata>> {
357 ops::logs::logs_remote_metadata(self, "root", None, false).await
358 }
359
360 #[instrument(skip_all, err)]
361 pub async fn root_logs_sync(
362 &self,
363 force: Option<bool>,
364 file: Option<&str>,
365 ) -> anyhow::Result<()> {
366 ops::logs::logs_sync(self, "root", force, file, false).await
367 }
368
369 #[instrument(skip_all, err)]
371 pub fn root_logs_search(
372 &self,
373 query: &str,
374 format: &str,
375 limit: &Option<usize>,
376 ) -> anyhow::Result<String> {
377 ops::app::logs_search("root", query, format, limit)
378 }
379
380 #[instrument(skip_all, err)]
382 pub fn app_logs_local_metadata(&self, proj_path: &str) -> anyhow::Result<Vec<LogFileMetadata>> {
383 ops::logs::logs_local_metadata(proj_path, true)
384 }
385
386 #[instrument(skip_all, err)]
388 pub async fn app_logs_remote_metadata(
389 &self,
390 proj_path: &str,
391 ) -> anyhow::Result<Vec<LogFileMetadata>> {
392 ops::logs::logs_remote_metadata(self, proj_path, None, true).await
393 }
394
395 #[instrument(skip_all, err)]
396 pub async fn app_logs_sync(
397 &self,
398 proj_path: &str,
399 force: Option<bool>,
400 file: Option<&str>,
401 ) -> anyhow::Result<()> {
402 ops::logs::logs_sync(self, proj_path, force, file, true).await
403 }
404
405 #[instrument(skip_all, err)]
407 pub fn app_logs_search(
408 &self,
409 proj_path: &str,
410 query: &str,
411 format: &str,
412 limit: &Option<usize>,
413 ) -> anyhow::Result<String> {
414 ops::app::logs_search(proj_path, query, format, limit)
415 }
416
417 #[instrument(skip(self), err)]
419 pub async fn app_accounts_list(&self, proj_path: &str) -> anyhow::Result<String> {
420 ops::app::accounts_list(self, proj_path).await
421 }
422
423 #[instrument(skip(self), err)]
425 pub async fn items_list(&self, proj_path: &str, model_name: &str) -> anyhow::Result<String> {
426 ops::models::items_list(self, proj_path, model_name).await
427 }
428
429 #[instrument(skip(self), err)]
431 pub async fn write(&self, proj_path: &str, asset_path: &str) -> anyhow::Result<()> {
432 ops::assets::write(self, proj_path, asset_path).await
433 }
434
435 #[instrument(skip(self), err)]
437 pub async fn write_all(&self, proj_path: &str) -> anyhow::Result<()> {
438 ops::assets::write_all(self, proj_path).await
439 }
440
441 #[instrument(skip(self), err)]
443 pub async fn upload(&self, proj_path: &str, template_name: &str) -> anyhow::Result<()> {
444 ops::templates::upload(self, proj_path, template_name).await
445 }
446
447 #[instrument(skip(self), err)]
449 pub async fn upload_all(&self, proj_path: &str) -> anyhow::Result<()> {
450 ops::templates::upload_all(self, proj_path).await
451 }
452
453 #[instrument(skip(self), err)]
455 pub async fn install(&self, proj_path: &str, action_name: &str) -> anyhow::Result<()> {
456 ops::actions::install(self, proj_path, action_name).await
457 }
458
459 #[instrument(skip(self), err)]
461 pub async fn install_all(&self, proj_path: &str) -> anyhow::Result<()> {
462 ops::actions::install_all(self, proj_path).await
463 }
464
465 #[instrument(skip(self), err)]
467 pub async fn update(&self, proj_path: &str) -> anyhow::Result<()> {
468 ops::content::update(self, proj_path).await
469 }
470
471 #[instrument(skip(self, secret), err)]
473 pub async fn store(&self, proj_path: &str, name: &str, secret: &[u8]) -> anyhow::Result<()> {
474 ops::secrets::store(self, proj_path, name, secret).await
475 }
476
477 fn sign_token(
478 client_dir: &Path,
479 token: &mut BytesMut,
480 signing_key: Option<SigningKey>,
481 duration_s: Option<u32>,
482 ) -> anyhow::Result<()> {
483 let client_exp = match SystemTime::now()
484 .checked_add(Duration::from_secs(u64::from(duration_s.unwrap_or(3))))
485 {
486 Some(v) => v,
487 None => bail!("failed to add"),
488 }
489 .duration_since(SystemTime::UNIX_EPOCH)?
490 .as_secs();
491
492 token.put_u64(client_exp);
493
494 let mut signing_key = if let Some(sk) = signing_key {
495 sk
496 } else {
497 let signing_key_bytes: [u8; 32] =
498 match fs_err::read(client_dir.join("signing_key"))?.try_into() {
499 Ok(v) => v,
500 Err(_) => bail!("failed to convert"),
501 };
502
503 let signing_key: SigningKey = SigningKey::from_bytes(&signing_key_bytes);
504 signing_key
505 };
506
507 let signature = signing_key.sign(&token[..]);
508
509 token.put(&signature.to_bytes()[..]);
510
511 Ok(())
512 }
513}
514
515pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
516 if dir.is_dir() {
517 for entry in fs_err::read_dir(dir)? {
518 let entry = entry?;
519 let path = entry.path();
520 if path.is_dir() {
521 traverse(&path, cb)?;
522 } else {
523 cb(&entry);
524 }
525 }
526 }
527 Ok(())
528}