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 - /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> { - 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; - } -}