Retire the mixnet exit; document Tor as the transport

The Goblin wallet moved off the Nym mixnet to Tor: it now reaches relays
over a Tor circuit to their clearnet endpoint, so the co-located mixnet
exit this package bundled is retired.

- delete the vendored `mixexit/` crate and the hardened
  `deploy/systemd/floonet-mixexit.service` bare-metal unit
- docker-compose: drop the `mixexit` service (COMPOSE_PROFILES=exit) and
  its `mixexit-data` volume; fix the top-of-file service list
- .env.example: replace the "Mixnet exit" block (COMPOSE_PROFILES=exit,
  FLOONET_EXIT_UPSTREAM) with the Tor onion toggle
- README: replace the "Mixnet exit" section with "Tor onion", fix the
  feature table, deploy paths, and the COMPOSE_PROFILES reference row

Add Tor as the first-class replacement deploy option, the same recipe
already proven in production: an optional `tor` compose service
(COMPOSE_PROFILES=tor) plus deploy/tor/torrc, a stock system tor daemon
whose hidden service forwards straight to strfry's websocket listener
(no TLS on that hop, the onion is already encrypted end to end). strfry
core stays stock; this is packaging and docs, no relay patch.

The default stack is unchanged (the onion is opt-in, as the exit was).
name-authority builds green; cargo test and clippy pass. docker-compose
validated (structure + YAML).
This commit is contained in:
2ro
2026-07-04 06:30:51 -04:00
parent 785d2008c8
commit ab1a72d652
10 changed files with 81 additions and 355 deletions
+8 -11
View File
@@ -71,18 +71,15 @@ GOBLINPAY_WEBHOOK_SECRET=
# Seconds the write policy plugin caches paid-status verdicts.
FLOONET_PAID_CACHE_SECS=60
# --- Mixnet exit (optional) ---
# --- Tor onion (optional) ---
# Uncomment to ALSO run the bundled scoped mixnet exit, so wallets can reach
# this relay over the mixnet. The exit forwards ONLY to this stack's own TLS
# front (never arbitrary targets) and sees only ciphertext. On first start it
# prints (and stores) its stable mixnet address; publish that address in the
# relay pool listing so wallets can use it.
#COMPOSE_PROFILES=exit
# Where the exit pipes accepted streams. The default is this stack's own
# proxy; only change it if your TLS terminates elsewhere.
FLOONET_EXIT_UPSTREAM=caddy:443
# Uncomment to ALSO run a Tor onion in front of the relay (the `tor` compose
# service), so wallets can reach it over Tor without a Tor exit hop. The onion
# forwards straight to the relay's websocket listener; see deploy/tor/torrc.
# tor prints the .onion address to its logs on first start and stores its key
# on the tor-data volume (back it up: losing it rotates the address). Publish
# the .onion so wallets can find it.
#COMPOSE_PROFILES=tor
# --- Name authority policy tunables ---
+31 -25
View File
@@ -13,7 +13,7 @@ through strfry's own extension points:
| --- | --- |
| `plugin/floonet_writepolicy.py` | The write policy plugin: default-deny kind whitelist, optional NIP-42 gate, optional paid-write gate |
| `name-authority/` | The bundled name authority (Rust/axum/SQLite): NIP-05 resolution, NIP-98 self-service registration, optional GoblinPay paywall — co-located on the relay's own domain by default |
| `mixexit/` | An optional, scoped mixnet exit so wallets can reach this relay over the mixnet |
| `deploy/tor/` | An optional Tor onion service so wallets can reach this relay over Tor without a Tor exit hop |
| `deploy/` | strfry conf + Dockerfile + apply-spec.sh, Caddy TLS proxy, landing page, hardened systemd units |
## Deploy
@@ -23,7 +23,7 @@ Pick your comfort level. All three paths produce the same relay.
### 1. Docker Compose (recommended)
One command brings up the whole unit: relay + name authority + auto-TLS
proxy (and, if enabled, the mixnet exit).
proxy (and, if enabled, a Tor onion).
```sh
cp .env.example .env # set FLOONET_DOMAIN, FLOONET_BASE_URL, FLOONET_RELAYS
@@ -44,17 +44,18 @@ cd name-authority && cargo build --release
```
Then install the hardened units from `deploy/systemd/` (each unit's header
has the exact install commands): `floonet-strfry.service`,
`floonet-authority.service` and, optionally, `floonet-mixexit.service`.
Put Caddy or nginx in front (see `deploy/Caddyfile`); the proxy MUST set
`X-Real-IP`, the authority's rate limiting keys off it.
has the exact install commands): `floonet-strfry.service` and
`floonet-authority.service`. Put Caddy or nginx in front (see
`deploy/Caddyfile`); the proxy MUST set `X-Real-IP`, the authority's rate
limiting keys off it. To also front the relay with a Tor onion, run a system
tor with the snippet in `deploy/tor/torrc` (see "Tor onion" below).
### 3. From source (developers)
`deploy/strfry/Dockerfile` and `apply-spec.sh` document the strfry build
exactly; the authority and the exit are plain `cargo build` crates; the
plugin is a single Python file with no dependencies. `plugin/test_policy.py`
and `cargo test` in `name-authority/` run the test suites.
exactly; the authority is a plain `cargo build` crate; the plugin is a single
Python file with no dependencies. `plugin/test_policy.py` and `cargo test` in
`name-authority/` run the test suites.
## The kind whitelist (the keystone)
@@ -184,24 +185,29 @@ event ids (replay rejection).
and the rest of `/api/*` stay on the authority's own domain. The snippet sets
`X-Real-IP` (load-bearing — the authority's per-IP rate limiter keys off it).
## Mixnet exit (optional)
## Tor onion (optional)
Uncomment `COMPOSE_PROFILES=exit` in `.env` and the package also runs
`floonet-mixexit`: a small, unbonded mixnet client that accepts incoming
mixnet streams and pipes every one of them to this stack's own TLS front.
Wallets that prefer not to touch DNS or reveal their relay choice can then
reach this relay entirely over the mixnet, with end-to-end TLS; the exit
sees only ciphertext.
Goblin wallets connect to relays over Tor: the client opens a Tor circuit and
reaches the relay's ordinary clearnet endpoint (`FLOONET_DOMAIN`) through a
Tor exit, so the relay never sees the user's real IP. That works against any
Floonet relay with no extra setup here, and it is the whole transport story:
Tor hides the user's network location; the kind whitelist and gift-wrapped
(kind 1059) payloads hide everything else from the relay itself. The relay
needs no privacy component of its own.
It is deliberately **scoped**: per-stream targets are never honored, the one
upstream is fixed by config, so it is structurally not an open proxy and
carries no open-proxy liability. No bonding, no tokens, no directory
listing.
An operator who wants to remove the Tor-exit hop entirely can front the relay
with a **Tor onion service**. Uncomment `COMPOSE_PROFILES=tor` in `.env` and
the package also runs the `tor` service: a stock system tor daemon whose
hidden service forwards straight to strfry's websocket listener (no TLS on
that hop, since the onion transport is already encrypted and authenticated end
to end). Wallets then reach the relay over an `.onion` with no exit hop at all.
On first start it prints its **stable mixnet address** (also written to the
data volume's `nym_address.txt`). Publish that address in your relay pool
listing (the `exit` field) so wallets can find it, and back the data
directory up: losing it rotates the address.
tor prints the `.onion` address to its logs on first start and stores its key
on the `tor-data` volume; back that volume up, since losing it rotates the
address. Publish the `.onion` so wallets can find it. Without Docker, run a
system tor with the snippet in `deploy/tor/torrc` (a `HiddenServiceDir` plus a
`HiddenServicePort` pointed at the relay's local websocket port) alongside the
`floonet-strfry.service` unit.
## Extending the policy (plugins, paid resources)
@@ -258,7 +264,7 @@ essentials:
| `FLOONET_WRITE_PRICE_GRIN` | `0` | price of write access, in GRIN |
| `GOBLINPAY_URL` / `GOBLINPAY_TOKEN` | unset | your GoblinPay server |
| `GOBLINPAY_WEBHOOK_SECRET` | unset | enables the webhook receiver |
| `COMPOSE_PROFILES` | unset | `exit` also runs the mixnet exit |
| `COMPOSE_PROFILES` | unset | `tor` also runs a Tor onion in front of the relay |
## Note for Goblin wallet users
-59
View File
@@ -1,59 +0,0 @@
# Hardened systemd unit for the bundled mixnet exit on bare metal.
#
# Install:
# cd mixexit && cargo build --release
# sudo install -m0755 target/release/floonet-mixexit /usr/local/bin/
# sudo install -m0644 ../deploy/systemd/floonet-mixexit.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now floonet-mixexit
#
# The exit pipes every accepted mixnet stream to ONE fixed upstream (your own
# relay's TLS front) and honors no per-stream targets, so it is structurally
# not an open proxy. Its mixnet identity persists in the state directory:
# back it up, losing it rotates the exit's address and strands wallet pins.
# After first start, publish the address from
# /var/lib/floonet-mixexit/nym_address.txt in your relay pool listing.
[Unit]
Description=floonet-mixexit (scoped mixnet exit for the co-located relay)
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
DynamicUser=yes
# Where the exit pipes accepted streams: your relay's public TLS host:port.
Environment=FLOONET_EXIT_UPSTREAM=127.0.0.1:443
# Persistent mixnet identity at /var/lib/floonet-mixexit.
StateDirectory=floonet-mixexit
StateDirectoryMode=0750
Environment=FLOONET_MIXEXIT_DIR=/var/lib/floonet-mixexit
ExecStart=/usr/local/bin/floonet-mixexit
Restart=on-failure
RestartSec=5
# --- hardening ---
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
ReadWritePaths=/var/lib/floonet-mixexit
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.target
+7
View File
@@ -0,0 +1,7 @@
# Minimal Tor daemon that fronts the Floonet relay with an onion service.
# Stock tor from Alpine, no patches; all config lives in the mounted torrc.
FROM alpine:3.20
RUN apk add --no-cache tor
# tor starts as root only long enough to create/own its data dir, then drops
# to the unprivileged `tor` user named in torrc (`User tor`).
ENTRYPOINT ["tor", "-f", "/etc/tor/torrc"]
+16
View File
@@ -0,0 +1,16 @@
# Tor onion service in front of the Floonet relay (docker-compose `tor`
# profile). The hidden service forwards straight to strfry's websocket
# listener over the compose network; no TLS is needed on that hop because
# the onion transport is already encrypted and authenticated end to end.
#
# The .onion address is derived from the key tor writes under
# HiddenServiceDir. It is printed to the tor logs on first start and lives on
# the tor-data volume; back that volume up, since losing it rotates the
# address. Publish the .onion so wallets can reach this relay over Tor.
User tor
DataDirectory /var/lib/tor
HiddenServiceDir /var/lib/tor/floonet-relay
# onion virtual port 80 (ws default) -> the relay container's ws listener
HiddenServicePort 80 relay:7777
+19 -20
View File
@@ -10,8 +10,9 @@
# - authority : the bundled name authority (name@domain -> pubkey, with
# optional paid names / paid write access via GoblinPay)
# - caddy : auto-TLS reverse proxy terminating HTTPS for both
# - mixexit : OPTIONAL scoped mixnet exit (COMPOSE_PROFILES=exit), so
# wallets can reach this relay over the mixnet
# - tor : OPTIONAL Tor onion in front of the relay
# (COMPOSE_PROFILES=tor), so wallets can reach it over Tor
# without a Tor exit hop. See deploy/tor/torrc.
#
# Set FLOONET_DOMAIN / FLOONET_BASE_URL / FLOONET_RELAYS in `.env` (copy
# .env.example) BEFORE bringing it up: Caddy obtains a certificate for
@@ -86,30 +87,28 @@ services:
- caddy-data:/data
- caddy-config:/config
# The optional co-located mixnet exit. Off unless the `exit` profile is
# active (set COMPOSE_PROFILES=exit in .env, the package's exit toggle).
# It pipes every accepted mixnet stream to this stack's own TLS front, so
# wallets reach the relay over the mixnet with end-to-end TLS; the exit
# sees only ciphertext and can reach nothing but this relay. Its stable
# mixnet address is printed at startup and written to the volume's
# nym_address.txt; publish that address (relay pool `exit` field) so
# wallets can find it.
mixexit:
build: ./mixexit
image: floonet-mixexit:latest
# Optional Tor onion service in front of the relay. Off unless the `tor`
# profile is active (set COMPOSE_PROFILES=tor in .env). It runs a plain
# system tor daemon whose hidden service forwards straight to the relay's
# websocket listener (no TLS on that hop: the onion transport is already
# encrypted and authenticated end to end). Wallets can then reach the relay
# over Tor without a Tor exit hop. The .onion address is derived from a key
# in the tor-data volume and printed to the tor logs on first start; back
# the volume up, since losing it rotates the address. See deploy/tor/torrc.
tor:
build: ./deploy/tor
image: floonet-tor:latest
restart: unless-stopped
profiles: ["exit"]
profiles: ["tor"]
depends_on:
- caddy
environment:
FLOONET_MIXEXIT_DIR: /data
FLOONET_EXIT_UPSTREAM: ${FLOONET_EXIT_UPSTREAM:-caddy:443}
- relay
volumes:
- mixexit-data:/data
- ./deploy/tor/torrc:/etc/tor/torrc:ro
- tor-data:/var/lib/tor
volumes:
relay-data:
authority-data:
caddy-data:
caddy-config:
mixexit-data:
tor-data:
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "floonet-mixexit"
version = "0.1.0"
edition = "2024"
license = "Apache-2.0"
description = "Scoped mixnet exit bundled with a Floonet relay: pipes accepted mixnet streams to ONE fixed upstream (never arbitrary targets)."
## Pinned upstream nym rev. This is the same nym-sdk revision the Goblin
## wallet builds against (its `goblin` branch is this rev plus one
## Android-only TLS-roots commit), so both ends speak the same MixnetStream
## protocol. Developing against a local nym checkout instead:
## cargo build --config 'patch."https://github.com/nymtech/nym".nym-sdk.path="../../nym/sdk/rust/nym-sdk"'
[dependencies]
nym-sdk = { git = "https://github.com/nymtech/nym", rev = "b6eb391e85be7eb8fca62def6d1ac32fd1108c30" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "signal"] }
## Only to surface nym-sdk's tracing logs (RUST_LOG-style filtering).
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release]
strip = true
-34
View File
@@ -1,34 +0,0 @@
# Builds the bundled mixnet exit (floonet-mixexit): a scoped, unbonded mixnet
# client that accepts incoming mixnet streams and pipes every one of them to
# ONE fixed upstream, the TLS front of this very stack. It is structurally not
# an open proxy: per-stream targets are never honored, so running it carries
# no open-proxy liability and needs no exit policy.
#
# Enabled by the `exit` compose profile (COMPOSE_PROFILES=exit in .env).
# Note: the first build compiles the pinned nym-sdk from source; expect it to
# take a while.
FROM rust:1-bookworm AS builder
WORKDIR /build
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libssl-dev protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Non-root; the persistent mixnet identity lives under /data. Back that
# directory up: losing it rotates the exit's mixnet address and strands
# wallet pins until their next relay-pool refresh.
RUN useradd --system --uid 10001 --home-dir /data --shell /usr/sbin/nologin mixexit \
&& mkdir -p /data \
&& chown -R mixexit:mixexit /data
COPY --from=builder /build/target/release/floonet-mixexit /usr/local/bin/floonet-mixexit
USER mixexit
WORKDIR /data
VOLUME ["/data"]
ENV FLOONET_MIXEXIT_DIR=/data
ENTRYPOINT ["/usr/local/bin/floonet-mixexit"]
-2
View File
@@ -1,2 +0,0 @@
hard_tabs = true
edition = "2024"
-184
View File
@@ -1,184 +0,0 @@
// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! floonet-mixexit — the SCOPED Nym exit bundled with a Floonet relay.
//!
//! An ordinary UNBONDED mixnet client (no nym-node, no pledge, no directory
//! listing) that accepts incoming [`MixnetStream`]s and pipes each one to ONE
//! fixed upstream — the operator's own relay. No per-stream target or host
//! header is honored, so this is structurally NOT an open proxy: the only
//! thing it can ever reach is the configured relay, which is why operators
//! carry zero open-proxy liability and need no exit policy.
//!
//! The mixnet identity persists in `FLOONET_MIXEXIT_DIR`, so `nym_address()`
//! is STABLE across restarts — that address is what wallets pin (relay-pool
//! `exit` field / NIP-11 `nym_exit`). Wallets run hostname-validated TLS
//! (SNI = the relay host) end-to-end THROUGH the pipe, so this exit sees only
//! ciphertext. Design: ~/.claude/plans/floonet-nym-exit.md.
use std::path::PathBuf;
use nym_sdk::mixnet::{MixnetClientBuilder, MixnetStream, StoragePaths};
use tokio::io::copy_bidirectional;
use tokio::net::TcpStream;
const USAGE: &str = "\
floonet-mixexit — scoped Nym exit for a Floonet relay
Accepts incoming mixnet streams and pipes each one to ONE fixed upstream
(the co-located relay). Per-stream targets are never honored, so this is
structurally not an open proxy. The mixnet identity persists in the data
dir, keeping the nym address stable across restarts.
USAGE:
floonet-mixexit [--help | --selftest]
MODES:
(none) serve: accept mixnet streams, pipe each to the upstream
--selftest connect to the mixnet, print the (stable) nym address and
exit — never touches the upstream
--help this text
ENVIRONMENT:
FLOONET_MIXEXIT_DIR data dir for the persistent mixnet identity;
the nym address is also written to
<dir>/nym_address.txt [default: ./mixexit-data]
FLOONET_EXIT_UPSTREAM fixed host:port every stream is piped to
[default: relay.goblin.st:443]
RUST_LOG nym-sdk log filter [default: warn]
";
/// Data dir for the persistent mixnet identity (`FLOONET_MIXEXIT_DIR`).
fn data_dir() -> PathBuf {
std::env::var_os("FLOONET_MIXEXIT_DIR")
.map(Into::into)
.unwrap_or_else(|| PathBuf::from("./mixexit-data"))
}
/// The ONE upstream every stream is piped to (`FLOONET_EXIT_UPSTREAM`).
fn upstream() -> String {
std::env::var("FLOONET_EXIT_UPSTREAM").unwrap_or_else(|_| "relay.goblin.st:443".to_string())
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mode = std::env::args().nth(1);
match mode.as_deref() {
Some("--help" | "-h") => {
print!("{USAGE}");
return Ok(());
}
None | Some("--selftest") => {}
Some(other) => {
eprintln!("unknown argument: {other}\n\n{USAGE}");
std::process::exit(2);
}
}
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
)
.init();
// Persistent identity: same data dir → same keystore (generated on first
// run) → the SAME nym address across restarts. That address is what
// wallets pin, so back this directory up — losing it rotates the address
// and strands wallet pins until the next pool/NIP-11 refresh.
let dir = data_dir();
std::fs::create_dir_all(&dir)?;
let storage_paths = StoragePaths::new_from_dir(&dir)?;
let mut client = MixnetClientBuilder::new_with_default_storage(storage_paths)
.await?
.build()?
.connect_to_mixnet()
.await?;
let address = *client.nym_address();
let address_file = dir.join("nym_address.txt");
std::fs::write(&address_file, format!("{address}\n"))?;
println!("=============================================================");
println!(" floonet-mixexit is on the mixnet. Nym address (STABLE — pin");
println!(" this in the relay pool `exit` field / NIP-11 `nym_exit`):");
println!(" {address}");
println!(" also written to {}", address_file.display());
println!("=============================================================");
if mode.as_deref() == Some("--selftest") {
println!("selftest OK");
client.disconnect().await;
return Ok(());
}
let upstream = upstream();
println!("piping every accepted stream to fixed upstream {upstream}");
let mut listener = client.listener()?;
loop {
tokio::select! {
_ = shutdown_signal() => {
println!("shutdown signal received; stopping");
break;
}
accepted = listener.accept() => match accepted {
Some(stream) => {
let upstream = upstream.clone();
tokio::spawn(pipe(stream, upstream));
}
None => {
eprintln!("mixnet stream router stopped; exiting");
break;
}
}
}
}
client.disconnect().await;
println!("floonet-mixexit stopped");
Ok(())
}
/// One accepted stream: TCP to the FIXED upstream (never a caller-chosen
/// target), then bytes both ways until either side closes. Errors are logged
/// and drop only this stream — the accept loop keeps serving.
async fn pipe(mut mix: MixnetStream, upstream: String) {
let mut tcp = match TcpStream::connect(&upstream).await {
Ok(tcp) => tcp,
Err(e) => {
eprintln!("stream dropped: upstream {upstream} connect failed: {e}");
return;
}
};
match copy_bidirectional(&mut mix, &mut tcp).await {
Ok((up, down)) => println!("stream closed ({up} B in → relay, {down} B relay → out)"),
Err(e) => eprintln!("stream ended with error: {e}"),
}
}
/// Resolves on SIGINT (Ctrl-C) or SIGTERM (systemd/docker stop).
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM handler");
tokio::select! {
_ = ctrl_c => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = ctrl_c.await;
}
}