diff options
| author | Owen Jacobson <owen@grimoire.ca> | 2020-06-03 23:26:24 -0400 |
|---|---|---|
| committer | Owen Jacobson <owen@grimoire.ca> | 2020-06-03 23:26:24 -0400 |
| commit | 21de322d6cf221867ec9600e1a31777a195c7597 (patch) | |
| tree | 1aab43a7559daa252cd1412a9bc2f8e3f367fe33 /src | |
| parent | 23687bea794a18ff594658e9006457bff7b4a752 (diff) | |
| parent | ebd04ae88f28a547eabbb44e1eccfd19965be7ae (diff) | |
Replace Python (apistar) version with Rust (actix-web) version.
Diffstat (limited to 'src')
| -rw-r--r-- | src/main.rs | 38 | ||||
| -rw-r--r-- | src/things-to-check.yml | 39 | ||||
| -rw-r--r-- | src/twelve.rs | 242 | ||||
| -rw-r--r-- | src/view.rs | 214 |
4 files changed, 533 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8faebd4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,38 @@ +#![feature(proc_macro_hygiene)] + +use actix_web::{App, HttpServer}; +use std::io; +use thiserror::Error; + +mod twelve; +mod view; + +#[derive(Error, Debug)] +pub enum Error { + #[error("Unable to determine port number: {0}")] + PortError(#[from] twelve::Error), + #[error("Unable to initialize web view: {0}")] + ViewError(#[from] view::Error), + #[error("Unexpected IO error: {0}")] + IOError(#[from] io::Error), +} + +type Result = std::result::Result<(), Error>; + +#[actix_rt::main] +async fn main() -> Result { + let port = twelve::port(3000)?; + + let service = view::make_service()?; + + let app_factory = move || + App::new() + .configure(|cfg| service(cfg)); + + HttpServer::new(app_factory) + .bind(port)? + .run() + .await?; + + Ok(()) +} diff --git a/src/things-to-check.yml b/src/things-to-check.yml new file mode 100644 index 0000000..6147971 --- /dev/null +++ b/src/things-to-check.yml @@ -0,0 +1,39 @@ +--- +# Insert new items at the bottom. The index into this list is the item's +# permalink. +# +# Yes, this is HTML and no, I don't care about injection. +- permissions +- cabling +- for a full disk +- the cache +- for a version conflict +- for a duplex mismatch +- the firewall rules +- <code>resolv.conf</code> +- <code>/etc/hosts</code> +- DNS +- <code>CR/LF</code> +- setuid/setgid bits +- the default gateway +- for IP conflicts +- the logs +- the port number +- for a zonefile dot +- I/O dammit +- the mounts +- the power +- for the wrong whitespace +- if it reloaded into bad config +- IP forwarding +- the trailing slash +- the MAC address +- if the filesystem is out of inodes +- the line length +- if you're on the wrong wifi network +- if the vpn timed out +- if that's the wrong host +- the security groups +- the fucking binlogs +- the documentation +- the manpages diff --git a/src/twelve.rs b/src/twelve.rs new file mode 100644 index 0000000..ea63bf4 --- /dev/null +++ b/src/twelve.rs @@ -0,0 +1,242 @@ +//! A [twelve-factor application][1] reads [its configuration][2] from the environment. +//! +//! In many cases, "read" directly maps to the target binary inspecting the +//! OS-provided environment dictionary. This module provides supporting tools +//! for reading configuration data from the environment, via `std::env`, and +//! converting it to useful types. +//! +//! [1]: https://12factor.net/ +//! [2]: https://12factor.net/config + +use std::env; +use std::io; +use std::net::{Ipv4Addr, Ipv6Addr, IpAddr, SocketAddr, ToSocketAddrs}; +use std::num; +use thiserror::Error; + +/// Errors that can arise when reading a port number from the environment. +/// +/// For convenience when returning errors into `main`, this type can be +/// converted to std::io::Error. +#[derive(Error, Debug)] +pub enum Error { + /// PORT was set, but contained a non-unicode value that sys::env can't parse. + /// + /// For obvious reasons, this cannot be converted to a port number. Rather + /// than ignoring this error, we report it, so that misconfiguration can be + /// detected early. + #[error("PORT must be a number ({source})")] + NotUnicode { + #[from] + source: env::VarError, + }, + /// PORT was set, but was set to a non-numeric value.FnOnce + /// + /// PORT can only be used to select a port number if numeric. Rather than + /// ignoring this error, we report it, so that misconfiguration can be + /// detected early. + #[error("PORT must be a number ({source})")] + ParseError { + #[from] + source: num::ParseIntError, + } +} + +/// A listen address consisting of only a port number. +/// +/// Listening on this address will bind to both the ip4 and ip6 addresses on the +/// current host, assuming both ip4 and ip6 are supported. +#[derive(Debug, Clone)] +pub struct PortAddr { + /// When used in an std::net::SocketAddr context, this is the port number to + /// bind on. + port: u16 +} + +fn v4(port_addr: &PortAddr) -> SocketAddr { + SocketAddr::new(IpAddr::from(Ipv4Addr::UNSPECIFIED), port_addr.port) +} + +fn v6(port_addr: &PortAddr) -> SocketAddr { + SocketAddr::new(IpAddr::from(Ipv6Addr::UNSPECIFIED), port_addr.port) +} + +impl ToSocketAddrs for PortAddr { + type Iter = std::vec::IntoIter<SocketAddr>; + + fn to_socket_addrs(&self) -> io::Result<Self::Iter> { + let addrs = vec![ + v6(self), + v4(self), + ]; + + Ok(addrs.into_iter()) + } +} + +/// Query the environment for a port number. +/// +/// This will read the PORT environment variable. If set, it will use the value +/// (as a number). If it's unset, then this will use the passed `default_port` +/// number to choose the app's default port. If the PORT environment variable +/// is set but cannot be interpreted as a port number, this will return an error +/// indicating why, to assist the user in correcting their configuration. +/// # Examples +/// +/// ``` +/// use std::net::TcpListener; +/// mod twelve; +/// +/// // Listen on port 3000 (or $PORT if set), on global ip4 and ip6 interfaces. +/// let port = twelve::port(3000)?; +/// let listener = TcpListener::bind(port); +/// ``` +pub fn port(default_port: u16) -> Result<PortAddr, Error> { + let port = match env::var("PORT") { + Ok(env_port) => env_port.parse()?, + Err(e) => match e { + env::VarError::NotPresent => default_port, + env::VarError::NotUnicode(_) => return Err(Error::from(e)), + }, + }; + + Ok(PortAddr{ + port, + }) +} + +#[cfg(test)] +mod tests { + use lazy_static::lazy_static; + use quickcheck::{Arbitrary, Gen, TestResult}; + use quickcheck_macros::quickcheck; + use std::env; + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use std::sync::Mutex; + + use super::*; + + impl Arbitrary for PortAddr { + fn arbitrary<G: Gen>(g: &mut G) -> Self { + Self { + port: u16::arbitrary(g), + } + } + } + + #[quickcheck] + fn port_addr_as_socket_addr_has_v4(addr: PortAddr) -> bool { + let socket_addrs = addr.to_socket_addrs() + .unwrap() + .collect::<Vec<_>>(); + + socket_addrs.iter() + .any(|&socket_addr| socket_addr.is_ipv4()) + } + + #[quickcheck] + fn port_addr_as_socket_addr_has_v6(addr: PortAddr) -> bool { + let socket_addrs = addr.to_socket_addrs() + .unwrap() + .collect::<Vec<_>>(); + + socket_addrs.iter() + .any(|&socket_addr| socket_addr.is_ipv6()) + } + + #[quickcheck] + fn port_addr_as_socket_addr_all_have_port(addr: PortAddr) -> bool { + let socket_addrs = addr.to_socket_addrs() + .unwrap() + .collect::<Vec<_>>(); + + socket_addrs.iter() + .all(|&socket_addr| socket_addr.port() == addr.port) + } + + #[derive(Default)] + struct Runner; + + impl Runner { + // This mostly serves to keep a mutex locked for the duration of a + // function. See ENV_MUTEX, below. + fn run<T>(&self, f: impl FnOnce() -> T) -> T { + f() + } + } + + lazy_static! { + // The tests in this module manipulate a global, shared, external + // resource (the PORT environment variable). The quickcheck tool + // attempts to accelerate testing by running multiple threads, but this + // causes race conditions as test A stomps on state used by test B. + // Serialize tests through a mutex. + // + // Huge hack. + static ref ENV_MUTEX: Mutex<Runner> = Mutex::new(Runner::default()); + } + + // Runs a body with ENV_MUTEX locked. Easier to write. + fn env_locked<T>(f: impl FnOnce() -> T) -> T { + ENV_MUTEX.lock() + .unwrap() + .run(f) + } + + #[quickcheck] + fn port_preserves_numeric_values(env_port: u16, default_port: u16) -> TestResult { + if env_port == default_port { + return TestResult::discard(); + } + + env_locked(|| { + env::set_var("PORT", env_port.to_string()); + + let read_port = port(default_port) + .unwrap(); + + TestResult::from_bool(read_port.port == env_port) + }) + } + + #[quickcheck] + fn port_rejects_strings(env_port: String, default_port: u16) -> TestResult { + if env_port.contains("\x00") { + return TestResult::discard(); + } + + env_locked(|| { + env::set_var("PORT", env_port.to_string()); + + let port_result = port(default_port); + + TestResult::from_bool(port_result.is_err()) + }) + } + + #[quickcheck] + fn port_uses_default(default_port: u16) -> bool { + env_locked(|| { + env::remove_var("PORT"); + + let read_port = port(default_port) + .unwrap(); + + read_port.port == default_port + }) + } + + #[test] + fn port_non_unicode() { + let non_unicode = OsStr::from_bytes(&[0xF5u8]); + + env_locked(|| { + env::set_var("PORT", non_unicode); + + let result = port(1234); + + assert!(result.is_err()); + }) + } +}
\ No newline at end of file diff --git a/src/view.rs b/src/view.rs new file mode 100644 index 0000000..448b27c --- /dev/null +++ b/src/view.rs @@ -0,0 +1,214 @@ +//! HTML resources that help users troubleshoot problems. +//! +//! This provides a single endpoint, as well as necessary application data to +//! power it. The endpoint can be mounted on an actix_web App using the exposed +//! `make_service(…)` function. +//! +//! # Examples +//! +//! ``` +//! let service = view::make_service()?; +//! let app_factory = move || +//! App::new() +//! .configure(|cfg| service(cfg)); +//! +//! HttpServer::new(app_factory) +//! .bind(port)? +//! .run() +//! .await?; +//! ``` +//! +//! # Endpoints +//! +//! * `/` (`GET`): an HTML page suggesting one thing to check. +//! +//! Takes an optional `item` URL parameter, which must be an integer between 0 +//! and the number of options available (not provided). If `item` is provided, +//! this endpoint returns a fixed result (the `item`th suggestion in the +//! backing data); otherwise, it returns a randomly-selected result, for +//! fortuitous suggesting. +//! +//! The returned page is always `text/html` on success. Invalid `item` indices +//! will return an error. +//! +//! # Data +//! +//! This module creates a data item in the configured application, consisting of +//! a list of strings loaded from a YAML constant. The data comes from a file in +//! this module parsed at compile time — our target deployment environments +//! don't support modifying it without triggering a rebuild anyways. It's parsed +//! on startup, however, and invalid data can cause `make_service` to fail. +//! +//! When adding suggestions, add them at the end. This will ensure that existing +//! links to existing items are not invalidated or changed - the `item` +//! parameter to the `/` endpoint is a literal index into this list. + +use actix_web::{get, error, web}; +use maud::{DOCTYPE, html, Markup, PreEscaped}; +use rand::thread_rng; +use rand::seq::SliceRandom; +use serde::{Serialize, Deserialize}; +use serde_urlencoded::ser; +use std::iter; +use thiserror::Error; +use url; + +#[derive(Error, Debug)] +enum UrlError { + #[error("Unable to generate URL: {0}")] + UrlGenerationError(error::UrlGenerationError), + #[error("Unable to generate URL: {0}")] + SerializationError(#[from] ser::Error), +} + +// In actix-web-2.0.0, UrlGenerationError neither implements Error nor Fail, +// so thiserror can't automatically generate a From implementation for us. +// This isn't perfect, but it gets the thing shipped. This omission is fixed in +// actix_web 3.0.0, which is in alpha as of this writing. +impl From<error::UrlGenerationError> for UrlError { + fn from(err: error::UrlGenerationError) -> Self { + UrlError::UrlGenerationError(err) + } +} + +impl From<UrlError> for error::Error { + fn from(err: UrlError) -> Self { + error::ErrorInternalServerError(err) + } +} + +trait Urls { + fn index_url(&self, query: ItemQuery) -> Result<url::Url, UrlError>; +} + +impl Urls for web::HttpRequest { + fn index_url(&self, query: ItemQuery) -> Result<url::Url, UrlError> { + let mut url = self.url_for("index", iter::empty::<&str>())?; + + let query = serde_urlencoded::to_string(query)?; + url.set_query(Some(&query)); + + Ok(url) + } +} + +#[derive(Serialize, Deserialize, Default)] +struct ItemQuery { + item: Option<usize>, +} + +impl From<&usize> for ItemQuery { + fn from(idx: &usize) -> Self { + ItemQuery { + item: Some(*idx), + } + } +} + +fn index_view(req: impl Urls, idx: &usize, thing: &String) -> Result<Markup, UrlError> { + Ok(html! { + (DOCTYPE) + html { + head { + title { "Have you checked " (thing) "?" } + style { + (PreEscaped(" + body { + background: #dddde7; + font-color: #888; + font-family: Helvetica, sans-serif; + display: flex; + flex-direction: column; + justify-content: center; + height: 100vh; + margin: 0; + } + + section { + width: 600px; + margin: auto; + } + + p { + font-size: 24px; + } + + a { + text-decoration: none; + } + ")) + } + meta property="og:type" content="website"; + meta property="og:title" content="Troubleshooting suggestion"; + meta property="og:description" content={ "Have you checked " (thing) "?" }; + } + body { + section { + p { "Have you checked " (PreEscaped(thing)) "?" } + p { + a href=( req.index_url(ItemQuery::default())? ) { "That wasn't it, suggest something else." } + } + p { + a href=( req.index_url(ItemQuery::from(idx))? ) { "Share this troubleshooting suggestion." } + } + } + a href="https://github.com/ojacobson/things-to-check" { + img + style="position: absolute; top: 0; right: 0; border: 0;" + src="https://camo.githubusercontent.com/38ef81f8aca64bb9a64448d0d70f1308ef5341ab/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f6769746875622f726962626f6e732f666f726b6d655f72696768745f6461726b626c75655f3132313632312e706e67" alt="Fork me on GitHub" + data-canonical-src="https://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png"; + } + } + } + }) +} + +#[get("/")] +async fn index( + req: web::HttpRequest, + data: web::Data<Things>, + query: web::Query<ItemQuery>, +) -> error::Result<Markup> { + let thing = match query.item { + Some(index) => data.0.get(index), + None => data.0.choose(&mut thread_rng()), + }; + + let (index, thing) = match thing { + Some(x) => x, + None => return Err(error::ErrorNotFound("Not found")), + }; + + Ok(index_view(req, index, thing)?) +} + +const THINGS: &str = include_str!("things-to-check.yml"); + +#[derive(Clone)] +struct Things(Vec<(usize, String)>); + +/// Errors that can arise initializing the service. +#[derive(Error, Debug)] +pub enum Error { + /// Indicates that the included YAML was invalid in some way. This is only + /// fixable by recompiling the program with correct YAML. + #[error("Unable to load Things To Check YAML: {0}")] + DeserializeError(#[from] serde_yaml::Error) +} + +/// Set up an instance of this service. +/// +/// The returned function will configure any actix-web App with the necessary +/// state to tell people how to troubleshoot problems. +pub fn make_service() -> Result<impl Fn(&mut web::ServiceConfig) + Clone, Error> { + let things: Vec<String> = serde_yaml::from_str(THINGS)?; + let things = things.into_iter() + .enumerate() + .collect(); + let things = Things(things); + + Ok(move |cfg: &mut web::ServiceConfig| { + cfg.data(things.clone()) + .service(index); + }) +} |
