commit ccd6608d82c66d7d9f4e16ffda0b033cdea67858 from: Murilo Ijanc date: Thu Apr 9 14:37:04 2026 UTC initial import commit - /dev/null commit + ccd6608d82c66d7d9f4e16ffda0b033cdea67858 blob - /dev/null blob + aa71412182b3d73392b68fe3bb2e82bfede5fb87 (mode 644) --- /dev/null +++ .gitignore @@ -0,0 +1,2 @@ +build/ +test.html blob - /dev/null blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644) --- /dev/null +++ .rustfmt.toml @@ -0,0 +1 @@ +max_width = 80 blob - /dev/null blob + ccb3f3d7f0d5c2380172474e614e4d98aa56aa1f (mode 644) --- /dev/null +++ LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2025-2026 Murilo Ijanc' + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. blob - /dev/null blob + d2ec20164094efd0e477d21bf80afb2959c68e90 (mode 644) --- /dev/null +++ Makefile @@ -0,0 +1,30 @@ +RUSTC ?= $(shell rustup which rustc 2>/dev/null || which rustc) +RUSTFLAGS ?= -C opt-level=2 -C strip=symbols -l tls +VERSION = 0.1.0 +PREFIX ?= /usr/local +MANDIR ?= $(PREFIX)/share/man + +BUILD = build +BIN = $(BUILD)/asp + +MAIN = asp.rs + +.PHONY: all clean install + +all: $(BIN) + +$(BIN): $(MAIN) + mkdir -p $(BUILD) + ASP_VERSION=$(VERSION) TMPDIR=/tmp $(RUSTC) --edition 2024 \ + --crate-type bin --crate-name asp $(RUSTFLAGS) \ + -L $(BUILD) \ + -o $@ $< + +clean: + rm -rf $(BUILD) + +install: $(BIN) + install -d $(PREFIX)/bin $(MANDIR)/man1 + install -m 755 $(BIN) $(PREFIX)/bin/asp + install -m 644 asp.1 $(MANDIR)/man1/asp.1 + blob - /dev/null blob + 5c44ab8649e73a57d90fd5dc439b2ad4850b19ad (mode 644) --- /dev/null +++ README.md @@ -0,0 +1,61 @@ +asp - Ana Status Page +================================== +asp is a minimal status page generator written in Rust. +It reads service checks from a CSV file, runs them in parallel, +and outputs a static HTML page. HTTPS uses libtls(3) from +LibreSSL. + + +Requirements +------------ +- rustc (edition 2024) +- libtls (LibreSSL or LibreTLS) +- ping(8) for ICMP checks + + +Installation +------------ +Edit Makefile to match your local setup (asp is installed into +the /usr/local/bin namespace by default). + +Afterwards enter the following command to build and install asp: + + make clean install + + +Running asp +----------- +Create a checks.csv file: + + group, 0, Web, - + http, 200, Website, https://example.com + ping, 0, DNS, 8.8.8.8 + port, 0, SSH, example.com 22 + +Generate the status page: + + asp -f checks.csv > status.html + +Use with cron(8) for periodic updates: + + */5 * * * * asp -f /etc/asp/checks.csv > /var/www/status.html + + +Configuration +------------- +See asp(1) for the full CSV format and options: + + man asp + + +Download +-------- + got clone ssh://anon@ijanc.org/asp + git clone https://git.ijanc.org/asp.git + git clone https://git.sr.ht/~ijanc/asp + git clone https://github.com/ijanc/asp.git + + +License +------- +ISC — see LICENSE. blob - /dev/null blob + de87bf296f4c20d8bbc5339fc9d0d78f383c845f (mode 644) --- /dev/null +++ asp.1 @@ -0,0 +1,133 @@ +.\" +.\" Copyright (c) 2025-2026 Murilo Ijanc' +.\" +.\" Permission to use, copy, modify, and/or distribute this software for any +.\" purpose with or without fee is hereby granted, provided that the above +.\" copyright notice and this permission notice appear in all copies. +.\" +.\" THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +.\" WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +.\" MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +.\" ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +.\" WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +.\" ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +.\" OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +.\" +.Dd $Mdocdate: April 9 2026 $ +.Dt ASP 1 +.Os +.Sh NAME +.Nm asp +.Nd Ana Status Page +.Sh SYNOPSIS +.Nm asp +.Op Fl V +.Nm asp +.Op Fl f Ar checks +.Op Fl i Ar incidents +.Op Fl t Ar timeout +.Sh DESCRIPTION +.Nm +is a minimal status page generator. +It reads service checks from a CSV file, executes them in parallel, +and outputs a static HTML page to standard output. +.Pp +HTTPS connections use +.Xr tls_init 3 +from LibreSSL. +Ping checks use the system +.Xr ping 8 +command. +TCP port checks use native socket connections. +.Pp +The options are as follows: +.Bl -tag -width Ds +.It Fl V +Print the version and exit. +.It Fl f Ar checks +Path to the checks CSV file. +Default: +.Pa checks.csv . +.It Fl i Ar incidents +Path to the incidents text file. +Default: +.Pa incidents.txt . +.It Fl t Ar timeout +Timeout in seconds for each check. +Default: 10. +.El +.Sh CSV FORMAT +The checks file contains one check per line with four comma-separated +fields. +Empty lines and lines starting with +.Sq # +are ignored. +.Pp +.D1 Ar type , Ar expected , Ar name , Ar host +.Pp +The fields are as follows: +.Bl -tag -width "expected" +.It Ar type +Check type. +Supported values: +.Cm http , +.Cm http4 , +.Cm http6 , +.Cm ping , +.Cm ping4 , +.Cm ping6 , +.Cm port , +.Cm port4 , +.Cm port6 , +.Cm group . +Types ending in +.Sq 4 +or +.Sq 6 +force IPv4 or IPv6 resolution. +.It Ar expected +Expected result code. +For HTTP checks, the expected HTTP status code (e.g.\& 200) +after following up to 5 redirects. +For ping and port checks, the expected exit code (0 for success). +.It Ar name +Display name shown in the status page. +.It Ar host +Target to check. +A URL for HTTP checks, a hostname or IP for ping checks, +or a hostname and port separated by a space for port checks. +For group entries, use +.Sq - . +.El +.Pp +Example: +.Bd -literal -offset indent +group, 0, Web Services, - +http, 200, Website, https://example.com +ping, 0, DNS Server, 8.8.8.8 +port, 0, SSH, example.com 22 +.Ed +.Sh INCIDENTS +The incidents file contains one incident per line as free-form text. +Each non-empty line is rendered as a paragraph in the status page. +If the file does not exist, the incidents section is omitted. +.Sh EXIT STATUS +.Ex -std asp +.Sh EXAMPLES +Generate a status page: +.Bd -literal -offset indent +$ asp -f checks.csv > status.html +.Ed +.Pp +Use with +.Xr cron 8 : +.Bd -literal -offset indent +*/5 * * * * asp -f /etc/asp/checks.csv > /var/www/status.html +.Ed +.Sh SEE ALSO +.Xr curl 1 , +.Xr ping 8 , +.Xr cron 8 , +.Xr tls_init 3 +.Sh AUTHORS +.An Murilo Ijanc' Aq Mt murilo@ijanc.org blob - /dev/null blob + b1c6e1b68dee3acc61a1caa530b96c67464d8796 (mode 644) --- /dev/null +++ asp.rs @@ -0,0 +1,708 @@ +// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et : +// +// Copyright (c) 2026 Murilo Ijanc' +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// + +use std::env; +use std::ffi::{CStr, CString}; +use std::fs; +use std::io::{self, Read, Write}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::os::raw::{c_char, c_int}; +use std::os::unix::io::AsRawFd; +use std::process::Command; +use std::thread; +use std::time::Duration; + +const VERSION: &str = env!("ASP_VERSION"); +const TLS_WANT_POLLIN: isize = -2; +const TLS_WANT_POLLOUT: isize = -3; + +const HEAD: &str = r#" + + + + +Status + + + +
+"#; + +// libtls(3) FFI + +enum Tls {} +enum TlsCfg {} + +unsafe extern "C" { + fn tls_init() -> c_int; + fn tls_config_new() -> *mut TlsCfg; + fn tls_config_free(cfg: *mut TlsCfg); + fn tls_client() -> *mut Tls; + fn tls_configure( + ctx: *mut Tls, + cfg: *mut TlsCfg, + ) -> c_int; + fn tls_connect_socket( + ctx: *mut Tls, + fd: c_int, + name: *const c_char, + ) -> c_int; + fn tls_read( + ctx: *mut Tls, + buf: *mut u8, + len: usize, + ) -> isize; + fn tls_write( + ctx: *mut Tls, + buf: *const u8, + len: usize, + ) -> isize; + fn tls_close(ctx: *mut Tls) -> c_int; + fn tls_free(ctx: *mut Tls); + fn tls_error(ctx: *mut Tls) -> *const c_char; +} + +struct Check { + kind: String, + expected: i32, + name: String, + host: String, +} + +struct CheckResult { + name: String, + ok: bool, + info: String, + group: bool, +} + +fn usage() { + eprintln!( + "usage: asp [-V] [-f checks] [-i incidents] \ + [-t timeout]" + ); +} + +fn parse_args() -> Option<(String, String, u64)> { + let args: Vec = env::args().collect(); + let mut checks = "checks.csv".to_string(); + let mut incidents = "incidents.txt".to_string(); + let mut timeout: u64 = 10; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-V" => { + println!("asp {}", VERSION); + std::process::exit(0); + } + "-f" => { + i += 1; + checks = args.get(i)?.clone(); + } + "-i" => { + i += 1; + incidents = args.get(i)?.clone(); + } + "-t" => { + i += 1; + timeout = args.get(i)?.parse().ok()?; + } + _ => return None, + } + i += 1; + } + Some((checks, incidents, timeout)) +} + +fn parse_checks(path: &str) -> Vec { + let data = match fs::read_to_string(path) { + Ok(d) => d, + Err(e) => { + eprintln!("asp: {}: {}", path, e); + std::process::exit(1); + } + }; + data.lines() + .filter(|l| { + let t = l.trim(); + !t.is_empty() && !t.starts_with('#') + }) + .filter_map(|line| { + let c: Vec<&str> = + line.splitn(4, ',').map(|s| s.trim()).collect(); + if c.len() < 4 { + return None; + } + Some(Check { + kind: c[0].to_string(), + expected: c[1].parse().ok()?, + name: c[2].to_string(), + host: c[3].to_string(), + }) + }) + .collect() +} + +fn ipv(kind: &str) -> &str { + if kind.ends_with('4') { + "4" + } else if kind.ends_with('6') { + "6" + } else { + "" + } +} + +fn resolve( + host: &str, + port: u16, + v: &str, +) -> io::Result { + let addrs: Vec = + (host, port).to_socket_addrs()?.collect(); + let addr = match v { + "4" => addrs.iter().find(|a| a.is_ipv4()), + "6" => addrs.iter().find(|a| a.is_ipv6()), + _ => addrs.first(), + }; + addr.copied().ok_or_else(|| { + io::Error::new( + io::ErrorKind::AddrNotAvailable, + "no address", + ) + }) +} + +fn parse_url(url: &str) -> Option<(&str, &str, u16, &str)> { + let (scheme, rest) = url.split_once("://")?; + let (hp, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let (host, port) = if hp.starts_with('[') { + let end = hp.find(']')?; + let h = &hp[1..end]; + let p = + if hp.len() > end + 1 && hp.as_bytes()[end + 1] == b':' + { + hp[end + 2..].parse().ok()? + } else if scheme == "https" { + 443 + } else { + 80 + }; + (h, p) + } else { + match hp.rsplit_once(':') { + Some((h, p)) => (h, p.parse().ok()?), + None => { + let p = + if scheme == "https" { 443 } else { 80 }; + (hp, p) + } + } + }; + Some((scheme, host, port, path)) +} + +fn esc(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +unsafe fn tls_err(ctx: *mut Tls) -> String { + let p = unsafe { tls_error(ctx) }; + if p.is_null() { + "TLS error".into() + } else { + unsafe { + CStr::from_ptr(p).to_string_lossy().into_owned() + } + } +} + +fn now() -> String { + Command::new("date") + .arg("+%FT%T%z") + .output() + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .trim() + .to_string() + }) + .unwrap_or_default() +} + +fn parse_headers(raw: &str) -> (i32, Option) { + let mut code = 0; + let mut location = None; + for (i, line) in raw.lines().enumerate() { + if i == 0 { + code = line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + continue; + } + if line.len() > 9 { + let lower = line[..9].to_ascii_lowercase(); + if lower == "location:" { + location = + Some(line[9..].trim().to_string()); + } + } + } + (code, location) +} + +fn resolve_redirect(base: &str, loc: &str) -> String { + if loc.starts_with("http://") + || loc.starts_with("https://") + { + return loc.to_string(); + } + if loc.starts_with("//") { + let scheme = + base.split("://").next().unwrap_or("https"); + return format!("{}:{}", scheme, loc); + } + if let Some((scheme, host, port, _)) = parse_url(base) { + if (port == 80 && scheme == "http") + || (port == 443 && scheme == "https") + { + format!("{}://{}{}", scheme, host, loc) + } else { + format!("{}://{}:{}{}", scheme, host, port, loc) + } + } else { + loc.to_string() + } +} + +fn http_get( + url: &str, + v: &str, + timeout: Duration, +) -> Result<(i32, Option), String> { + let (scheme, host, port, path) = + parse_url(url).ok_or("invalid URL")?; + let addr = + resolve(host, port, v).map_err(|e| e.to_string())?; + let stream = TcpStream::connect_timeout(&addr, timeout) + .map_err(|e| e.to_string())?; + let _ = stream.set_read_timeout(Some(timeout)); + let _ = stream.set_write_timeout(Some(timeout)); + + let req = format!( + "GET {} HTTP/1.0\r\nHost: {}\r\n\ + Connection: close\r\nUser-Agent: asp\r\n\r\n", + path, host + ); + + let headers = if scheme == "https" { + unsafe { https_req(&stream, host, &req)? } + } else { + http_req(&stream, &req)? + }; + + Ok(parse_headers(&headers)) +} + +fn check_http( + url: &str, + expected: i32, + v: &str, + timeout: Duration, +) -> (bool, String) { + let mut url = url.to_string(); + for _ in 0..5 { + let (code, loc) = match http_get(&url, v, timeout) + { + Ok(r) => r, + Err(e) => return (false, e), + }; + match loc { + Some(l) if (300..400).contains(&code) => { + url = resolve_redirect(&url, &l); + } + _ => { + let info = format!( + "status {}, expected {}", + code, expected + ); + return (code == expected, info); + } + } + } + (false, "too many redirects".into()) +} + +fn http_req( + stream: &TcpStream, + req: &str, +) -> Result { + let mut s = stream; + s.write_all(req.as_bytes()) + .map_err(|e| e.to_string())?; + let mut buf = [0u8; 4096]; + let mut total = 0; + loop { + let n = s + .read(&mut buf[total..]) + .map_err(|e| e.to_string())?; + if n == 0 { + break; + } + total += n; + if total >= 4 + && buf[..total] + .windows(4) + .any(|w| w == b"\r\n\r\n") + { + break; + } + if total >= buf.len() { + break; + } + } + if total == 0 { + return Err("empty response".into()); + } + Ok(String::from_utf8_lossy(&buf[..total]).into_owned()) +} + +unsafe fn https_req( + stream: &TcpStream, + host: &str, + req: &str, +) -> Result { + unsafe { + let ctx = tls_client(); + if ctx.is_null() { + return Err("tls_client failed".into()); + } + let cfg = tls_config_new(); + if cfg.is_null() { + tls_free(ctx); + return Err("tls_config_new failed".into()); + } + if tls_configure(ctx, cfg) != 0 { + let e = tls_err(ctx); + tls_config_free(cfg); + tls_free(ctx); + return Err(e); + } + tls_config_free(cfg); + + let name = CString::new(host) + .map_err(|_| "invalid host")?; + if tls_connect_socket( + ctx, + stream.as_raw_fd(), + name.as_ptr(), + ) != 0 + { + let e = tls_err(ctx); + tls_close(ctx); + tls_free(ctx); + return Err(e); + } + + let bytes = req.as_bytes(); + let mut off = 0; + while off < bytes.len() { + let n = tls_write( + ctx, + bytes[off..].as_ptr(), + bytes.len() - off, + ); + if n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT { + continue; + } + if n < 0 { + let e = tls_err(ctx); + tls_close(ctx); + tls_free(ctx); + return Err(e); + } + off += n as usize; + } + + let mut buf = [0u8; 4096]; + let mut total = 0; + loop { + let n = tls_read( + ctx, + buf[total..].as_mut_ptr(), + buf.len() - total, + ); + if n == TLS_WANT_POLLIN || n == TLS_WANT_POLLOUT { + continue; + } + if n <= 0 { + break; + } + total += n as usize; + if total >= 4 + && buf[..total] + .windows(4) + .any(|w| w == b"\r\n\r\n") + { + break; + } + if total >= buf.len() { + break; + } + } + + tls_close(ctx); + tls_free(ctx); + + if total == 0 { + return Err("empty response".into()); + } + Ok(String::from_utf8_lossy(&buf[..total]) + .into_owned()) + } +} + +fn check_ping( + host: &str, + expected: i32, + v: &str, + timeout: u64, +) -> (bool, String) { + let mut cmd = Command::new("ping"); + if !v.is_empty() { + cmd.arg(format!("-{}", v)); + } + cmd.args(["-W", &timeout.to_string(), "-c", "1", host]); + match cmd.output() { + Ok(o) => { + let rc = o.status.code().unwrap_or(1); + if rc == expected { + (true, String::new()) + } else { + (false, "host unreachable".into()) + } + } + Err(e) => (false, e.to_string()), + } +} + +fn check_port( + host: &str, + expected: i32, + v: &str, + timeout: Duration, +) -> (bool, String) { + let (hostname, portstr) = match host.rsplit_once(' ') { + Some(p) => p, + None => return (false, "invalid host port".into()), + }; + let port: u16 = match portstr.parse() { + Ok(p) => p, + Err(_) => return (false, "invalid port".into()), + }; + let addr = match resolve(hostname, port, v) { + Ok(a) => a, + Err(e) => return (false, e.to_string()), + }; + let rc = match TcpStream::connect_timeout(&addr, timeout) { + Ok(_) => 0, + Err(_) => 1, + }; + if rc == expected { + (true, String::new()) + } else { + (false, "connection refused".into()) + } +} + +fn run_checks( + checks: &[Check], + timeout: u64, +) -> Vec { + let dur = Duration::from_secs(timeout); + thread::scope(|s| { + let handles: Vec<_> = checks + .iter() + .map(|c| { + s.spawn(move || { + if c.kind.starts_with("group") { + return CheckResult { + name: c.name.clone(), + ok: true, + info: String::new(), + group: true, + }; + } + let v = ipv(&c.kind); + let (ok, info) = + if c.kind.starts_with("http") { + check_http( + &c.host, c.expected, v, dur, + ) + } else if c.kind.starts_with("ping") { + check_ping( + &c.host, c.expected, v, + timeout, + ) + } else if c.kind.starts_with("port") { + check_port( + &c.host, c.expected, v, dur, + ) + } else { + ( + false, + format!( + "unknown: {}", + c.kind + ), + ) + }; + CheckResult { + name: c.name.clone(), + ok, + info, + group: false, + } + }) + }) + .collect(); + handles + .into_iter() + .map(|h| h.join().unwrap()) + .collect() + }) +} + +fn render_html(results: &[CheckResult], incidents: &str) { + let outages = results + .iter() + .filter(|r| !r.group && !r.ok) + .count(); + + print!("{}", HEAD); + println!("

Status

"); + + if outages > 0 { + println!( + "
  • \ + {} Outage(s)
", + outages + ); + } else { + println!( + "
  • \ + All Systems Operational
" + ); + } + + println!("

Services

\n
    "); + for r in results { + if r.group { + println!( + "
  • {}
  • ", + esc(&r.name) + ); + } else if r.ok { + println!( + "
  • {} \ + Operational
  • ", + esc(&r.name) + ); + } else { + println!( + "
  • {} \ + ({})\ + \ + Disrupted
  • ", + esc(&r.name), + esc(&r.info) + ); + } + } + println!("
"); + + if !incidents.is_empty() { + println!("

Incidents

"); + for line in incidents.lines() { + if !line.trim().is_empty() { + println!("

{}

", esc(line)); + } + } + } + + println!( + "

Last check: {}

", + now() + ); + println!( + "

Powered by \ + asp

" + ); + println!("
\n\n"); +} + +fn main() { + let (checks_path, incidents_path, timeout) = + match parse_args() { + Some(a) => a, + None => { + usage(); + std::process::exit(1); + } + }; + + unsafe { + if tls_init() != 0 { + eprintln!("asp: tls_init failed"); + std::process::exit(1); + } + } + + let checks = parse_checks(&checks_path); + let results = run_checks(&checks, timeout); + let incidents = + fs::read_to_string(&incidents_path).unwrap_or_default(); + + render_html(&results, &incidents); +} blob - /dev/null blob + fbceccf7efd9d7f8be1fe677f0a5e6b39e859132 (mode 644) --- /dev/null +++ checks.csv @@ -0,0 +1,10 @@ +# Web +group, 0, Web, - +http, 200, Google, https://google.com +http, 200, Example, http://example.com + +# DNS +group, 0, DNS, - +ping, 0, Google DNS, 8.8.8.8 +ping, 0, Cloudflare DNS, 1.1.1.1 +port, 0, Google DNS, 8.8.8.8 53 blob - /dev/null blob + 89b0b33f2648e9b510de38e432f0034c748126bc (mode 644) --- /dev/null +++ incidents.txt @@ -0,0 +1,2 @@ +2026/04/09 10:00 - DNS resolution slow for 5 minutes. Resolved. +2026/04/01 08:30 - Scheduled maintenance on web services. No downtime.