summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2020-06-03 23:26:24 -0400
committerOwen Jacobson <owen@grimoire.ca>2020-06-03 23:26:24 -0400
commit21de322d6cf221867ec9600e1a31777a195c7597 (patch)
tree1aab43a7559daa252cd1412a9bc2f8e3f367fe33 /src
parent23687bea794a18ff594658e9006457bff7b4a752 (diff)
parentebd04ae88f28a547eabbb44e1eccfd19965be7ae (diff)
Replace Python (apistar) version with Rust (actix-web) version.
Diffstat (limited to 'src')
-rw-r--r--src/main.rs38
-rw-r--r--src/things-to-check.yml39
-rw-r--r--src/twelve.rs242
-rw-r--r--src/view.rs214
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);
+ })
+}