From d600e17bad173759eee1f1200da07156a14ba943 Mon Sep 17 00:00:00 2001 From: Vishvananda Abrams Date: Tue, 14 Jan 2025 10:33:05 -0800 Subject: [PATCH 1/2] Rename issuer to info and add more metadata This renames the `issuer` command to `info` and adds the app name and url to the data printed. Output now looks like: ``` name=local url=http://localhost:5000 iss=http://localhost:5000 sub=local ``` --- README.md | 14 ++--- example.factor-app | 3 +- src/dirs.rs | 96 +++++++++++++++++++++++++++++++++ src/env.rs | 11 +++- src/identity/mod.rs | 26 ++------- src/identity/providers/local.rs | 14 ++--- src/main.rs | 67 ++++++++++++++++------- 7 files changed, 172 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index c4b6953..a0049c2 100644 --- a/README.md +++ b/README.md @@ -172,15 +172,17 @@ If a matching token is supplied, then the header `X-Factor-Client-Id` will be set to the value of the matching client id. To reject, requests that don't match, use the flag `--reject-unknown` -`factor issuer` +`factor info` -Prints out the issuer and subject for the application. If factor is already -running it will dynamically print out the current issuer, otherwise loads the -identity provider to determine the issuer. +Prints out info for the current application. If factor is already running it +will dynamically print out the current data, otherwise loads the identity +provider to determine the values. The output will be something like: ``` -issuer=http://localhost:5000 -subject=local +name=local +url=http://localhost:5000 +iss=http://localhost:5000 +sub=local ``` diff --git a/example.factor-app b/example.factor-app index 286d74e..234efd7 100644 --- a/example.factor-app +++ b/example.factor-app @@ -1,10 +1,11 @@ app = "local" path = "." +# if the app is behind a gateway, specify the url here +# url = "https://example.com" [id] name = "local" provider = "local" -iss = "$NGROK_URL" # if not using ngrok, set issuer to public hostname of app secret = "..." # base64 encoded bootstrap secret -> openssl rand -base64 32 sub = "local" diff --git a/src/dirs.rs b/src/dirs.rs index 682b23d..3a34b4d 100644 --- a/src/dirs.rs +++ b/src/dirs.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use anyhow::Result; use directories::ProjectDirs; +use tokio::fs::write; const QUALIFIER: &str = "dev"; const ORGANIZATION: &str = "twelve-factor"; @@ -33,3 +34,98 @@ pub fn home_dir() -> Result { .ok_or_else(|| anyhow::anyhow!("Could not find home directory")) .map(|dirs| dirs.home_dir().to_path_buf()) } + +const URL_FILENAME: &str = "url"; +const ISS_FILENAME: &str = "issuer"; + +/// Get the stored url from the data directory +/// +/// # Errors +/// +/// Returns an error if: +/// - Cannot access data directory +/// - Url file doesn't exist or can't be read +pub fn get_stored_url() -> Result { + let url_path = get_data_dir()?.join(URL_FILENAME); + std::fs::read_to_string(&url_path) + .map_err(|e| anyhow::anyhow!("Failed to read url file: {}", e)) +} + +/// Write the url for the app to the data directory +/// # Errors +/// +/// Returns an error if: +/// - Cannot access data directory +/// - `write` returns an error, which can happen if: +/// - The file cannot be created or opened. +/// - There is an error writing to the file. +/// - See [`tokio::fs::write`]. +pub async fn write_url(url: String) -> Result<()> { + let url_path = get_data_dir()?.join(URL_FILENAME); + write(&url_path, url.as_bytes()).await?; + Ok(()) +} + +/// Delete the url file from the data directory +/// +/// # Errors +/// +/// Returns an error if: +/// - Cannot access data directory +/// - `remove_file` returns an error, which can happen if: +/// - The file doesn't exist. +/// - The user lacks permissions to remove the file. +/// - Some other I/O error occurred. +/// - See [`tokio::fs::remove_file`]. +pub async fn delete_url() -> Result<()> { + let data_dir = get_data_dir()?; + let url_path = data_dir.join(URL_FILENAME); + tokio::fs::remove_file(&url_path).await?; + Ok(()) +} + +/// Get the stored iss from the data directory +/// +/// # Errors +/// +/// Returns an error if: +/// - Cannot access data directory +/// - Iss file doesn't exist or can't be read +pub fn get_stored_iss() -> Result { + let iss_path = get_data_dir()?.join(ISS_FILENAME); + std::fs::read_to_string(&iss_path) + .map_err(|e| anyhow::anyhow!("Failed to read iss file: {}", e)) +} + +/// Write the iss for the app to the data directory +/// # Errors +/// +/// Returns an error if: +/// - Cannot access data directory +/// - `write` returns an error, which can happen if: +/// - The file cannot be created or opened. +/// - There is an error writing to the file. +/// - See [`tokio::fs::write`]. +pub async fn write_iss(iss: String) -> Result<()> { + let iss_path = get_data_dir()?.join(ISS_FILENAME); + write(&iss_path, iss.as_bytes()).await?; + Ok(()) +} + +/// Delete the iss file from the data directory +/// +/// # Errors +/// +/// Returns an error if: +/// - Cannot access data directory +/// - `remove_file` returns an error, which can happen if: +/// - The file doesn't exist. +/// - The user lacks permissions to remove the file. +/// - Some other I/O error occurred. +/// - See [`tokio::fs::remove_file`]. +pub async fn delete_iss() -> Result<()> { + let data_dir = get_data_dir()?; + let iss_path = data_dir.join(ISS_FILENAME); + tokio::fs::remove_file(&iss_path).await?; + Ok(()) +} diff --git a/src/env.rs b/src/env.rs index 5b65cc4..1ca92fd 100644 --- a/src/env.rs +++ b/src/env.rs @@ -9,6 +9,7 @@ use std::{ }; use anyhow::Result; +use log::error; use notify::{Event, RecursiveMode, Watcher}; use reqwest; use serde::de::DeserializeOwned; @@ -51,7 +52,10 @@ where let string_value = var_with_on_change(key, string_callback)?; - let value = serde_json::from_str(&string_value).map_err(|_| VarError::NotPresent)?; + let value = serde_json::from_str(&string_value).map_err(|e| { + error!("Failed to parse JSON: {}", e); + VarError::NotPresent + })?; Ok(value) } @@ -68,7 +72,10 @@ where T: DeserializeOwned + Send + Sync + 'static, { let string_value = var(key)?; - let value = serde_json::from_str(&string_value).map_err(|_| VarError::NotPresent)?; + let value = serde_json::from_str(&string_value).map_err(|e| { + error!("Failed to parse JSON: {}", e); + VarError::NotPresent + })?; Ok(value) } diff --git a/src/identity/mod.rs b/src/identity/mod.rs index 329a39c..13747e5 100644 --- a/src/identity/mod.rs +++ b/src/identity/mod.rs @@ -9,12 +9,10 @@ use log::{error, info, trace, warn}; pub use providers::*; use regex::Regex; use serde::{Deserialize, Serialize}; -use tokio::{fs::write, sync::watch, time::interval}; +use tokio::{sync::watch, time::interval}; use super::{dirs, env, server::Service}; -const ISSUER_FILENAME: &str = "issuer"; - macro_rules! identity_providers { ($($variant:ident),*) => { #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, strum_macros::Display)] @@ -76,7 +74,6 @@ pub struct IdentitySyncService { path: PathBuf, audience: String, provider: Arc, - issuer_path: PathBuf, } impl IdentitySyncService { @@ -101,7 +98,6 @@ impl IdentitySyncService { let key = format!("{}_{}", target_id_safe, "IDENTITY"); let filename = format!("{target_id}.token"); let path = PathBuf::from(path).join(filename); - let issuer_path = dirs::get_data_dir()?.join(ISSUER_FILENAME); // make sure that we empty any existing old identity env::set_var_file(&key, "", &path)?; @@ -110,15 +106,13 @@ impl IdentitySyncService { path, audience: audience.to_string(), provider, - issuer_path, }; Ok(service) } async fn write_issuer(&self) -> Result<()> { let issuer = self.provider.get_iss().await?; - write(&self.issuer_path, issuer.as_bytes()).await?; - Ok(()) + dirs::write_iss(issuer).await } } @@ -142,8 +136,8 @@ impl Service for IdentitySyncService { tokio::select! { _ = shutdown.changed() => { // Clean up issuer file on shutdown - if let Err(e) = std::fs::remove_file(&self.issuer_path) { - warn!("Failed to remove issuer file: {}", e); + if let Err(e) = dirs::delete_iss().await { + error!("Failed to delete issuer file: {}", e); } break; } @@ -225,15 +219,3 @@ pub fn get_claims(jwt: &str) -> Result { Ok(claims) } - -/// Get the stored issuer from the data directory -/// -/// # Errors -/// -/// Returns an error if: -/// - Cannot access data directory -/// - Issuer file doesn't exist or can't be read -pub fn get_stored_issuer() -> Result { - let issuer_path = dirs::get_data_dir()?.join(ISSUER_FILENAME); - std::fs::read_to_string(&issuer_path).map_err(|e| anyhow!("Failed to read issuer file: {}", e)) -} diff --git a/src/identity/providers/local.rs b/src/identity/providers/local.rs index e8e54f6..8b88811 100644 --- a/src/identity/providers/local.rs +++ b/src/identity/providers/local.rs @@ -14,13 +14,12 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - env, + dirs, identity::{IdentityProvider, ProviderConfig}, }; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { - pub iss: String, pub secret: String, pub sub: Option, } @@ -106,7 +105,8 @@ impl Provider { /// would indicate internal key corruption) /// - The PEM-encoded key cannot be converted to JWT format pub fn new(config: Config) -> Result { - let private_key = generate_rsa_key_from_secret(&config.secret)?; + let private_key = generate_rsa_key_from_secret(&config.secret) + .map_err(|e| anyhow!("Invalid secret: {}", e))?; let private_key_pem = private_key.to_pkcs1_pem(LineEnding::CR)?.to_string(); let key = EncodingKey::from_rsa_pem(private_key_pem.as_bytes())?; let public_key = private_key.to_public_key(); @@ -133,8 +133,7 @@ struct Claims { #[async_trait] impl IdentityProvider for Provider { async fn get_iss(&self) -> Result { - // if iss is still an env var, expand it now - env::expand(&self.config.iss) + dirs::get_stored_url() } async fn get_jwks(&self) -> Result> { let json = serde_json::to_string_pretty(&self.jwks)?; @@ -156,11 +155,8 @@ impl IdentityProvider for Provider { async fn get_token(&self, audience: &str) -> Result { let sub = self.config.sub.as_ref().context("Sub not configured")?; - // if iss is still an env var, expand it now - let iss = env::expand(&self.config.iss)?; - let claims = Claims { - iss, + iss: self.get_iss().await?, sub: sub.to_string(), aud: audience.to_string(), exp: (SystemTime::now() + std::time::Duration::from_secs(1800)) // Expiration time (30 minutes from now) diff --git a/src/main.rs b/src/main.rs index 0b08148..4079a6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use dotenvy::dotenv; use factor::{ child, dirs, env, identity, identity::IdProvider, ngrok, proxy, proxy::IncomingIdentity, }; -use log::{debug, error, info, trace, warn}; +use log::{debug, info, trace, warn}; use notify::{Event, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use tokio::{ @@ -49,6 +49,8 @@ struct AppConfig { app: String, #[serde(default = "default_app_path")] path: String, + #[serde(default)] + url: String, id: Option, ngrok: Option, } @@ -144,6 +146,10 @@ enum Commands { #[arg(long, default_value = ".")] path: String, + /// Url where app is available + #[arg(long, default_value = "")] + url: String, + /// Identity provider to use #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(IdProvider::variants()))] id_provider: Option, @@ -202,8 +208,8 @@ enum Commands { #[arg(required = true)] command: Vec, }, - /// Print issuer and subject from identity token - Issuer, + /// Print info about the current factor app + Info, } fn main() -> Result<(), anyhow::Error> { @@ -242,12 +248,14 @@ fn main() -> Result<(), anyhow::Error> { app, id_provider, path, + url, id_targets, } => { handle_create( app, id_provider.as_ref(), path, + url, id_targets, &global_config, &cli.config, @@ -288,14 +296,22 @@ fn main() -> Result<(), anyhow::Error> { &app_config, )?; } - Commands::Issuer => { - handle_issuer(&app_config)?; + Commands::Info => { + handle_info(&app_config)?; } } Ok(()) } -fn handle_issuer(app_config: &AppConfig) -> Result<(), anyhow::Error> { +fn handle_info(app_config: &AppConfig) -> Result<(), anyhow::Error> { + let name = &app_config.app; + let url = if let Ok(url) = dirs::get_stored_url() { + url + } else { + info!("Could not get stored url, falling back to default"); + "http://localhost:5000".to_string() + }; + // Get the provider from app config let id_config = app_config .id @@ -307,21 +323,25 @@ fn handle_issuer(app_config: &AppConfig) -> Result<(), anyhow::Error> { let rt = Runtime::new()?; // Get issuer from stored value, or fallback to getting it from a token - let issuer = if let Ok(iss) = identity::get_stored_issuer() { + let issuer = if let Ok(iss) = dirs::get_stored_iss() { iss } else { info!("Could not get stored issuer, falling back to provider"); - rt.block_on(provider.get_iss())? + match rt.block_on(provider.get_iss()) { + Ok(iss) => iss, + Err(_) => url.clone(), + } }; - // Get subject directly let subject = rt.block_on(provider.get_sub())?; // Print in key=value format let stdout = std::io::stdout(); let mut handle = stdout.lock(); - writeln!(handle, "issuer={issuer}")?; - writeln!(handle, "subject={subject}")?; + writeln!(handle, "name={name}")?; + writeln!(handle, "url={url}")?; + writeln!(handle, "iss={issuer}")?; + writeln!(handle, "sub={subject}")?; Ok(()) } @@ -330,6 +350,7 @@ fn handle_create( app: &String, id_provider: Option<&String>, path: &String, + url: &String, id_targets: &[(String, String)], global_config: &GlobalConfig, config_path: &String, @@ -381,6 +402,7 @@ fn handle_create( let app_config = AppConfig { app: app.to_string(), path: path.to_string(), + url: url.to_string(), ngrok: global_config.ngrok.clone(), id: Some(AppIdConfig { name: provider_name.to_string(), @@ -521,7 +543,7 @@ fn setup_env_watcher() -> Result<(watch::Sender, watch::Receiver), a } })?; if let Err(e) = fswatcher.watch(Path::new(".env"), RecursiveMode::NonRecursive) { - error!("Error watching .env: {e}"); + warn!("Error watching .env: {e}"); } else { // forget the watcher so it continues to function std::mem::forget(fswatcher); @@ -544,12 +566,20 @@ fn handle_run( let runtime: Arc = Runtime::new()?.into(); let ngrok_url = maybe_run_background_ngrok(&runtime, app_config, port, ipv6); - if let Some(url) = ngrok_url { + if let Some(url) = ngrok_url.as_ref() { env::set_var("NGROK_URL", url); + } + let url = if !app_config.url.is_empty() { + app_config.url.clone() + } else if let Some(ngrok_url) = ngrok_url { + ngrok_url + } else { + format!("http://localhost:{port}") }; - let (file_tx, mut file_rx) = setup_env_watcher()?; + let (_file_tx, mut file_rx) = setup_env_watcher()?; runtime.block_on(async { + dirs::write_url(url).await?; let mut should_exit = false; while !should_exit { let mut server = factor::server::Server::new_from_runtime(runtime.clone()); @@ -565,7 +595,10 @@ fn handle_run( server.run(); trace!("Waiting for changes"); - wait_for_signals(&mut server, &file_tx, &mut file_rx, &mut should_exit).await?; + wait_for_signals(&mut server, &mut file_rx, &mut should_exit).await?; + } + if let Err(e) = dirs::delete_url().await { + warn!("Failed to delete url: {}", e); } Ok(()) }) @@ -574,7 +607,6 @@ fn handle_run( #[cfg(unix)] async fn wait_for_signals( server: &mut factor::server::Server, - file_tx: &tokio::sync::watch::Sender, file_rx: &mut tokio::sync::watch::Receiver, should_exit: &mut bool, ) -> Result<(), anyhow::Error> { @@ -646,7 +678,6 @@ async fn wait_for_signals( sleep(Duration::from_millis(300)).await; info!("File change detected. Restarting services..."); server.shutdown().await; - file_tx.send(false)?; info!("All services shut down. Restarting..."); } }; @@ -657,7 +688,6 @@ async fn wait_for_signals( #[cfg(not(unix))] async fn wait_for_signals( server: &mut factor::server::Server, - file_tx: &tokio::sync::watch::Sender, file_rx: &mut tokio::sync::watch::Receiver, should_exit: &mut bool, ) -> Result<(), anyhow::Error> { @@ -682,7 +712,6 @@ async fn wait_for_signals( sleep(Duration::from_millis(300)).await; info!("File change detected. Restarting services..."); server.shutdown().await; - file_tx.send(false)?; info!("All services shut down. Restarting..."); } }; From 909bd44b629f24f1e4a25fd5043a6ebd74482c95 Mon Sep 17 00:00:00 2001 From: Vishvananda Abrams Date: Thu, 16 Jan 2025 12:44:08 -0800 Subject: [PATCH 2/2] add a --json option to info --- src/main.rs | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4079a6f..2428d70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,7 +209,11 @@ enum Commands { command: Vec, }, /// Print info about the current factor app - Info, + Info { + /// Output in JSON format + #[arg(long, short = 'j', default_value = "false")] + json: bool, + }, } fn main() -> Result<(), anyhow::Error> { @@ -296,14 +300,14 @@ fn main() -> Result<(), anyhow::Error> { &app_config, )?; } - Commands::Info => { - handle_info(&app_config)?; + Commands::Info { json } => { + handle_info(&app_config, *json)?; } } Ok(()) } -fn handle_info(app_config: &AppConfig) -> Result<(), anyhow::Error> { +fn handle_info(app_config: &AppConfig, json: bool) -> Result<(), anyhow::Error> { let name = &app_config.app; let url = if let Ok(url) = dirs::get_stored_url() { url @@ -338,10 +342,20 @@ fn handle_info(app_config: &AppConfig) -> Result<(), anyhow::Error> { // Print in key=value format let stdout = std::io::stdout(); let mut handle = stdout.lock(); - writeln!(handle, "name={name}")?; - writeln!(handle, "url={url}")?; - writeln!(handle, "iss={issuer}")?; - writeln!(handle, "sub={subject}")?; + if json { + let info = serde_json::json!({ + "name": name, + "url": url, + "iss": issuer, + "sub": subject, + }); + writeln!(handle, "{info}")?; + } else { + writeln!(handle, "name={name}")?; + writeln!(handle, "url={url}")?; + writeln!(handle, "iss={issuer}")?; + writeln!(handle, "sub={subject}")?; + } Ok(()) }