diff --git a/.idea/trifid.iml b/.idea/trifid.iml index 2b729a4..b4614b5 100644 --- a/.idea/trifid.iml +++ b/.idea/trifid.iml @@ -8,6 +8,7 @@ + diff --git a/Cargo.lock b/Cargo.lock index 6b489f0..c01a55a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -781,6 +781,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "checked_int_cast" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17cc5e6b5ab06331c33589842070416baa137e8b0eb912b008cfd4a78ada7919" + [[package]] name = "chrono" version = "0.4.24" @@ -993,6 +999,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot 0.12.1", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -2347,6 +2378,25 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "qr2term" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2a1e77b5cd714b04247ad912b7c8fe9a1fe1d58425048249def91bcf690e4c" +dependencies = [ + "crossterm", + "qrcode", +] + +[[package]] +name = "qrcode" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d2f1455f3630c6e5107b4f2b94e74d76dea80736de0981fd27644216cff57f" +dependencies = [ + "checked_int_cast", +] + [[package]] name = "quick-protobuf" version = "0.8.1" @@ -2972,6 +3022,27 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732768f1176d21d09e076c23a93123d40bba92d50c4058da34d45c8de8e682b9" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.1" @@ -3242,6 +3313,20 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d" +[[package]] +name = "tfcli" +version = "0.1.0" +dependencies = [ + "clap 4.2.7", + "dirs 5.0.1", + "ipnet", + "qr2term", + "reqwest", + "serde", + "tokio", + "url", +] + [[package]] name = "tfclient" version = "0.1.8" @@ -3371,9 +3456,21 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.48.0", ] +[[package]] +name = "tokio-macros" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.16", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 9b3de10..17eb7c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,5 +5,6 @@ members = [ "trifid-api/trifid_api_entities", "tfclient", "trifid-pki", - "dnapi-rs" + "dnapi-rs", + "tfcli" ] \ No newline at end of file diff --git a/tfcli/Cargo.toml b/tfcli/Cargo.toml new file mode 100644 index 0000000..a6ef972 --- /dev/null +++ b/tfcli/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tfcli" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = { version = "4", features = ["derive", "env"] } +url = "2.3.1" +reqwest = { version = "0.11.17", features = ["json"] } +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", features = ["full"] } +dirs = "5.0.1" +qr2term = "0.3.1" +ipnet = "2.7.2" \ No newline at end of file diff --git a/tfcli/src/account.rs b/tfcli/src/account.rs new file mode 100644 index 0000000..7a7724f --- /dev/null +++ b/tfcli/src/account.rs @@ -0,0 +1,239 @@ +use std::error::Error; +use std::fs; +use serde::{Deserialize, Serialize}; +use url::Url; +use crate::AccountCommands; +use crate::api::APIErrorResponse; + +pub async fn account_main(command: AccountCommands, server: Url) -> Result<(), Box> { + match command { + AccountCommands::Create { email } => create_account(email, server).await, + AccountCommands::MagicLink { magic_link_token } => auth_magic_link(magic_link_token, server).await, + AccountCommands::MfaSetup {} => create_mfa_authenticator(server).await, + AccountCommands::MfaSetupFinish {code, token} => finish_mfa_authenticator(token, code, server).await, + AccountCommands::Mfa {code} => mfa_auth(code, server).await + } +} + +#[derive(Serialize)] +pub struct CreateAccountBody { + pub email: String +} + +pub async fn create_account(email: String, server: Url) -> Result<(), Box> { + let client = reqwest::Client::new(); + let res = client.post(server.join("/v1/signup")?).json(&CreateAccountBody { email }).send().await?; + + if res.status().is_success() { + println!("Account created successfully, check your email."); + println!("Finish creating your account with 'tfcli account magic-link --magic-link-token [magic-link-token]'."); + } else { + + let resp: APIErrorResponse = res.json().await?; + + eprintln!("[error] Error creating account: {} {}", resp.errors[0].code, resp.errors[0].message); + + std::process::exit(1); + } + + Ok(()) +} + +#[derive(Serialize)] +pub struct MagicLinkBody { + #[serde(rename = "magicLinkToken")] + pub magic_link_token: String +} + +#[derive(Deserialize)] +pub struct MagicLinkSuccess { + pub data: MagicLinkSuccessBody +} +#[derive(Deserialize)] +pub struct MagicLinkSuccessBody { + #[serde(rename = "sessionToken")] + pub session_token: String +} + +pub async fn auth_magic_link(magic_token: String, server: Url) -> Result<(), Box> { + let client = reqwest::Client::new(); + let res = client.post(server.join("/v1/auth/verify-magic-link")?).json(&MagicLinkBody { magic_link_token: magic_token }).send().await?; + + if res.status().is_success() { + let resp: MagicLinkSuccess = res.json().await?; + + let token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); + + fs::write(&token_store, resp.data.session_token)?; + + println!("Session token saved to {}", token_store.display()); + println!("You will likely need to authorize with 2fa, run 'tfcli account mfa --code=[code]' to finish logging in, or 'tfcli account mfa-setup' to setup 2FA."); + } else { + let resp: APIErrorResponse = res.json().await?; + + eprintln!("[error] Error getting session token: {} {}", resp.errors[0].code, resp.errors[0].message); + + std::process::exit(1); + } + + Ok(()) +} + +#[derive(Serialize, Deserialize)] +pub struct WhoamiResponse { + pub data: WhoamiResponseData, + pub metadata: WhoamiResponseMetadata, +} + +#[derive(Serialize, Deserialize)] +pub struct WhoamiResponseData { + #[serde(rename = "actorType")] + pub actor_type: String, + pub actor: WhoamiResponseDataActor, +} + +#[derive(Serialize, Deserialize)] +pub struct WhoamiResponseDataActor { + pub id: String, + #[serde(rename = "organizationID")] + pub organization_id: Option, + pub email: String, + #[serde(rename = "createdAt")] + pub created_at: String, + #[serde(rename = "hasTOTPAuthenticator")] + pub has_totp_authenticator: bool, +} + +#[derive(Serialize, Deserialize)] +pub struct WhoamiResponseMetadata {} + +#[derive(Deserialize)] +pub struct CreateMfaResponse { + pub data: CreateMfaResponseData +} +#[derive(Deserialize)] +pub struct CreateMfaResponseData { + #[serde(rename = "totpToken")] + pub totp_token: String, + pub secret: String, + pub url: String +} + +pub async fn create_mfa_authenticator(server: Url) -> Result<(), Box> { + let client = reqwest::Client::new(); + + // load session token + let token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); + let session_token = fs::read_to_string(&token_store)?; + + // do we have mfa already? + let whoami: WhoamiResponse = client.get(server.join("/v2/whoami")?).bearer_auth(&session_token).send().await?.json().await?; + + if whoami.data.actor.has_totp_authenticator { + eprintln!("[error] user already has a totp authenticator, cannot add another one"); + std::process::exit(1); + } + + let res = client.post(server.join("/v1/totp-authenticators")?).bearer_auth(&session_token).body("{}").send().await?; + + if res.status().is_success() { + let resp: CreateMfaResponse = res.json().await?; + + println!("--- TOTP SETUP INFORMATION ---"); + println!("To complete setup, you'll need a TOTP-compatible app, such as Google Authenticator or Authy."); + println!("Scan the following code with your authenticator app:"); + qr2term::print_qr(resp.data.url)?; + println!("Alternatively, enter the following secret into your authenticator app: '{}'", resp.data.secret); + println!("Once done, enable TOTP by running the following command with the code shown on your authenticator app:"); + println!("tfcli account mfa-setup-finish --token {} --code [CODE IN AUTHENTICATOR]", resp.data.totp_token); + println!("This code will expire in 10 minutes."); + } else { + let resp: APIErrorResponse = res.json().await?; + + eprintln!("[error] Error adding MFA to account: {} {}", resp.errors[0].code, resp.errors[0].message); + + std::process::exit(1); + } + + Ok(()) +} + +#[derive(Serialize)] +pub struct MfaVerifyBody { + #[serde(rename = "totpToken")] + pub totp_token: String, + pub code: String +} + +#[derive(Deserialize)] +pub struct MFASuccess { + pub data: MFASuccessBody +} +#[derive(Deserialize)] +pub struct MFASuccessBody { + #[serde(rename = "authToken")] + pub auth_token: String +} + +pub async fn finish_mfa_authenticator(token: String, code: String, server: Url) -> Result<(), Box> { + let client = reqwest::Client::new(); + + // load session token + let token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); + let session_token = fs::read_to_string(&token_store)?; + + let res = client.post(server.join("/v1/verify-totp-authenticators")?).json(&MfaVerifyBody {totp_token: token, code }).bearer_auth(session_token).send().await?; + + if res.status().is_success() { + let resp: MFASuccess = res.json().await?; + + let token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); + + fs::write(&token_store, resp.data.auth_token)?; + + println!("Auth token saved to {}", token_store.display()); + println!("You are now fully logged-in. This 2fa token will expire in about 10 minutes, at which point you will need to authenticate again with 'tfcli account mfa'"); + } else { + let resp: APIErrorResponse = res.json().await?; + + eprintln!("[error] Error verifying MFA code: {} {}", resp.errors[0].code, resp.errors[0].message); + + std::process::exit(1); + } + + Ok(()) +} + +#[derive(Serialize)] +pub struct MfaAuthBody { + pub code: String +} + +pub async fn mfa_auth(code: String, server: Url) -> Result<(), Box> { + let client = reqwest::Client::new(); + + // load session token + let token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); + let session_token = fs::read_to_string(&token_store)?; + + let res = client.post(server.join("/v1/auth/totp")?).json(&MfaAuthBody { code }).bearer_auth(session_token).send().await?; + + if res.status().is_success() { + let resp: MFASuccess = res.json().await?; + + let token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); + + fs::write(&token_store, resp.data.auth_token)?; + + println!("Auth token saved to {}", token_store.display()); + println!("You are now fully logged-in. This 2fa token will expire in about 10 minutes, at which point you will need to authenticate again with 'tfcli account mfa'"); + } else { + let resp: APIErrorResponse = res.json().await?; + + eprintln!("[error] Error verifying MFA code: {} {}", resp.errors[0].code, resp.errors[0].message); + + std::process::exit(1); + } + + Ok(()) +} diff --git a/tfcli/src/api.rs b/tfcli/src/api.rs new file mode 100644 index 0000000..5f9f913 --- /dev/null +++ b/tfcli/src/api.rs @@ -0,0 +1,12 @@ +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct APIErrorResponse { + pub errors: Vec +} +#[derive(Deserialize)] +pub struct APIError { + pub code: String, + pub message: String, + pub path: Option +} \ No newline at end of file diff --git a/tfcli/src/main.rs b/tfcli/src/main.rs new file mode 100644 index 0000000..c1d2c86 --- /dev/null +++ b/tfcli/src/main.rs @@ -0,0 +1,137 @@ +use std::error::Error; +use std::fs; +use clap::{Parser, Subcommand}; +use ipnet::Ipv4Net; +use url::Url; +use crate::account::account_main; +use crate::network::network_main; +use crate::org::org_main; + +pub mod account; +pub mod api; +pub mod network; +pub mod org; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +#[command(propagate_version = true)] +pub struct Args { + #[command(subcommand)] + command: Commands, + #[clap(short, long, env = "TFCLI_SERVER")] + /// The base URL of your trifid-api instance. Defaults to the value in $XDG_CONFIG_HOME/tfcli-server-url.conf or the TFCLI_SERVER environment variable. + server: Option +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Manage your trifid account + Account { + #[command(subcommand)] + command: AccountCommands + }, + /// Manage the networks associated with your trifid account + Network { + #[command(subcommand)] + command: NetworkCommands + }, + /// Manage the organization associated with your trifid account + Org { + #[command(subcommand)] + command: OrgCommands + } +} + +#[derive(Subcommand, Debug)] +pub enum AccountCommands { + /// Create a new trifid account on the designated server + Create { + #[clap(short, long)] + email: String + }, + /// Log in to your account with a magic-link token acquired via email or the trifid-api logs. + MagicLink { + #[clap(short, long)] + magic_link_token: String + }, + /// Create a new TOTP authenticator on this account to enable authorizing with 2fa and performing all management tasks. + MfaSetup {}, + /// Finish creating a new TOTP authenticator by inputting the code shown on your authenticator app. + MfaSetupFinish { + #[clap(short, long)] + code: String, + #[clap(short, long)] + token: String + }, + /// Create a new short-lived authentication token by inputting the code shown on your authenticator app. + Mfa { + #[clap(short, long)] + code: String + } +} + +#[derive(Subcommand, Debug)] +pub enum NetworkCommands { + /// List all networks associated with your trifid account. + List {}, + /// Lookup a specific network by ID. + Lookup { + #[clap(short, long)] + id: String + } +} + +#[derive(Subcommand, Debug)] +pub enum OrgCommands { + /// Create an organization on your trifid-api server. NOTE: This command ONLY works on trifid-api servers. It will NOT work on original DN servers. + Create { + #[clap(short, long)] + cidr: Ipv4Net + } +} + +#[tokio::main] +async fn main() { + match main2().await { + Ok(_) => (), + Err(e) => { + eprintln!("There was an error during execution: {}", e); + std::process::exit(1); + } + } +} + +async fn main2() -> Result<(), Box> { + let args = Args::parse(); + + // find server + let server; + + if let Some(srv) = args.server { + server = srv; // Environment variable or CLI takes precedence over config file + } else { + let srv_url_file = dirs::config_dir().unwrap().join("tfcli-server-url.conf"); + if !srv_url_file.exists() { + eprintln!("[error] no server URL available: '-s/--server' not provided, TFCLI_SERVER not set, and {} does not exist", srv_url_file.display()); + eprintln!("[error] please set a server url via any of these mechanisms and try again"); + std::process::exit(1); + } + let url_s = fs::read_to_string(&srv_url_file)?; + let url = match Url::parse(&url_s) { + Ok(u) => u, + Err(e) => { + eprintln!("[error] unable to parse the URL in {}", srv_url_file.display()); + eprintln!("[error] urlparse returned error '{}'", e); + eprintln!("[error] please correct the error and try again"); + std::process::exit(1); + } + }; + server = url; + } + + match args.command { + Commands::Account { command } => account_main(command, server).await, + Commands::Network { command } => network_main(command, server).await, + Commands::Org { command } => org_main(command, server).await + } +} \ No newline at end of file diff --git a/tfcli/src/network.rs b/tfcli/src/network.rs new file mode 100644 index 0000000..6a59574 --- /dev/null +++ b/tfcli/src/network.rs @@ -0,0 +1,114 @@ +use std::error::Error; +use std::fs; +use serde::Deserialize; +use url::Url; +use crate::api::APIErrorResponse; +use crate::NetworkCommands; + +pub async fn network_main(command: NetworkCommands, server: Url) -> Result<(), Box> { + match command { + NetworkCommands::List {} => list_networks(server).await, + NetworkCommands::Lookup {id} => get_network(id, server).await + } +} + +#[derive(Deserialize)] +pub struct NetworkListResp { + pub data: Vec +} + +#[derive(Deserialize)] +pub struct Network { + pub id: String, + pub cidr: String, + #[serde(rename = "organizationID")] + pub organization_id: String, + #[serde(rename = "signingCAID")] + pub signing_ca_id: String, + #[serde(rename = "createdAt")] + pub created_at: String, + #[serde(rename = "lighthousesAsRelays")] + pub lighthouses_as_relays: bool, + pub name: String +} + +pub async fn list_networks(server: Url) -> Result<(), Box> { + let client = reqwest::Client::new(); + + // load session token + let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); + let session_token = fs::read_to_string(&sess_token_store)?; + let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); + let auth_token = fs::read_to_string(&auth_token_store)?; + + let token = format!("{} {}", session_token, auth_token); + + let res = client.get(server.join("/v1/networks")?).bearer_auth(token).send().await?; + + if res.status().is_success() { + let resp: NetworkListResp = res.json().await?; + + for network in &resp.data { + println!(" Network: {}", network.id); + println!(" CIDR: {}", network.cidr); + println!(" Organization: {}", network.organization_id); + println!(" Signing CA: {}", network.signing_ca_id); + println!("Dedicated Relays: {}", !network.lighthouses_as_relays); + println!(" Name: {}", network.name); + println!(" Created At: {}", network.created_at); + println!(); + } + + if resp.data.is_empty() { + println!("No networks found"); + } + } else { + let resp: APIErrorResponse = res.json().await?; + + eprintln!("[error] Error listing networks: {} {}", resp.errors[0].code, resp.errors[0].message); + + std::process::exit(1); + } + + Ok(()) +} + +#[derive(Deserialize)] +pub struct NetworkGetResponse { + pub data: Network +} + +pub async fn get_network(id: String, server: Url) -> Result<(), Box> { + let client = reqwest::Client::new(); + + // load session token + let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); + let session_token = fs::read_to_string(&sess_token_store)?; + let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); + let auth_token = fs::read_to_string(&auth_token_store)?; + + let token = format!("{} {}", session_token, auth_token); + + let res = client.get(server.join(&format!("/v1/networks/{}", id))?).bearer_auth(token).send().await?; + + if res.status().is_success() { + let network: Network = res.json::().await?.data; + + println!(" Network: {}", network.id); + println!(" CIDR: {}", network.cidr); + println!(" Organization: {}", network.organization_id); + println!(" Signing CA: {}", network.signing_ca_id); + println!("Dedicated Relays: {}", !network.lighthouses_as_relays); + println!(" Name: {}", network.name); + println!(" Created At: {}", network.created_at); + + } else { + let resp: APIErrorResponse = res.json().await?; + + eprintln!("[error] Error listing networks: {} {}", resp.errors[0].code, resp.errors[0].message); + + std::process::exit(1); + } + + Ok(()) +} diff --git a/tfcli/src/org.rs b/tfcli/src/org.rs new file mode 100644 index 0000000..6b30dc0 --- /dev/null +++ b/tfcli/src/org.rs @@ -0,0 +1,54 @@ +use std::error::Error; +use std::fs; +use ipnet::Ipv4Net; +use serde::{Deserialize, Serialize}; +use url::Url; +use crate::OrgCommands; +use crate::api::APIErrorResponse; + +pub async fn org_main(command: OrgCommands, server: Url) -> Result<(), Box> { + match command { + OrgCommands::Create { cidr } => create_org(cidr, server).await, + } +} + +#[derive(Serialize)] +pub struct CreateOrgBody { + pub cidr: String +} + +#[derive(Deserialize)] +pub struct OrgCreateResponse { + pub organization: String, + pub ca: String, + pub network: String +} + +pub async fn create_org(cidr: Ipv4Net, server: Url) -> Result<(), Box> { + let client = reqwest::Client::new(); + + let sess_token_store = dirs::config_dir().unwrap().join("tfcli-session.token"); + let session_token = fs::read_to_string(&sess_token_store)?; + let auth_token_store = dirs::config_dir().unwrap().join("tfcli-auth.token"); + let auth_token = fs::read_to_string(&auth_token_store)?; + + let token = format!("{} {}", session_token, auth_token); + + let res = client.post(server.join("/v1/organization")?).json(&CreateOrgBody { cidr: cidr.to_string() }).bearer_auth(token).send().await?; + + if res.status().is_success() { + let resp: OrgCreateResponse = res.json().await?; + println!("Created organization successfully."); + println!("Organization: {}", resp.organization); + println!(" Signing CA: {}", resp.ca); + println!(" Network: {}", resp.network); + } else { + let resp: APIErrorResponse = res.json().await?; + + eprintln!("[error] Error creating org: {} {}", resp.errors[0].code, resp.errors[0].message); + + std::process::exit(1); + } + + Ok(()) +}