diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9f09a..38c0318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,10 +44,13 @@ Initial public release. `quptime` user, `ProtectSystem=strict`, all capabilities dropped by default. - **Multi-arch Docker images** (`linux/amd64`, `linux/arm64`) - published to `git.cer.sh/axodouble/quptime`. + published to `git.cer.sh/axodouble/quptime` (primary) and + `ghcr.io/axodouble/quptime` (GitHub push-mirror) on every tag. - **Static Linux binaries** (`amd64`, `arm64`) published per tag with - a `SHA256SUMS` file; the official installer verifies the checksum - before placing the binary on disk. + a `SHA256SUMS` file to both Gitea Releases (primary) and GitHub + Releases (mirror). The official installer prefers Gitea, falls back + to GitHub on failure, and verifies the checksum before placing the + binary on disk. ### Security diff --git a/README.md b/README.md index 67a8410..6e7db78 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,37 @@ trust — no central CA, no shared secret. ### From pre-built binary -This can be done in one step, either by downloading the latest release from -the [Gitea releases page](https://git.cer.sh/axodouble/quptime/releases) or by running the following script: +The canonical home is Gitea; the repo is push-mirrored to GitHub on +every tag. Releases and multi-arch container images are published to +both. + +| Source | Releases | Container image | +| ---------------- | ------------------------------------------------------------ | -------------------------------- | +| Gitea (primary) | | `git.cer.sh/axodouble/quptime` | +| GitHub (mirror) | | `ghcr.io/axodouble/quptime` | + +One-step install — tries Gitea first, falls back to GitHub automatically: + ```sh curl -fsSL https://git.cer.sh/Axodouble/QUptime/raw/branch/master/install.sh | sudo bash +# or, via the GitHub mirror: +# curl -fsSL https://raw.githubusercontent.com/Axodouble/QUptime/master/install.sh | sudo bash ``` +The script verifies the binary against the published `SHA256SUMS` +before installing and refuses to proceed on a mismatch. + +### From Docker + +```sh +docker pull git.cer.sh/axodouble/quptime:latest +# or, via the GitHub mirror: +# docker pull ghcr.io/axodouble/quptime:latest +``` + +See [docs/deployment/docker.md](docs/deployment/docker.md) for compose +recipes. + ## Why Most uptime monitors are either a SaaS or a single box that, by diff --git a/docker/docker-compose-tailscale.yml b/docker/docker-compose-tailscale.yml index b7f5c9d..0e75be3 100644 --- a/docker/docker-compose-tailscale.yml +++ b/docker/docker-compose-tailscale.yml @@ -25,7 +25,7 @@ services: restart: unless-stopped quptime: - image: git.cer.sh/axodouble/quptime:master + image: git.cer.sh/axodouble/quptime:latest container_name: quptime environment: # host:port other QUptime nodes use to reach this one. Use the diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index cefd94c..dec1f32 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -7,6 +7,13 @@ daemon can bind privileged ports and open ICMP sockets; override with ## Image references +The same multi-arch (amd64 + arm64) image is published to two +registries. **The Gitea registry is the canonical source** — it also +publishes canary `:master` builds on every branch push. GHCR is a +tag-only push-mirror for users who can't reach `git.cer.sh`. + +Primary — Gitea registry: + ``` git.cer.sh/axodouble/quptime:master # tip of main, multi-arch git.cer.sh/axodouble/quptime:latest # latest tagged release @@ -14,6 +21,14 @@ git.cer.sh/axodouble/quptime:v0.0.1 # specific tagged release git.cer.sh/axodouble/quptime:latest-amd64 # single-arch (if you must pin) ``` +Fallback — GitHub Container Registry: + +``` +ghcr.io/axodouble/quptime:latest # latest tagged release +ghcr.io/axodouble/quptime:v0.0.1 # specific tagged release +ghcr.io/axodouble/quptime:0.0 # latest patch in the 0.0 minor line +``` + The image embeds `QUPTIME_DIR=/etc/quptime` and declares it a volume — treat it as the only piece of state worth persisting. diff --git a/docs/installation.md b/docs/installation.md index 0eb48e7..a92bca1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,22 +10,36 @@ matches how you manage software on the host. ## Pre-built binary (recommended) -Releases are published to the [Gitea releases -page](https://git.cer.sh/axodouble/quptime/releases) with a -`SHA256SUMS` file. Two architectures are built: `linux-amd64` and -`linux-arm64`. +Every tag triggers identical builds on both sources, so either one +serves the same artefact set. Gitea is the canonical home; GitHub is a +push-mirror. + +Primary — Gitea releases: + + +Fallback — GitHub releases (mirrored from the same tag): + + +Each release ships `qu-${TAG}-linux-amd64`, `qu-${TAG}-linux-arm64`, +and a `SHA256SUMS` file. ```sh # Always pin to a tag — `latest` resolves on the server side. -TAG=v0.1.0 +TAG=v0.0.1 ARCH=amd64 # or arm64 +# Primary: Gitea curl -fSL -o qu \ "https://git.cer.sh/axodouble/quptime/releases/download/${TAG}/qu-${TAG}-linux-${ARCH}" curl -fSL -o SHA256SUMS \ "https://git.cer.sh/axodouble/quptime/releases/download/${TAG}/SHA256SUMS" -# Verify before installing. +# (or the GitHub mirror — substitute the host below if Gitea is unreachable) +# https://github.com/Axodouble/QUptime/releases/download/${TAG}/qu-${TAG}-linux-${ARCH} +# https://github.com/Axodouble/QUptime/releases/download/${TAG}/SHA256SUMS + +# Verify before installing. Use the SHA256SUMS from the SAME source +# as the binary — never mix. sha256sum --check --ignore-missing SHA256SUMS install -m 0755 qu /usr/local/bin/qu @@ -34,31 +48,37 @@ install -m 0755 qu /usr/local/bin/qu ## One-line install script The repo ships an `install.sh` that handles the download, checksum, -shell-completion installation, and a default systemd unit file. Run it +shell-completion installation, and a hardened systemd unit. Run it under `sudo` so it can write to `/usr/local/bin` and `/etc/systemd/system`. ```sh curl -fsSL https://git.cer.sh/Axodouble/QUptime/raw/branch/master/install.sh | sudo bash +# or, via the GitHub mirror: +# curl -fsSL https://raw.githubusercontent.com/Axodouble/QUptime/master/install.sh | sudo bash ``` What it does: -1. Looks up the latest release via the Gitea API. -2. Downloads the binary to `/usr/local/bin/qu`. +1. Looks up the latest release via the Gitea API; falls back to the + GitHub API if Gitea is unreachable. +2. Downloads the per-arch binary and the matching `SHA256SUMS` from + the same source, then verifies the checksum. Refuses to install on + a mismatch. 3. Installs bash / zsh / fish completion if a target directory exists. -4. Writes `/etc/systemd/system/qu-serve.service` and enables it (but - does **not** start it — you need to run `qu init` first). - -The unit it writes is minimal. For a production unit with hardening, -see the [systemd deployment guide](deployment/systemd.md). +4. Creates a dedicated `quptime` system user and writes + `/etc/systemd/system/quptime.service` (hardened — matches the unit + in [systemd.md](deployment/systemd.md)). Enables but does not start + the service, so you can configure identity before first boot. ## Build from source Requires Go 1.24.2 or newer. ```sh +# Either remote — Gitea is canonical, GitHub is a push-mirror. git clone https://git.cer.sh/axodouble/quptime.git +# git clone https://github.com/Axodouble/QUptime.git cd quptime go build -ldflags "-X main.version=$(git describe --tags --always)" -o qu ./cmd/qu @@ -74,15 +94,26 @@ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o qu ./cmd/qu ## Docker image -A multi-arch (`amd64` + `arm64`) image is published to the Gitea -registry on every tag and every push to `master`: +The same multi-arch (`amd64` + `arm64`) image is published to two +registries on every tag. The Gitea registry is the canonical source +and also gets canary `:master` builds; GHCR is a tag-only mirror. + +Primary — Gitea registry: ``` -git.cer.sh/axodouble/quptime:master # tip of main +git.cer.sh/axodouble/quptime:master # tip of main (canary) git.cer.sh/axodouble/quptime:latest # latest tagged release git.cer.sh/axodouble/quptime:v0.0.1 # pinned release ``` +Fallback — GitHub Container Registry: + +``` +ghcr.io/axodouble/quptime:latest # latest tagged release +ghcr.io/axodouble/quptime:v0.0.1 # pinned release +ghcr.io/axodouble/quptime:0.0 # latest 0.0.x +``` + See the [Docker deployment guide](deployment/docker.md) for compose files and volume layout. diff --git a/install.sh b/install.sh index 4569096..9be656a 100644 --- a/install.sh +++ b/install.sh @@ -1,12 +1,17 @@ #!/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. +# Downloads the latest released `qu` binary, 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. +# +# Release sources, tried in order: +# 1. Gitea: git.cer.sh/axodouble/quptime/releases (primary — canonical home) +# 2. GitHub: github.com/Axodouble/QUptime/releases (push-mirror fallback) +# +# Idempotent — re-running upgrades the binary and refreshes the unit +# without touching the data directory. set -euo pipefail INSTALL_BIN="/usr/local/bin/qu" @@ -15,8 +20,15 @@ SERVICE_NAME="$(basename "$SERVICE_FILE")" 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" + +# Release sources, in preference order. Each row is: +# || +# The asset URL is concatenated with `//`. Adjust here +# if the project moves hosts. +SOURCES=( + "gitea|https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest|https://git.cer.sh/axodouble/quptime/releases/download" + "github|https://api.github.com/repos/Axodouble/QUptime/releases/latest|https://github.com/Axodouble/QUptime/releases/download" +) fail() { echo "Error: $*" >&2 @@ -38,6 +50,51 @@ write_completion() { return 1 } +# fetch_from_source tries one release source end-to-end: pulls the +# latest tag from its API, downloads the per-arch binary and the +# accompanying SHA256SUMS, and verifies the checksum. Returns 0 on +# success (with RELEASE and BINARY_NAME set as globals) or 1 if any +# step fails — callers can then try the next source. Stderr is kept +# quiet so a failed primary doesn't spam the operator before the +# fallback is attempted. +fetch_from_source() { + local api_url=$1 + local release_base=$2 + local tmpdir=$3 + + local release + release=$(curl -fsSL --proto '=https' --tlsv1.2 "$api_url" 2>/dev/null | jq -r '.tag_name' 2>/dev/null) \ + || return 1 + [ -n "$release" ] && [ "$release" != "null" ] || return 1 + + local binary_name="qu-${release}-linux-${ARCH}" + local binary_url="${release_base}/${release}/${binary_name}" + local sums_url="${release_base}/${release}/SHA256SUMS" + + curl -fsSL --proto '=https' --tlsv1.2 -o "$tmpdir/$binary_name" "$binary_url" 2>/dev/null \ + || return 1 + curl -fsSL --proto '=https' --tlsv1.2 -o "$tmpdir/SHA256SUMS" "$sums_url" 2>/dev/null \ + || return 1 + + # Verify against the SHA256SUMS that came from the same source as + # the binary. Never mix sources here — verifying a GitHub-hosted + # binary against a Gitea-hosted SHA256SUMS would defeat the + # tamper check. + ( + cd "$tmpdir" + if ! grep -E "[[:space:]]\\*?${binary_name}\$" SHA256SUMS > expected.sum; then + exit 1 + fi + if ! sha256sum -c expected.sum >/dev/null 2>&1; then + exit 1 + fi + ) || return 1 + + RELEASE="$release" + BINARY_NAME="$binary_name" + return 0 +} + require_command curl require_command jq require_command sha256sum @@ -55,44 +112,39 @@ if [ ! -w "$(dirname "$INSTALL_BIN")" ]; then fail "Cannot write to $(dirname "$INSTALL_BIN"). Run this script with sudo, or set INSTALL_BIN to a writable location." fi -# --- 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" - -BINARY_NAME="qu-${RELEASE}-linux-${ARCH}" -BINARY_URL="${RELEASE_BASE}/${RELEASE}/${BINARY_NAME}" -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. +# --- download + verify (with fallback) ---------------------------------- 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" +# Globals filled in by fetch_from_source on success. +RELEASE="" +BINARY_NAME="" +INSTALLED_FROM="" +INSTALLED_TMP="" -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" +for source_spec in "${SOURCES[@]}"; do + IFS='|' read -r src_name src_api src_base <<<"$source_spec" + src_tmp="$TMPDIR/$src_name" + mkdir -p "$src_tmp" + echo "> trying release source: $src_name" + # `set -e` would abort the whole script the moment fetch_from_source + # returns nonzero; we want the loop to fall through to the next + # source instead. Wrap the call so a failure is just data. + if fetch_from_source "$src_api" "$src_base" "$src_tmp"; then + INSTALLED_FROM="$src_name" + INSTALLED_TMP="$src_tmp" + echo "> $src_name: ${RELEASE} ✓ checksum OK" + break 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" + echo "> $src_name: unavailable" +done -install -m 0755 "$TMPDIR/$BINARY_NAME" "$INSTALL_BIN" -echo "> qu ${RELEASE} installed to $INSTALL_BIN" +if [ -z "$INSTALLED_FROM" ]; then + fail "no release source reachable — tried: $(printf '%s ' "${SOURCES[@]%%|*}"). Check network access to git.cer.sh and github.com." +fi + +install -m 0755 "$INSTALLED_TMP/$BINARY_NAME" "$INSTALL_BIN" +echo "> qu ${RELEASE} installed to $INSTALL_BIN (source: $INSTALLED_FROM)" # --- shell completions -------------------------------------------------- if "$INSTALL_BIN" --help 2>/dev/null | grep -q "completion"; then