Compare commits

..

6 commits

7 changed files with 498 additions and 693 deletions

968
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,8 @@ readme = "README.md"
repository = "https://github.com/edward-shen/bunbun" repository = "https://github.com/edward-shen/bunbun"
[dependencies] [dependencies]
actix-web = "1.0" actix-web = "2.0"
actix-rt = "1.0"
serde = "1.0" serde = "1.0"
serde_yaml = "0.8" serde_yaml = "0.8"
handlebars = "2.0" handlebars = "2.0"

View file

@ -31,7 +31,7 @@ If you're looking to build a release binary, here are the steps I use:
1. `cargo build --release` 1. `cargo build --release`
2. `strip target/release/bunbun` 2. `strip target/release/bunbun`
3. `upx --lzma bunbun` 3. `upx --lzma target/release/bunbun`
LZMA provides the best level of compress for Rust binaries; it performs at the LZMA provides the best level of compress for Rust binaries; it performs at the
same level as `upx --ultra-brute` without the time cost and [without breaking same level as `upx --ultra-brute` without the time cost and [without breaking

View file

@ -1,5 +1,5 @@
name: "bunbun" name: "bunbun"
about: "Search/jump multiplexer service" about: "Search/jump multiplexer service."
args: args:
- verbose: - verbose:

38
src/error.rs Normal file
View file

@ -0,0 +1,38 @@
use std::fmt;
#[derive(Debug)]
#[allow(clippy::enum_variant_names)]
pub enum BunBunError {
IoError(std::io::Error),
ParseError(serde_yaml::Error),
WatchError(hotwatch::Error),
LoggerInitError(log::SetLoggerError),
}
impl fmt::Display for BunBunError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BunBunError::IoError(e) => e.fmt(f),
BunBunError::ParseError(e) => e.fmt(f),
BunBunError::WatchError(e) => e.fmt(f),
BunBunError::LoggerInitError(e) => e.fmt(f),
}
}
}
/// Generates a from implementation from the specified type to the provided
/// bunbun error.
macro_rules! from_error {
($from:ty, $to:ident) => {
impl From<$from> for BunBunError {
fn from(e: $from) -> Self {
BunBunError::$to(e)
}
}
};
}
from_error!(std::io::Error, IoError);
from_error!(serde_yaml::Error, ParseError);
from_error!(hotwatch::Error, WatchError);
from_error!(log::SetLoggerError, LoggerInitError);

View file

@ -1,6 +1,7 @@
use actix_web::middleware::Logger; use actix_web::middleware::Logger;
use actix_web::{App, HttpServer}; use actix_web::{App, HttpServer};
use clap::{crate_authors, crate_version, load_yaml, App as ClapApp}; use clap::{crate_authors, crate_version, load_yaml, App as ClapApp};
use error::BunBunError;
use handlebars::Handlebars; use handlebars::Handlebars;
use hotwatch::{Event, Hotwatch}; use hotwatch::{Event, Hotwatch};
use libc::daemon; use libc::daemon;
@ -8,54 +9,17 @@ use log::{debug, error, info, trace, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::cmp::min; use std::cmp::min;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt;
use std::fs::{read_to_string, OpenOptions}; use std::fs::{read_to_string, OpenOptions};
use std::io::Write; use std::io::Write;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
mod error;
mod routes; mod routes;
mod template_args; mod template_args;
const DEFAULT_CONFIG: &[u8] = include_bytes!("../bunbun.default.yaml"); const DEFAULT_CONFIG: &[u8] = include_bytes!("../bunbun.default.yaml");
#[derive(Debug)]
#[allow(clippy::enum_variant_names)]
enum BunBunError {
IoError(std::io::Error),
ParseError(serde_yaml::Error),
WatchError(hotwatch::Error),
LoggerInitError(log::SetLoggerError),
}
impl fmt::Display for BunBunError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BunBunError::IoError(e) => e.fmt(f),
BunBunError::ParseError(e) => e.fmt(f),
BunBunError::WatchError(e) => e.fmt(f),
BunBunError::LoggerInitError(e) => e.fmt(f),
}
}
}
/// Generates a from implementation from the specified type to the provided
/// bunbun error.
macro_rules! from_error {
($from:ty, $to:ident) => {
impl From<$from> for BunBunError {
fn from(e: $from) -> Self {
BunBunError::$to(e)
}
}
};
}
from_error!(std::io::Error, IoError);
from_error!(serde_yaml::Error, ParseError);
from_error!(hotwatch::Error, WatchError);
from_error!(log::SetLoggerError, LoggerInitError);
/// Dynamic variables that either need to be present at runtime, or can be /// Dynamic variables that either need to be present at runtime, or can be
/// changed during runtime. /// changed during runtime.
pub struct State { pub struct State {
@ -64,10 +28,10 @@ pub struct State {
groups: Vec<RouteGroup>, groups: Vec<RouteGroup>,
/// Cached, flattened mapping of all routes and their destinations. /// Cached, flattened mapping of all routes and their destinations.
routes: HashMap<String, String>, routes: HashMap<String, String>,
renderer: Handlebars,
} }
fn main() -> Result<(), BunBunError> { #[actix_rt::main]
async fn main() -> Result<(), BunBunError> {
let yaml = load_yaml!("cli.yaml"); let yaml = load_yaml!("cli.yaml");
let matches = ClapApp::from(yaml) let matches = ClapApp::from(yaml)
.version(crate_version!()) .version(crate_version!())
@ -82,13 +46,11 @@ fn main() -> Result<(), BunBunError> {
// config has default location provided, unwrapping is fine. // config has default location provided, unwrapping is fine.
let conf_file_location = String::from(matches.value_of("config").unwrap()); let conf_file_location = String::from(matches.value_of("config").unwrap());
let conf = read_config(&conf_file_location)?; let conf = read_config(&conf_file_location)?;
let renderer = compile_templates();
let state = Arc::from(RwLock::new(State { let state = Arc::from(RwLock::new(State {
public_address: conf.public_address, public_address: conf.public_address,
default_route: conf.default_route, default_route: conf.default_route,
routes: cache_routes(&conf.groups), routes: cache_routes(&conf.groups),
groups: conf.groups, groups: conf.groups,
renderer,
})); }));
// Daemonize after trying to read from config and before watching; allow user // Daemonize after trying to read from config and before watching; allow user
@ -100,42 +62,12 @@ fn main() -> Result<(), BunBunError> {
} }
} }
let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?; let _watch = start_watch(state.clone(), conf_file_location)?;
// TODO: keep retry watching in separate thread
// Closures need their own copy of variables for proper lifecycle management
let state_ref = state.clone();
let conf_file_location_clone = conf_file_location.clone();
let watch_result = watch.watch(&conf_file_location, move |e: Event| {
if let Event::Write(_) = e {
trace!("Grabbing writer lock on state...");
let mut state = state.write().unwrap();
trace!("Obtained writer lock on state!");
match read_config(&conf_file_location_clone) {
Ok(conf) => {
state.public_address = conf.public_address;
state.default_route = conf.default_route;
state.routes = cache_routes(&conf.groups);
state.groups = conf.groups;
info!("Successfully updated active state");
}
Err(e) => warn!("Failed to update config file: {}", e),
}
} else {
debug!("Saw event {:#?} but ignored it", e);
}
});
match watch_result {
Ok(_) => info!("Watcher is now watching {}", &conf_file_location),
Err(e) => warn!(
"Couldn't watch {}: {}. Changes to this file won't be seen!",
&conf_file_location, e
),
}
HttpServer::new(move || { HttpServer::new(move || {
App::new() App::new()
.data(state_ref.clone()) .data(state.clone())
.app_data(compile_templates())
.wrap(Logger::default()) .wrap(Logger::default())
.service(routes::hop) .service(routes::hop)
.service(routes::list) .service(routes::list)
@ -143,7 +75,8 @@ fn main() -> Result<(), BunBunError> {
.service(routes::opensearch) .service(routes::opensearch)
}) })
.bind(&conf.bind_address)? .bind(&conf.bind_address)?
.run()?; .run()
.await?;
Ok(()) Ok(())
} }
@ -224,6 +157,9 @@ fn read_config(config_file_path: &str) -> Result<Config, BunBunError> {
Ok(serde_yaml::from_str(&config_str)?) Ok(serde_yaml::from_str(&config_str)?)
} }
/// Generates a hashmap of routes from the data structure created by the config
/// file. This should improve runtime performance and is a better solution than
/// just iterating over the config object for every hop resolution.
fn cache_routes(groups: &[RouteGroup]) -> HashMap<String, String> { fn cache_routes(groups: &[RouteGroup]) -> HashMap<String, String> {
let mut mapping = HashMap::new(); let mut mapping = HashMap::new();
for group in groups { for group in groups {
@ -261,3 +197,51 @@ fn compile_templates() -> Handlebars {
register_template!["index", "list", "opensearch"]; register_template!["index", "list", "opensearch"];
handlebars handlebars
} }
/// Starts the watch on a file, if possible. This will only return an Error if
/// the notify library (used by Hotwatch) fails to initialize, which is
/// considered to be a more serve error as it may be indicative of a low-level
/// problem. If a watch was unsuccessfully obtained (the most common is due to
/// the file not existing), then this will simply warn before returning a watch
/// object.
///
/// This watch object should be kept in scope as dropping it releases all
/// watches.
fn start_watch(
state: Arc<RwLock<State>>,
config_file_path: String,
) -> Result<Hotwatch, BunBunError> {
let mut watch = Hotwatch::new_with_custom_delay(Duration::from_millis(500))?;
// TODO: keep retry watching in separate thread
// Closures need their own copy of variables for proper lifecycle management
let config_file_path_clone = config_file_path.clone();
let watch_result = watch.watch(&config_file_path, move |e: Event| {
if let Event::Write(_) = e {
trace!("Grabbing writer lock on state...");
let mut state = state.write().unwrap();
trace!("Obtained writer lock on state!");
match read_config(&config_file_path_clone) {
Ok(conf) => {
state.public_address = conf.public_address;
state.default_route = conf.default_route;
state.routes = cache_routes(&conf.groups);
state.groups = conf.groups;
info!("Successfully updated active state");
}
Err(e) => warn!("Failed to update config file: {}", e),
}
} else {
debug!("Saw event {:#?} but ignored it", e);
}
});
match watch_result {
Ok(_) => info!("Watcher is now watching {}", &config_file_path),
Err(e) => warn!(
"Couldn't watch {}: {}. Changes to this file won't be seen!",
&config_file_path, e
),
}
Ok(watch)
}

View file

@ -3,7 +3,8 @@ use crate::State;
use actix_web::get; use actix_web::get;
use actix_web::http::header; use actix_web::http::header;
use actix_web::web::{Data, Query}; use actix_web::web::{Data, Query};
use actix_web::{HttpResponse, Responder}; use actix_web::{HttpRequest, HttpResponse, Responder};
use handlebars::Handlebars;
use itertools::Itertools; use itertools::Itertools;
use log::debug; use log::debug;
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
@ -11,6 +12,8 @@ use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
type StateData = Data<Arc<RwLock<State>>>;
/// https://url.spec.whatwg.org/#fragment-percent-encode-set /// https://url.spec.whatwg.org/#fragment-percent-encode-set
const FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS const FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ') .add(b' ')
@ -21,9 +24,18 @@ const FRAGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b'+'); .add(b'+');
#[get("/ls")] #[get("/ls")]
pub fn list(data: Data<Arc<RwLock<State>>>) -> impl Responder { pub async fn list(
data: Data<Arc<RwLock<State>>>,
req: HttpRequest,
) -> impl Responder {
let data = data.read().unwrap(); let data = data.read().unwrap();
HttpResponse::Ok().body(data.renderer.render("list", &data.groups).unwrap()) HttpResponse::Ok().body(
req
.app_data::<Handlebars>()
.unwrap()
.render("list", &data.groups)
.unwrap(),
)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -32,8 +44,9 @@ pub struct SearchQuery {
} }
#[get("/hop")] #[get("/hop")]
pub fn hop( pub async fn hop(
data: Data<Arc<RwLock<State>>>, data: StateData,
req: HttpRequest,
query: Query<SearchQuery>, query: Query<SearchQuery>,
) -> impl Responder { ) -> impl Responder {
let data = data.read().unwrap(); let data = data.read().unwrap();
@ -42,8 +55,9 @@ pub fn hop(
(Some(path), args) => HttpResponse::Found() (Some(path), args) => HttpResponse::Found()
.header( .header(
header::LOCATION, header::LOCATION,
data req
.renderer .app_data::<Handlebars>()
.unwrap()
.render_template( .render_template(
&path, &path,
&template_args::query( &template_args::query(
@ -109,11 +123,12 @@ fn resolve_hop(
} }
#[get("/")] #[get("/")]
pub fn index(data: Data<Arc<RwLock<State>>>) -> impl Responder { pub async fn index(data: StateData, req: HttpRequest) -> impl Responder {
let data = data.read().unwrap(); let data = data.read().unwrap();
HttpResponse::Ok().body( HttpResponse::Ok().body(
data req
.renderer .app_data::<Handlebars>()
.unwrap()
.render( .render(
"index", "index",
&template_args::hostname(data.public_address.clone()), &template_args::hostname(data.public_address.clone()),
@ -123,7 +138,7 @@ pub fn index(data: Data<Arc<RwLock<State>>>) -> impl Responder {
} }
#[get("/bunbunsearch.xml")] #[get("/bunbunsearch.xml")]
pub fn opensearch(data: Data<Arc<RwLock<State>>>) -> impl Responder { pub async fn opensearch(data: StateData, req: HttpRequest) -> impl Responder {
let data = data.read().unwrap(); let data = data.read().unwrap();
HttpResponse::Ok() HttpResponse::Ok()
.header( .header(
@ -131,8 +146,9 @@ pub fn opensearch(data: Data<Arc<RwLock<State>>>) -> impl Responder {
"application/opensearchdescription+xml", "application/opensearchdescription+xml",
) )
.body( .body(
data req
.renderer .app_data::<Handlebars>()
.unwrap()
.render( .render(
"opensearch", "opensearch",
&template_args::hostname(data.public_address.clone()), &template_args::hostname(data.public_address.clone()),