diff options
Diffstat (limited to 'angelsharkd')
-rw-r--r-- | angelsharkd/Cargo.toml | 8 | ||||
-rw-r--r-- | angelsharkd/README.md | 4 | ||||
-rw-r--r-- | angelsharkd/src/main.rs | 1 | ||||
-rw-r--r-- | angelsharkd/src/routes/extensions/README.md | 13 | ||||
-rw-r--r-- | angelsharkd/src/routes/extensions/mod.rs | 35 | ||||
-rw-r--r-- | angelsharkd/src/routes/extensions/simple_deprov.rs | 5 | ||||
-rw-r--r-- | angelsharkd/src/routes/extensions/simple_search/README.md | 79 | ||||
-rw-r--r-- | angelsharkd/src/routes/extensions/simple_search/mod.rs | 66 | ||||
-rw-r--r-- | angelsharkd/src/routes/extensions/simple_search/types.rs | 167 | ||||
-rw-r--r-- | angelsharkd/src/routes/mod.rs | 1 |
10 files changed, 375 insertions, 4 deletions
diff --git a/angelsharkd/Cargo.toml b/angelsharkd/Cargo.toml index f820528..9fdb00c 100644 --- a/angelsharkd/Cargo.toml +++ b/angelsharkd/Cargo.toml @@ -1,10 +1,14 @@ [package] name = "angelsharkd" -version = "0.1.2" +version = "0.2.0" edition = "2021" authors = ["Adam T. Carpenter <adam.carpenter@adp.com>"] description = "A HTTP interface into one or more Communication Managers" +[features] +simple_search = [] +simple_deprov = [] + [dependencies.libangelshark] path = "../libangelshark" @@ -20,7 +24,7 @@ default-features = false version = "0.4" [dependencies.env_logger] -version = "0.8" +version = "0.9" [dependencies.serde] version = "1" diff --git a/angelsharkd/README.md b/angelsharkd/README.md index 405a039..8315c74 100644 --- a/angelsharkd/README.md +++ b/angelsharkd/README.md @@ -80,8 +80,8 @@ Response Template: Here are some examples. ```json -POST / - ossi[ +POST /ossi + [ { "acms": ["CM01"], "command": "list stat 17571230000" diff --git a/angelsharkd/src/main.rs b/angelsharkd/src/main.rs index 8a8a53e..790b641 100644 --- a/angelsharkd/src/main.rs +++ b/angelsharkd/src/main.rs @@ -30,6 +30,7 @@ async fn main() -> Result<()> { let routes = routes::index() .or(routes::ossi(&config)) + .or(routes::extensions::filter(&config)) .with(if config.debug_mode || config.origin == "*" { warp::cors() .allow_any_origin() diff --git a/angelsharkd/src/routes/extensions/README.md b/angelsharkd/src/routes/extensions/README.md new file mode 100644 index 0000000..1aed1dd --- /dev/null +++ b/angelsharkd/src/routes/extensions/README.md @@ -0,0 +1,13 @@ +# Angelshark Daemon Extensions + +This module aims to provide a simple way of extending Angelshark's basic +functionality (running commands on the ACM) with additional HTTP endpoints that +run one or more commands to achieve a basic business task. + +This functionality may not be desirable for all end users, and therefore is +completely opt-in with feature flags. For example, at compile time, you can add +`--features simple_search` to enable a given extension called `simple_search`. + +To add additional features, read `mod.rs` and `Cargo.toml` for `angelsharkd` to +see how to conditionally incorporate your own warp HTTP filters into the +project. diff --git a/angelsharkd/src/routes/extensions/mod.rs b/angelsharkd/src/routes/extensions/mod.rs new file mode 100644 index 0000000..7f217d6 --- /dev/null +++ b/angelsharkd/src/routes/extensions/mod.rs @@ -0,0 +1,35 @@ +use crate::config::Config; +use warp::{path, Filter, Rejection, Reply}; + +#[cfg(feature = "simple_deprov")] +mod simple_deprov; +#[cfg(feature = "simple_search")] +mod simple_search; + +/// The extension filter; consists of all compiled optional Angelshark extension +/// filters combined under `/extensions`. +pub fn filter(config: &Config) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { + // Note: this next line deals with the common edge case of having no + // extensions loaded with feature flags. It ensures that the the type + // checking is right when the return `.and()` is called below. + let filters = default().or(default()); + + // Block to enable simple_search extension feature. Instantiates a + // searchable haystack and configures filters to handle search requests. + #[cfg(feature = "simple_search")] + let haystack = simple_search::Haystack::new(config.runner.clone()); + #[cfg(feature = "simple_search")] + let filters = filters + .or(simple_search::search_filter(haystack.clone())) + .or(simple_search::refresh_filter(haystack)); + + #[cfg(feature = "simple_deprov")] + let filters = filters.or(simple_deprov::filter()); + + path("extensions").and(filters) +} + +/// The default, informational extension route. +fn default() -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { + warp::path::end().map(|| "Angelshark extension route index. Enable extensions with feature switches and access them at `/extensions/<feature>`.") +} diff --git a/angelsharkd/src/routes/extensions/simple_deprov.rs b/angelsharkd/src/routes/extensions/simple_deprov.rs new file mode 100644 index 0000000..2b5ad40 --- /dev/null +++ b/angelsharkd/src/routes/extensions/simple_deprov.rs @@ -0,0 +1,5 @@ +use warp::{Filter, Rejection, Reply}; + +pub fn filter() -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { + warp::path("deprov").map(|| -> &str { todo!() }) // TODO: +} diff --git a/angelsharkd/src/routes/extensions/simple_search/README.md b/angelsharkd/src/routes/extensions/simple_search/README.md new file mode 100644 index 0000000..c0df17a --- /dev/null +++ b/angelsharkd/src/routes/extensions/simple_search/README.md @@ -0,0 +1,79 @@ +# Daemon Extension `simple_search` + +This extension implements fast and simple extension searching for clients. Data +to be searched is downloaded from configured ACMs as a "haystack." Clients pass +a list of one or more "needles" to search for. Haystack entries matching all +provided needles are returned. + +The haystack starts out empty. To trigger a download, use the `refresh` +endpoint. Refreshes happen in the background, so clients always receive an +immediate response. This means that clients will be searching on "stale" data +(or on the first run, no data) until the refresh completes. + +There is no built-in scheduler for periodic haystack refreshes. It is +recommended to use an external scheduler such as `cron(8)`. + +## Getting Started + +To enable this feature, compile `angelsharkd` with the `simple_search` flag: + +```sh +cargo build --bin angelsharkd --features simple_search ... +``` + +This extension expects a single environment variable, +`ANGELSHARKD_EXT_SEARCH_ACMS`, to be set at runtime. This var should be a list +of ACM names from your `asa.cfg`. For example, if your `asa.cfg` is configured +for ACMs named 01, 02, and 03, and you want to search over 01 and 03, run +`angelsharkd` like this: + +``` +ANGELSHARKD_ADDR='127.0.0.1:3000' ANGELSHARKD_EXT_SEARCH_ACMS='01 03' angelsharkd +``` + +## `GET /extensions/search/refresh` Refresh Haystack + +`GET /extensions/search/refresh` + +``` +200 OK text/plain +Refresh scheduled. +``` + +## `POST /extensions/search` Search Haystack + +The return type is the entire list of fields from the `list extension-type` +command, the ROOM field from the `list station` command, and the configured name +of the ACM the entry was found on. + +```json +POST /extensions/search +[ + "carpenat" +] +``` + +```json +200 OK +[ + [ + "17571230000", + "station-user", + "5", + "1", + "1005", + "Carpenter, Adam", + "2", + "", + "CM01", + "carpenat" + ], + ... +] +``` + +## Logging + +The `refresh` endpoint always returns successfully. Any errors encountered +during the refresh are logged as `ERROR`. Successful completed refreshes are +logged as `INFO`. diff --git a/angelsharkd/src/routes/extensions/simple_search/mod.rs b/angelsharkd/src/routes/extensions/simple_search/mod.rs new file mode 100644 index 0000000..5b5dc8e --- /dev/null +++ b/angelsharkd/src/routes/extensions/simple_search/mod.rs @@ -0,0 +1,66 @@ +use log::{error, info}; +use std::convert::Infallible; +pub use types::Haystack; +use types::*; +use warp::{ + body::{content_length_limit, json}, + get, + hyper::{header, StatusCode}, + path, post, + reply::{self, with}, + Filter, Rejection, Reply, +}; + +mod types; + +/// Returns a warp filter to handle HTTP POSTs for searching the haystack. +pub fn search_filter( + haystack: Haystack, +) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { + path("search") + .and(post()) + .and(content_length_limit(1024 * 16)) + .and(json()) + .and_then(move |terms: Needles| search(haystack.to_owned(), terms)) + .with(with::header(header::PRAGMA, "no-cache")) + .with(with::header(header::CACHE_CONTROL, "no-store, max-age=0")) + .with(with::header(header::X_FRAME_OPTIONS, "DENY")) +} + +/// Returns a warp filter to handle HTTP GETs for refreshing the haystack. +pub fn refresh_filter( + haystack: Haystack, +) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { + path!("search" / "refresh") + .and(get()) + .and_then(move || refresh(haystack.to_owned())) +} + +/// Runs the search request to find all needles in the haystack and converts the +/// results into a reply. +async fn search(haystack: Haystack, needles: Needles) -> Result<impl Reply, Infallible> { + // Ok(haystack.search(Vec::new())?) + // if let Ok(matches = haystack.search(needle); + match haystack.search(needles) { + Ok(matches) => Ok(reply::with_status(reply::json(&matches), StatusCode::OK)), + Err(e) => Ok(reply::with_status( + reply::json(&e.to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + )), + } +} + +/// Immediately returns. Spawns an asynchronous task to complete the haystack +/// refresh in the background. +async fn refresh(haystack: Haystack) -> Result<impl Reply, Infallible> { + // Run refresh as a background task and immediately return. + tokio::spawn(async move { + if let Err(e) = haystack.refresh() { + error!("{}", e.to_string()); // TODO: use logger + } else { + info!("Search haystack refreshed."); + } + }); + + Ok("Refresh scheduled") +} diff --git a/angelsharkd/src/routes/extensions/simple_search/types.rs b/angelsharkd/src/routes/extensions/simple_search/types.rs new file mode 100644 index 0000000..72156e5 --- /dev/null +++ b/angelsharkd/src/routes/extensions/simple_search/types.rs @@ -0,0 +1,167 @@ +use anyhow::{anyhow, Context, Error}; +use libangelshark::{AcmRunner, Message, ParallelIterator}; +use log::error; +use std::{ + collections::HashMap, + env, + sync::{Arc, Mutex}, +}; + +const ANGELSHARKD_EXT_SEARCH_ACMS: &str = "ANGELSHARKD_EXT_SEARCH_ACMS"; +const OSSI_STAT_NUMBER_FIELD: &str = "8005ff00"; +const OSSI_STAT_ROOM_FIELD: &str = "0031ff00"; +const OSSI_LIST_STAT_CMD: &str = "list station"; +const OSSI_LIST_EXT_CMD: &str = "list extension-type"; + +/// Collection of search terms +pub type Needles = Vec<String>; + +/// Collection of ACM extension types with ROOMs (if applicable) and ACM names +type HaystackEntries = Vec<Vec<String>>; + +/// Represents a searchable, refreshable collection of ACM extension data. +#[derive(Clone)] +pub struct Haystack { + entries: Arc<Mutex<HaystackEntries>>, + runner: AcmRunner, +} + +impl Haystack { + pub fn new(runner: AcmRunner) -> Self { + Self { + entries: Arc::new(Mutex::new(Vec::new())), + runner, + } + } + + /// Searches for haystack entries that contain all given needles and returns them. + pub fn search(&self, needles: Needles) -> Result<HaystackEntries, Error> { + let needles: Vec<_> = needles.iter().map(|n| n.to_lowercase()).collect(); + + let entries = self + .entries + .lock() + .map_err(|e| anyhow!(e.to_string())) + .with_context(|| "Failed to get haystack inner data lock.")?; + + let matches = entries + .iter() + .filter(|entry| { + let entry_str = entry.join("").to_lowercase(); + needles + .iter() + .all(|needle| entry_str.contains(needle.as_str())) + }) + .cloned() + .collect(); + Ok(matches) + } + + /// Refreshes the haystack data by running relevant commands on a runner, + /// parsing the results, and updating the entries field with the fresh data. + /// TODO: Do we want simultaneous refreshes to be possible? + /// TODO: The entry generation could probably be simplified and the number of clones reduced. + pub fn refresh(&self) -> Result<(), Error> { + let mut runner = self.runner.to_owned(); + + // Queue jobs in ACM runner + let configured_acms = env::var(ANGELSHARKD_EXT_SEARCH_ACMS).with_context(|| { + format!( + "{} var missing. Cannot refresh haystack.", + ANGELSHARKD_EXT_SEARCH_ACMS + ) + })?; + + // Generate jobs and queue on runner. + for acm in configured_acms.split_whitespace() { + runner + .queue_input(acm, &Message::new(OSSI_LIST_EXT_CMD)) + .queue_input( + acm, + &Message { + command: String::from(OSSI_LIST_STAT_CMD), + fields: Some(vec![ + String::from(OSSI_STAT_NUMBER_FIELD), + String::from(OSSI_STAT_ROOM_FIELD), + ]), + datas: None, + error: None, + }, + ); + } + + // Run jobs and collect output. Filter out uneeded commands, combine errors. + let output: Result<Vec<(String, Vec<Message>)>, Error> = runner + .run() + .map(|(name, output)| { + let output: Vec<Message> = output? + .into_iter() + .filter(|m| m.command != "logoff") + .collect(); + Ok((name, output)) + }) + .collect(); + let output = output.with_context(|| "Failed to run refresh commands on ACM(s).")?; + + // Log any ACM errors encountered + for error in output + .iter() + .map(|(_, messages)| messages) + .flatten() + .filter_map(|m| m.error.as_ref()) + { + error!("ACM error: {}", error); + } + + // Build a map of station number-to-rooms + let rooms: HashMap<String, String> = output + .iter() + .map(|(_, messages)| { + messages + .iter() + .filter(|message| message.command == OSSI_LIST_STAT_CMD) + .filter_map(|message| message.datas.to_owned()) + .flatten() + .filter_map(|stat| Some((stat.get(0)?.to_owned(), stat.get(1)?.to_owned()))) + }) + .flatten() + .collect(); + + // Build the haystack from the room map and extension-type output + let haystack: HaystackEntries = output + .into_iter() + .map(|(acm_name, messages)| { + let rooms = &rooms; + let acm_name = format!("CM{}", acm_name); + + messages + .into_iter() + .filter(|message| message.command == OSSI_LIST_EXT_CMD) + .filter_map(|message| message.datas) + .flatten() + .map(move |mut extension| { + let room = extension + .get(0) + .map(|num| rooms.get(num)) + .flatten() + .map(|room| room.to_owned()) + .unwrap_or_else(String::new); + + extension.push(acm_name.to_owned()); + extension.push(room); + extension + }) + }) + .flatten() + .collect(); + + // Overwrite shared haystack entries with new data. + let mut lock = self + .entries + .lock() + .map_err(|e| anyhow!(e.to_string())) + .with_context(|| "Failed to get haystack inner data lock.")?; + *lock = haystack; + Ok(()) + } +} diff --git a/angelsharkd/src/routes/mod.rs b/angelsharkd/src/routes/mod.rs index 40d024d..cee0657 100644 --- a/angelsharkd/src/routes/mod.rs +++ b/angelsharkd/src/routes/mod.rs @@ -13,6 +13,7 @@ use warp::{ }; mod dtos; +pub mod extensions; /// GET / -> Name and version # of app. pub fn index() -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { |