-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathconfig.example.toml
More file actions
378 lines (349 loc) · 18.5 KB
/
Copy pathconfig.example.toml
File metadata and controls
378 lines (349 loc) · 18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# Hora configuration. Copy to config.toml and edit.
# The path is resolved from $HORA_CONFIG (default: ./config.toml).
#
# Any `VAR` in this file is replaced with the environment variable at load,
# so secrets can stay out of the file.
[page]
# Title shown on the public status page and in the browser tab.
title = "Hora - Status"
# Number of days rendered in the daily uptime bar.
history_days = 90
[server]
# Address the HTTP server binds to. Behind a reverse proxy, keep it on loopback.
bind = "127.0.0.1:8787"
# SQLite database file. Created if missing. In Docker, point this at a volume.
database_path = "hora.db"
# Browser origins allowed to call the JSON API (CORS). Empty = allow any (read-only, public data).
allowed_origins = []
# Trust this header for the client IP when rate limiting (e.g. behind Cloudflare).
# Only safe if a proxy you control sets it and direct origin access is blocked.
# Unset = smart detection (x-forwarded-for / x-real-ip / peer).
# client_ip_header = "cf-connecting-ip"
# Per-IP API rate limit (applied to /api/* only). One request slot is replenished
# every `rate_limit_refill_secs` seconds, up to a burst of `rate_limit_burst`.
rate_limit_refill_secs = 1
rate_limit_burst = 30
# Token required to view private monitors (those with `public = false`), sent
# as `Authorization: Bearer <token>` (preferred - a `?token=` query also works,
# but URLs end up in browser history and intermediary logs). It gates the full
# view on the status page, /api/summary, /api/monitors/{id}/latency, /metrics,
# /history and /history.atom; without it those endpoints only show public
# monitors. Read live: a change applies on the next reload.
# auth_token = "${HORA_AUTH_TOKEN}"
# Per-group viewer tokens for the /status/{group} pages (lightweight
# multi-tenancy): the token reveals the FULL view of its group - private
# monitors included - and nothing else. Also accepted by the group-scoped
# SLA report (/report/2026-05?group=...). The global auth_token always works.
# [server.group_tokens]
# "Clients ACME" = "${ACME_TOKEN}"
# --- Notification channels ------------------------------------------------
# Each channel has a unique `name` and a `type`. Several channels may share a
# type (e.g. two Discord webhooks). A monitor routes to specific channels with
# its `notify = ["name", ...]` field; without it, every channel is used.
# An empty secret (e.g. an unset VAR) simply disables that channel.
# [[channels]]
# name = "ops-telegram"
# type = "telegram"
# token = "${HORA_TELEGRAM_TOKEN}"
# chat_id = "123456"
# [[channels]]
# name = "alerts-discord"
# type = "discord"
# webhook_url = "${DISCORD_WEBHOOK}"
# [[channels]]
# name = "team-slack"
# type = "slack"
# webhook_url = "${SLACK_WEBHOOK}"
# [[channels]]
# name = "generic"
# type = "webhook" # POSTs { event, monitor, message?, days_left? } as JSON
# url = "${WEBHOOK_URL}"
# [[channels]]
# name = "email-ops"
# type = "email"
# host = "smtp.example.com"
# port = 587 # default; STARTTLS (set implicit_tls = true for 465)
# username = "hora@example.com"
# password = "${SMTP_PASSWORD}"
# from = "Hora <hora@example.com>"
# to = "ops@example.com"
# [[channels]]
# name = "ops-matrix"
# type = "matrix"
# homeserver = "https://matrix.org"
# token = "${MATRIX_TOKEN}" # access token of the sending (bot) user
# room_id = "!abcdef:matrix.org"
# [[channels]]
# name = "oncall-sms"
# type = "freemobile" # texts your own Free Mobile number
# user = "12345678" # account id, from "Notifications par SMS"
# pass = "${FREEMOBILE_PASS}" # API key generated in that same option
# [[channels]]
# name = "ntfy-alerts"
# type = "ntfy" # ntfy.sh or self-hosted ntfy server
# url = "https://ntfy.sh/my-topic" # topic URL
# token = "${NTFY_TOKEN}" # optional; for private ntfy servers
# [[channels]]
# name = "gotify-alerts"
# type = "gotify" # self-hosted Gotify server
# url = "https://gotify.example.com"
# token = "${GOTIFY_TOKEN}" # application token
# [[channels]]
# name = "pushover-alerts"
# type = "pushover" # Pushover notifications
# token = "${PUSHOVER_TOKEN}" # application API token
# user = "${PUSHOVER_USER}" # user key (recipient)
# Alerting and retention policy, independent of any channel.
[alerts]
# Consecutive failed checks before a monitor is alerted as down. Kills flapping / false positives.
fail_threshold = 3
# Also alert when a monitor is *degraded* (up, but slower than its `degraded_over_ms`),
# not only when it is down. Uses the same fail_threshold. Requires `degraded_over_ms`
# set on the monitor - without it, a monitor is never degraded. Default: false.
# alert_on_degraded = false
# Warn this many days before a TLS certificate expires.
cert_expiry_days = 14
# Warn this many days before a registered domain expires (for monitors that
# opt in with `domain_expiry`; checked once a day against the registry, RDAP).
# domain_expiry_days = 14
# Default history retention in days, overridable per monitor via `retention_days`.
default_retention_days = 90
# Failed probes are re-tried once (after 1s) before being recorded, so a
# single network blip between Hora and the target never lands in the history,
# the uptime numbers or the error budget. Override per monitor with
# `probe_retries = 0..5` (0 = record every raw result).
#
# Root-cause alert grouping: a monitor confirmed down whose upstream
# (`depends_on`) is also down waits this many seconds; if the upstream alerts
# first (or already did), the dependent's alert folds into that single
# notification - one alert per incident, not one per affected monitor. Its
# recovery stays silent too. 0 = every monitor alerts independently.
# group_window_secs = 30
#
# Anti-flood window for pushed alerts (POST /api/monitors/{id}/alert) that
# carry a `dedup_key`: a repeat of the same key within this many seconds is
# coalesced (dropped and counted) instead of dispatched again, so a flapping
# producer pages once. 0 = dispatch every pushed alert. Default: 300.
# push_alert_window_secs = 300
#
# Notification channel watchdog: consecutive delivery failures before Hora
# alerts the OTHER channels that a channel is broken ("channel 'telegram' is
# failing"). Each delivery already retries 3 times internally, so the default
# of 3 represents 9 total failed attempts — enough to ride through a transient
# blip. The alert fires once per failure streak; a successful delivery resets
# the counter. 1 = alert on the first failure (aggressive). Default: 3.
# channel_fail_threshold = 3
# --- Weekly digest ----------------------------------------------------------
# A recap of the last 7 days through the notification channels: "99.97%
# overall, 2 incidents" plus one line per monitor (uptime, incidents, error
# budget when an SLO is set). Informational only - it never alerts. Preview
# the exact text anytime with `hora digest`.
# [digest]
# schedule = "0 8 * * 1" # five-field cron, UTC (default: Monday 08:00)
# notify = ["ops-telegram"] # optional; default: every configured channel
# --- Scheduled maintenance ------------------------------------------------
# Alerts are muted for the affected monitors during the window (checks still
# recorded; the card shows a "maintenance" badge). `monitors` empty = all.
# [[maintenance]]
# title = "DB upgrade"
# start = "2026-06-08T00:00:00Z" # RFC 3339, quoted
# end = "2026-06-08T02:00:00Z"
# monitors = ["database"]
# --- Incidents / announcements --------------------------------------------
# Shown as a banner at the top of the status page. Edit and reload to update.
# [[incidents]]
# title = "Investigating elevated latency"
# body = "We are looking into reports of slow responses."
# severity = "warning" # info | warning | critical | resolved
# at = "2026-06-07T12:00:00Z" # optional, RFC 3339
# --- Monitors -------------------------------------------------------------
# kind = "http" (default), "tcp", "icmp", "push" or "dns".
# For http: target is a URL; up = 2xx (or expected_status if set).
# For tcp: target is "host:port"; up = TCP connect succeeds.
# For icmp: target is a host or IP (no port); up = ICMP echo reply. Uses an
# unprivileged datagram socket, so it works in rootless Docker without
# CAP_NET_RAW (the kernel net.ipv4.ping_group_range, Docker's default,
# must cover the process). IPv4 and IPv6 are both supported.
# For push: no target; the job calls POST /api/push/{id} (see below).
# For dns: target is a hostname; resolves and asserts expected record type/value.
# TLS certificate expiry is checked automatically for https:// monitors.
#
# Topology (optional):
# group = "infra" # display group on the status page
# depends_on = ["db", "cache"] # upstream monitors this one depends on
# When a monitor goes down, the alert is annotated:
# - "caused by X" if an upstream it depends on is also down (symptom)
# - "impacts: A, B, C" if all its upstreams are up (root cause, blast radius)
# The dependency graph must be acyclic (validated at load).
#
# Visibility (optional):
# public = false # hide from unauthenticated viewers (requires auth_token)
# public_error_detail = true # show the raw failure reason to anonymous viewers;
# # by default they only see a safe category ("HTTP 500",
# # "content check failed") since the stored detail can
# # carry response snippets or asserted keywords
[[monitors]]
id = "website"
name = "Website"
kind = "http"
target = "https://example.com"
interval_secs = 60
timeout_secs = 10
group = "app"
depends_on = ["api"]
# expected_status = 200 # optional; default: any 2xx counts as up
# degraded_over_ms = 800 # optional; up but slower than this = degraded (yellow)
# slo_latency_ms = 500 # optional; the 24h p95 is flagged met/breached against this
# slo_uptime = 99.9 # optional; availability SLO (%). Shows the error budget
# # left on the status page and arms burn-rate alerts
# # ("burning error budget at 14.4x - exhausted in ~6h").
# slo_window_days = 30 # optional; the SLO window (default 30 days)
# check_cert = false # optional; override TLS expiry checking
# retention_days = 30 # optional; override how long this monitor's history is kept
# headers = { Accept = "text/html", Authorization = "Bearer ${TOKEN}" } # optional
# notify = ["alerts-discord"] # optional; route alerts to specific channels
# proxy = "socks5://127.0.0.1:9050" # optional; route requests through a proxy
# public = false # optional; hide from public status page
# public_error_detail = true # optional; publish the raw failure reason (default:
# # anonymous viewers see a safe category only)
# cert_pin = "abc123..." # optional; SHA-256 of leaf public key (detects cert changes)
# domain_expiry = "example.com" # optional; the *registered* domain (not the
# # subdomain), checked daily via RDAP - alerts
# # `alerts.domain_expiry_days` before it expires
# dual_stack = true # optional; probe IPv4 AND IPv6 and require both - catches
# # the service whose IPv6 has been dead for weeks behind a
# # healthy IPv4 ("IPv6 failing: connection timed out (IPv4
# # ok)"). http/tcp/icmp with a hostname target; no proxy.
# # The probing host itself needs working IPv4 and IPv6 -
# # in Docker, default bridge networks have NO IPv6, so this
# # would blame the container's network, not your service:
# # enable IPv6 on the network (or host networking) first.
[[monitors]]
id = "api"
name = "API"
kind = "http"
target = "https://api.example.com/health"
interval_secs = 30
timeout_secs = 10
group = "app"
depends_on = ["database"]
# Body assertions (http only):
# keyword = "operational" # body must contain this (keyword_invert = true → must NOT)
# json_query = "$.status" # JSONPath (RFC 9535) evaluated against a JSON body
# json_expected = "ok" # the queried value must equal this (omit = must match a node)
# max_body_kb = 256 # cap on the body read for assertions (default 1024 = 1 MiB)
[[monitors]]
id = "database"
name = "Database"
kind = "tcp"
target = "db.example.com:5432"
interval_secs = 60
timeout_secs = 5
group = "infra"
# ICMP echo (ping) to a host or IP. No port; up = an echo reply within timeout.
[[monitors]]
id = "gateway"
name = "Gateway"
kind = "icmp"
target = "192.0.2.1" # or a hostname, or an IPv6 address
interval_secs = 30
timeout_secs = 5
group = "infra"
# degraded_over_ms = 200 # optional; flag slow round-trips as degraded
# Heartbeat / push monitor: down if no ping arrives within `interval_secs`.
# The job calls: curl -fsS -X POST -H "X-Push-Token: ${BACKUP_TOKEN}" \
# "https://status.example.com/api/push/nightly-backup"
# The header keeps the token out of proxy/access logs (`?token=` also works).
# Optional query: ?status=down&msg=...&ping=<ms>
#
# Cron-aware variant: declare WHEN the job runs and Hora alerts only if a
# scheduled run misses its grace window - a 03:00 backup pinging at 03:05 is
# fine, one still silent at 03:30 + grace is down. With `schedule` set,
# `interval_secs` is just how often Hora re-evaluates (60 is fine).
[[monitors]]
id = "nightly-backup"
name = "Nightly backup"
kind = "push"
interval_secs = 90000 # max gap before "down" (~25h for a daily job)
# push_token = "${BACKUP_TOKEN}" # optional but recommended
# schedule = "0 3 * * *" # five-field cron, UTC; replaces the fixed-gap rule
# grace_secs = 1800 # how late a scheduled ping may be (default 30m)
# Exec monitor: run an external check, monitoring-plugins convention (exit
# 0 = up, 1 = degraded, anything else = down; first stdout line = message).
# Opens the whole Nagios/Icinga plugin ecosystem - or a 5-line script watching
# another container through a (rootless) Docker socket.
#
# Security model: requires the HORA_EXEC_DIR environment variable (NOT a
# config key - the hot-reloadable config alone must never run code), and
# command[0] is resolved strictly inside that directory. No shell is involved
# (command is a raw argv) and plugins get a scrubbed environment, never the
# daemon's (which carries your channel tokens).
# [[monitors]]
# id = "raid"
# name = "RAID"
# kind = "exec"
# command = ["check_raid", "--no-sudo"] # runs $HORA_EXEC_DIR/check_raid
# interval_secs = 300
# timeout_secs = 30 # the plugin is SIGKILLed past this
# DNS monitor: resolve a hostname; `dns_expected` pins the answer (hijack
# detection - alerts when the answer doesn't match). Without it any non-empty
# answer counts as up: CDN and round-robin answers rotate constantly, so
# alerting on mere change would flap.
# [[monitors]]
# id = "dns-check"
# name = "DNS Check"
# kind = "dns"
# target = "example.com" # hostname to resolve
# interval_secs = 300
# timeout_secs = 10
# dns_record = "A" # optional; A (default), AAAA, CNAME, MX, NS, TXT, SRV, SOA, PTR
# dns_expected = "1.2.3.4" # optional; expected answer (comma-separated, order-insensitive)
# dns_resolver = "8.8.8.8:53" # optional; custom resolver (default: system)
# --- Mutual surveillance (who watches the watcher) ------------------------
# A [health] section makes this node emit an outbound "dead-man" heartbeat: it
# POSTs to each peer's `ping_url` every `interval_secs`, but ONLY while it is
# locally healthy (its scheduler is ticking and its database is writable). If the
# node hangs or dies, the pings stop and the receiver marks it down. Nothing here
# is a bespoke protocol - every exchange is plain HTTP, so a peer can be another
# Hora, a healthchecks.io / UptimeRobot endpoint, or a cron job.
# [health] and [[peers]] reload live (SIGHUP or a config-file edit): adding,
# removing or changing a peer takes effect without a restart.
# [health]
# id = "hora-a" # this node's global identity (how peers refer to it)
# interval_secs = 60 # how often to emit heartbeats when healthy
# grace_secs = 180 # startup grace before a never-seen peer is alerted
# quorum = true # before alerting a peer down, ask the OTHER peers'
# # /healthz: if any still sees it up, it's a partition
# # (PeerLinkDegraded), not an outage. No-op with < 3 nodes.
# confirm_with_peers = true # multi-vantage confirmation: when a MONITOR confirms
# # down, ask the peers to probe the same target before
# # alerting. The alert says "confirmed down from 3/3
# # vantage points" (real outage) or "seen UP by hora-b"
# # (network issue near this node). A peer only probes
# # targets in its OWN config (never an arbitrary one),
# # and its [[peers]] entry for this node must set a
# # listen_token. Strictly fail-open: unreachable peers
# # never block or suppress the alert. Override per
# # monitor with confirm_with_peers = true/false.
# heartbeat_url = "${HC_PING_URL}" # optional extra dead-man target (e.g. healthchecks.io)
# Each [[peers]] entry has two independent halves; set either or both:
# OUT (ping_url) - I heartbeat the peer when I'm healthy.
# IN (expect_every_secs) - I watch the peer and alert if it stops pinging me.
# [[peers]]
# id = "hora-b" # the peer's [health].id (must match across the mesh)
# name = "Hora B (Paris)"
# ping_url = "https://b.example/api/push/hora-a" # OUT: the peer's push endpoint for me
# ping_token = "${PEER_B_TOKEN}" # sent as the X-Push-Token header
# expect_every_secs = 90 # IN: mark the peer down if it goes silent this long
# listen_token = "${PEER_B_IN}" # token the peer must present when it pings me
# # (peer pings POST /api/push/hora-b; listen_id defaults to id)
# witness_url = "https://b.example/healthz" # optional; default = origin(ping_url)/healthz
# notify = ["ops-telegram"] # optional; route this peer's alerts
# OUT-only peer: a plain dead-man to an external receiver (no watching).
# [[peers]]
# id = "healthchecks"
# name = "healthchecks.io"
# ping_url = "https://hc-ping.com/<uuid>"
# To be watched BY UptimeRobot instead: point an UptimeRobot keyword monitor at
# this node's /healthz and match the keyword "ok" (the top-level "status" field is
# "ok" only while the node is fully healthy). No peer entry needed for that.