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 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
24pub 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 #[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 #[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 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 #[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 #[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 ops::account::forgot_password(self, new_password, recovery_code).await
242 }
243
244 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}