1pub mod auto;
24mod health_path;
25pub mod heartbeat;
26pub mod probe;
27pub mod service;
28pub mod state;
29pub mod store;
30mod target;
31mod view;
32
33use std::path::{Path, PathBuf};
34
35use serde::{Deserialize, Serialize};
36
37use crate::error::{Error, Result};
38use crate::org::OrgId;
39pub use service::Monitors;
40
41pub const MAX_PER_ORG: usize = 200;
43pub const MIN_INTERVAL: u64 = 30;
45pub const AUTO_PREFIX: &str = "app-";
47pub const STACK_PREFIX: &str = "stack-";
49pub const ACCESS_ID_SECRET: &str = "CF_ACCESS_CLIENT_ID";
52pub const ACCESS_SECRET_SECRET: &str = "CF_ACCESS_CLIENT_SECRET";
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum Kind {
57 Http,
58 Tcp,
59 App,
60 Service,
61}
62
63impl Kind {
64 pub fn as_str(self) -> &'static str {
65 match self {
66 Kind::Http => "http",
67 Kind::Tcp => "tcp",
68 Kind::App => "app",
69 Kind::Service => "service",
70 }
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct Header {
79 pub name: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub value: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub secret: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct Monitor {
90 pub name: String,
91 #[serde(rename = "type")]
92 pub kind: Kind,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub url: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub host: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub port: Option<u16>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub app: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub stack: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub service: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub domain: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub path: Option<String>,
116 #[serde(default = "get")]
118 pub method: String,
119 #[serde(default = "expected")]
121 pub expected_status: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub keyword: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub keyword_absent: Option<String>,
128 #[serde(default)]
129 pub follow_redirects: bool,
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub headers: Vec<Header>,
132 #[serde(default = "interval")]
134 pub interval: u64,
135 #[serde(default = "timeout")]
137 pub timeout: u64,
138 #[serde(default = "two")]
140 pub failure_threshold: u32,
141 #[serde(default = "two")]
143 pub recovery_threshold: u32,
144 #[serde(default = "cert_days")]
146 pub cert_expiry_days: u32,
147 #[serde(default)]
148 pub paused: bool,
149 #[serde(default)]
152 pub auto: bool,
153 #[serde(default)]
154 pub created_at: u64,
155 #[serde(default)]
156 pub updated_at: u64,
157}
158
159fn get() -> String {
160 "GET".into()
161}
162fn expected() -> String {
163 "200-399".into()
164}
165fn interval() -> u64 {
166 60
167}
168fn timeout() -> u64 {
169 10
170}
171fn two() -> u32 {
172 2
173}
174fn cert_days() -> u32 {
175 14
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct Settings {
182 #[serde(default = "yes")]
184 pub auto_monitors: bool,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub exclude_apps: Vec<String>,
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub exclude_services: Vec<String>,
192}
193
194fn yes() -> bool {
195 true
196}
197
198impl Default for Settings {
199 fn default() -> Settings {
200 Settings {
201 auto_monitors: true,
202 exclude_apps: Vec::new(),
203 exclude_services: Vec::new(),
204 }
205 }
206}
207
208pub fn validate_name(n: &str) -> Result<()> {
210 let ok = !n.is_empty()
211 && n.len() <= 63
212 && n.starts_with(|c: char| c.is_ascii_lowercase())
213 && n.chars()
214 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
215 if ok {
216 Ok(())
217 } else {
218 Err(Error::invalid(format!(
219 "monitor name {n:?}: [a-z0-9-], starting with a letter, at most 63 characters"
220 )))
221 }
222}
223
224pub fn validate_service_name(s: &str) -> Result<()> {
226 let ok = !s.is_empty()
227 && s.len() <= 63
228 && s.chars()
229 .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c));
230 if ok {
231 Ok(())
232 } else {
233 Err(Error::invalid(format!(
234 "service {s:?}: a compose service's name ([A-Za-z0-9._-], at most 63)"
235 )))
236 }
237}
238
239pub fn parse_status(s: &str) -> Result<Vec<(u16, u16)>> {
241 let bad = || {
242 Error::invalid(format!(
243 "expected_status {s:?}: codes and ranges like 200-399 or 200,204"
244 ))
245 };
246 let mut out = Vec::new();
247 for part in s.split(',').map(str::trim) {
248 let (a, b) = part.split_once('-').unwrap_or((part, part));
249 let a: u16 = a.trim().parse().map_err(|_| bad())?;
250 let b: u16 = b.trim().parse().map_err(|_| bad())?;
251 if !(100..=599).contains(&a) || !(100..=599).contains(&b) || a > b {
252 return Err(bad());
253 }
254 out.push((a, b));
255 }
256 if out.is_empty() || out.len() > 10 {
257 return Err(bad());
258 }
259 Ok(out)
260}
261
262const RESERVED_HEADERS: &[&str] = &[
264 "host",
265 "content-length",
266 "transfer-encoding",
267 "connection",
268 "upgrade",
269];
270
271fn validate_headers(hs: &[Header]) -> Result<()> {
272 if hs.len() > 10 {
273 return Err(Error::invalid("at most 10 headers"));
274 }
275 for h in hs {
276 let n = &h.name;
277 let token = !n.is_empty()
278 && n.len() <= 64
279 && n.chars()
280 .all(|c| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c));
281 if !token || RESERVED_HEADERS.contains(&n.to_ascii_lowercase().as_str()) {
282 return Err(Error::invalid(format!("header name {n:?}")));
283 }
284 match (&h.value, &h.secret) {
285 (Some(v), None) if v.len() <= 1024 && !v.contains(['\r', '\n']) => {}
286 (None, Some(s)) => crate::secrets::validate_name(s)?,
287 _ => {
288 return Err(Error::invalid(format!(
289 "header {n}: a value (without line breaks, at most 1024) or a secret, not both"
290 )));
291 }
292 }
293 }
294 Ok(())
295}
296
297fn validate_tcp_host(h: &str) -> Result<()> {
298 let ok = !h.is_empty()
299 && h.len() <= 253
300 && h.chars()
301 .all(|c| c.is_ascii_alphanumeric() || ".-:[]".contains(c));
302 if ok {
303 Ok(())
304 } else {
305 Err(Error::invalid(format!(
306 "host {h:?} is not a host name or address"
307 )))
308 }
309}
310
311impl Monitor {
312 pub fn new(name: &str, kind: Kind) -> Monitor {
314 serde_json::from_value(serde_json::json!({"name": name, "type": kind}))
315 .expect("defaults deserialize")
316 }
317
318 pub fn validate(&self) -> Result<()> {
320 validate_name(&self.name)?;
321 self.validate_target()?;
322 if !(MIN_INTERVAL..=86_400).contains(&self.interval) {
323 return Err(Error::invalid(format!(
324 "interval: {MIN_INTERVAL} to 86400 seconds"
325 )));
326 }
327 if !(1..=60).contains(&self.timeout) || self.timeout >= self.interval {
328 return Err(Error::invalid(
329 "timeout: 1 to 60 seconds, under the interval",
330 ));
331 }
332 for (what, n) in [
333 ("failure_threshold", self.failure_threshold),
334 ("recovery_threshold", self.recovery_threshold),
335 ] {
336 if !(1..=10).contains(&n) {
337 return Err(Error::invalid(format!("{what}: 1 to 10 checks")));
338 }
339 }
340 if self.cert_expiry_days > 365 {
341 return Err(Error::invalid("cert_expiry_days: 0 to 365"));
342 }
343 Ok(())
344 }
345
346 fn validate_target(&self) -> Result<()> {
347 let http_only = self.keyword.is_some()
348 || self.keyword_absent.is_some()
349 || !self.headers.is_empty()
350 || self.follow_redirects;
351 match self.kind {
352 Kind::Http => {
353 let url = self
354 .url
355 .as_deref()
356 .ok_or_else(|| Error::invalid("an http monitor needs url"))?;
357 crate::net::parse_url(url).map_err(|e| Error::invalid(format!("url: {e}")))?;
358 self.refuse(&[
359 ("host", self.host.is_some()),
360 ("port", self.port.is_some()),
361 ("app", self.app.is_some()),
362 (
363 "stack or service",
364 self.stack.is_some() || self.service.is_some(),
365 ),
366 ("domain", self.domain.is_some()),
367 ("path", self.path.is_some()),
368 ])?;
369 }
370 Kind::Tcp => {
371 validate_tcp_host(self.host.as_deref().unwrap_or_default())?;
372 if self.port.is_none_or(|p| p == 0) {
373 return Err(Error::invalid("a tcp monitor needs host and port"));
374 }
375 self.refuse(&[
376 ("url", self.url.is_some()),
377 ("app", self.app.is_some()),
378 (
379 "stack or service",
380 self.stack.is_some() || self.service.is_some(),
381 ),
382 ("domain", self.domain.is_some()),
383 ("path", self.path.is_some()),
384 ("keyword, headers or follow_redirects", http_only),
385 ])?;
386 }
387 Kind::App => {
388 let app = self.app.as_deref().unwrap_or_default();
389 crate::app::validate_app_name(app)
390 .map_err(|_| Error::invalid(format!("app {app:?}: an app's name")))?;
391 self.validate_path()?;
392 self.refuse(&[
393 ("url", self.url.is_some()),
394 ("host", self.host.is_some()),
395 ("port", self.port.is_some()),
396 (
397 "stack or service",
398 self.stack.is_some() || self.service.is_some(),
399 ),
400 ])?;
401 }
402 Kind::Service => {
403 let stack = self.stack.as_deref().unwrap_or_default();
404 crate::stack::validate_stack_name(stack)
405 .map_err(|_| Error::invalid(format!("stack {stack:?}: a stack's name")))?;
406 validate_service_name(self.service.as_deref().unwrap_or_default())?;
407 self.validate_path()?;
408 self.refuse(&[
409 ("url", self.url.is_some()),
410 ("host", self.host.is_some()),
411 ("port", self.port.is_some()),
412 ("app", self.app.is_some()),
413 ])?;
414 }
415 }
416 if self.kind != Kind::Tcp {
417 if !matches!(self.method.as_str(), "GET" | "HEAD") {
418 return Err(Error::invalid("method: GET or HEAD"));
419 }
420 parse_status(&self.expected_status)?;
421 for k in [&self.keyword, &self.keyword_absent].into_iter().flatten() {
422 if k.is_empty() || k.len() > 200 {
423 return Err(Error::invalid("keywords: 1 to 200 characters"));
424 }
425 }
426 validate_headers(&self.headers)?;
427 }
428 Ok(())
429 }
430
431 fn validate_path(&self) -> Result<()> {
432 match &self.path {
433 Some(p) if !p.starts_with('/') || p.len() > 1024 || p.contains(char::is_whitespace) => {
434 Err(Error::invalid("path: starts with /, no spaces"))
435 }
436 _ => Ok(()),
437 }
438 }
439
440 pub fn follows(&self) -> bool {
442 matches!(self.kind, Kind::App | Kind::Service)
443 }
444
445 fn refuse(&self, fields: &[(&str, bool)]) -> Result<()> {
446 match fields.iter().find(|(_, set)| *set) {
447 Some((f, _)) => Err(Error::invalid(format!(
448 "{f} does not apply to a {} monitor",
449 self.kind.as_str()
450 ))),
451 None => Ok(()),
452 }
453 }
454
455 pub fn secrets(&self) -> Vec<&str> {
457 self.headers
458 .iter()
459 .filter_map(|h| h.secret.as_deref())
460 .collect()
461 }
462
463 pub fn target(&self) -> String {
465 match self.kind {
466 Kind::Http => probe::display_url(self.url.as_deref().unwrap_or_default()),
467 Kind::Tcp => format!(
468 "{}:{}",
469 self.host.as_deref().unwrap_or_default(),
470 self.port.unwrap_or_default()
471 ),
472 Kind::App => format!("app {}", self.app.as_deref().unwrap_or_default()),
473 Kind::Service => format!(
474 "service {}/{}",
475 self.stack.as_deref().unwrap_or_default(),
476 self.service.as_deref().unwrap_or_default()
477 ),
478 }
479 }
480}
481
482pub fn dir(state: &Path, org: &OrgId) -> PathBuf {
484 org.dir(state).join("monitors")
485}
486
487pub(crate) fn read_json<T: for<'de> Deserialize<'de> + Default>(p: &Path) -> Result<T> {
488 match std::fs::read(p) {
489 Ok(b) => {
490 serde_json::from_slice(&b).map_err(|e| Error::invalid(format!("{}: {e}", p.display())))
491 }
492 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
493 Err(e) => Err(e.into()),
494 }
495}
496
497pub(crate) fn write_json<T: Serialize>(p: &Path, v: &T) -> Result<()> {
498 if let Some(d) = p.parent() {
499 std::fs::create_dir_all(d)?;
500 }
501 let tmp = p.with_extension("tmp");
502 std::fs::write(&tmp, serde_json::to_vec_pretty(v)?)?;
503 std::fs::rename(&tmp, p)?;
504 Ok(())
505}
506
507pub fn human(ms: u64) -> String {
509 let s = ms / 1000;
510 match s {
511 0..60 => format!("{s}s"),
512 60..3600 => format!("{}m {}s", s / 60, s % 60),
513 3600..86_400 => format!("{}h {}m", s / 3600, s % 3600 / 60),
514 _ => format!("{}d {}h", s / 86_400, s % 86_400 / 3600),
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use serde_json::json;
522
523 fn m(v: serde_json::Value) -> Result<Monitor> {
524 let m: Monitor = serde_json::from_value(v).map_err(|e| Error::invalid(e.to_string()))?;
525 m.validate()?;
526 Ok(m)
527 }
528
529 #[test]
530 fn defaults_and_validation() {
531 let h =
532 m(json!({"name": "shop", "type": "http", "url": "https://shop.example.com/"})).unwrap();
533 assert_eq!(
534 (
535 h.interval,
536 h.timeout,
537 h.failure_threshold,
538 h.recovery_threshold
539 ),
540 (60, 10, 2, 2)
541 );
542 assert_eq!(
543 (
544 h.method.as_str(),
545 h.expected_status.as_str(),
546 h.cert_expiry_days
547 ),
548 ("GET", "200-399", 14)
549 );
550 assert!(m(json!({"name": "x", "type": "http"})).is_err());
551 assert!(m(json!({"name": "x", "type": "http", "url": "ftp://a"})).is_err());
552 assert!(
553 m(json!({"name": "x", "type": "http", "url": "https://a/", "interval": 10})).is_err()
554 );
555 assert!(m(json!({"name": "x", "type": "http", "url": "https://a/", "interval": 30, "timeout": 30})).is_err());
556 assert!(m(json!({"name": "x", "type": "http", "url": "https://a/", "port": 80})).is_err());
557 assert!(
558 m(json!({"name": "x", "type": "http", "url": "https://a/", "method": "POST"})).is_err()
559 );
560 assert!(
561 m(json!({"name": "x", "type": "http", "url": "https://a/", "failure_threshold": 0}))
562 .is_err()
563 );
564 assert!(m(json!({"name": "X", "type": "http", "url": "https://a/"})).is_err());
565 assert!(m(json!({"name": "x", "type": "http", "url": "https://a/", "bogus": 1})).is_err());
566 m(json!({"name": "db", "type": "tcp", "host": "db.example.com", "port": 5432})).unwrap();
567 assert!(m(json!({"name": "db", "type": "tcp", "host": "db.example.com"})).is_err());
568 assert!(m(json!({"name": "db", "type": "tcp", "host": "a b", "port": 1})).is_err());
569 assert!(
570 m(json!({"name": "db", "type": "tcp", "host": "a", "port": 1, "keyword": "x"}))
571 .is_err()
572 );
573 m(json!({"name": "web", "type": "app", "app": "web", "path": "/healthz"})).unwrap();
574 assert!(m(json!({"name": "web", "type": "app", "app": "web", "path": "healthz"})).is_err());
575 assert!(
576 m(json!({"name": "web", "type": "app", "app": "web", "url": "https://a/"})).is_err()
577 );
578 }
579
580 #[test]
581 fn headers_and_status_ranges() {
582 let ok = json!({"name": "a", "type": "http", "url": "https://a/", "headers": [
583 {"name": "CF-Access-Client-Id", "secret": "CF_ID"}, {"name": "Accept", "value": "text/html"}]});
584 assert_eq!(m(ok).unwrap().secrets(), vec!["CF_ID"]);
585 for bad in [
586 json!([{"name": "Host", "value": "x"}]),
587 json!([{"name": "X", "value": "a\r\nb: c"}]),
588 json!([{"name": "X", "value": "a", "secret": "S"}]),
589 json!([{"name": "X"}]),
590 json!([{"name": "Bad Name", "value": "a"}]),
591 ] {
592 assert!(
593 m(json!({"name": "a", "type": "http", "url": "https://a/", "headers": bad}))
594 .is_err(),
595 "{bad}"
596 );
597 }
598 assert_eq!(parse_status("200-399").unwrap(), vec![(200, 399)]);
599 assert_eq!(
600 parse_status("200, 204,300-301").unwrap(),
601 vec![(200, 200), (204, 204), (300, 301)]
602 );
603 for bad in ["", "abc", "399-200", "99", "200-700", "200,"] {
604 assert!(parse_status(bad).is_err(), "{bad}");
605 }
606 }
607
608 #[test]
609 fn words() {
610 assert_eq!(human(4_000), "4s");
611 assert_eq!(human(252_000), "4m 12s");
612 assert_eq!(human(7_500_000), "2h 5m");
613 assert_eq!(human(266_400_000), "3d 2h");
614 assert_eq!(Monitor::new("x", Kind::Tcp).interval, 60);
615 }
616}