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