A monitoring system for gnoland validator nodes.
Validator machine(s) Central server
──────────────────── ─────────────────────────────────────────────────
┌──────────────────┐ ┌────────┐ ┌─────────────┐
│ sentinel │ │ │ │ watchtower │ ┌─────────────────┐
│ ──────────────── │ HTTPS POST │ │ │ ─────────── │──▶│ Loki + │
│ RPC collector │───────────────────▶│ Caddy │─▶│ auth │──▶│ VictoriaMetrics │
│ Log collector │ │ (TLS) │ │ rate limit │ └─────────────────┘
│ OTLP relay │ │ │ │ IP ban │ ┌─────────┐ │
│ Resource monitor │ └────────┘ └─────────────┘ │ Grafana │◀─┘
│ Metadata │ └─────────┘
└──────────────────┘
- sentinel — runs on each validator machine. Collects RPC data, logs, OTLP metrics, resource stats, and node metadata. Ships everything to watchtower over HTTPS, or to a beacon over Noise when the validator sits behind a sentry.
- beacon (optional) — runs on a sentry node fronting the validator. Terminates the sentinel's Noise connection, augments each RPC tick with a
sentry_*view (peer count, build info, p2p.pex), and forwards upstream to watchtower. See Sentry-fronted setup (with beacon). - watchtower — runs centrally. Authenticates each sentinel by bearer token, enforces rate limits and IP bans, and forwards to VictoriaMetrics and Loki.
- Caddy — TLS termination and reverse proxy. Exposes Grafana and the watchtower API publicly on ports 80/443.
- VictoriaMetrics — stores time-series metrics from the RPC and OTLP collectors.
- Loki — stores structured logs from the log collector.
- Grafana — visualises metrics and logs via provisioned dashboards.
For the full catalogue of signals collected (per pipeline, cadence, transport, delta semantics), see docs/data-collected.md.
Validator machine:
- Either Docker (Option 1) or a native
sentinelbinary (Option 2) - Network access to one of:
- the central
watchtowerover HTTPS (port 443), for direct setups - the sentry's
beaconover TCP (default port 8080, configurable), for sentry-fronted setups
- the central
Server:
- Docker Engine 24+ and Docker Compose v2
- A public domain with DNS pointing to the server (for Caddy TLS)
- Ports 80 and 443 open in the firewall
To ship data to a watchtower, the sentinel needs a bearer token from the central server's admin (see Adding and removing validators).
The image bootstraps its own config and keys on first run, so you can pull and start in a few commands:
docker pull ghcr.io/aeddi/gno-watchtower/sentinel:latest
sudo mkdir -p /etc/sentinel
docker run -d --name sentinel \
--restart unless-stopped \
-v /etc/sentinel:/etc/sentinel \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-p 127.0.0.1:4318:4318 \
ghcr.io/aeddi/gno-watchtower/sentinel:latestFor image tag conventions, see Versioning.
On first start the entrypoint writes a default /etc/sentinel/config.toml with placeholder values and crash-loops with a clear validation error until you fill the placeholders. Edit /etc/sentinel/config.toml and set at least:
[server] url→https://<DOMAIN>/watchtower[server] token→ bearer token frommake add-validator[logs] container_name→ your gnoland container name
For all configurable fields, see Sentinel config.
Then docker restart sentinel. Validate end-to-end with:
docker exec sentinel sentinel doctor /etc/sentinel/config.tomlIf your validator is behind a sentry, see Sentry-fronted setup (with beacon) — your Noise keypair is already in /etc/sentinel/keys.
Install via release tarball or go install:
# Either: download a release archive
# https://github.com/aeddi/gno-watchtower/releases
tar -xzf sentinel_<version>_linux_amd64.tar.gz
sudo install -m 0755 sentinel /usr/local/bin/sentinel
# Or: build from source (requires Go 1.25+)
go install github.com/aeddi/gno-watchtower/cmd/sentinel@latestGenerate and edit the config:
sudo mkdir -p /etc/sentinel
sudo sentinel generate-config /etc/sentinel/config.toml
sudo $EDITOR /etc/sentinel/config.toml # set [server] url, [server] token, [logs] container_name, etc.
sudo sentinel doctor /etc/sentinel/config.tomlsystemd unit at /etc/systemd/system/sentinel.service:
[Unit]
Description=Gnoland Sentinel
After=network.target
[Service]
ExecStart=/usr/local/bin/sentinel run --log-format=journal /etc/sentinel/config.toml
Restart=on-failure
[Install]
WantedBy=multi-user.targetFor all configurable fields, see Sentinel config.
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now sentinelAfter any later config change, run sudo systemctl restart sentinel to apply.
journald log source: tails the gnoland node's logs from systemd-journald (use this when gnoland itself runs as a systemd service writing to journald). The published binaries (release tarballs and
go installartifacts) are pure-Go (CGO_ENABLED=0) and don't support[logs] source = "journald". To use it you must build with cgo on a host that haslibsystemd-dev(or equivalent) installed:CGO_ENABLED=1 go install github.com/aeddi/gno-watchtower/cmd/sentinel@latest
If your validator is behind a sentry, generate the Noise keypair before systemctl enable --now sentinel, then see Sentry-fronted setup (with beacon):
sudo sentinel keygen /etc/sentinel/keysIf your validator sits behind a sentry, run a beacon on the sentry. The sentinel connects to the beacon over Noise (TCP); the beacon forwards everything upstream and adds a sentry_* view (peer count, build info, p2p.pex) to each RPC tick so Grafana can show "direct vs. via-sentry" side-by-side.
Validator machine Sentry machine Central server
───────────────── ────────────── ──────────────
┌──────────────┐ Noise (TCP) ┌────────────┐ HTTPS POST ┌────────────┐
│ sentinel │────────────────▶│ beacon │─────────────────▶│ watchtower │
└──────────────┘ └─────┬──────┘ └────────────┘
│ GET /status, /net_info
▼
sentry gnoland RPC
The validator keeps the bearer token; the beacon is a dumb pass-through that only rewrites the /rpc body. Tampering with the token is still caught upstream by the watchtower.
Beacon — Docker (on the sentry):
docker pull ghcr.io/aeddi/gno-watchtower/beacon:latest
sudo mkdir -p /etc/beacon
docker run -d --name beacon \
--restart unless-stopped \
-v /etc/beacon:/etc/beacon \
-v /path/to/sentry-config.toml:/sentry-config.toml:ro \
-p 8080:8080 \
ghcr.io/aeddi/gno-watchtower/beacon:latestLike the sentinel image, the beacon image bootstraps /etc/beacon/config.toml and /etc/beacon/keys/ on first run. Edit /etc/beacon/config.toml:
[server] url→https://<DOMAIN>/watchtower[rpc] rpc_url→ sentry's local gnoland RPC (typicallyhttp://localhost:26657)[metadata] config_path→/sentry-config.toml(the in-container path of the second volume mount above)
Then docker restart beacon. The beacon's public key is at /etc/beacon/keys/pubkey — copy it for the sentinel side.
Beacon — Native binary + systemd: mirror Option 2 above with these substitutions:
- archive:
beacon_<version>_<os>_<arch>.tar.gz(orgo install github.com/aeddi/gno-watchtower/cmd/beacon@latest) - systemd
ExecStart:/usr/local/bin/beacon run --log-format=journal /etc/beacon/config.toml
Sentinel side: in /etc/sentinel/config.toml, switch the URL scheme to noise:// and point [beacon] at the sentinel's keys directory:
[server]
url = "noise://<sentry-host>:8080"
token = "<bearer-token>"
[beacon]
keys_dir = "/etc/sentinel/keys"
public_key = "<beacon-public-key-hex>" # optional; pins the beacon's identityThe Noise-XX handshake always provides confidentiality. Whether each side authenticates the other is opt-in — see Beacon authentication modes.
Run from deploy/:
-
Clone the repository:
git clone https://github.com/aeddi/gno-watchtower.git cd gno-watchtower/deploy -
Copy and edit the environment file:
cp .env.example .env $EDITOR .env # set DOMAIN, GRAFANA_ADMIN_PASSWORD
-
Create the initial watchtower config. The deploy stack mounts
watchtower.tomlread-only from the host, so you must create it beforemake up. The defaults below wire watchtower to the in-stack VictoriaMetrics and Loki services and pick sane rate-limit values; override only what you need (see Watchtower config for the full schema):cat > watchtower.toml <<'EOF' [server] listen_addr = "0.0.0.0:8080" [security] # Per-validator rate limits; 429s surface as watchtower_rate_limited_total{validator}. rate_limit_rps = 100 # rate_limit_burst must be >= number of concurrent sentinel data types (rpc + metrics + logs + otlp = 4) rate_limit_burst = 200 ban_threshold = 5 ban_duration = "15m" [victoria_metrics] url = "http://victoria-metrics:8428" [loki] url = "http://loki:3100" EOF
-
Start the stack:
make up
-
Grafana is available at
https://<DOMAIN>. Log in with the admin credentials from.env.
Caddy serves Grafana at https://<DOMAIN>/ and reverse-proxies https://<DOMAIN>/watchtower to the watchtower API — that's why sentinels point their [server] url at the /watchtower path.
Other Make targets in deploy/:
make down— stops the stackmake restart svc=<service>— restarts one servicemake logs svc=<service>— follows logs for one servicemake add-validator/make remove-validator— see Adding and removing validators
Run these commands from the deploy/ directory.
Add a validator:
make add-validator name=val-01 permissions=rpc,metrics,logs,otlp logs_min_level=infoThis generates a cryptographically secure bearer token, appends the [validators.val-01] block to watchtower.toml, restarts watchtower, and prints the token. Paste the token into the sentinel's config.toml under [server] token.
Remove a validator:
make remove-validator name=val-01This removes the [validators.val-01] block from watchtower.toml and restarts watchtower. The sentinel will receive 401 responses and stop sending data.
The Noise-XX handshake always encrypts the channel. The server is authenticated only when the sentinel pins [beacon] public_key; the client is authenticated only when the beacon allowlists the sentinel's public key in [beacon] authorized_keys. Both directions are opt-in:
sentinel [beacon] public_key |
beacon [beacon] authorized_keys |
Result |
|---|---|---|
| unset | empty | Encrypted, but either side accepts any peer. Cheapest to deploy; rely on network controls. |
| set | empty | Sentinel pins the beacon; any sentinel may connect to the beacon. |
| unset | non-empty | Beacon allowlists sentinel public keys; sentinel accepts whatever beacon identity the TCP hostname resolves to. |
| set | non-empty | Mutual pinning. Recommended for production. |
Public keys are at <keys_dir>/pubkey (printed on first generation by keygen, or by the entrypoint on first container start).
Run sentinel generate-config <path> for a fully annotated example. Key fields:
| Section | Field | Description |
|---|---|---|
[server] |
url |
Watchtower base URL (https://...) or beacon address (noise://host:port) |
[server] |
token |
Bearer token from make add-validator |
[rpc] |
rpc_url |
Local gnoland RPC (default http://localhost:26657) |
[rpc] |
poll_interval |
Poll interval for status/net_info/num_unconfirmed_txs (default 3s) |
[logs] |
source |
docker or journald (journald requires a cgo build — see Option 2) |
[logs] |
container_name |
Gnoland container name (when source = "docker") |
[logs] |
journald_unit |
systemd unit name (when source = "journald") |
[logs] |
min_level |
Minimum log level to ship |
[otlp] |
listen_addr |
Local OTLP listener — point gnoland's exporter here (default localhost:4318) |
[resources] |
source |
host, docker, or both |
[resources] |
container_name |
Gnoland container name (when source = "docker" or "both") |
[metadata] |
config_path |
Path to gnoland's config.toml (read for p2p.pex, moniker, etc.) |
[metadata] |
config_get_cmd |
Alternative to config_path — shell command using %s for the key name (set one, not both) |
[self] |
report_interval |
Cadence of sentinel self-stats (bytes sent, drops, retries; default 30s) |
[health] |
listen_addr |
Local health endpoint (default 127.0.0.1:8081; used by the Docker HEALTHCHECK) |
[beacon] |
keys_dir |
Sentinel Noise keys directory (required when [server] url uses noise://) |
[beacon] |
public_key |
Pinned beacon public key (optional, see authentication modes) |
Run beacon generate-config <path> for the annotated example. Key fields:
| Section | Field | Description |
|---|---|---|
[server] |
url |
Upstream watchtower URL (https://<DOMAIN>/watchtower) |
[beacon] |
listen_addr |
Noise listener (default 0.0.0.0:8080) |
[beacon] |
keys_dir |
Beacon Noise keys directory |
[beacon] |
authorized_keys |
Optional allowlist of sentinel public keys (hex), see authentication modes |
[beacon] |
handshake_timeout |
Bound on the Noise handshake phase (default 5s) |
[rpc] |
rpc_url |
Sentry's local gnoland RPC |
[metadata] |
config_path |
Sentry's gnoland config.toml (for p2p.pex augmentation) |
[metadata] |
config_get_cmd |
Alternative to config_path — shell command using %s for the key name (set one, not both) |
| Section | Field | Description |
|---|---|---|
[server] |
listen_addr |
HTTP listen address (default 0.0.0.0:8080) |
[security] |
rate_limit_rps |
Per-validator request rate limit |
[security] |
rate_limit_burst |
Token-bucket burst (must be ≥ 4 — sentinel concurrency) |
[security] |
ban_threshold |
Failed auth attempts before banning the source IP |
[security] |
ban_duration |
Ban duration (Go duration string, e.g. 15m) |
[victoria_metrics] |
url |
VictoriaMetrics ingest URL |
[loki] |
url |
Loki ingest URL |
One [validators.<name>] block per sentinel:
| Field | Description |
|---|---|
token |
Bearer token for this validator (hex string, unique across validators) |
permissions |
Allowed data types: any subset of ["rpc", "metrics", "logs", "otlp"] |
logs_min_level |
Minimum log level forwarded to Loki: debug, info, warn, error |
| Variable | Description | Default |
|---|---|---|
DOMAIN |
Public domain for Caddy TLS and Grafana | monitoring.example.com |
GRAFANA_ADMIN_USER |
Grafana admin username | admin |
GRAFANA_ADMIN_PASSWORD |
Grafana admin password | changeme |
METRICS_RETENTION |
VictoriaMetrics retention in months | 6 |
LOGS_RETENTION |
Loki log retention period (e.g. 2160h = 90 days) |
2160h |
Releases follow Semantic Versioning. Each release publishes:
- Binaries on the Releases page for
sentinel,beacon, andwatchtower—linux/{amd64,arm64}anddarwin/{amd64,arm64}. - Docker images at
ghcr.io/aeddi/gno-watchtower/{sentinel,beacon,watchtower}. Available tags::vX.Y.Z,:X.Y.Z,:X.Y,:latest— releases:main,:sha-<short>— main-branch builds (not for production)
Check the version of a running binary with <bin> version (or -v for commit + build time + Go toolchain).