mod ops;
use anyhow::bail;
use bytes::{BufMut, BytesMut};
use fs_err::DirEntry;
use ordinary_auth::token::{extract_hmac_no_check, get_exp};
use std::env::home_dir;
use std::path::Path;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use tracing::instrument;
use crate::ApiInfo;
use ed25519_dalek::{SigningKey, ed25519::signature::SignerMut};
use ordinary_monitor::LogFileMetadata;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use totp_rs::TOTP;
use uuid::Uuid;
pub struct OrdinaryApiClient<'a> {
pub(crate) addr: &'a str,
pub(crate) account: &'a str,
pub(crate) api_domain: Option<&'a str>,
pub(crate) client: Client,
pub(crate) correlation_id: Option<Uuid>,
}
fn compress_zstd(val: &[u8]) -> std::io::Result<Vec<u8>> {
zstd::stream::encode_all(std::io::Cursor::new(val), 17)
}
fn strip_http(addr: &str) -> &str {
if let Some(stripped) = addr.strip_prefix("https://") {
return stripped;
}
if let Some(stripped) = addr.strip_prefix("http://") {
return stripped;
}
addr
}
fn get_client_dir(domain: &str, account: &str) -> PathBuf {
home_dir()
.expect("failed to get home dir")
.join(".ordinary")
.join("clients")
.join(domain)
.join(account)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AccountMeta {
pub id: String,
pub host: String,
pub domain: String,
pub name: String,
pub project: String,
pub permissions: Vec<u8>,
pub refresh_exp: u64,
}
impl<'a> OrdinaryApiClient<'a> {
pub fn new(
addr: &'a str,
account: &'a str,
api_domain: Option<&'a str>,
danger_accept_invalid_certs: bool,
user_agent: &str,
correlation_id: bool,
) -> anyhow::Result<OrdinaryApiClient<'a>> {
tracing::debug!("initializing Ordinary API client");
let mut client_builder = Client::builder().use_rustls_tls().zstd(true);
client_builder = client_builder.user_agent(user_agent);
if danger_accept_invalid_certs {
client_builder = client_builder.danger_accept_invalid_certs(true);
}
let client = client_builder.build()?;
Ok(OrdinaryApiClient {
addr,
account,
api_domain,
client,
correlation_id: correlation_id.then(Uuid::new_v4),
})
}
pub(crate) fn get_password_hash_and_domain(&self, password: &str) -> (Vec<u8>, &'a str) {
let (mut input, domain) = match self.api_domain {
Some(domain) => (domain.as_bytes().to_vec(), domain),
None => (
strip_http(self.addr).as_bytes().to_vec(),
strip_http(self.addr),
),
};
input.extend_from_slice(self.account.as_bytes());
input.extend_from_slice(password.as_bytes());
let mut hasher = Sha256::new();
hasher.update(&input);
let password = hasher.finalize().to_vec();
(password, domain)
}
#[allow(clippy::missing_panics_doc)]
pub fn get_host(domain: &str, account: &str) -> anyhow::Result<String> {
let clients = home_dir()
.expect("failed to get home dir")
.join(".ordinary")
.join("clients");
let host = fs_err::read_to_string(clients.join(domain).join(account).join("host"))?;
Ok(host)
}
#[allow(clippy::missing_panics_doc)]
pub fn list_accounts() -> anyhow::Result<Vec<AccountMeta>> {
let clients = home_dir()
.expect("failed to get home dir")
.join(".ordinary")
.join("clients");
let mut out = vec![];
for entry in fs_err::read_dir(&clients)? {
let path = entry?.path();
if path.is_dir()
&& let Some(domain) = &path.strip_prefix(&clients)?.to_str()
{
for entry in fs_err::read_dir(&path)? {
let path = entry?.path();
if path.is_dir()
&& let Some(account) = path.strip_prefix(clients.join(domain))?.to_str()
{
let host = fs_err::read_to_string(
clients.join(domain).join(account).join("host"),
)?;
out.push(Self::get_account(&host, domain, account)?);
}
}
}
}
Ok(out)
}
pub fn get_account(host: &str, domain: &str, account: &str) -> anyhow::Result<AccountMeta> {
tracing::debug!("getting account");
let path = get_client_dir(domain, account);
tracing::debug!(path = %path.display());
let refresh_token = fs_err::read(path.join("refresh_token"))?;
let access_token = fs_err::read(path.join("access_token"))?;
tracing::debug!("extracting claims");
let claims = extract_hmac_no_check(&access_token)?;
let claims_vec = match flexbuffers::Reader::get_root(
&claims[..claims.len().checked_sub(8 + 64).unwrap_or(claims.len())],
) {
Ok(v) => v.as_vector(),
Err(_) => flexbuffers::Reader::get_root(claims)?.as_vector(),
};
let system_claims = claims_vec.idx(0).as_vector();
let token_uuid_bytes: [u8; 16] = system_claims.idx(0).as_blob().0.try_into()?;
let token_uuid_str = Uuid::from_bytes(token_uuid_bytes).to_string();
let project = claims_vec.idx(1).as_str();
let permissions = claims_vec
.idx(2)
.as_vector()
.iter()
.map(|r| r.as_u8())
.collect::<Vec<u8>>();
Ok(AccountMeta {
id: token_uuid_str,
host: (*host).to_owned(),
domain: (*domain).to_owned(),
name: (*account).to_owned(),
project: project.to_owned(),
permissions,
refresh_exp: get_exp(&refresh_token)?,
})
}
#[instrument(skip_all, err)]
pub async fn register(
&self,
password: &str,
invite_code: &str,
) -> anyhow::Result<(TOTP, String)> {
ops::account::register(self, password, invite_code).await
}
#[instrument(skip_all, err)]
pub async fn login(&self, password: &str, mfa_code: &str) -> anyhow::Result<()> {
ops::account::login(self, password, mfa_code).await
}
pub async fn get_access(
&self,
duration_s: Option<u32>,
correlation_id: Option<String>,
) -> anyhow::Result<Vec<u8>> {
ops::account::get_access(self, duration_s, correlation_id).await
}
#[instrument(skip_all, err)]
pub async fn reset_password(
&self,
old_password: &str,
mfa_code: &str,
new_password: &str,
) -> anyhow::Result<()> {
ops::account::reset_password(self, old_password, mfa_code, new_password).await
}
#[instrument(skip_all, err)]
pub async fn forgot_password(
&self,
new_password: &str,
recovery_code: &str,
) -> anyhow::Result<()> {
ops::account::forgot_password(self, new_password, recovery_code).await
}
#[instrument(skip_all, err)]
pub async fn mfa_totp_reset(&self, password: &str, mfa_code: &str) -> anyhow::Result<TOTP> {
ops::account::mfa_totp_reset(self, password, mfa_code).await
}
#[instrument(skip_all, err)]
pub async fn mfa_totp_lost(&self, password: &str, recovery_code: &str) -> anyhow::Result<TOTP> {
ops::account::mfa_totp_lost(self, password, recovery_code).await
}
#[instrument(skip_all, err)]
pub async fn recovery_codes_reset(
&self,
password: &str,
mfa_code: &str,
) -> anyhow::Result<String> {
ops::account::recovery_codes_reset(self, password, mfa_code).await
}
#[instrument(skip_all, err)]
pub async fn delete_account(&self, password: &str, mfa_code: &str) -> anyhow::Result<()> {
ops::account::delete(self, password, mfa_code).await
}
#[instrument(skip(self), err)]
pub async fn invite_api_account(
&self,
app_domain: &str,
account_name: &str,
permissions: Vec<u8>,
) -> anyhow::Result<String> {
ops::account::invite_account(self, app_domain, account_name, permissions).await
}
#[instrument(skip(self), err)]
pub async fn api_accounts_list(&self, proj_path: Option<&str>) -> anyhow::Result<String> {
ops::account::accounts_list(self, proj_path).await
}
#[instrument(skip(self), err)]
pub async fn deploy(&self, proj_path: &str) -> anyhow::Result<u16> {
ops::app::deploy(self, proj_path).await
}
#[instrument(skip(self), err)]
pub async fn kill(&self, proj_path: &str) -> anyhow::Result<()> {
ops::app::kill(self, proj_path).await
}
#[instrument(skip(self), err)]
pub async fn restart(&self, proj_path: &str) -> anyhow::Result<u16> {
ops::app::restart(self, proj_path).await
}
#[instrument(skip(self), err)]
pub async fn erase(&self, proj_path: &str) -> anyhow::Result<()> {
ops::app::erase(self, proj_path).await
}
#[instrument(skip_all, err)]
pub async fn root_get_info(&self) -> anyhow::Result<ApiInfo> {
ops::root::info(self, None).await
}
#[instrument(skip_all, err)]
pub async fn root_lock_account(&self, account: &str) -> anyhow::Result<()> {
ops::root::set_lock(self, None, account, true).await
}
#[instrument(skip_all, err)]
pub async fn root_unlock_account(&self, account: &str) -> anyhow::Result<()> {
ops::root::set_lock(self, None, account, false).await
}
#[instrument(skip_all, err)]
pub fn root_logs_local_metadata(&self) -> anyhow::Result<Vec<LogFileMetadata>> {
ops::logs::logs_local_metadata("root", false)
}
#[instrument(skip_all, err)]
pub async fn root_logs_remote_metadata(&self) -> anyhow::Result<Vec<LogFileMetadata>> {
ops::logs::logs_remote_metadata(self, "root", None, false).await
}
#[instrument(skip_all, err)]
pub async fn root_logs_sync(
&self,
force: Option<bool>,
file: Option<&str>,
) -> anyhow::Result<()> {
ops::logs::logs_sync(self, "root", force, file, false).await
}
#[instrument(skip_all, err)]
pub fn root_logs_search(
&self,
query: &str,
format: &str,
limit: &Option<usize>,
) -> anyhow::Result<String> {
ops::app::logs_search("root", query, format, limit)
}
#[instrument(skip_all, err)]
pub fn app_logs_local_metadata(&self, proj_path: &str) -> anyhow::Result<Vec<LogFileMetadata>> {
ops::logs::logs_local_metadata(proj_path, true)
}
#[instrument(skip_all, err)]
pub async fn app_logs_remote_metadata(
&self,
proj_path: &str,
) -> anyhow::Result<Vec<LogFileMetadata>> {
ops::logs::logs_remote_metadata(self, proj_path, None, true).await
}
#[instrument(skip_all, err)]
pub async fn app_logs_sync(
&self,
proj_path: &str,
force: Option<bool>,
file: Option<&str>,
) -> anyhow::Result<()> {
ops::logs::logs_sync(self, proj_path, force, file, true).await
}
#[instrument(skip_all, err)]
pub fn app_logs_search(
&self,
proj_path: &str,
query: &str,
format: &str,
limit: &Option<usize>,
) -> anyhow::Result<String> {
ops::app::logs_search(proj_path, query, format, limit)
}
#[instrument(skip(self), err)]
pub async fn app_accounts_list(&self, proj_path: &str) -> anyhow::Result<String> {
ops::app::accounts_list(self, proj_path).await
}
#[instrument(skip(self), err)]
pub async fn items_list(&self, proj_path: &str, model_name: &str) -> anyhow::Result<String> {
ops::models::items_list(self, proj_path, model_name).await
}
#[instrument(skip(self), err)]
pub async fn write(&self, proj_path: &str, asset_path: &str) -> anyhow::Result<()> {
ops::assets::write(self, proj_path, asset_path).await
}
#[instrument(skip(self), err)]
pub async fn write_all(&self, proj_path: &str) -> anyhow::Result<()> {
ops::assets::write_all(self, proj_path).await
}
#[instrument(skip(self), err)]
pub async fn upload(&self, proj_path: &str, template_name: &str) -> anyhow::Result<()> {
ops::templates::upload(self, proj_path, template_name).await
}
#[instrument(skip(self), err)]
pub async fn upload_all(&self, proj_path: &str) -> anyhow::Result<()> {
ops::templates::upload_all(self, proj_path).await
}
#[instrument(skip(self), err)]
pub async fn install(&self, proj_path: &str, action_name: &str) -> anyhow::Result<()> {
ops::actions::install(self, proj_path, action_name).await
}
#[instrument(skip(self), err)]
pub async fn install_all(&self, proj_path: &str) -> anyhow::Result<()> {
ops::actions::install_all(self, proj_path).await
}
#[instrument(skip(self), err)]
pub async fn update(&self, proj_path: &str) -> anyhow::Result<()> {
ops::content::update(self, proj_path).await
}
#[instrument(skip(self, secret), err)]
pub async fn store(&self, proj_path: &str, name: &str, secret: &[u8]) -> anyhow::Result<()> {
ops::secrets::store(self, proj_path, name, secret).await
}
fn sign_token(
client_dir: &Path,
token: &mut BytesMut,
signing_key: Option<SigningKey>,
duration_s: Option<u32>,
) -> anyhow::Result<()> {
let client_exp = match SystemTime::now()
.checked_add(Duration::from_secs(u64::from(duration_s.unwrap_or(3))))
{
Some(v) => v,
None => bail!("failed to add"),
}
.duration_since(SystemTime::UNIX_EPOCH)?
.as_secs();
token.put_u64(client_exp);
let mut signing_key = if let Some(sk) = signing_key {
sk
} else {
let signing_key_bytes: [u8; 32] =
match fs_err::read(client_dir.join("signing_key"))?.try_into() {
Ok(v) => v,
Err(_) => bail!("failed to convert"),
};
let signing_key: SigningKey = SigningKey::from_bytes(&signing_key_bytes);
signing_key
};
let signature = signing_key.sign(&token[..]);
token.put(&signature.to_bytes()[..]);
Ok(())
}
}
pub fn traverse(dir: &Path, cb: &dyn Fn(&DirEntry)) -> std::io::Result<()> {
if dir.is_dir() {
for entry in fs_err::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
traverse(&path, cb)?;
} else {
cb(&entry);
}
}
}
Ok(())
}