One tap. I'm alive. Works even when the internet doesn't.
Nabz is a censorship-resistant alive-ping system. It does exactly one job: let a person under an internet shutdown, blackout, or hostile network tell the people who care about them — I'm still here — and let those people verify it, without trusting the network in between. A check-in ("ping") is a tiny signed message that asserts nothing except this person was alive at this time: no location, no content, no metadata beyond what proves liveness. Nabz is built for the worst network conditions on purpose — it ships four independent transport layers (Telegram → HTTPS → DNS → Waku p2p) and a client only needs one of them to get a single bit of truth out of a censored region.
Public status dashboard (packages/web, Next.js + Tailwind). Anyone can
look up a handle and see a single traffic-light answer — green / amber / red —
with the last ping time and which transport carried it.
Home / lookup:
Status cards (green = pinged today, amber = pinged earlier, red = never
pinged). Note the Transport: line — it shows which of the four layers
actually delivered the last ping (waku, dns, https, telegram):
The home page is a live capture of
pnpm --filter @nabz/web dev. The status cards are rendered from the real[handle]/page.tsxmarkup with representative data (the live page pulls the same fields from the API/Postgres).
One signed "I'm alive at time T" assertion, from a phone inside a shutdown to a green dot on a dashboard outside it — over whichever transport the censor forgot to close:
══════════════════════════ INSIDE THE SHUTDOWN ══════════════════════════
1. IDENTITY ANCHOR 2. SIGN (on device)
┌────────────────────┐ ┌──────────────────────────────┐
│ Telegram OAuth │ │ payload = user_id | date | │
│ X (Twitter) OAuth │ ──────▶ │ timestamp | nonce │
│ Ethereum wallet │ │ sig = ed25519 / EIP-191 │
│ (or local ed25519) │ │ + fresh random nonce │
└────────────────────┘ └───────────────┬──────────────┘
│
3. TRANSPORT FALLBACK — any ONE that escapes wins
│
┌────────────────┬────────────────┬────────┴───────────┐
▼ ▼ ▼ ▼
① Telegram bot ② HTTPS POST ③ DNS query ④ Waku Light Push
→ /ping → /p mirror [sig].[nonce]. → topic
[uid].[date]. /nabz/1/ping/proto
p.nabz.TLD
│ │ │ │
════════╪════════════════╪════════════════╪═════════════════════╪══ CENSOR BOUNDARY
│ │ │ │
▼ ▼ ▼ ▼
Telegram Server /p dns-server Observer(s)
ingest (Fastify) decodes → /p-dns Filter-subscribe
│ │ │ │
└────────────────┴───────┬────────┴─────────────────────┘
▼
4. VERIFY (server-side)
┌────────────────────────────────────────┐
│ • signature valid for the pubkey? │
│ • nonce fresh? (anti-replay: unique │
│ per user_id + date) │
│ • DNS pings = best-effort (8-char │
│ truncated sig, labelled 'dns') │
└────────────────────┬───────────────────┘
▼
5. LIVENESS / ALIVE STATUS
Postgres row → dashboard turns 🟢
(privacy mode enforced before status leaves)
═══════════════════════ OUTSIDE — anyone who cares ═══════════════════════
The anti-replay guarantee lives in step 4: each ping carries fresh random bytes
and pings are unique per (user_id, date), so a packet captured on the wire
can't be replayed to manufacture a future "alive." The DNS transport can only
carry 8 hex chars of signature, so those pings are recorded as best-effort
liveness (transport: 'dns'), never as complete cryptographic proofs.
Internet shutdowns are not abstract. They are deliberate, and they are used to hide what happens while the lights are off:
- Iran, 2019 — a near-total blackout lasted roughly 77 days in parts of the country during the November protests. Families abroad spent weeks refreshing dead apps with no way to know if relatives were alive.
- Sudan, 2019 & 2023 — weeks-long, nationwide cutoffs imposed around mass violence, severing the diaspora from people inside exactly when contact mattered most.
- Myanmar, post-2021 coup — one of the longest-running rolling shutdowns in the world, with whole townships cut off for months at a time.
Existing tools miss the same thing every time. Messengers, calls, and social posts all assume a working, unfiltered path to a specific service — the first thing a censor removes. Mesh and offline apps assume the two people are physically near each other; a shutdown usually separates someone inside the country from someone outside it. Satellite kit assumes hardware most people don't have. Nabz's bet is narrower and more achievable: don't try to carry a conversation — carry one bit ("alive at time T"), and carry it over whatever channel the censor forgot to close.
Nabz proves liveness, not safety. It is a harm-reduction tool, not a panic button or a substitute for real emergency infrastructure.
A Nabz client tries transports in order, most reliable first, falling back until something escapes the censored network. The client only needs one to succeed.
| # | Transport | Path | Survives when… |
|---|---|---|---|
| 1 | Telegram bot | /ping to the Nabz bot |
Telegram is reachable (often whitelisted, proxied, or MTProto-tunneled) |
| 2 | HTTPS | POST /p to any Nabz server mirror / domain front |
Plain outbound HTTPS still works |
| 3 | DNS subdomain encoding | A single DNS query whose name is the ping | DNS still resolves — almost always the last thing a censor breaks |
| 4 | Waku p2p (Logos) | Light Push to the Waku network on a content topic | Everything centralized is blocked, but libp2p peers are reachable |
Layers 1–2 are conventional and fast. Layer 3 is the backstop for "the internet is down": in nearly every shutdown the resolver still answers, because breaking DNS breaks the censor's own infrastructure too. Layer 4 is the last-resort, no-trusted-server path — it does not depend on any Nabz domain, IP, or operator being reachable.
The ping is encoded into the domain name itself. The client just resolves:
[sig8].[nonce4].[userid].[date].p.nabz.TLD
sig8— first 8 hex chars of the ed25519 signature (truncated liveness proof)nonce4— 4 hex bytes, anti-replayuserid— the pinging user's iddate—YYYY-MM-DD(a single DNS label, no dots)
The authoritative resolver for p.nabz.TLD is the Nabz dns-server (Go,
miekg/dns). It does not need to return a real record — the act of asking
the question is the ping. The server parses the labels, forwards them to the
API, and returns NXDOMAIN. No HTTP request, no TCP connection, no TLS
handshake ever leaves the device; to a censor it looks like a lookup for a
domain that doesn't exist. Because the DNS label budget only carries 8 sig
chars, DNS pings are recorded as best-effort liveness under shutdown, clearly
marked transport: 'dns', never as cryptographically complete proofs.
When Telegram, HTTPS, and DNS are all blocked — no whitelisted service, no reachable mirror, even the resolver poisoned — there is no trusted server left to talk to. Layer 4 stops talking to servers entirely and broadcasts the ping into a decentralized peer-to-peer network instead.
- Built on Waku (
@waku/sdk), a family of censorship-resistant p2p messaging protocols on libp2p, and part of the Logos stack. - The client runs a light node and uses the Light Push protocol to publish without operating a full relay node.
- Pings go to the content topic
/nabz/1/ping/proto, protobuf-encoded as aPingMessage. - The Nabz server (or any volunteer relay) subscribes via the Filter protocol on the same content topic and ingests received pings into the same database every other transport feeds.
Because there is no Nabz domain, IP, or operator in the path, this layer keeps
working when every centralized route is dead — the message rides peer-to-peer
to anyone listening. See Logos / Waku integration
below and packages/waku-transport/README.md.
The Waku transport is Nabz's answer to the question "what if there is no server we can trust or even reach?" It is the only layer with zero centralized dependency in its data path.
Stack
-
@waku/sdklight node, bootstrapped from the default Waku fleet, with peer discovery over libp2p. Once bootstrapped, message relay is purely peer-to-peer. -
Light Push + Filter. The sender uses Light Push to inject a message without running a full node. The receiver (server or relay) uses Filter to receive only the messages it cares about, again without a full node — so both ends are cheap enough to run on a phone or a tiny VPS.
-
Content topic:
/nabz/1/ping/proto— every Nabz ping, network-wide, shares this single topic. -
Wire format: a protobuf
PingMessage:syntax = "proto3"; message PingMessage { string user_id = 1; string date = 2; string timestamp = 3; string nonce = 4; string sig = 5; }
Field names are kept snake_case so the on-wire object is byte-for-byte the same payload shape every other transport (
/p) already accepts.
What it does when every centralized route is blocked
- Layers 1–3 fail (no Telegram, no reachable HTTPS mirror, DNS poisoned).
- The client spins up an ephemeral Waku light node, waits for a remote peer
that speaks Light Push, encodes the signed ping as
PingMessage, pushes it to/nabz/1/ping/proto, then shuts the node down. - Somewhere reachable, a Filter subscriber on the same content topic decodes the message and writes it to the Nabz database — the same row a Telegram or HTTPS ping would have produced. The dashboard turns green. No Nabz-controlled IP or domain was ever contacted by the client.
Roadmap — Nomos on-chain registry. Today the public-key ↔ identity binding still lives in the Nabz database. The planned next step is a Nomos (the Logos privacy-preserving blockchain) on-chain registry: identity registration and ping verification anchored on-chain, so a ping can be verified with no trusted Nabz server in the loop at all — a fully decentralized liveness proof.
You choose how you prove who you are. All options produce a stable account whose pings can be verified:
- Telegram OAuth — zero friction. Bot-mediated pings are custodial: the Nabz server holds an ed25519 keypair and signs on your behalf. You trust the operator in exchange for a one-tap experience with no keys to manage.
- X (Twitter) OAuth — link an X identity to your handle.
- Ethereum wallet — full self-custody. You sign each ping locally with your
wallet using EIP-191 (
personal_sign). The server only verifies; it never holds your key.
Self-custody (Ethereum, or a local ed25519 keypair) is always recommended for people who can't trust any intermediary.
Set per account:
public— anyone can verify your last ping. Useful for journalists, activists, or anyone who wants the world to know they're accounted for.private— only mutual contacts (you've each added the other) can see your status. Everyone else gets "private", not "offline".
A missing ping is never silently exposed: privacy is enforced before any status is returned.
- ed25519 keypair on-device. Self-custody clients generate and keep the private key locally; only the public key is registered.
- Signed payloads. Every ping signs
user_id|date|timestamp|nonce. The server verifies the signature against the registered public key before a ping counts. - Ethereum option uses EIP-191
verifyMessagefor signature recovery. - Nonce anti-replay. Each ping carries fresh random bytes; pings are unique
per
(user_id, date)so a captured packet can't manufacture a future "alive". - Truncated DNS proof. The DNS transport can only carry 8 sig chars, so DNS
pings are best-effort liveness signals, clearly labelled
transport: 'dns', never treated as complete cryptographic proofs. - Minimal data. A ping reveals existence-at-a-time and nothing else.
Nabz is a pnpm + Go monorepo. Each transport is an independent process so a blocked layer can never take another down.
| Package | Stack | Role |
|---|---|---|
packages/bot |
TypeScript · Telegraf | Telegram bot — /start, /ping, /check. Transport layer 1. |
packages/server |
TypeScript · Fastify · Postgres | Core API: identity, ping ingest (/p, /p-dns), verification, privacy, status. Transport layer 2 and the ingest sink for layers 3–4. |
packages/dns-server |
Go · miekg/dns |
Authoritative UDP DNS server that decodes pings from query names and forwards them to the API. Transport layer 3. |
packages/waku-transport |
TypeScript · @waku/sdk · protobuf |
Waku light-node send/subscribe over content topic /nabz/1/ping/proto. Transport layer 4 (Logos). |
packages/web |
TypeScript · Next.js · Tailwind | Public status dashboard — look up a handle, see green / amber / red. |
A full system diagram, per-layer detail, and the end-to-end data flow are in
docs/architecture.md.
The whole stack — Postgres, server, bot, DNS server, Waku transport, and the
web dashboard — is wired up in docker-compose.yml. Each
package has its own multi-stage Dockerfile (slim runtime images; a static Go
binary for dns-server). No secrets are baked into images or the compose file;
everything sensitive is read from .env.
# 1. Generate the pnpm lockfile (the Node Dockerfiles build with
# `pnpm install --frozen-lockfile`, so the lockfile must exist first).
pnpm install
# 2. Configure
cp .env.example .env
# Fill in at least: BOT_TOKEN, SERVER_ED25519_PK / SERVER_ED25519_SK,
# SERVER_PUBKEY (= SERVER_ED25519_PK). Change POSTGRES_PASSWORD for any
# non-local deployment.
# 3. Build and start everything
docker compose up --build
# 4. One-time: apply the DB schema (psql isn't in the slim runtime image,
# so run it against the postgres service)
docker compose exec -T postgres \
psql -U "${POSTGRES_USER:-nabz}" -d "${POSTGRES_DB:-nabz}" \
< packages/server/src/schema.sql| Service | Port (host) | Notes |
|---|---|---|
web |
3000 |
Next.js dashboard |
server |
3001 |
Fastify API, /health healthcheck |
dns-server |
53/udp |
privileged port — run with sufficient privileges |
postgres |
— | internal only; data in the postgres-data volume |
bot, waku-transport |
— | no exposed ports |
server waits for postgres to be healthy; bot, dns-server,
waku-transport, and web wait for server. The web image bakes
NEXT_PUBLIC_SERVER_URL at build time (Next.js inlines NEXT_PUBLIC_*), so
set it before building if the API isn't at http://localhost:3001.
Prerequisites
- Node 20+ and pnpm 9 (
packageManagerpinspnpm@9.7.0) - Go 1.22+ (for
packages/dns-server) - Docker + Docker Compose (for the full-stack run)
- Postgres 16 (or use the Dockerized one)
Scope. The pnpm workspace covers the four Node packages
(bot, server, waku-transport, web); dns-server is a standalone Go
module built and tested separately. Together they are the five workspace
projects that make up Nabz.
pnpm install # lockfile is committed; resolution is skipped when up to date
# Postgres + schema
createdb nabz
export DATABASE_URL=postgres://localhost:5432/nabz
psql "$DATABASE_URL" -f packages/server/src/schema.sql
cp .env.example .env # then fill in the blanks
# Run all Node services in parallel (≈ `pnpm -r --parallel dev`)
pnpm devPer-package dev:
pnpm --filter @nabz/server dev # API on :3001
pnpm --filter @nabz/bot dev # Telegram bot
pnpm --filter @nabz/web dev # Next.js dashboard
pnpm --filter @nabz/waku-transport dev # Waku light node
cd packages/dns-server && go mod tidy && sudo DNS_PORT=53 go run . # DNSBot-mediated (Telegram) pings are signed by the server. Generate an ed25519 keypair once and share the public half with the bot:
node -e '
const {generateKeyPairSync}=require("crypto");
const {publicKey,privateKey}=generateKeyPairSync("ed25519");
const pk=publicKey.export({format:"der",type:"spki"}).subarray(12).toString("hex");
const sk=privateKey.export({format:"der",type:"pkcs8"}).subarray(16).toString("hex");
console.log("SERVER_ED25519_PK="+pk);
console.log("SERVER_ED25519_SK="+sk);
'Set SERVER_ED25519_PK / SERVER_ED25519_SK on the server and
SERVER_PUBKEY (= the same PK) on the bot.
The suite is vitest for every Node package and go test for the DNS
server.
⚠️ There is no roottestscript —pnpm testat the repo root fails withERR_PNPM_NO_SCRIPT Missing script: test. Run tests recursively or per package instead.
# All Node packages (bot, server, waku-transport, web)
pnpm -r test
# A single package
pnpm --filter @nabz/server test
pnpm --filter @nabz/waku-transport test
# Go DNS server
cd packages/dns-server && go test ./... -vCI. .github/workflows/ci.yml runs
automatically on every push and pull request to main:
- a matrix leg per Node package (
bot,server,waku-transport,web), - a
go test ./...leg fordns-server, - a
docker compose build --no-cacheleg that proves every Dockerfile builds.
The Node legs run with a postgres:16 service container (pg_isready
healthcheck, DATABASE_URL exported). Only packages/server/src/lib/db.test.ts
uses it; the DB-integration suite skips itself when DATABASE_URL is unset,
so the other legs (and local runs without Postgres) stay green.
Copy .env.example to .env. Defaults shown are safe for
local use; blank values must be set before the stack is useful.
| Var | Used by | Meaning |
|---|---|---|
POSTGRES_DB |
postgres | Database name (default nabz) |
POSTGRES_USER |
postgres | Database user (default nabz) |
POSTGRES_PASSWORD |
postgres | Change for any non-local deployment |
DATABASE_URL |
server | Postgres connection string (derived from POSTGRES_* inside compose) |
PORT |
server | API port (default 3001) |
HOST |
server | API bind address (default 0.0.0.0) |
LOG_LEVEL |
server | Fastify/pino log level (default info) |
SERVER_ED25519_PK |
server | Custodial signing public key (hex) for bot-mediated pings |
SERVER_ED25519_SK |
server | Custodial signing private key (hex) |
BOT_TOKEN |
bot | Telegram bot token from @BotFather |
SERVER_URL |
bot | Base URL of the Nabz API (non-compose runs only) |
SERVER_PUBKEY |
bot | Must equal the server's SERVER_ED25519_PK |
DNS_PORT |
dns-server | UDP listen port (default 53) |
NABZ_API_URL |
dns-server | API base; decoded DNS pings are forwarded here |
NEXT_PUBLIC_SERVER_URL |
web | API base for the dashboard (inlined at build time) |
- Nomos on-chain registry — anchor identity registration and ping verification on the Logos/Nomos chain so liveness can be proven with no trusted Nabz server in the loop (see Logos / Waku integration).
- PWA, 1-tap UX — an installable progressive web app whose entire UI is a single "I'm alive" button that walks the transport fallback automatically.
- Decentralized identity — DID / on-chain key binding so the public-key ↔ handle mapping no longer lives only in the Nabz database.
MIT © Nabz contributors.
Built to be forked, mirrored, and self-hosted — resilience comes from there being many of these, run by people you trust.

