Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .github/workflows/build-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
# - workflow_call from release.yml: the semver tags and `latest` are published,
# and the manifest list digest is returned so the caller can attest and sign it.
#
# A fourth path lives in build-demo/merge-demo: on a published, non-prerelease
# release only, the same Dockerfile is built again with DEMO_MODE=true and
# published as the single tag `:demo` (read-only public demo). It never touches
# jobs.merge.outputs, which release.yml reads to attest and sign the normal image.
#
# Security model: no default token permissions, every action pinned to a full
# commit SHA, checkout without persisted credentials, dynamic values passed
# through env: rather than interpolated into run: blocks, and no build cache on
Expand Down Expand Up @@ -245,6 +250,109 @@ jobs:
if-no-files-found: error
retention-days: 1

# Second variant of the same image: identical layers except the Go binary,
# linked with DEMO_MODE=true so it refuses every mutating request. Kept as
# its own job instead of a matrix dimension on `build` above, because that
# job already branches on PR-vs-push and on whether to scan; a third axis
# would multiply those branches instead of adding one readable block, and
# would risk the demo variant sneaking into steps.build.outputs.digest that
# `merge` reads. A workflow_call inherits the caller's event, so the `if:`
# only matches the call from release.yml.
build-demo:
name: Build demo (${{ matrix.platform }})
if: github.event_name == 'release' && !github.event.release.prerelease
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: amd64
runner: ubuntu-24.04
- platform: arm64
runner: ubuntu-24.04-arm

steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit

- name: Validate required secrets
env:
LICENSE_PUBLIC_KEY: ${{ vars.LICENSE_PUBLIC_KEY }}
run: |
if [ -z "$LICENSE_PUBLIC_KEY" ]; then
echo "::error::LICENSE_PUBLIC_KEY secret is not set. Aborting build."
exit 1
fi

- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Compute build metadata
run: |
echo "BUILD_VERSION=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
echo "BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Not scanned separately: same base image, same dependencies, same
# Dockerfile as the normal image, which the pull_request gate above
# already scans on every change. The only difference DEMO_MODE makes is
# one linked-in string constant in the Go binary. Releases carry no
# Trivy step for the normal image either (see the `build` job above),
# so this keeps the same scanning posture as its normal counterpart.
# No cache on this path either: it publishes, same rule as `build`.
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: linux/${{ matrix.platform }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
provenance: false
no-cache: true
build-args: |
VERSION=${{ env.BUILD_VERSION }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ env.BUILD_DATE }}
LICENSE_PUBLIC_KEY=${{ vars.LICENSE_PUBLIC_KEY }}
DEMO_MODE=true

- name: Export digest
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
mkdir -p /tmp/demo-digests
touch "/tmp/demo-digests/${DIGEST#sha256:}"

# Named demo-digests-*, not digests-demo-*: the merge job above downloads
# by the glob digests-*, which is unanchored on the right and would
# therefore also match digests-demo-amd64. demo-digests-* starts with a
# different literal, so it cannot match that glob, and merge-demo's own
# glob (demo-digests-*) cannot match the normal digests-* artifacts either.
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-digests-${{ matrix.platform }}
path: /tmp/demo-digests/*
if-no-files-found: error
retention-days: 1

merge:
name: Merge manifests
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -326,3 +434,56 @@ jobs:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
VERSION: ${{ steps.meta.outputs.version }}
run: docker buildx imagetools inspect "${IMAGE_REF}:${VERSION}"

# Kept separate from `merge` on purpose: this job has no `outputs:` at all,
# so it cannot become a source for jobs.merge.outputs.* by accident (those
# feed the top-level outputs: block that release.yml reads to attest and
# sign the normal image). No `if:` needed: when build-demo is skipped, the
# default `success()` condition skips this job too.
merge-demo:
name: Merge demo manifests
runs-on: ubuntu-24.04
timeout-minutes: 15
needs: build-demo
permissions:
contents: read
packages: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit

- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: demo-digests-*
merge-multiple: true

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Single fixed tag: the demo image never carries a sha, a semver or
# latest, so there is no metadata-action tag list to compute here.
- name: Create manifest list and push
working-directory: /tmp/digests
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
run: |
# shellcheck disable=SC2046
docker buildx imagetools create \
-t "${IMAGE_REF}:demo" \
$(printf "${IMAGE_REF}@sha256:%s " *)

- name: Inspect image
env:
IMAGE_REF: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
run: docker buildx imagetools inspect "${IMAGE_REF}:demo"
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_DATE=unknown
ARG LICENSE_PUBLIC_KEY
ARG DEMO_MODE=false

RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
Expand All @@ -34,7 +35,8 @@ RUN --mount=type=cache,target=/go/pkg/mod \
-X main.version=${VERSION} \
-X main.commit=${COMMIT} \
-X main.buildDate=${BUILD_DATE} \
-X main.publicKeyB64=${LICENSE_PUBLIC_KEY}" \
-X main.publicKeyB64=${LICENSE_PUBLIC_KEY} \
-X main.demoMode=${DEMO_MODE}" \
-o /out/maintenant \
./cmd/maintenant

Expand Down
10 changes: 10 additions & 0 deletions cmd/maintenant/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var (
commit = "unknown"
buildDate = "unknown"
publicKeyB64 = ""
demoMode = ""
)

// defaultAgentDataDir is where an agent keeps its identity and liveness file
Expand Down Expand Up @@ -80,6 +81,7 @@ func main() {
cfg.Commit = commit
cfg.BuildDate = buildDate
cfg.PublicKeyB64 = publicKeyB64
cfg.DemoMode = demoMode == "true"
if err := app.MergeArgsIntoConfig(&cfg, visited); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(2)
Expand All @@ -96,6 +98,9 @@ func main() {
logger.Warn("insecure TLS verification disabled — do not use in production")
}
logger.Info("maintenant starting", "version", version, "commit", commit, "build_date", buildDate, "mode", cfg.Mode)
if cfg.DemoMode {
logger.Info("demo mode enabled: instance is read-only")
}

// Loaded before anything can probe: both server and agent modes run from
// here, and a bad path must stop the process rather than turn every TLS
Expand All @@ -108,6 +113,11 @@ func main() {
logger.Info("extra CA bundle trusted", "path", cfg.CACertFile)
}

if err := cfg.ValidateDemo(os.Getenv); err != nil {
logger.Error("invalid demo configuration", "error", err)
os.Exit(1)
}

// Checked before the agent branch: an agent handed a connection string
// must refuse it, not ignore it (FR-003, FR-030).
if err := cfg.ValidateStorage(); err != nil {
Expand Down
12 changes: 12 additions & 0 deletions docs/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ host or any credential.
| `GET` | `/api/v1/heartbeats/{id}/pings` | List raw pings |
| `GET` | `/api/v1/heartbeats/{id}/uptime/daily` | Daily uptime percentages |

### Outbound Heartbeats

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/outbound-heartbeats` | List all targets |
| `POST` | `/api/v1/outbound-heartbeats` | Create a target |
| `PUT` | `/api/v1/outbound-heartbeats/{id}` | Update a target |
| `DELETE` | `/api/v1/outbound-heartbeats/{id}` | Delete a target |
| `POST` | `/api/v1/outbound-heartbeats/{id}/send` | Send a ping now |

See [Outbound Heartbeats](../features/heartbeats.md#outbound-heartbeats).

### Ping Endpoints (Public)

These routes do not require authentication:
Expand Down
48 changes: 48 additions & 0 deletions docs/features/heartbeats.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,54 @@ POST /api/v1/heartbeats/{id}/resume

---

## Outbound Heartbeats

A monitoring tool cannot report its own outage. Outbound heartbeats solve this by letting another maintenant instance watch this one: this instance pings a heartbeat monitor on the other instance at a fixed interval, and if the pings stop, the other instance raises a `deadline_missed` alert.

### Setting It Up

1. On the **other** instance, create a heartbeat monitor with an interval that matches the one you will use below, plus some grace time. Copy its ping URL.
2. On **this** instance, open **Heartbeats → Outgoing** and click **New target**.
3. Paste the ping URL, give the target a name and choose an interval.
4. Click **Send now** to check the target right away. The row shows the HTTP status it received, or the error.

Two instances can watch each other this way, each with one incoming and one outgoing heartbeat.

### How Sends Work

- Each target is called with a `GET` request, with a 10-second timeout and the `User-Agent` `maintenant/<version> outbound-heartbeat`.
- The interval goes from 30 seconds to 24 hours.
- A 2xx response counts as a success. Any other status, or a network error, is recorded as the target's last error and logged as a warning.
- A disabled target keeps its settings but is no longer called.

!!! note "HTTPS and public addresses only"
Targets must use `https://` and resolve to a public address. Loopback and
private ranges are refused when the target is saved and again when it is
called, so the feature cannot be used to reach services on your internal
network. The other instance therefore has to be reachable over HTTPS from
this one.

### API

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/outbound-heartbeats` | List all targets |
| `POST` | `/api/v1/outbound-heartbeats` | Create a target |
| `PUT` | `/api/v1/outbound-heartbeats/{id}` | Update a target |
| `DELETE` | `/api/v1/outbound-heartbeats/{id}` | Delete a target |
| `POST` | `/api/v1/outbound-heartbeats/{id}/send` | Send a ping now |

```json
{
"name": "Watched by monitoring.example.com",
"url": "https://monitoring.example.com/ping/4f7c2d9e-1b3a-4c5d-8e6f-0a1b2c3d4e5f",
"interval_seconds": 60,
"enabled": true
}
```

---

## Public Ping Endpoints

The `/ping/` routes are designed to be publicly accessible. They do not require authentication, since your cron jobs and external services need to reach them directly.
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/swarm.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ maintenant uses the Docker SDK Swarm API, which is available in:
| Docker Engine | >= 19.03 |
| Docker Desktop | >= 3.0 |

Any Docker Engine version that supports Swarm mode is compatible. The Swarm API has been stable since Docker 1.12, but Docker 19.03+ is recommended for security and feature completeness.
The daemon must expose Docker API 1.40 or later: older engines are refused.

---

Expand Down
1 change: 1 addition & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ curl -fsSL https://install.maintenant.dev | sudo bash
- `install` (coreutils) and `useradd`
- `systemctl`, unless installing with `--no-service`
- Root access (`sudo`)
- To monitor containers: Docker Engine 19.03 or later (API 1.40)

---

Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/DemoModeBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!--
Copyright 2026 Benjamin Touchard (kOlapsis)

Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
or a commercial license. You may not use this file except in compliance
with one of these licenses.

AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.html
Commercial: See COMMERCIAL-LICENSE.md

Source: https://github.com/kolapsis/maintenant
-->

<script setup lang="ts">
import AlertBanner from '@/components/ui/AlertBanner.vue'
import { useEdition } from '@/composables/useEdition'

const { isDemo } = useEdition()
</script>

<template>
<AlertBanner v-if="isDemo" severity="info" label="DEMO MODE" class="shrink-0">
Demo mode: read-only. You can explore everything, changes are disabled.
</AlertBanner>
</template>
5 changes: 4 additions & 1 deletion frontend/src/components/ToastContainer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ const iconMap = {
:class="`toast--${toast.type}`"
>
<component :is="iconMap[toast.type]" :size="16" class="toast__icon shrink-0" />
<span class="toast__msg text-sm font-medium">{{ toast.message }}</span>
<div class="toast__msg flex flex-col gap-0.5 text-sm">
<span v-if="toast.title" class="font-semibold">{{ toast.title }}</span>
<span :class="toast.title ? '' : 'font-medium'">{{ toast.message }}</span>
</div>
</div>
</TransitionGroup>
</div>
Expand Down
32 changes: 32 additions & 0 deletions frontend/src/components/__tests__/DemoModeBanner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2026 Benjamin Touchard (Kolapsis)
//
// Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0)
// or a commercial license. See COMMERCIAL-LICENSE.md.

import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import DemoModeBanner from '@/components/DemoModeBanner.vue'

const isDemo = ref(false)

vi.mock('@/composables/useEdition', () => ({
useEdition: () => ({ isDemo }),
}))

describe('DemoModeBanner', () => {
it('renders nothing when the instance is not in demo mode', () => {
isDemo.value = false
const wrapper = mount(DemoModeBanner)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})

it('renders a non-dismissible banner when the instance is in demo mode', () => {
isDemo.value = true
const wrapper = mount(DemoModeBanner)
const banner = wrapper.find('[role="alert"]')
expect(banner.exists()).toBe(true)
expect(banner.text()).toContain('Demo mode: read-only.')
expect(wrapper.find('button[aria-label="Dismiss"]').exists()).toBe(false)
})
})
Loading