floonet-rs: hardened nostr-rs-relay for the Grin community
Test and build / test_floonet-rs (push) Has been cancelled
Test and build / test_floonet-rs (push) Has been cancelled
nostr-rs-relay + a default-deny admission pipeline (kinds 0,3,5,13,1059, 10002,10050,27235 only), NIP-42 auth, neutral NIP-11, a built-in name authority (paid names via GoblinPay), and a config-toggled co-located mixnet exit supervisor. Single binary + installer + hardened systemd, or Docker Compose. Relay core untouched (additive admission + authority).
This commit is contained in:
Generated
+8449
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
# floonet-mixexit: the scoped mixnet exit bundled with a Floonet relay.
|
||||
#
|
||||
# Built separately from the relay (it pulls the whole nym-sdk tree):
|
||||
# cargo build --release --manifest-path mixexit/Cargo.toml
|
||||
# The nym-sdk path dependency expects the Goblin nym checkout (branch
|
||||
# `goblin`) two directories up; adjust the path for your layout.
|
||||
|
||||
[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)."
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
## Path dep into the local nym checkout (branch goblin, pinned rev; the
|
||||
## same checkout the Goblin wallet path-depends on, so both ends speak
|
||||
## the same MixnetStream protocol).
|
||||
nym-sdk = { path = "../../nym/sdk/rust/nym-sdk" }
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
hard_tabs = true
|
||||
edition = "2024"
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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 mixnet 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 mixnet 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 mixnet 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) mixnet address and
|
||||
exit — never touches the upstream
|
||||
--help this text
|
||||
|
||||
ENVIRONMENT:
|
||||
FLOONET_MIXEXIT_DIR data dir for the persistent mixnet identity;
|
||||
the mixnet 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. Mixnet 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user