Skip to content

Repository files navigation

Nabz

One tap. I'm alive. Works even when the internet doesn't.

What is Nabz

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.


Screenshots

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:

Nabz web dashboard — home

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):

Nabz web dashboard — status states

The home page is a live capture of pnpm --filter @nabz/web dev. The status cards are rendered from the real [handle]/page.tsx markup with representative data (the live page pulls the same fields from the API/Postgres).


The ping lifecycle

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.


Why

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.


How it works — 4-layer transport fallback

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.

Layer 3 — the DNS transport

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-replay
  • userid — the pinging user's id
  • date — 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.

Layer 4 — the Waku p2p transport (last resort)

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 a PingMessage.
  • 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.


Logos / Waku integration

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/sdk light 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

  1. Layers 1–3 fail (no Telegram, no reachable HTTPS mirror, DNS poisoned).
  2. 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.
  3. 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.


Identity

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.


Privacy modes

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.


Security model

  • 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 verifyMessage for 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.

Architecture

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.


Run with Docker

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.


Development

Prerequisites

  • Node 20+ and pnpm 9 (packageManager pins pnpm@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 dev

Per-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 .   # DNS

Generating the custodial server keypair

Bot-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.


Testing

The suite is vitest for every Node package and go test for the DNS server.

⚠️ There is no root test script — pnpm test at the repo root fails with ERR_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 ./... -v

CI. .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 for dns-server,
  • a docker compose build --no-cache leg 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.


Environment variables

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)

Roadmap

  • 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.

License

MIT © Nabz contributors.

Built to be forked, mirrored, and self-hosted — resilience comes from there being many of these, run by people you trust.

About

Censorship-resistant daily alive-ping with Telegram, HTTPS, and DNS fallback transports. Identity via Telegram OAuth, X OAuth, or Ethereum wallet.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages