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
+166 -29
View File
@@ -1,21 +1,28 @@
#!/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."
} }
@@ -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
# --- systemd unit -------------------------------------------------------
if ! command -v systemctl >/dev/null 2>&1; then if ! command -v systemctl >/dev/null 2>&1; then
echo "> Warning: systemd is not available on this system. qu serve will not be automatically started on boot." echo
echo "Installation complete, before starting qu serve, make sure to run qu init and read the documentation." 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