Repository navigation
04 hosting services
Section [2] of the original brief. Three methods, from strongest privacy to most operationally involved. The exact addressing clients use is in §3.4. Code lives under
services/.
| Method | Service has a Nym address? | Clearnet reachable? | Inbound ports needed? | Exit Gateway involved? | Privacy |
|---|---|---|---|---|---|
| A. Pure mixnet provider | ✅ (stable) | ❌ | None | ❌ | Strongest |
| B-private. Hybrid mixnet-only | ✅ | ❌ zero clearnet | None | ❌ for mixnet path | Strongest for HTTP |
| B. Hybrid (bridge) | ✅ | ✅ (via Caddy/nginx) | 443 only (clearnet) | ❌ for mixnet path | Good |
| C. Self-hosted Gateway + service | ✅ (pinned to your Gateway) | optional | 1789/9000/443 | ❌ for mixnet path | Strongest + stable address |
The recommended default is A, or B-private when you have a real HTTP app but want no clearnet at all. Use classic B only when ordinary web users must reach you. C is only necessary when you must guarantee address stability or operate network infrastructure.
┌─────────────────────────── your server ───────────────────────────┐
│ │
mixnet ──▶ Gateway ─────▶│ nym client (embedded) ──▶ your app ──▶ local DB / RPC / ... │
│ (outbound WSS only; no inbound port) │
│ │
clearnet ──▶ :443 ──────▶│ Caddy / nginx ─────────▶ same app │
│ │
└────────────────────────────────────────────────────────────────────┘
The cross-platform client ships a Service tab (primary navigation, next to Portal) that keeps this section in sync with the UI:
-
Portal identity — generates or imports a separate keypair from your
social identity (localStorage key
fly.portal.identity); - Quick start — copyable commands for Linux, Docker, and Windows, plus a link to the CI-published release assets (GitHub latest release);
- Status — paste the running provider's address to test reachability with a measured round-trip latency.
The provider address always comes from the provider's own startup output /
nym-address.txt — the browser never invents one. See
web/src/application/runPortal.ts (pure logic)
and web/src/adapters/driving/portal/RunPortal.tsx (the tab).
# terminal 1 — the service
cd services/echo-provider
SP_DATA_DIR=./sp-storage cargo run --release
# prints: Service provider Nym address: <id.enc@gw>
# terminal 2 — a client reaches it by that address
cargo run --release --bin provider-client -- "$(cat nym-address.txt)" "hello"
# -> sent in 15µs; anonymous reply in ~1.6s: "echo: hello"From the browser: Start messaging client → paste the address into the recipient field → Send. Verified end to end.
# 1. your web app (any HTTP server: nginx, Caddy, your own)
python3 -m http.server 8081 --bind 127.0.0.1
# 2. the bridge: an embedded Nym client that forwards to the backend
cd services/hybrid-bridge
BACKEND_URL=http://127.0.0.1:8081 SP_DATA_DIR=./bridge-storage cargo run --release
# prints: Hybrid bridge Nym address: <id.enc@gw>
# 3. connect over the mixnet by sending the request envelope to that address
{"method":"GET","path":"/","headers":{},"body_base64":""}
# reply (verified):
{"status":200,"headers":{"content-type":"text/html"},"body_base64":"PCFkb2N0eXBlIGh0bWw+…"}The backend sees an ordinary local call (GET / HTTP/1.1 200); the bridge opens
no inbound ports.
No Caddy. No ports:. No inbound firewall rule. Real HTTP backend, reachable
only by Nym address:
cd services/hybrid-bridge
docker compose -f docker-compose.mixnet-only.yml up --build
docker compose -f docker-compose.mixnet-only.yml logs bridge # Nym address
./verify-no-clearnet.sh docker-compose.mixnet-only.yml # proves itArchitecture:
mixnet clients ──▶ bridge (outbound WSS only) ──▶ app (internal:true network)
clearnet users ──▶ NOTHING (no listener, no port, no proxy)
-
appis on aninternal: trueDocker network — onlybridgereacheshttp://app:80. Compose publishes nothing. -
bridgebinds nothing; it dials OUT to its Nym gateway. - Containers run
read_only+no-new-privileges. - Host firewall default-deny inbound (
firewall-no-inbound.sh); outbound stays open (gateway WSS + nym-api need it). - Private nginx profile (
nginx.private.conf):server_tokens off,access_log off, hidden-file denials.
Add Caddy in front for simultaneous clearnet access —
services/hybrid-bridge/docker-compose.yml wires both front doors to one app.
A service provider is an ordinary Nym client embedded in your app. It:
- dials out to its Gateway over WSS (so it can run behind a deny-all firewall, in a DMZ, with no public IP);
- the Gateway stores incoming messages while the provider is briefly offline;
- requests arrive as Sphinx packets. Each carries a bundle of SURBs (single-use reply blocks) pre-computed by the client;
- the provider replies with
send_reply(sender_tag, ...)— it never learns the client's address, and the client never learns the provider's IP.
mixnet ──▶ your app + embedded Nym client ──▶ backend it already talks to
(this is what you modify) (unchanged, no Nym awareness)
MixnetClient::connect_new() / new_ephemeral() generate a fresh address on
every restart, breaking every client that cached the old one. Real providers
must use on-disk storage:
let paths = StoragePaths::new_from_dir("./sp-storage")?;
let mut client = MixnetClientBuilder::new_with_default_storage(paths)
.await?
.build()?
.connect_to_mixnet()
.await?;The Gateway identity is part of the address. To guarantee stability, run your own Gateway (§3.3) and pin the provider to it:
let builder = MixnetClientBuilder::new_with_default_storage(paths)
.await?
.request_gateway("<gateway-identity-key>");See services/echo-provider/. It uses the
messaging API (wait_for_messages / send_reply) — the only shape a
browser client can speak today.
use nym_sdk::mixnet::{MixnetClientBuilder, MixnetMessageSender, StoragePaths};
let paths = StoragePaths::new_from_dir(std::env::var("SP_DATA_DIR")?)?;
let mut builder = MixnetClientBuilder::new_with_default_storage(paths).await?;
if let Ok(gw) = std::env::var("SP_GATEWAY") {
builder = builder.request_gateway(gw);
}
let mut client = builder.build()?.connect_to_mixnet().await?;
println!("Nym address: {}", client.nym_address());
while let Some(messages) = client.wait_for_messages().await {
for msg in messages {
if msg.message.is_empty() { continue; } // SURB replenishment
let Some(tag) = msg.sender_tag else { continue; };
client.send_reply(tag, handle(&msg.message)).await?;
}
}Run:
cd services/echo-provider
SP_DATA_DIR=./sp-storage cargo run --release
# prints: Nym address: <id>.<enc>@<gateway>Or with Docker:
docker build -t echo-provider services/echo-provider
docker run --rm -v "$PWD/sp-storage:/data" -e SP_DATA_DIR=/data echo-providerThis exact flow was run against mainnet: the provider was started, and a separate client reached it purely by Nym address — no inbound port, no clearnet endpoint, and the provider never learned the client's address.
$ SP_DATA_DIR=./sp-storage cargo run --release
Service provider Nym address:
CpJbfEuhMdKBdztyF3WhNdmupkiTRqxrn6EKA3cbAbQ1.DLPmXTqD6yvRqJxmBMeBfkF1HuGW3r8TUVRoZA7E6ZPc@GJqd3ZxpXWSNxTfx7B1pPtswpetH4LnJdFeLeY5KUuN
# the ordinary client in the browser receives a "sender_tag" and replies via SURB
A minimal client is included in the same crate:
cd services/echo-provider
cargo run --release --bin provider-client -- "$(cat nym-address.txt)" "hello hosted service"
# provider: CpJbfEuh…@GJqd3Zxp…
# client address: EwZ8rKQG…@62c8JqJC…
# sent in 15.1µs: "hello hosted service"
# anonymous reply in 1.571204715s: "echo: hello hosted service"The provider prints received N bytes: "…" and replied via SURB (N bytes)
for each request.
The client sends on a fixed Poisson schedule whether or not you have data:
| Stream | Default | Rate |
|---|---|---|
| Real packets | 1 every 20 ms | ~50/s |
| Loop cover packets | 1 every 200 ms | ~5/s |
| Total | ~55 pkt/s |
Consequences:
- Capacity is shared. Every reply to every client comes out of ~50 real packets/s. Measure reply sizes; run multiple provider clients when saturated.
- Idle is not free. With nothing to send you emit cover traffic — same size, same rate, same CPU.
- It never stops. The provider and its Gateway exchange this stream continuously.
Every reply consumes one SURB; clients must pre-send enough. Bulk transfers
(syncing a chain, streaming media) exhaust the SURB budget — use the Stream
module (listener()/accept()) or smolmix for that, not messaging.
- No clearnet IP for the service is ever exposed.
- Client↔provider content stays Sphinx-encrypted across the mixnet; your process decrypts it before it reaches anything downstream.
- The Gateway learns the provider's IP (it is the provider's own Gateway) but not which client is talking to it.
You want ordinary browsers (clearnet) and mixnet clients to reach the same application. The pattern is a shared backend with two front doors:
clearnet users ──▶ Caddy :443 ─────────────┐
├──▶ app (localhost / compose network)
mixnet clients ──▶ mixnet-bridge (nym-sdk) ─┘
- Caddy / nginx terminates TLS for the public web.
-
services/hybrid-bridge/embeds a Nym client, receives mixnet requests, forwards them to the backend over the local network, and returns the response as a SURB reply.
This is not an Exit Gateway: the bridge is a normal Nym client, so no outproxy policy or exit liability is involved. The bridge is the "reverse proxy that accepts Sphinx packets and forwards to a local web server".
services:
app:
image: nginx:alpine
volumes:
- ./public:/usr/share/nginx/html:ro
expose: ["80"]
bridge:
build: ./bridge
environment:
SP_DATA_DIR: /data
BACKEND_URL: http://app:80
volumes:
- bridge-data:/data
depends_on: [app]
restart: unless-stopped
# no ports: the bridge only dials out
caddy:
image: caddy:2-alpine
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on: [app]
volumes:
bridge-data:
caddy-data:example.com {
encode zstd gzip
reverse_proxy app:80
}
nginx equivalent (server block): location / { proxy_pass http://app:80; }
with proxy_set_header Host $host; X-Forwarded-For $remote_addr;.
Mixnet messages are opaque bytes, so define a tiny envelope the bridge understands. Keep it minimal and validate hard:
{ "method": "GET", "path": "/api/status", "headers": {}, "body_base64": "" }Response:
{ "status": 200, "headers": {}, "body_base64": "..." }The bridge must:
- allow-list methods and paths (never forward arbitrary absolute URLs — that would turn it into an open proxy);
- cap body size to the Sphinx plaintext limit (≤ 2031 bytes per packet);
- never log client identifiers.
This is the opposite direction (you are the client). You do not host
anything. A browser or nym-socks5-client sends a SOCKS5 request through the
mixnet to an Exit Gateway's Network Requester, which connects to the clearnet
site. The site sees the Exit Gateway's IP. Useful when you want to browse, not
when you want to host.
That is exactly the bridge above. There is no need for a special reverse proxy plugin; the Nym client is the ingress.
Only needed for guaranteed address stability or if you are operating
infrastructure. Two parts: run a nym-node in entry-gateway mode (the
Gateway that holds your provider's mailbox), and pin your provider to it.
Running an exit gateway is a separate, higher-risk decision (you route other people's traffic to the open internet and receive abuse complaints). See Nym's Community Counsel pages before doing it. This section covers an entry gateway, which is what address stability requires.
- A VPS with a static, publicly routable IP (not in a DMZ behind NAT).
- Ports: 1789 mixnet, 9000 client WebSocket, 8080 HTTP API (bind to localhost or firewall), 1790 verloc.
- A Nyx wallet to bond the node.
Modes: --mode mixnode | entry-gateway | exit-gateway | exit-providers-only.
Run exactly one mode; multiple modes make the node non-routable.
Config lives at ~/.nym/nym-nodes/<ID>/config/config.toml. run initialises
on first use; --init-only writes config without starting; -w/--write-changes
applies CLI overrides to the file.
# one-off: accept T&Cs explicitly, announce your public IP
./nym-node run \
--id my-gateway \
--mode entry-gateway \
--public-ips "$(curl -4 -s https://ifconfig.me)" \
--hostname gateway.example.com \
--location CH \
--accept-operator-terms-and-conditions \
--wireguard-enabled trueInitialise only (recommended, then inspect config.toml):
./nym-node run --id my-gateway --init-only --mode entry-gateway \
--public-ips "$(curl -4 -s https://ifconfig.me)" \
--hostname gateway.example.com --location CHGet the gateway identity key to pin your provider:
./nym-node node-details --id my-gateway
# or, from the running node:
curl -s http://127.0.0.1:8080/api/v1/rolesThen pin the provider:
.request_gateway("<gateway-identity-key>")[host]
public_ips = ["203.0.113.10"]
hostname = "gateway.example.com"
location = "CH"
[mixnet]
bind_address = "0.0.0.0:1789"
[entry]
bind_address = "0.0.0.0:9000"
# Set when behind a reverse proxy terminating WSS:
# announce_ws_port = 443
[http]
bind_address = "127.0.0.1:8080"
[verloc]
bind_address = "0.0.0.0:1790"
# [wireguard]
# enabled = true
# bind_address = "0.0.0.0:51822"[Unit]
Description=Nym Node (entry-gateway)
After=network-online.target
Wants=network-online.target
[Service]
User=nym
LimitNOFILE=65536
ExecStart=/usr/local/bin/nym-node run --id my-gateway --mode entry-gateway --accept-operator-terms-and-conditions
KillSignal=SIGINT
Restart=on-failure
RestartSec=30
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now nym-node
journalctl -fu nym-nodeUse the Nym Wallet "Bond" flow or nym-cli (nyxd for chain ops). You will
need the node's identity/sphinx keys and bonding signature from
nym-node bonding-information. Unbonded nodes are not routable.
Clients prefer WSS (wss://gateway.example.com:443). Terminate TLS with
Caddy/nginx and proxy the WebSocket upgrade to 127.0.0.1:9000; advertise the
proxied port in config. See Nym's "WSS & Reverse Proxy" operator page.
public ──▶ Gateway VPS (1789/9000/443) static IP, bonded
│
│ outbound WSS only
▼
private ──▶ provider host (no inbound ports, deny-all firewall)
└─ app + embedded nym client, address pinned to your gateway
A client addresses your service by one string:
<identity-key>.<encryption-key>@<gateway-identity-key>
- Three base58 32-byte keys. Example from Nym's docs:
DguTcdkWWtDyUFLvQxRdcA8qZhardhE1ZXy1YCC7Zfmq.Dxreouj5RhQqMb3ZaAxgXFdGkmfbDKwk457FdeHGKmQQ@4kjgWmFU1tcGAZYRZR57yFuVAexjLbJ5M7jvo3X5Hkcf - Your provider prints it on startup. Distribute it out of band — there is deliberately no discovery system, because clients should not be enumerable.
- Pin it in client config, a QR code, a signed release, or an ENS record.
Programmatic use (reference Sphinx crate):
let addr = sphinx_core::NymAddress::parse(s)?;
let destination = addr.destination_address(); // final Sphinx layer
let gateway = addr.gateway_identity(); // which Gateway holds the mailboxBrowser use: pass the string to client.send({ recipient, data }) and reply via
the SDK's sender_tag / send_reply (see docs/03-browser-client.md).
Security
- Provider has no inbound ports; egress only to its Gateway.
- Keys on encrypted storage; back up
sp-storage/or you lose the address. - Never log Nym addresses, destinations, or payloads.
- Hybrid bridge uses a strict method/path allow-list (no open proxy).
- If you run an exit: explicit exit policy, abuse process, legal review.
Reliability
- Provider is supervised (systemd/Docker
restart: unless-stopped). - Gateway IP is static and bonded if self-hosted.
- Monitor message queue depth and reply rate vs the ~50 pkt/s budget.
- Plan SURB replenishment for chatty clients; size replies to packets.
Privacy
- Register/re-register the same keys and the same Gateway on restart
(persistent storage +
request_gateway). - Decide identity lifetime deliberately: ephemeral vs persistent.
- Keep cover traffic on in production; disabling it leaks activity shape.
Limits (state these to stakeholders)
- Messaging suits request/response, not bulk transfer.
- A service provider cannot accept inbound TCP connections; it only replies.
- If the pinned Gateway disappears, the address stops working.
Next: 03-browser-client.md — the cross-platform
(web + Android) client that speaks to these services.
⚠️ Experimental / pre-audit. This repository is under active development and has not had an independent security audit. The Sphinx core incrates/is a reference implementation, never production crypto. Do not rely on it for high-stakes anonymity yet — see the threat model. Apache-2.0 © 2026 odelyzid (tribewarez). Report a security issue.
Nysiris docs
- Home
- 1. High-Level Architecture
- 2. Addressing & Routing Deep-Dive: how "DNS" works in Sphinx/Nym
- 3. Browser & Mobile Client (Web, Android, Desktop)
- 4. Hosting a Web Server / Service Reachable via the Nym Mixnet
- 5. Security Analysis & Recommendations
- 6. Implementation Roadmap
- 7. Desktop on Linux Mint / Debian
- 8. Hidden-Service Abstraction
- 9. nysiris-social: Metadata-Minimal Microblog + Encrypted DMs
- 10. Portal Layers: Data, Replication, Discovery, Reputation
- 11. UX Redesign — Calm, Private-by-Default Client
- 12. Windows build (MSYS2 / MSVC)
- 13. Hosting SDK &
nysirisCLI