From ab1a72d65239a1da64501730fb9d79770cd30b7e Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:30:51 -0400 Subject: [PATCH] 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). --- .env.example | 19 ++- README.md | 56 ++++---- deploy/systemd/floonet-mixexit.service | 59 -------- deploy/tor/Dockerfile | 7 + deploy/tor/torrc | 16 +++ docker-compose.yml | 39 +++--- mixexit/Cargo.toml | 20 --- mixexit/Dockerfile | 34 ----- mixexit/rustfmt.toml | 2 - mixexit/src/main.rs | 184 ------------------------- 10 files changed, 81 insertions(+), 355 deletions(-) delete mode 100644 deploy/systemd/floonet-mixexit.service create mode 100644 deploy/tor/Dockerfile create mode 100644 deploy/tor/torrc delete mode 100644 mixexit/Cargo.toml delete mode 100644 mixexit/Dockerfile delete mode 100644 mixexit/rustfmt.toml delete mode 100644 mixexit/src/main.rs diff --git a/.env.example b/.env.example index a41c0ea..80d2113 100644 --- a/.env.example +++ b/.env.example @@ -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 --- diff --git a/README.md b/README.md index 0dec937..901294b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/deploy/systemd/floonet-mixexit.service b/deploy/systemd/floonet-mixexit.service deleted file mode 100644 index f1dbcbb..0000000 --- a/deploy/systemd/floonet-mixexit.service +++ /dev/null @@ -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 diff --git a/deploy/tor/Dockerfile b/deploy/tor/Dockerfile new file mode 100644 index 0000000..fc0a13e --- /dev/null +++ b/deploy/tor/Dockerfile @@ -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"] diff --git a/deploy/tor/torrc b/deploy/tor/torrc new file mode 100644 index 0000000..621e587 --- /dev/null +++ b/deploy/tor/torrc @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3b76fb6..baeb0e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/mixexit/Cargo.toml b/mixexit/Cargo.toml deleted file mode 100644 index 61c5821..0000000 --- a/mixexit/Cargo.toml +++ /dev/null @@ -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 diff --git a/mixexit/Dockerfile b/mixexit/Dockerfile deleted file mode 100644 index 5aee7b1..0000000 --- a/mixexit/Dockerfile +++ /dev/null @@ -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"] diff --git a/mixexit/rustfmt.toml b/mixexit/rustfmt.toml deleted file mode 100644 index 7dbfd36..0000000 --- a/mixexit/rustfmt.toml +++ /dev/null @@ -1,2 +0,0 @@ -hard_tabs = true -edition = "2024" diff --git a/mixexit/src/main.rs b/mixexit/src/main.rs deleted file mode 100644 index df0bc8d..0000000 --- a/mixexit/src/main.rs +++ /dev/null @@ -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 -