Compare commits

..

11 commits

11 changed files with 264 additions and 194 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ target
**/*.rs.bk **/*.rs.bk
*.ron *.ron
.vscode/ .vscode/
!endstat_conf.example.ron

3
Cargo.lock generated
View file

@ -426,6 +426,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [ dependencies = [
"num-integer 0.1.39 (registry+https://github.com/rust-lang/crates.io-index)", "num-integer 0.1.39 (registry+https://github.com/rust-lang/crates.io-index)",
"num-traits 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)", "num-traits 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.90 (registry+https://github.com/rust-lang/crates.io-index)",
"time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)", "time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)",
] ]
@ -653,8 +654,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"actix 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", "actix 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)",
"actix-web 1.0.0-beta.2 (registry+https://github.com/rust-lang/crates.io-index)", "actix-web 1.0.0-beta.2 (registry+https://github.com/rust-lang/crates.io-index)",
"chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)",
"env_logger 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)", "env_logger 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"reqwest 0.9.15 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.9.15 (registry+https://github.com/rust-lang/crates.io-index)",
"ron 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", "ron 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)",
"serde 1.0.90 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.90 (registry+https://github.com/rust-lang/crates.io-index)",

View file

@ -13,4 +13,4 @@ actix = "0.8"
tokio = "0.1" tokio = "0.1"
tera = "0.11" tera = "0.11"
env_logger = "0.6" env_logger = "0.6"
lazy_static = "1.3.0" chrono = { version = "0.4", features = ["serde"] }

View file

@ -1,6 +1,30 @@
# endstat # endstat
EndStat is an easy-to-use lazy **End**point **Stat**us checking tool, meant for `endstat` is an easy-to-use **End**point **Stat**us checking tool, meant for
checking the health of various web locations. It supports arbitrary domains and checking the health of various web locations. It supports arbitrary domains and
ports, status matching, and body matching using a quick-to-understand config ports, status matching, and body matching using [ron][ron], a quick-to-understand config
file. file notation, built in [Rust][rust] using [actix][actix].
My motivation was that I wanted to make a dashboard that was easy-to-use to make
sure my homelab services were running when I screwed around with config files.
[ron]: https://github.com/ron-rs/ron
[rust]: https://rust-lang.org
[actix]: https://github.com/ron-rs/ron
## Features
- HTTP/HTTPS
- Arbitrary ports
- Expected body and/or status code responses
- Optional no redirect following
- API endpoint (`/api`)
## Getting started
There's an example config file that you can simply rename to `endstat_conf.ron`.
It should be a relatively comprehensive example of what sort of flexibility
`endstat` offers.
If you're building from source, execute `cargo run`.
If you've gotten this binary from somewhere else, simply execute it.

40
endstat_conf.example.ron Normal file
View file

@ -0,0 +1,40 @@
#![enable(implicit_some)]
(
refresh_time: 60,
bind_address: "0.0.0.0:8080",
websites: [
(
label: "Basic usage",
endpoints: [
(label: "Supports HTTPS", endpoint: "https://example.com"),
(label: "Supports HTTP", endpoint: "http://example.com"),
(label: "Supports no redirection", endpoint: "http://google.com", code: 301, follow_redirects: false),
]
),
(
label: "More features!",
base: "http://portquiz.net/",
endpoints: [
(label: "You can even set a base url"),
(label: "Or use different ports", port: 8080),
],
),
(
label: "Even more stuff!",
endpoints: [
(label: "Or expect different reponse codes (like 418)", endpoint: "http://error418.net/", code: 418),
(label: "Or bodies!", endpoint: "http://urlecho.appspot.com/echo", body: "None"),
(label: "Or both!", endpoint: "http://urlecho.appspot.com/echo?status=503&Content-Type=text%2Fhtml&body=None", body: "None", code: 503),
],
),
(
label: "Some error messages",
endpoints:[
(label: "The code doesn't match!", endpoint: "http://example.com/", code: 204),
(label: "The body doesn't match!", endpoint: "http://example.com/", body: "asdf"),
(label: "Here's an error", endpoint: "https://some-invalid-website.arpa")
]
),
]
)

View file

@ -7,12 +7,13 @@ pub struct EndpointConfig {
pub port: Option<u16>, pub port: Option<u16>,
pub code: Option<u16>, pub code: Option<u16>,
pub body: Option<String>, pub body: Option<String>,
pub follow_redirects: Option<bool>,
} }
#[derive(Deserialize, Serialize, Debug, Clone)] #[derive(Deserialize, Serialize, Debug, Clone)]
pub struct WebsiteConfig { pub struct WebsiteConfig {
pub label: String, pub label: String,
pub base: String, pub base: Option<String>,
pub endpoints: Vec<EndpointConfig>, pub endpoints: Vec<EndpointConfig>,
} }

View file

@ -1,34 +1,35 @@
use crate::config::*; use crate::{config::*, State};
use crate::utils::EpochTimestamp;
use actix_web::{ use actix_web::{
error::ErrorInternalServerError, web::Data, Error as WebError, HttpResponse, error::ErrorInternalServerError, web::Data, Error as WebError, HttpResponse,
Result as WebResult, Result as WebResult,
}; };
use reqwest::{Client, Url, UrlError}; use chrono::prelude::*;
use serde::Serialize; use serde::Serialize;
use std::sync::{Arc, RwLock};
use tera::{Context, Tera}; use tera::{Context, Tera};
#[derive(Clone, Serialize, Default, Debug)] #[derive(Clone, Serialize, Default, Debug)]
pub struct Status { pub struct EndpointStatus {
status: u8, pub status: u8,
location: String, pub location: String,
domain: String, pub endpoint: String,
endpoint: String, pub error: Option<String>,
error: Option<String>, }
#[derive(Serialize, Debug)]
pub struct StatusGroup {
pub label: String,
pub endpoints: Vec<EndpointStatus>,
} }
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct QueryResults { pub struct QueryResults {
pub last_update: EpochTimestamp, pub timestamp: DateTime<Utc>,
pub timestamp_str: String,
pub refresh_time: u64, pub refresh_time: u64,
pub config: Config, pub config: Config,
pub statuses: Vec<Status>, pub groups: Vec<StatusGroup>,
} }
type State = Arc<RwLock<QueryResults>>;
pub fn index(tmpl: Data<Tera>, state: Data<State>) -> WebResult<HttpResponse, WebError> { pub fn index(tmpl: Data<Tera>, state: Data<State>) -> WebResult<HttpResponse, WebError> {
let state = state.read().unwrap(); let state = state.read().unwrap();
let mut ctx = Context::new(); let mut ctx = Context::new();
@ -42,94 +43,5 @@ pub fn index(tmpl: Data<Tera>, state: Data<State>) -> WebResult<HttpResponse, We
pub fn json_endpoint(state: Data<State>) -> HttpResponse { pub fn json_endpoint(state: Data<State>) -> HttpResponse {
let state = state.read().unwrap(); let state = state.read().unwrap();
HttpResponse::Ok().json(&state.statuses) HttpResponse::Ok().json(&*state.groups)
}
pub fn update_state(state: State) {
let mut new_timestamp = None;
let mut new_statuses = None;
{
let read_state = state.read().unwrap();
if EpochTimestamp::now() - read_state.last_update >= read_state.refresh_time {
new_timestamp = Some(EpochTimestamp::now());
new_statuses = Some(update_status(&read_state.config));
}
}
if new_timestamp.is_some() {
let mut write_state = state.try_write().expect("Could not unlock");
write_state.last_update = new_timestamp.unwrap();
write_state.statuses = new_statuses.unwrap();
}
}
fn update_status(config: &Config) -> Vec<Status> {
let client = Client::new();
let mut results: Vec<Status> = vec![];
for website_conf in &config.websites {
for endpoint in &website_conf.endpoints {
results.push(get_result(website_conf, &client, endpoint));
}
}
results
}
fn get_result(website_conf: &WebsiteConfig, client: &Client, endpoint: &EndpointConfig) -> Status {
let (label, path, port, code, body) = get_endpoint_info(endpoint.clone());
let url = get_url(&website_conf.base, &path, port).expect("reading config");
let ping_result = client.get(&url).send();
match ping_result {
Ok(mut res) => {
let res_body = res.text().expect("could not get body of request");
let does_code_match = res.status() == code;
let does_body_match = body.is_empty() || res_body == body;
let mut error = None;
if !does_code_match {
error = Some(format!(
"Status code mismatch: {} != {}.",
res.status().as_u16(),
code
));
}
if !does_body_match {
error = Some(if let Some(msg) = error {
format!(
"{} Body mismatch: {} != {}.",
msg,
res_body.len(),
body.len()
)
} else {
format!("Body mismatch: {} != {}.", res_body.len(), body.len())
});
}
Status {
status: if error.is_some() { 1 } else { 0 },
location: url,
domain: website_conf.label.clone(),
endpoint: label,
error,
}
}
Err(e) => Status {
status: 2,
location: url,
domain: website_conf.label.clone(),
endpoint: label,
error: Some(format!("{}", e)),
},
}
}
fn get_url(base: &String, path: &String, port: Option<u16>) -> Result<String, UrlError> {
let mut url = Url::parse(base)?.join(path)?;
if let Err(e) = url.set_port(port) {
println!("{:?}", e);
}
Ok(url.into_string())
} }

View file

@ -1,5 +1,6 @@
extern crate actix; extern crate actix;
extern crate actix_web; extern crate actix_web;
extern crate chrono;
extern crate env_logger; extern crate env_logger;
extern crate reqwest; extern crate reqwest;
extern crate ron; extern crate ron;
@ -10,21 +11,24 @@ extern crate tera;
mod config; mod config;
mod handlers; mod handlers;
mod utils; mod updater;
use self::config::*; use self::{config::*, handlers::*, updater::*};
use self::handlers::*;
use self::utils::EpochTimestamp;
use actix::System; use actix::System;
use actix_web::{middleware::Logger, web::resource, App, HttpServer}; use actix_web::{middleware::Logger, web::resource, App, HttpServer};
use chrono::prelude::*;
use ron::de::from_str; use ron::de::from_str;
use std::{ use std::{
fs::read_to_string, fs::read_to_string,
sync::{Arc, RwLock}, sync::{Arc, RwLock},
time::Duration, time::Duration,
}; };
use tokio::prelude::{Future, Stream}; use tokio::{
use tokio::timer::Interval; prelude::{Future, Stream},
timer::Interval,
};
pub type State = Arc<RwLock<QueryResults>>;
fn main() { fn main() {
System::run(move || { System::run(move || {
@ -36,12 +40,14 @@ fn main() {
std::env::set_var("RUST_LOG", "actix_web=info"); std::env::set_var("RUST_LOG", "actix_web=info");
env_logger::init(); env_logger::init();
let state = Arc::new(RwLock::new(QueryResults { let state: State = Arc::new(RwLock::new(QueryResults {
last_update: EpochTimestamp::now(), timestamp: Utc::now(),
timestamp_str: Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
refresh_time: config.refresh_time.clone(), refresh_time: config.refresh_time.clone(),
config: config.clone(), config: config.clone(),
statuses: vec![], groups: update_status(&config),
})); }));
let clone_state = Arc::clone(&state); let clone_state = Arc::clone(&state);
HttpServer::new(move || { HttpServer::new(move || {
@ -60,10 +66,9 @@ fn main() {
.start(); .start();
tokio::spawn( tokio::spawn(
Interval::new_interval(Duration::from_millis(5000)) Interval::new_interval(Duration::from_secs(config.refresh_time))
.for_each(move |_| { .for_each(move |_| {
let state = Arc::clone(&clone_state); update_state(Arc::clone(&clone_state));
update_state(state);
Ok(()) Ok(())
}) })
.map_err(|_| ()), .map_err(|_| ()),

106
src/updater.rs Normal file
View file

@ -0,0 +1,106 @@
use crate::{
config::*,
handlers::{EndpointStatus, StatusGroup},
State,
};
use chrono::prelude::*;
use reqwest::{Client, RedirectPolicy, Url, UrlError};
use std::time::Duration;
pub fn update_state(state: State) {
let new_statuses = { Some(update_status(&state.read().unwrap().config)) };
let mut write_state = state.try_write().expect("Could not unlock");
write_state.timestamp = Utc::now();
write_state.timestamp_str = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
write_state.groups = new_statuses.unwrap();
}
pub fn update_status(config: &Config) -> Vec<StatusGroup> {
let mut results: Vec<StatusGroup> = Vec::with_capacity(config.websites.len());
for website_conf in &config.websites {
let mut group = Vec::with_capacity(website_conf.endpoints.len());
for endpoint in &website_conf.endpoints {
let mut client_builder = Client::builder().timeout(Some(Duration::from_secs(5)));
if let Some(false) = endpoint.follow_redirects {
client_builder = client_builder.redirect(RedirectPolicy::none());
}
let client = client_builder.build().unwrap();
group.push(get_result(website_conf, &client, endpoint));
}
results.push(StatusGroup {
label: website_conf.label.clone(),
endpoints: group,
});
}
results
}
fn get_result(
website_conf: &WebsiteConfig,
client: &Client,
endpoint: &EndpointConfig,
) -> EndpointStatus {
let (label, path, port, code, body) = get_endpoint_info(endpoint.clone());
let url = get_url(&website_conf.base, &path, port).expect("reading config");
let ping_result = client.get(&url).send();
match ping_result {
Ok(mut res) => {
let res_body = res.text().expect("could not get body of request");
let does_code_match = res.status() == code;
let does_body_match = body.is_empty() || res_body == body;
let mut error = None;
if !does_code_match {
error = Some(format!(
"Status code mismatch: {} != {}.",
res.status().as_u16(),
code
));
}
if !does_body_match {
error = Some(if let Some(msg) = error {
format!(
"{} Body mismatch: {} != {}.",
msg,
res_body.len(),
body.len()
)
} else {
format!("Body mismatch: {} != {}.", res_body.len(), body.len())
});
}
EndpointStatus {
status: if error.is_some() { 1 } else { 0 },
location: url,
endpoint: label,
error,
}
}
Err(e) => EndpointStatus {
status: 2,
location: url,
endpoint: label,
error: Some(format!("{}", e)),
},
}
}
fn get_url(base: &Option<String>, path: &String, port: Option<u16>) -> Result<String, UrlError> {
let mut url = if let Some(base) = base {
Url::parse(base)?.join(path)?
} else {
Url::parse(path)?
};
if let Err(e) = url.set_port(port) {
println!("{:?}", e);
}
Ok(url.into_string())
}

View file

@ -1,49 +0,0 @@
use serde::{Serialize, Serializer};
use std::{
fmt::{Display, Formatter, Result as FmtResult},
ops::Sub,
time::{SystemTime, UNIX_EPOCH},
};
#[derive(PartialEq, PartialOrd, Copy, Clone, Debug)]
pub struct EpochTimestamp(SystemTime);
impl EpochTimestamp {
pub fn now() -> Self {
EpochTimestamp(SystemTime::now())
}
}
impl Sub for EpochTimestamp {
type Output = u64;
fn sub(self, other: EpochTimestamp) -> u64 {
self.0.duration_since(other.0).unwrap_or_default().as_secs()
}
}
impl Display for EpochTimestamp {
fn fmt(&self, f: &mut Formatter) -> FmtResult {
write!(
f,
"{}",
self.0
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
)
}
}
impl Serialize for EpochTimestamp {
fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
s.serialize_u64(
self.0
.duration_since(SystemTime::from(UNIX_EPOCH))
.unwrap_or_default()
.as_secs(),
)
}
}

View file

@ -4,55 +4,84 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<title>Endstat</title> <title>Endstat</title>
<link href="https://fonts.googleapis.com/css?family=Montserrat:200|Source+Code+Pro:400" rel="stylesheet">
<style> <style>
body { body {
background-color: #212121; background-color: #212121;
margin: 0;
color: #fff; color: #fff;
font-family: 'Montserrat', sans-serif;
width: 700px;
margin: 0 auto;
margin-top: 5rem;
} }
main { main {
width: 700px; padding: 1rem;
margin: 0 auto; width: 100%;
}
header {
display: flex;
align-items: flex-end;
justify-content: space-between;
} }
section { section {
display: flex; display: flex;
align-items: stretch;
background-color: #424242; background-color: #424242;
align-items: center;
margin: 1rem 0; margin: 1rem 0;
padding: 1rem;
border-radius: 1rem; border-radius: 1rem;
} }
p { margin: 0; } p { margin: 0; }
a {
color: #fff;
text-decoration: none;
}
h1 { margin: 0; }
h3 { margin: 0; text-align: justify; }
h1 { display: inline; } .info {
display: flex;
align-items: baseline;
}
.spacer {
flex: 1 0 1rem;
}
.indicator { .indicator {
width: 1rem; width: 1rem;
height: 1rem; min-height: 100%;
border-radius: 1rem; border-radius: 1rem 0 0 1rem;
} }
.ok { background-color: green; } .ok { background-color: #4ed34e; }
.warn { background-color: yellow; } .warn { background-color: #fcfc64; }
.error { background-color: red; } .error { background-color: #ff392e; }
.error-msg { margin-top: 1rem; font-family: 'Source Code Pro', monospace;}
</style> </style>
</head> </head>
<body> <body>
<main> <header>
<h1>Welcome!</h1> <h1>Status Overview</h1>
<h1>{{ results.last_update }}</h1> <p>{{ results.timestamp_str }}</p>
{% for status in results.statuses -%} </header>
{% for group in results.groups -%}
<h2>{{ group.label }}</h2>
{% for status in group.endpoints -%}
<section> <section>
<p>{{ status.domain }}</p> <aside class="indicator {% if status.status == 0 %}ok{% elif status.status == 1 %}warn{% else %}error{% endif %}"></aside>
<p>{{ status.endpoint }}</p> <main>
<p>{{ status.location }}</p> <div class="info">
{% if status.error %}<p>{{ status.error }}</p>{% endif %} <h3>{{ status.endpoint }}</h3>
<div class="indicator {% if status.status == 0 %}ok{% elif status.status == 1 %}warn{% else %}error{% endif %}"></div> <div class="spacer"></div>
<a href="{{ status.location }}">{{ status.location }}</a>
</div>
{% if status.error %}<p class="error-msg">{{ status.error }}</p>{% endif %}
</main>
</section> </section>
{% endfor -%} {% endfor -%}
</main> {% endfor -%}
</body> </body>
</html> </html>