use chrono::{DateTime, Utc};
use serde::Serialize;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use url::Url;
use uuid::Uuid;
pub const MANIFEST_VERSION: u32 = 2;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct AppManifest {
pub app_version: Option<String>,
pub git_sha: Option<String>,
pub jobs: Vec<String>,
pub crons: Vec<CronEntry>,
pub base_url: Option<String>,
pub monitors: Option<Vec<HttpMonitor>>,
}
impl AppManifest {
pub fn app_version(mut self, app_version: impl Into<String>) -> Self {
self.app_version = Some(app_version.into());
self
}
pub fn git_sha(mut self, git_sha: impl Into<String>) -> Self {
self.git_sha = Some(git_sha.into());
self
}
pub fn jobs(mut self, jobs: Vec<String>) -> Self {
self.jobs = jobs;
self
}
pub fn crons(mut self, crons: Vec<CronEntry>) -> Self {
self.crons = crons;
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn monitors(mut self, monitors: Vec<HttpMonitor>) -> Self {
self.monitors = Some(monitors);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
Get,
Head,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HttpMonitor {
pub id: String,
pub target: String,
pub method: HttpMethod,
pub interval_seconds: u64,
pub timeout_seconds: u64,
pub expected_status_min: u16,
pub expected_status_max: u16,
pub failure_threshold: u32,
pub enabled: bool,
}
impl HttpMonitor {
pub fn new(id: impl Into<String>, target: impl Into<String>) -> Self {
Self {
id: id.into(),
target: target.into(),
method: HttpMethod::Get,
interval_seconds: 60,
timeout_seconds: 10,
expected_status_min: 200,
expected_status_max: 299,
failure_threshold: 3,
enabled: true,
}
}
pub fn method(mut self, method: HttpMethod) -> Self {
self.method = method;
self
}
pub fn interval_seconds(mut self, seconds: u64) -> Self {
self.interval_seconds = seconds;
self
}
pub fn timeout_seconds(mut self, seconds: u64) -> Self {
self.timeout_seconds = seconds;
self
}
pub fn expected_status(mut self, min: u16, max: u16) -> Self {
self.expected_status_min = min;
self.expected_status_max = max;
self
}
pub fn failure_threshold(mut self, threshold: u32) -> Self {
self.failure_threshold = threshold;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MonitorTargetError {
#[error("relative monitor target requires base_url")]
MissingBaseUrl,
#[error("monitor target must be an absolute URL or root-relative path")]
NonRootRelative,
#[error("network-path monitor targets are not allowed")]
NetworkPath,
#[error("invalid monitor target URL: {0}")]
InvalidTarget(String),
#[error("invalid monitor base URL: {0}")]
InvalidBase(String),
#[error("only HTTP and HTTPS monitor URLs are supported")]
UnsupportedScheme,
#[error("monitor URLs may not contain credentials")]
Credentials,
#[error("monitor URLs may not contain fragments")]
Fragment,
#[error("monitor URL must contain a host")]
MissingHost,
#[error("localhost monitor targets are not allowed")]
Localhost,
#[error("monitor target uses a forbidden IP address")]
ForbiddenIp,
}
pub fn resolve_monitor_target(
base_url: Option<&str>,
target: &str,
) -> Result<Url, MonitorTargetError> {
if target.starts_with("//") {
return Err(MonitorTargetError::NetworkPath);
}
let url = if target.starts_with('/') {
if target
.bytes()
.any(|byte| matches!(byte, b'\\' | b'\t' | b'\r' | b'\n'))
{
return Err(MonitorTargetError::NetworkPath);
}
let base = base_url.ok_or(MonitorTargetError::MissingBaseUrl)?;
let parsed =
Url::parse(base).map_err(|e| MonitorTargetError::InvalidBase(e.to_string()))?;
validate_url(&parsed).map_err(|error| match error {
MonitorTargetError::InvalidTarget(message) => MonitorTargetError::InvalidBase(message),
other => other,
})?;
let joined = parsed
.join(target)
.map_err(|e| MonitorTargetError::InvalidTarget(e.to_string()))?;
if monitor_origin(&joined)? != monitor_origin(&parsed)? {
return Err(MonitorTargetError::NetworkPath);
}
joined
} else {
Url::parse(target).map_err(|e| {
if e == url::ParseError::RelativeUrlWithoutBase {
MonitorTargetError::NonRootRelative
} else {
MonitorTargetError::InvalidTarget(e.to_string())
}
})?
};
validate_url(&url)?;
Ok(url)
}
pub fn monitor_origin(url: &Url) -> Result<String, MonitorTargetError> {
validate_url(url)?;
let host = match url.host().ok_or(MonitorTargetError::MissingHost)? {
url::Host::Domain(name) => name.trim_end_matches('.').to_ascii_lowercase(),
url::Host::Ipv4(ip) => ip.to_string(),
url::Host::Ipv6(ip) => format!("[{ip}]"),
};
let port = match (url.scheme(), url.port()) {
("http", Some(80)) | ("https", Some(443)) | (_, None) => String::new(),
(_, Some(port)) => format!(":{port}"),
};
Ok(format!("{}://{host}{port}", url.scheme()))
}
fn validate_url(url: &Url) -> Result<(), MonitorTargetError> {
if !matches!(url.scheme(), "http" | "https") {
return Err(MonitorTargetError::UnsupportedScheme);
}
if !url.username().is_empty() || url.password().is_some() {
return Err(MonitorTargetError::Credentials);
}
if url.fragment().is_some() {
return Err(MonitorTargetError::Fragment);
}
let host = url.host().ok_or(MonitorTargetError::MissingHost)?;
match host {
url::Host::Domain(name)
if name.trim_end_matches('.').eq_ignore_ascii_case("localhost")
|| name
.trim_end_matches('.')
.to_ascii_lowercase()
.ends_with(".localhost") =>
{
Err(MonitorTargetError::Localhost)
}
url::Host::Ipv4(ip) if is_forbidden_monitor_ip(ip.into()) => {
Err(MonitorTargetError::ForbiddenIp)
}
url::Host::Ipv6(ip) if is_forbidden_monitor_ip(ip.into()) => {
Err(MonitorTargetError::ForbiddenIp)
}
_ => Ok(()),
}
}
pub fn is_forbidden_monitor_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => forbidden_v4(ip),
IpAddr::V6(ip) => forbidden_v6(ip),
}
}
fn forbidden_v4(ip: Ipv4Addr) -> bool {
let value = u32::from(ip);
ip.is_unspecified()
|| ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_broadcast()
|| ip.is_documentation()
|| value >> 24 == 0
|| value & 0xffc0_0000 == 0x6440_0000 || value & 0xffff_ff00 == 0xc000_0000 || value & 0xffff_ff00 == 0xc058_6300 || value & 0xfffe_0000 == 0xc612_0000 || value & 0xf000_0000 == 0xf000_0000 }
fn forbidden_v6(ip: Ipv6Addr) -> bool {
let octets = ip.octets();
let compatible_v4 = octets[..12]
.iter()
.all(|byte| *byte == 0)
.then(|| Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]));
ip.is_unspecified()
|| ip.is_loopback()
|| ip.is_multicast()
|| ip.is_unique_local()
|| ip.is_unicast_link_local()
|| ip.to_ipv4_mapped().is_some_and(forbidden_v4)
|| compatible_v4.is_some_and(forbidden_v4)
|| octets[..12] == [0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0] || (ip.segments()[0] == 0x0064 && ip.segments()[1] == 0xff9b
&& ip.segments()[2] == 0x0001) || (ip.segments()[0] == 0x0100 && ip.segments()[1..4] == [0, 0, 0]) || (ip.segments()[0] == 0x2001 && ip.segments()[1] < 0x0200) || (ip.segments()[0] == 0x2001 && ip.segments()[1] == 0x0db8) || ip.segments()[0] == 0x2002 || (ip.segments()[0] == 0x3fff && (ip.segments()[1] & 0xf000) == 0) || ip.segments()[0] == 0x5f00 || (ip.segments()[0] & 0xffc0) == 0xfec0 || (ip.segments()[0] & 0xe000) != 0x2000 }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CronEntry {
pub name: String,
pub schedule: String,
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("Configuration error: {0}")]
Configuration(String),
#[error("HTTP request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("Server returned error status: {0}")]
Status(reqwest::StatusCode),
}
#[derive(Debug, Serialize)]
struct ManifestPayload<'a> {
manifest_version: u32,
app_version: Option<&'a str>,
git_sha: Option<&'a str>,
jobs: Vec<JobPayload<'a>>,
crons: &'a [CronEntry],
base_url: Option<&'a str>,
monitors: Option<&'a [HttpMonitor]>,
booted_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
struct JobPayload<'a> {
name: &'a str,
}
impl<'a> ManifestPayload<'a> {
fn new(manifest: &'a AppManifest, booted_at: DateTime<Utc>) -> Self {
Self {
manifest_version: MANIFEST_VERSION,
app_version: manifest.app_version.as_deref(),
git_sha: manifest.git_sha.as_deref(),
jobs: manifest
.jobs
.iter()
.map(|name| JobPayload { name })
.collect(),
crons: &manifest.crons,
base_url: manifest.base_url.as_deref(),
monitors: manifest.monitors.as_deref(),
booted_at,
}
}
}
pub async fn send_manifest(
base_url: &str,
org_id: Uuid,
app_id: Uuid,
manifest: &AppManifest,
) -> Result<(), ManifestError> {
let url = Url::parse(base_url)
.and_then(|base| base.join(&format!("/api/orgs/{}/apps/{}/manifest", org_id, app_id)))
.map_err(|e| ManifestError::Configuration(format!("Invalid URL: {}", e)))?;
let payload = ManifestPayload::new(manifest, Utc::now());
let response = reqwest::Client::new()
.post(url)
.json(&payload)
.send()
.await?;
if !response.status().is_success() {
return Err(ManifestError::Status(response.status()));
}
Ok(())
}
pub async fn send_manifest_from_env(manifest: &AppManifest) -> Result<(), ManifestError> {
let base_url =
std::env::var("EYES_URL").unwrap_or_else(|_| "https://eyes.coreyja.com".to_string());
let org_id = uuid_from_env("EYES_ORG_ID")?;
let app_id = uuid_from_env("EYES_APP_ID")?;
send_manifest(&base_url, org_id, app_id, manifest).await
}
fn uuid_from_env(var: &str) -> Result<Uuid, ManifestError> {
let value = std::env::var(var)
.map_err(|_| ManifestError::Configuration(format!("{var} must be set")))?;
Uuid::parse_str(value.trim())
.map_err(|e| ManifestError::Configuration(format!("{var} is not a valid UUID: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_payload_serialization_shape() {
let manifest = AppManifest::default()
.app_version("1.2.3")
.git_sha("abc123")
.jobs(vec!["SendEmail".to_string(), "RefreshCache".to_string()])
.crons(vec![CronEntry {
name: "DailyDigest".to_string(),
schedule: "0 0 * * *".to_string(),
}]);
let booted_at = Utc::now();
let payload = ManifestPayload::new(&manifest, booted_at);
let json = serde_json::to_value(&payload).unwrap();
assert_eq!(
json,
serde_json::json!({
"manifest_version": 2,
"app_version": "1.2.3",
"git_sha": "abc123",
"jobs": [
{ "name": "SendEmail" },
{ "name": "RefreshCache" },
],
"crons": [
{ "name": "DailyDigest", "schedule": "0 0 * * *" },
],
"base_url": null,
"monitors": null,
"booted_at": serde_json::to_value(booted_at).unwrap(),
})
);
}
#[test]
fn test_empty_manifest_payload_serialization_shape() {
let manifest = AppManifest::default();
let booted_at = Utc::now();
let json = serde_json::to_value(ManifestPayload::new(&manifest, booted_at)).unwrap();
assert_eq!(json["manifest_version"], 2);
assert_eq!(json["app_version"], serde_json::Value::Null);
assert_eq!(json["git_sha"], serde_json::Value::Null);
assert_eq!(json["jobs"], serde_json::json!([]));
assert_eq!(json["crons"], serde_json::json!([]));
assert!(json["booted_at"].is_string());
}
#[test]
fn resolves_absolute_and_root_relative_targets() {
assert_eq!(
resolve_monitor_target(Some("https://example.com/app/"), "/health?full=1")
.unwrap()
.as_str(),
"https://example.com/health?full=1"
);
assert_eq!(
resolve_monitor_target(Some("https://example.com/app"), "/health")
.unwrap()
.as_str(),
"https://example.com/health"
);
assert_eq!(
resolve_monitor_target(None, "https://status.example.net/ping")
.unwrap()
.as_str(),
"https://status.example.net/ping"
);
}
#[test]
fn rejects_unsafe_or_ambiguous_targets() {
assert_eq!(
resolve_monitor_target(None, "/health"),
Err(MonitorTargetError::MissingBaseUrl)
);
assert_eq!(
resolve_monitor_target(None, "health"),
Err(MonitorTargetError::NonRootRelative)
);
assert_eq!(
resolve_monitor_target(Some("https://example.com"), "//evil.example"),
Err(MonitorTargetError::NetworkPath)
);
for target in [
"/\\evil.example/x",
"/\t/evil.example/x",
"/\n/evil.example/x",
"ftp://example.com/a",
"https://user@example.com/a",
"https://example.com/a#fragment",
"http://localhost/a",
"http://api.localhost/a",
"http://localhost./a",
"http://127.0.0.1/a",
"http://10.0.0.1/a",
"http://169.254.1.1/a",
"http://192.0.2.1/a",
"http://0.1.2.3/a",
"http://100.64.1.1/a",
"http://192.0.0.1/a",
"http://192.88.99.1/a",
"http://198.18.0.1/a",
"http://240.0.0.1/a",
"http://[::1]/a",
"http://[::7f00:1]/a",
"http://[fc00::1]/a",
"http://[2001:db8::1]/a",
"http://[fec0::1]/a",
"http://[64:ff9b::c000:201]/a",
"http://[3fff::1]/a",
"http://[5f00::1]/a",
] {
assert!(
resolve_monitor_target(None, target).is_err(),
"accepted {target}"
);
}
for target in [
"https://1.1.1.1/a",
"https://8.8.8.8/a",
"https://[2606:4700:4700::1111]/a",
] {
assert!(
resolve_monitor_target(None, target).is_ok(),
"rejected public target {target}"
);
}
}
#[test]
fn monitor_builder_serializes_stable_defaults() {
let monitor = HttpMonitor::new("public-health", "/health");
assert_eq!(
serde_json::to_value(monitor).unwrap(),
serde_json::json!({
"id": "public-health", "target": "/health", "method": "GET",
"interval_seconds": 60, "timeout_seconds": 10,
"expected_status_min": 200, "expected_status_max": 299,
"failure_threshold": 3, "enabled": true
})
);
}
}