use super::{
Error, get_bool_conf, get_hash_key, get_int_conf, get_str_conf,
get_str_slice_conf,
};
use async_trait::async_trait;
use bytes::Bytes;
use http::header::RETRY_AFTER;
use http::{HeaderName, HeaderValue, StatusCode};
use humantime::parse_duration;
use pingap_config::PluginConf;
use pingap_core::{
Ctx, HTTP_HEADER_CONTENT_HTML, HTTP_HEADER_CONTENT_TEXT, HttpHeader,
HttpResponse, Plugin, PluginStep, RequestPluginResult, constant_time_eq,
convert_header, ensure_verified_client_ip,
};
use pingora::proxy::Session;
use std::borrow::Cow;
use tracing::debug;
type Result<T, E = Error> = std::result::Result<T, E>;
const CATEGORY: &str = "maintenance";
pub struct Maintenance {
enabled: bool,
status: StatusCode,
retry_after: Option<HeaderValue>,
body: Bytes,
content_type: HttpHeader,
allow_ip_rules: Option<pingap_util::IpRules>,
allow_header: Option<HttpHeader>,
hash_value: String,
}
impl TryFrom<&PluginConf> for Maintenance {
type Error = Error;
fn try_from(value: &PluginConf) -> Result<Self> {
let hash_value = get_hash_key(value);
let invalid = |message: String| Error::Invalid {
category: CATEGORY.to_string(),
message,
};
let status = if value.contains_key("status") {
u16::try_from(get_int_conf(value, "status"))
.ok()
.filter(|status| (400..600).contains(status))
.and_then(|status| StatusCode::from_u16(status).ok())
.ok_or_else(|| {
invalid("status must be between 400 and 599".to_string())
})?
} else {
StatusCode::SERVICE_UNAVAILABLE
};
let retry_after = get_str_conf(value, "retry_after");
let retry_after = if retry_after.is_empty() {
None
} else {
let seconds = parse_duration(&retry_after)
.map_err(|e| invalid(format!("invalid retry_after: {e}")))?
.as_secs()
.max(1);
Some(HeaderValue::from(seconds))
};
let html = get_str_conf(value, "html");
let message = get_str_conf(value, "message");
if !html.is_empty() && !message.is_empty() {
return Err(invalid(
"message and html are two forms of one body, set one of them"
.to_string(),
));
}
let (body, content_type) = if !html.is_empty() {
(Bytes::from(html), HTTP_HEADER_CONTENT_HTML.clone())
} else if !message.is_empty() {
(Bytes::from(message), HTTP_HEADER_CONTENT_TEXT.clone())
} else {
(
Bytes::from_static(b"Service is under maintenance"),
HTTP_HEADER_CONTENT_TEXT.clone(),
)
};
let allow_ip_list = get_str_slice_conf(value, "allow_ip_list");
let allow_ip_rules = if allow_ip_list.is_empty() {
None
} else {
Some(
pingap_util::IpRules::try_new(&allow_ip_list)
.map_err(|e| invalid(format!("allow_ip_list: {e}")))?,
)
};
let allow_header = get_str_conf(value, "allow_header");
let allow_header = if allow_header.is_empty() {
None
} else {
let header = convert_header(&allow_header)
.map_err(|e| invalid(format!("allow_header: {e}")))?
.filter(|(_, value)| !value.is_empty())
.ok_or_else(|| {
invalid(format!(
"allow_header: {allow_header:?} should be name:value"
))
})?;
Some(header)
};
Ok(Self {
hash_value,
enabled: !value.contains_key("enabled")
|| get_bool_conf(value, "enabled"),
status,
retry_after,
body,
content_type,
allow_ip_rules,
allow_header,
})
}
}
impl Maintenance {
pub fn new(params: &PluginConf) -> Result<Self> {
debug!(
params = pingap_config::masked_toml(params),
"new maintenance plugin"
);
Self::try_from(params)
}
fn has_allow_header(&self, session: &Session) -> bool {
let Some((name, expected)) = &self.allow_header else {
return false;
};
let name: &HeaderName = name;
session
.req_header()
.headers
.get_all(name)
.iter()
.any(|value| {
constant_time_eq(value.as_bytes(), expected.as_bytes())
})
}
}
#[async_trait]
impl Plugin for Maintenance {
#[inline]
fn config_key(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.hash_value)
}
#[inline]
async fn handle_request(
&self,
step: PluginStep,
session: &mut Session,
ctx: &mut Ctx,
) -> pingora::Result<RequestPluginResult> {
if step != PluginStep::Request {
return Ok(RequestPluginResult::Skipped);
}
if !self.enabled {
return Ok(RequestPluginResult::Skipped);
}
if self.has_allow_header(session) {
return Ok(RequestPluginResult::Continue);
}
if let Some(rules) = &self.allow_ip_rules
&& rules
.is_match(ensure_verified_client_ip(session, ctx))
.unwrap_or(false)
{
return Ok(RequestPluginResult::Continue);
}
let mut builder = HttpResponse::builder(self.status)
.header(self.content_type.clone())
.body(self.body.clone())
.no_store();
if let Some(retry_after) = &self.retry_after {
builder = builder.header((RETRY_AFTER, retry_after.clone()));
}
Ok(RequestPluginResult::Respond(builder.finish()))
}
}
register_plugin!("maintenance", Maintenance);
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tokio_test::io::Builder;
fn new_plugin(conf: &str) -> Result<Maintenance> {
Maintenance::new(&toml::from_str::<PluginConf>(conf).unwrap())
}
async fn respond(
plugin: &Maintenance,
peer: &str,
headers: &str,
) -> Option<HttpResponse> {
let input = format!("GET /x HTTP/1.1\r\nHost: a\r\n{headers}\r\n");
let mock_io = Builder::new().read(input.as_bytes()).build();
let mut session = Session::new_h1(Box::new(mock_io));
session.read_request().await.unwrap();
let mut ctx = Ctx::default();
ctx.conn.remote_addr = Some(peer.to_string());
match plugin
.handle_request(PluginStep::Request, &mut session, &mut ctx)
.await
.unwrap()
{
RequestPluginResult::Respond(resp) => Some(resp),
_ => None,
}
}
fn header_of(resp: &HttpResponse, name: &str) -> Option<String> {
resp.headers.iter().flatten().find_map(|(key, value)| {
(key.as_str() == name).then(|| value.to_str().unwrap().to_string())
})
}
#[tokio::test]
async fn test_maintenance() {
let plugin = new_plugin("").unwrap();
let resp = respond(&plugin, "1.1.1.1", "").await.unwrap();
assert_eq!(StatusCode::SERVICE_UNAVAILABLE, resp.status);
assert_eq!(
"Service is under maintenance",
String::from_utf8_lossy(&resp.body)
);
assert_eq!(
Some("text/plain; charset=utf-8".to_string()),
header_of(&resp, "content-type")
);
assert_eq!(None, header_of(&resp, "retry-after"));
let plugin = new_plugin(
r#"
status = 500
retry_after = "10m"
html = "<h1>Back soon</h1>"
allow_ip_list = ["10.0.0.0/8", "192.168.1.9"]
allow_header = "X-Maintenance-Pass: s3cret"
"#,
)
.unwrap();
let resp = respond(&plugin, "1.1.1.1", "").await.unwrap();
assert_eq!(StatusCode::INTERNAL_SERVER_ERROR, resp.status);
assert_eq!("<h1>Back soon</h1>", String::from_utf8_lossy(&resp.body));
assert_eq!(
Some("text/html; charset=utf-8".to_string()),
header_of(&resp, "content-type")
);
assert_eq!(Some("600".to_string()), header_of(&resp, "retry-after"));
for peer in ["10.2.3.4", "192.168.1.9"] {
assert_eq!(
true,
respond(&plugin, peer, "").await.is_none(),
"{peer}"
);
}
for (peer, headers) in [
("192.168.1.10", ""),
("1.1.1.1", "X-Forwarded-For: 10.2.3.4\r\n"),
("1.1.1.1", "X-Real-IP: 10.2.3.4\r\n"),
] {
assert_eq!(
true,
respond(&plugin, peer, headers).await.is_some(),
"{peer} {headers}"
);
}
assert_eq!(
true,
respond(&plugin, "1.1.1.1", "X-Maintenance-Pass: s3cret\r\n")
.await
.is_none()
);
for headers in [
"X-Maintenance-Pass: guess\r\n",
"X-Maintenance-Pass: s3cret2\r\n",
"X-Maintenance-Pass:\r\n",
] {
assert_eq!(
true,
respond(&plugin, "1.1.1.1", headers).await.is_some(),
"{headers}"
);
}
let plugin = new_plugin("enabled = false").unwrap();
assert_eq!(true, respond(&plugin, "1.1.1.1", "").await.is_none());
let plugin = new_plugin("enabled = true").unwrap();
assert_eq!(true, respond(&plugin, "1.1.1.1", "").await.is_some());
}
#[test]
fn test_maintenance_params() {
let error = |conf: &str| new_plugin(conf).err().unwrap().to_string();
let prefix = "Plugin maintenance invalid, message: ";
for status in [200, 302, 600] {
assert_eq!(
format!("{prefix}status must be between 400 and 599"),
error(&format!("status = {status}"))
);
}
assert_eq!(
true,
error(r#"retry_after = "soon""#)
.starts_with(&format!("{prefix}invalid retry_after: ")),
);
assert_eq!(
format!(
"{prefix}message and html are two forms of one body, set one of them"
),
error("message = \"a\"\nhtml = \"<p>a</p>\"")
);
assert_eq!(
true,
error(r#"allow_ip_list = ["not-an-ip"]"#)
.starts_with(&format!("{prefix}allow_ip_list: ")),
);
for header in ["X-Pass", "X-Pass:"] {
assert_eq!(
format!(
"{prefix}allow_header: {header:?} should be name:value"
),
error(&format!("allow_header = \"{header}\""))
);
}
}
}