Commit Diff


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' <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
@@ -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' <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
@@ -0,0 +1,708 @@
+// 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('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+}
+
+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
@@ -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.