commit - /dev/null
commit + ccd6608d82c66d7d9f4e16ffda0b033cdea67858
blob - /dev/null
blob + aa71412182b3d73392b68fe3bb2e82bfede5fb87 (mode 644)
--- /dev/null
+++ .gitignore
+build/
+test.html
blob - /dev/null
blob + df99c69198f5813df5fc3eaa007a2af0e60a7bbd (mode 644)
--- /dev/null
+++ .rustfmt.toml
+max_width = 80
blob - /dev/null
blob + ccb3f3d7f0d5c2380172474e614e4d98aa56aa1f (mode 644)
--- /dev/null
+++ LICENSE
+Copyright (c) 2025-2026 Murilo Ijanc' <murilo@ijanc.org>
+
+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
+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
+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
+.\"
+.\" Copyright (c) 2025-2026 Murilo Ijanc' <murilo@ijanc.org>
+.\"
+.\" 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
+// vim: set tw=79 cc=80 ts=4 sw=4 sts=4 et :
+//
+// Copyright (c) 2026 Murilo Ijanc' <murilo@ijanc.org>
+//
+// 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#"<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Status</title>
+<style>
+:root { color-scheme: light dark; --gray: #E7E7E7; }
+@media (prefers-color-scheme: dark) { :root { --gray: #333; } }
+body { font-family: sans-serif; }
+h1 { margin-top: 30px; }
+ul { padding: 0; }
+li { list-style: none; margin-bottom: 2px; padding: 5px;
+ border-bottom: 1px solid var(--gray); }
+.container { max-width: 600px; width: 100%; margin: 15px auto; }
+.panel { text-align: center; padding: 10px; }
+.group { background: var(--gray); margin: 30px 0 10px; }
+.small { font-size: 80%; }
+.failed { color: #E25D6A; }
+.success { color: #52B86A; }
+.status { float: right; }
+.failed-bg { color: white; background: #E25D6A; }
+.success-bg { color: white; background: #52B86A; }
+.info { color: gray; }
+.info a { color: inherit; text-decoration: none; }
+.info a:hover { text-decoration: underline; }
+</style>
+</head>
+<body>
+<div class="container">
+"#;
+
+// 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<String> = 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<Check> {
+ 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<SocketAddr> {
+ let addrs: Vec<SocketAddr> =
+ (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<String>) {
+ 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>), 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<String, String> {
+ 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<String, String> {
+ 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<CheckResult> {
+ 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!("<h1>Status</h1>");
+
+ if outages > 0 {
+ println!(
+ "<ul><li class=\"panel failed-bg\">\
+ {} Outage(s)</li></ul>",
+ outages
+ );
+ } else {
+ println!(
+ "<ul><li class=\"panel success-bg\">\
+ All Systems Operational</li></ul>"
+ );
+ }
+
+ println!("<h1>Services</h1>\n<ul>");
+ for r in results {
+ if r.group {
+ println!(
+ "<li class=\"panel group\">{}</li>",
+ esc(&r.name)
+ );
+ } else if r.ok {
+ println!(
+ "<li>{} <span class=\"status success\">\
+ Operational</span></li>",
+ esc(&r.name)
+ );
+ } else {
+ println!(
+ "<li>{} <span class=\"small failed\">\
+ ({})</span>\
+ <span class=\"status failed\">\
+ Disrupted</span></li>",
+ esc(&r.name),
+ esc(&r.info)
+ );
+ }
+ }
+ println!("</ul>");
+
+ if !incidents.is_empty() {
+ println!("<h1>Incidents</h1>");
+ for line in incidents.lines() {
+ if !line.trim().is_empty() {
+ println!("<p>{}</p>", esc(line));
+ }
+ }
+ }
+
+ println!(
+ "<p class=\"small info\">Last check: {}</p>",
+ now()
+ );
+ println!(
+ "<p class=\"small info\">Powered by \
+ <a href=\"https://git.ijanc.org/asp\">asp</a></p>"
+ );
+ println!("</div>\n</body>\n</html>");
+}
+
+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
+# 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
+2026/04/09 10:00 - DNS resolution slow for 5 minutes. Resolved.
+2026/04/01 08:30 - Scheduled maintenance on web services. No downtime.