A lightweight, private, on-node Telegram "pocket glance" for Monad validator
nodes. Send /status from your phone and get a one-screen answer about your
validator — no dashboards, no third-party service seeing your node.
This is an on-demand glance, not a remote alerting service: check your node from your phone, and if something looks wrong, go to your laptop and dig in. It does include a minimal watchdog, but it runs on the node — if the server dies, the bot dies with it.
- On-node, private: reads only your own node. No data leaves the machine except the Telegram messages to you.
- Lightweight: one Go binary, one systemd unit, zero listening ports.
- Glance-first: a compact
/statuscard, with drill-down buttons only when you want more.
Prerequisites: a binary-mode Monad node (systemd services, no Docker) with
monad-status at /usr/local/bin/monad-status and the monad-ledger-tail
service running. Go 1.25+ if building from source.
Downloads the prebuilt static binary from the latest GitHub release (sha256 verified), checks the node preconditions, asks for the Telegram bot token / chat id, validator name and secp public key, then installs and enables the service. No repo clone or Go toolchain needed.
curl -fsSL https://raw.githubusercontent.com/EmberStake/monprobe/main/scripts/install-remote.sh -o monprobe-install.sh
sudo bash monprobe-install.shInteractive prompts only work when the script is run as a local file (as
above). Unattended runs (e.g. curl ... | bash or Ansible) are supported via
environment variables:
sudo env MONPROBE_TG_TOKEN="123:ABC" MONPROBE_TG_CHAT_ID=123456789 \
MONPROBE_VALIDATOR_NAME="my-validator" \
MONPROBE_SECP_KEY="03..." \
bash monprobe-install.shAdditional env overrides: MONPROBE_REPO (source repo, default
EmberStake/monprobe), MONPROBE_VERSION (release tag, default latest),
MONPROBE_NO_PROMPT=1 (never prompt).
git clone https://github.com/EmberStake/monprobe.git
cd monprobe
CGO_ENABLED=0 go build -o monprobe ./cmd/monprobe
sudo ./scripts/install.sh
sudo nano /etc/monprobe/config.yaml # token, allowed chat/user ids, secp key
sudo systemctl enable --now monprobe
journalctl -u monprobe -fThe installer creates the monprobe user, grants journal read access,
validates and installs the sudoers rule, installs the binary, unit and config,
and fails closed if any security precondition is violated. Once the service is
running, send /status in Telegram — if the card shows the Identity section
(hostname, network, public keys), the sudo monad-status path works inside the
systemd sandbox.
sudo systemctl disable --now monprobe
sudo rm -f /etc/systemd/system/monprobe.service
sudo systemctl daemon-reload
sudo rm -f /etc/sudoers.d/monprobe /usr/local/bin/monprobe
sudo rm -rf /etc/monprobe
sudo userdel monprobeCopy config.example.yaml and fill in:
telegram.token— from @BotFathertelegram.allowed_chat_ids— chat IDs allowed to talk to the bot. For a private chat (the normal case) this is the only thing you need — your user ID equals your chat ID. For group chats, leave this for the group and useallowed_user_idsinstead.telegram.allowed_user_ids— optional user allowlist (needed only for group chats). Note that this allowlist is not scoped to a chat: a listed user is answered wherever they talk to the bot, including a group someone else added it to.allowed_chat_idsalone never authorises a group, because in a group the sender's user ID never equals the chat ID.validator.secp_public_key— your validator's secp public key (used to attribute own blocks in the ledger stream and to discover your validator ID)validator.id— optional on-chain validator ID (skips ID discovery)metrics.url— your node's metrics endpoint (default127.0.0.1:9143)rpc.url— your node's own JSON-RPC endpoint (default127.0.0.1:8080); used exclusively for staking precompile readsstaking.*— enable/disable the 🏦 card and set the refresh intervalwatchdog.*— thresholds and intervals
| Command | Shows |
|---|---|
/status |
the glance card: sync status, height and lag vs external, epoch/round, peers, 24h own blocks, throughput, raptorcast p99, txpool, services, identity |
/start |
same as /status |
/help |
short command reference |
| button 🔄 Refresh | re-scrape and redraw the card in place |
| button 📡 Live | recent own block events from the ledger tail |
| button 🏦 Staking | stake, active-set standing, reliability (24h/7d/30d), payouts, registration |
The 🏦 Staking drill-down answers "is my stake working?" — ordered so the answer comes first and the paperwork last. Every number is read from your own node's RPC:
- Header — validator name (from config), on-chain ID, and active-set
membership (
getConsensusValidatorSet) at the current epoch. - 💎 Stake — total stake (
getValidatorexecution view), staker count (paginatedgetDelegators), self-stake (getDelegatorof the auth address), consensus stake, and commission. - 📈 Reliability — uptime over 24h/7d/30d computed from your own leader slots in the ledger stream (a slot either proposed one of your blocks or timed out), plus the last own commit and last timeout. monprobe does not replay journald on start, so these windows only cover this process's lifetime; when a window is longer than what has actually been observed the card says so instead of implying 30 days of evidence. Own-block events are retained for 31 days and older ones are dropped, so memory reaches a plateau rather than growing.
- 💵 Payouts — unclaimed commission rewards for the auth address and
wallet balance (
eth_getBalance). - 🪪 Registration — stake flags (
Ok/StakeTooLow/Withdrawn/DoubleSign) and the auth address.
If validator.id is not configured, monprobe discovers the ID once by
scanning the staking precompile for your secp public key (bounded
scan, cached in memory). Set validator.id explicitly to skip the
scan.
Sends a message only when something is actually wrong — once per condition, with a recovery message when it clears, plus a cooldown to prevent noise. A condition that flaps inside the cooldown stays silent in both directions: the suppressed alert produces no 🔴, and its clearing produces no orphan 🟢 either. Conditions covered:
- block height frozen longer than the stall threshold
- metrics endpoint unreachable
- core service down (monad-bft, monad-execution, monad-rpc)
- burst of own timeout events (your validator missing its leader slots; threshold/window configurable, recovered automatically when the burst ends)
Alerts are prefixed with the validator name from the config, so one Telegram chat can receive from several nodes without confusion.
| Source | Provides |
|---|---|
Node metrics 127.0.0.1:9143 |
height, commits, peers, proposals, statesync, consensus events, raptorcast p99 latency, txpool |
journalctl -u monad-ledger-tail |
your own proposed / finalized / timeout blocks (filtered by your secp key) → 24h own-block summary, 24h/7d/30d uptime, last commit/timeout, missed-block watchdog |
monad-status (via the fixed sudo rule) |
service states, epoch/round, statesync, block lag vs external RPC |
Local RPC 127.0.0.1:8080 (staking precompile 0x1000) |
validator ID, auth address, stake flags, self-stake, total stake, staker count, consensus stake, commission, unclaimed rewards, wallet balance, active-set membership — read from the staking precompile through your own node's RPC, never an external endpoint |
monprobe is designed to open zero holes in your validator:
- No inbound ports. The bot uses Telegram long polling (outbound-only).
A port scan of your validator looks exactly the same before and after
installing monprobe. The binary is built with
CGO_ENABLED=0(static). - No shell, no injection surface. Telegram messages are matched against a fixed dispatch table. User input never becomes part of a command, path or URL. All external processes run via fixed, hard-coded argument vectors. The bot accepts no arguments and is strictly read-only — it can report, never act.
- Least privilege. Runs as a dedicated
monprobeuser with no membership in themonadgroup — it cannot read your validator keys even if compromised. - One narrow sudo rule. Exactly one command is permitted:
monprobe ALL=(monad) NOPASSWD: /usr/local/bin/monad-status ""(the trailing""permits the command only with no arguments) The installer verifies thatmonad-statusis a root-owned ELF binary that neither group/other nor themonaduser can modify (including the containing directory), and refuses to install otherwise (a monad-writable monad-status would be an escalation path). monad-status prints public identifiers only. - Allowlist. Only chat IDs (and optionally user IDs) listed in the config get any response. Unknown senders get silence. Per-chat rate limiting included.
- Sandboxed systemd unit: read-only filesystem view, private /tmp, no
device access, no namespace/kernel access, minimal capability set.
NoNewPrivilegesis deliberately absent because the single fixed sudo command requires setuid — this is documented in the unit file so it is not "fixed" into silently disabling the monad-status data source. - Config file holds the bot token at
0600, owned by the monprobe user. The bot token is additionally redacted from all error/log output, so DNS blips can't leak it into the journal.
go test ./... # fixtures live in ./testdata, all synthetic
gofmt -l . && go vet ./...
CGO_ENABLED=0 go build -o monprobe ./cmd/monprobeEvery push and pull request runs gofmt, vet, the test suite and a static
build (.github/workflows/test.yml). The parser tests read from testdata/
and fail — rather than skip — when a fixture is missing, so a checkout that
cannot see them is a loud error and not a quietly shrunken suite. Those
fixtures are trimmed captures from a live testnet node with every identifier
replaced: placeholder secp keys, RFC 5737 peer addresses, generic hostnames.
MIT — see LICENSE.
