v0.0.1 release
Container image / image (push) Successful in 1m47s
Release / release (push) Successful in 1m46s

This commit is contained in:
2026-05-15 05:03:06 +00:00
parent 7b6acb20eb
commit 7bc33b1837
7 changed files with 310 additions and 42 deletions
+34
View File
@@ -0,0 +1,34 @@
# Build artifacts
/qu
/qu-*
/dist/
*.exe
*.test
*.out
# Go workspace / module cache (only relevant if vendored)
/vendor/
# Local node state — never commit anything that looks like a data dir
/quptime/
/etc/quptime/
node.yaml
cluster.yaml
trust.yaml
keys/
# Compose / secrets
.env
.env.local
*.local.yml
*.local.yaml
# Editor / OS scratch
*.swp
*.swo
*~
.DS_Store
# Test / coverage
coverage.out
coverage.html
+86
View File
@@ -0,0 +1,86 @@
# Changelog
All notable changes to this project are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v0.0.1] — 2026-05-15
Initial public release.
### Added
- **Quorum-based uptime monitoring.** Multiple cooperating nodes run
the same probes (HTTP, TCP, ICMP) and vote on the cluster-wide
truth. A check flips state only after two consecutive aggregate
evaluations agree (hysteresis), so single-node flake doesn't page
anyone.
- **Deterministic master election.** Among the live members of the
quorum the lexicographically smallest NodeID wins — no negotiation
step, no split-brain window.
- **mTLS inter-node transport** with TLS 1.3 minimum, SSH-style
fingerprint pinning, and a pre-shared `cluster_secret` gating the
Join RPC.
- **Replicated `cluster.yaml`** carrying peers, checks, and alerts.
Master is the only writer; followers receive monotonic-versioned
snapshots and converge on the latest. Hand-edits to the file on any
node are picked up by the manual-edit watcher and forwarded through
the master.
- **HTTP, TCP, and ICMP probes** with configurable interval,
timeout, expected status, and optional body-substring match. ICMP
defaults to unprivileged UDP-mode pings so the daemon can run as a
non-root user.
- **SMTP and Discord alerts** with optional Go `text/template`
subject/body overrides per alert, default-attach mode (`default:
true`), and per-check opt-outs via `suppress_alert_ids`.
- **Docker-friendly env-var configuration.** Every field in
`node.yaml` can also be supplied via a `QUPTIME_*` environment
variable; `qu serve` auto-initialises a fresh data volume from
these on first start, so `docker compose up` is enough to launch a
node.
- **Interactive TUI** (`qu tui`) for peers, checks, and alerts with
live refresh.
- **Hardened systemd unit** shipped via `install.sh`: dedicated
`quptime` user, `ProtectSystem=strict`, all capabilities dropped by
default.
- **Multi-arch Docker images** (`linux/amd64`, `linux/arm64`)
published to `git.cer.sh/axodouble/quptime`.
- **Static Linux binaries** (`amd64`, `arm64`) published per tag with
a `SHA256SUMS` file; the official installer verifies the checksum
before placing the binary on disk.
### Security
- Cluster secret is compared in constant time
(`crypto/subtle.ConstantTimeCompare`).
- Self-signed RSA certs minted at `qu init`; SPKI SHA-256
fingerprints are what's pinned, matching the canonical OpenSSL
representation.
- Private keys are written with mode `0600`; data and runtime
directories with `0700`/`0750`.
- All `cluster.yaml` writes go through an atomic `tmpfile + rename`.
- `install.sh` downloads the published `SHA256SUMS` and refuses to
install if the downloaded binary doesn't match.
### Known limitations
- **Cluster-wide secret distribution.** SMTP passwords and Discord
webhook URLs configured via `qu alert add …` are stored in
`cluster.yaml`, which is replicated to every node. Treat every node
as having read access to every alert credential. Restrict who can
reach the data directory accordingly. See
[docs/security.md](docs/security.md) for the threat model.
- **No automatic key rotation.** Rolling a node's identity means
wiping its data directory, running `qu init` again, and re-adding
it from another node.
- **No historical metrics.** Only the current aggregate state is kept
in memory. There is no built-in graph store, SLA calculator, or
audit log.
- **Master-flap state.** Aggregator hysteresis state lives in
memory on the current master. When leadership changes the new
master starts from `StateUnknown` and re-accumulates hysteresis —
expect a few seconds of delayed alerting after a master switch.
- **No release signing beyond SHA256SUMS** (no cosign / GPG).
Planned for a future release.
[v0.0.1]: https://git.cer.sh/axodouble/quptime/releases/tag/v0.0.1
+11 -2
View File
@@ -88,7 +88,7 @@ go build -o qu ./cmd/qu
To stamp the version into the binary: To stamp the version into the binary:
```sh ```sh
go build -ldflags "-X main.version=v0.1.0" -o qu ./cmd/qu go build -ldflags "-X main.version=v0.0.1" -o qu ./cmd/qu
qu --version qu --version
``` ```
@@ -100,7 +100,7 @@ amd64 and arm64, and publishes them as a Gitea release with a
`SHA256SUMS` file alongside. `SHA256SUMS` file alongside.
```sh ```sh
git tag v0.1.0 git tag v0.0.1
git push --tags git push --tags
``` ```
@@ -166,6 +166,15 @@ c0d4... charlie.example.com:9901 true 2026-05-12T15:01:32Z
## Adding checks and alerts ## Adding checks and alerts
> ⚠️ **Alert credentials are replicated cluster-wide.** SMTP passwords
> and Discord webhook URLs live in `cluster.yaml`, which is mirrored to
> every node. Any node that can read its own data directory can read
> every alert secret. Treat compromising one node as compromising every
> alert credential, and restrict who can reach `$QUPTIME_DIR` on each
> host (the hardened systemd unit and the Docker image both default to
> `0700`/`0750`). See [docs/security.md](docs/security.md) for the full
> threat model.
```sh ```sh
# alerts first so checks can reference them # alerts first so checks can reference them
qu alert add discord oncall --webhook https://discord.com/api/webhooks/... qu alert add discord oncall --webhook https://discord.com/api/webhooks/...
+6 -5
View File
@@ -9,8 +9,9 @@ daemon can bind privileged ports and open ICMP sockets; override with
``` ```
git.cer.sh/axodouble/quptime:master # tip of main, multi-arch git.cer.sh/axodouble/quptime:master # tip of main, multi-arch
git.cer.sh/axodouble/quptime:v0.1.0 # tagged release git.cer.sh/axodouble/quptime:latest # latest tagged release
git.cer.sh/axodouble/quptime:v0.1.0-amd64 # single-arch (if you must pin) git.cer.sh/axodouble/quptime:v0.0.1 # specific tagged release
git.cer.sh/axodouble/quptime:latest-amd64 # single-arch (if you must pin)
``` ```
The image embeds `QUPTIME_DIR=/etc/quptime` and declares it a volume — The image embeds `QUPTIME_DIR=/etc/quptime` and declares it a volume —
@@ -24,7 +25,7 @@ For a development cluster or a single-node smoke test:
# compose.yaml # compose.yaml
services: services:
quptime: quptime:
image: git.cer.sh/axodouble/quptime:v0.1.0 image: git.cer.sh/axodouble/quptime:latest
container_name: quptime container_name: quptime
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -76,7 +77,7 @@ For local testing of the full quorum machinery without three machines:
```yaml ```yaml
# compose.yaml # compose.yaml
x-quptime: &quptime x-quptime: &quptime
image: git.cer.sh/axodouble/quptime:v0.1.0 image: git.cer.sh/axodouble/quptime:latest
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
- NET_RAW - NET_RAW
@@ -146,7 +147,7 @@ The natural unit is one compose file per host, each running one
# /etc/qu-stack/compose.yaml # /etc/qu-stack/compose.yaml
services: services:
quptime: quptime:
image: git.cer.sh/axodouble/quptime:v0.1.0 image: git.cer.sh/axodouble/quptime:latest
container_name: quptime container_name: quptime
restart: unless-stopped restart: unless-stopped
environment: environment:
+1 -1
View File
@@ -51,7 +51,7 @@ services:
restart: unless-stopped restart: unless-stopped
quptime: quptime:
image: git.cer.sh/axodouble/quptime:v0.1.0 image: git.cer.sh/axodouble/quptime:latest
container_name: quptime container_name: quptime
environment: environment:
# host:port other QUptime nodes use to reach this one. Should be # host:port other QUptime nodes use to reach this one. Should be
+2 -1
View File
@@ -79,7 +79,8 @@ registry on every tag and every push to `master`:
``` ```
git.cer.sh/axodouble/quptime:master # tip of main git.cer.sh/axodouble/quptime:master # tip of main
git.cer.sh/axodouble/quptime:v0.1.0 # tagged release git.cer.sh/axodouble/quptime:latest # latest tagged release
git.cer.sh/axodouble/quptime:v0.0.1 # pinned release
``` ```
See the [Docker deployment guide](deployment/docker.md) for compose See the [Docker deployment guide](deployment/docker.md) for compose
+168 -31
View File
@@ -1,23 +1,30 @@
#!/bin/bash #!/bin/bash
# QUptime installer.
#
# Downloads the latest released `qu` binary from the Gitea release
# page, verifies it against the published SHA256SUMS, installs it to
# /usr/local/bin, and (on systemd hosts) drops in a hardened
# quptime.service that matches the unit documented in
# docs/deployment/systemd.md. Idempotent — re-running upgrades the
# binary and refreshes the unit without touching the data directory.
set -euo pipefail set -euo pipefail
INSTALL_BIN="/usr/local/bin/qu" INSTALL_BIN="/usr/local/bin/qu"
SERVICE_FILE="/etc/systemd/system/qu-serve.service" SERVICE_FILE="/etc/systemd/system/quptime.service"
SERVICE_USER="${SUDO_USER:-$(whoami)}" SERVICE_NAME="$(basename "$SERVICE_FILE")"
SERVICE_GROUP="$(id -gn "$SERVICE_USER" 2>/dev/null || echo root)" SERVICE_USER="quptime"
SERVICE_GROUP="quptime"
DATA_DIR="/etc/quptime"
REPO_API="https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest"
RELEASE_BASE="https://git.cer.sh/axodouble/quptime/releases/download"
fail() { fail() {
echo "Error: $*" >&2 echo "Error: $*" >&2
exit 1 exit 1
} }
echo_cmd() {
echo -e "\033[90m> $1\033[0m"
eval "$1"
}
require_command() { require_command() {
command -v "$1" > /dev/null 2>&1 || fail "$1 is not installed. Please install $1 and try again." command -v "$1" >/dev/null 2>&1 || fail "$1 is not installed. Please install $1 and try again."
} }
write_completion() { write_completion() {
@@ -31,52 +38,182 @@ write_completion() {
return 1 return 1
} }
require_command jq
require_command curl require_command curl
require_command jq
require_command sha256sum
require_command install
require_command mktemp
# --- target architecture ------------------------------------------------
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
aarch64|arm64) ARCH=arm64 ;;
*) fail "unsupported architecture: $(uname -m). Pre-built binaries are published for amd64 and arm64 only — build from source for other platforms." ;;
esac
if [ ! -w "$(dirname "$INSTALL_BIN")" ]; then if [ ! -w "$(dirname "$INSTALL_BIN")" ]; then
fail "You are not allowed to write to $(dirname "$INSTALL_BIN"). Run this script with sudo or install qu manually." fail "Cannot write to $(dirname "$INSTALL_BIN"). Run this script with sudo, or set INSTALL_BIN to a writable location."
fi fi
RELEASE=$(curl -s https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest | jq -r '.tag_name') # --- latest release tag -------------------------------------------------
RELEASE=$(curl -fsSL "$REPO_API" | jq -r '.tag_name')
[ -n "$RELEASE" ] && [ "$RELEASE" != "null" ] \
|| fail "could not determine the latest release tag from $REPO_API"
echo_cmd "curl -L -o '$INSTALL_BIN' 'https://git.cer.sh/axodouble/quptime/releases/download/${RELEASE}/qu-${RELEASE}-linux-amd64'" BINARY_NAME="qu-${RELEASE}-linux-${ARCH}"
echo_cmd "chmod +x '$INSTALL_BIN'" BINARY_URL="${RELEASE_BASE}/${RELEASE}/${BINARY_NAME}"
echo "> qu has been installed to $INSTALL_BIN" SUMS_URL="${RELEASE_BASE}/${RELEASE}/SHA256SUMS"
# --- download + verify --------------------------------------------------
# Stage in a temp dir so a failed verification never leaves a partial
# or unverified binary on disk.
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo "> downloading $BINARY_NAME"
curl -fsSL --proto '=https' --tlsv1.2 -o "$TMPDIR/$BINARY_NAME" "$BINARY_URL"
echo "> downloading SHA256SUMS"
curl -fsSL --proto '=https' --tlsv1.2 -o "$TMPDIR/SHA256SUMS" "$SUMS_URL"
echo "> verifying checksum"
# Pull just our binary's entry so sha256sum -c doesn't fail on the
# arches we didn't download.
(
cd "$TMPDIR"
if ! grep -E "[[:space:]]\\*?${BINARY_NAME}\$" SHA256SUMS > expected.sum; then
fail "no entry for $BINARY_NAME in published SHA256SUMS — refusing to install"
fi
if ! sha256sum -c expected.sum >/dev/null 2>&1; then
echo "expected: $(awk '{print $1}' expected.sum)"
echo "actual: $(sha256sum "$BINARY_NAME" | awk '{print $1}')"
fail "checksum mismatch for $BINARY_NAME — refusing to install"
fi
)
echo "> checksum OK"
install -m 0755 "$TMPDIR/$BINARY_NAME" "$INSTALL_BIN"
echo "> qu ${RELEASE} installed to $INSTALL_BIN"
# --- shell completions --------------------------------------------------
if "$INSTALL_BIN" --help 2>/dev/null | grep -q "completion"; then if "$INSTALL_BIN" --help 2>/dev/null | grep -q "completion"; then
write_completion bash /usr/share/bash-completion/completions/qu \ write_completion bash /usr/share/bash-completion/completions/qu \
|| write_completion bash /etc/bash_completion.d/qu || true || write_completion bash /etc/bash_completion.d/qu \
|| true
write_completion zsh /usr/share/zsh/site-functions/_qu || true write_completion zsh /usr/share/zsh/site-functions/_qu || true
write_completion fish /usr/share/fish/vendor_completions.d/qu.fish || true write_completion fish /usr/share/fish/vendor_completions.d/qu.fish || true
else else
echo "> qu does not expose completion support; skipping shell completion installation." echo "> qu does not expose completion support; skipping shell completion installation."
fi fi
if ! command -v systemctl > /dev/null 2>&1; then # --- systemd unit -------------------------------------------------------
echo "> Warning: systemd is not available on this system. qu serve will not be automatically started on boot." if ! command -v systemctl >/dev/null 2>&1; then
echo "Installation complete, before starting qu serve, make sure to run qu init and read the documentation." echo
echo "> systemd is not available on this system. Installation stops here."
echo "> Run \`qu serve\` manually (or wire it into the supervisor of your choice)."
exit 0 exit 0
fi fi
echo "> Creating systemd service file for qu serve..." # Dedicated service user. Hardened unit drops all capabilities and
cat > "$SERVICE_FILE" <<EOL # locks the daemon down with ProtectSystem=strict, so it must run as
# its own unprivileged account rather than the invoking sudo user.
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
echo "> creating system user $SERVICE_USER"
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
fi
install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$DATA_DIR"
echo "> writing $SERVICE_FILE"
cat > "$SERVICE_FILE" <<'EOF'
[Unit] [Unit]
Description=QUptime Serve Description=QUptime distributed uptime monitor
After=network.target Documentation=https://git.cer.sh/axodouble/quptime
Wants=network-online.target
After=network-online.target
[Service] [Service]
ExecStart=$INSTALL_BIN serve Type=simple
ExecStart=/usr/local/bin/qu serve
Restart=always Restart=always
User=$SERVICE_USER RestartSec=5s
Group=$SERVICE_GROUP
User=quptime
Group=quptime
# Where state lives. RuntimeDirectory creates /var/run/quptime/ each
# boot owned by User:Group with mode 0750.
Environment=QUPTIME_DIR=/etc/quptime
RuntimeDirectory=quptime
RuntimeDirectoryMode=0750
ReadWritePaths=/etc/quptime /var/run/quptime
# Hardening. Comment out individual directives if a probe needs
# something we've revoked.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
# Network access is required (we're a network monitor). Keep address
# families minimal — AF_NETLINK is needed for some libc lookups.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
# If you need raw ICMP, *also* uncomment:
# AmbientCapabilities=CAP_NET_RAW
# CapabilityBoundingSet=CAP_NET_RAW
# Otherwise drop all capabilities:
CapabilityBoundingSet=
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOL EOF
echo_cmd "systemctl daemon-reload" systemctl daemon-reload
echo_cmd "systemctl enable $(basename "$SERVICE_FILE")" systemctl enable "$SERVICE_NAME" >/dev/null
echo "> qu serve service has been created and enabled. You can start it with 'systemctl start $(basename "$SERVICE_FILE")'" echo "> ${SERVICE_NAME} installed and enabled (not yet started)"
echo "Installation complete, before starting qu serve, make sure to run qu init and read the documentation." cat <<EOF
Installation complete.
Next steps:
1. Initialise the node identity. Either:
a) Let \`qu serve\` auto-init from environment variables.
Drop a systemd override like:
sudo systemctl edit ${SERVICE_NAME}
[Service]
Environment=QUPTIME_ADVERTISE=<this-host>:9901
# On follower nodes, also set the shared join secret:
# Environment=QUPTIME_CLUSTER_SECRET=<paste from first node>
b) Or run \`qu init\` once explicitly:
sudo -u ${SERVICE_USER} QUPTIME_DIR=${DATA_DIR} \\
qu init --advertise <this-host>:9901
2. Start the service:
sudo systemctl start ${SERVICE_NAME}
sudo -u ${SERVICE_USER} qu status
3. For ICMP checks, the daemon defaults to unprivileged UDP-mode
pings — those need the ping_group_range sysctl widened to include
the ${SERVICE_USER} GID, or grant CAP_NET_RAW in the unit. See
docs/deployment/systemd.md for the recipes.
Full documentation: https://git.cer.sh/axodouble/quptime/src/branch/master/docs
EOF