Add Nym mixnet transport and single-container demo

Add an outbound-only Nym mixnet transport that tunnels FMP peer links
through a local nym-socks5-client SOCKS5 proxy into the Nym mixnet. It
structurally mirrors the Tor SOCKS5 transport (connection pool,
connect-on-send background promotion, FMP-v0 framing reused from TCP)
with the onion, inbound-listener, and control-port machinery removed.

Wires the transport through the full TransportHandle dispatch, NymConfig
(standard transport-instance pattern), and node instantiation, and
surfaces its counters in fipstop. Includes a mock SOCKS5 harness and unit
coverage for the address-parsing paths.

Also adds an isolated single-container example
(examples/sidecar-nostr-mixnet-relay/) demonstrating FIPS peering across
the mixnet end to end. No new crate dependencies: tokio_socks, socket2,
and futures are already pulled in by the Tor transport.
This commit is contained in:
oleksky
2026-06-13 19:38:01 +00:00
committed by Johnathan Corgan
parent fb8bb4fb97
commit 4e43cb81e9
20 changed files with 2345 additions and 5 deletions
+7
View File
@@ -16,6 +16,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Nym mixnet transport (`transports.nym`) for outbound peer links
tunneled through a local `nym-socks5-client` SOCKS5 proxy into the
Nym mixnet, as a privacy transport alongside Tor. Outbound-only and
not platform-gated, it reuses the existing FMP framing and adds no new
crate dependencies. A single-container example
(`examples/sidecar-nostr-mixnet-relay/`) demonstrates FIPS peering
across the mixnet end to end.
- Typed `RejectReason` classification for receive-path silent-rejection - Typed `RejectReason` classification for receive-path silent-rejection
sites across the node. Each rejection-and-return path now passes a sites across the node. Each rejection-and-return path now passes a
typed reason to `NodeStats::record_reject`, which routes it to a typed reason to `NodeStats::record_reject`, which routes it to a
+6
View File
@@ -169,6 +169,12 @@ and [testing/README.md](testing/README.md).
reachable exclusively over the FIPS mesh. The relay container reachable exclusively over the FIPS mesh. The relay container
shares the FIPS sidecar's network namespace and is isolated from shares the FIPS sidecar's network namespace and is isolated from
the host network. the host network.
- **[examples/sidecar-nostr-mixnet-relay/](examples/sidecar-nostr-mixnet-relay/)** —
Single-container demo of FIPS peering through a **mixnet**
(implemented with [Nym](https://nym.com/)): the FIPS daemon, the mixnet
proxy, and a strfry Nostr relay all in one isolated container, with
the direct route to the peer firewalled off so traffic provably
crosses the mixnet.
- **[examples/k8s-sidecar/](examples/k8s-sidecar/)** — Run FIPS as - **[examples/k8s-sidecar/](examples/k8s-sidecar/)** — Run FIPS as
a Kubernetes Pod sidecar. The sidecar creates `fips0` in the a Kubernetes Pod sidecar. The sidecar creates `fips0` in the
Pod's shared network namespace so every other container in the Pod's shared network namespace so every other container in the
+37
View File
@@ -0,0 +1,37 @@
# FIPS-over-Nym-mixnet demo configuration.
# Override these values or create a .env.local file.
# Node identity — generate with: fipsctl keygen
# Must be set before running: export FIPS_NSEC=<your-nsec>
FIPS_NSEC=
# Peer configuration (leave FIPS_PEER_NPUB empty for standalone operation).
# The peer MUST expose a TCP endpoint in nym mode — the Nym SOCKS5 proxy
# tunnels TCP streams. Find more public peers at https://join.fips.network/
# Default peer test-us03 exposes tcp:54.183.70.180:443 and udp:…:2121.
# For udp mode (see FIPS_PEER_TRANSPORT below) change FIPS_PEER_ADDR to
# 54.183.70.180:2121.
# The alias doubles as the peer's .fips hostname (<alias>.fips), so it
# must be a plain hostname label — no dots.
FIPS_PEER_NPUB=npub136yqae6na688fs75g95ppps3lxe07fvxefj77938zf47uhm6074sxw8ctm
FIPS_PEER_ADDR=54.183.70.180:443
FIPS_PEER_ALIAS=test-us03
# Transport — THE switch that selects mixnet vs. direct: nym | tcp | udp
# nym : peer traffic goes through the Nym mixnet via the in-container
# nym-socks5-client (started automatically, before FIPS). DEFAULT.
# tcp : direct TCP to FIPS_PEER_ADDR; the nym client is NOT started.
# udp : direct UDP — also set FIPS_PEER_ADDR to the peer's :2121 endpoint.
# To go back to a direct link, just set this to tcp (or udp) and re-run
# `docker compose up`. Nothing else needs to change for tcp.
FIPS_PEER_TRANSPORT=nym
# ----- Nym mixnet (only used when FIPS_PEER_TRANSPORT=nym) -----
# Network-requester service provider. Leave empty to auto-discover the
# best-scored provider from https://harbourmaster.nymtech.net/ at startup.
NYM_SERVICE_PROVIDER=
NYM_CLIENT_ID=fips-nym-client
FIPS_NYM_SOCKS5_ADDR=127.0.0.1:1080
# Logging
RUST_LOG=info
@@ -0,0 +1 @@
.env.local
@@ -0,0 +1,151 @@
# Single-container FIPS-over-Nym-mixnet demo.
#
# Everything runs in ONE container: the FIPS daemon, the nym-socks5-client
# mixnet proxy, the strfry Nostr relay, nginx, and dnsmasq. The entrypoint
# starts them in strict order so the SOCKS5 proxy is up before FIPS dials
# its peer through the mixnet.
#
# Platform: the image builds NATIVE for the host. The FIPS daemon must NOT
# run under emulation — Rosetta mis-translates the amd64 ChaCha20-Poly1305
# assembly (ring/BoringSSL), silently failing AEAD on larger frames (bloom
# filter announces are the first casualty). fips is therefore always
# compiled for the native arch.
#
# nym-socks5-client is the official prebuilt amd64 binary (Nym ships no
# other arch). On amd64 hosts it runs natively; on arm64 (Apple Silicon)
# it runs via Docker Desktop's binfmt/Rosetta handler, with the x86-64
# loader + glibc copied in from an amd64 stage. The nym client tolerates
# emulation; fips does not.
# ── Build stage: compile FIPS from source ──
FROM rust:1.94-slim-trixie AS builder
# bluer (BLE) and rustables (nftables) are unconditional dependencies on
# glibc Linux: bluer needs the dbus headers, rustables runs bindgen
# (libclang) against the libnftnl headers.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
pkg-config libdbus-1-dev libnftnl-dev libclang-dev clang && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock rust-toolchain.toml build.rs ./
COPY src ./src
RUN cargo build --release && \
cp target/release/fips target/release/fipsctl target/release/fipstop /usr/local/bin/
# ── strfry stage: collect the binary and its musl runtime ──
# The official strfry image is alpine (musl) based; the runtime stage below
# is debian (glibc), so the musl dynamic loader and the exact set of shared
# libraries strfry links against must come along.
FROM ghcr.io/hoytech/strfry:latest AS strfry
RUN mkdir -p /strfry-libs && \
ldd /app/strfry | awk '$3 ~ /^\// {print $3}' | xargs -I{} cp {} /strfry-libs/ && \
cp /lib/ld-musl-*.so.1 /strfry-libs/
# ── nym stage: official prebuilt amd64 binary + its glibc runtime ──
# Pinned amd64-only stage regardless of host arch. Collects the x86-64
# dynamic loader and the binary's library closure so the binary can run
# inside the native (possibly arm64) runtime image via binfmt/Rosetta.
FROM --platform=linux/amd64 debian:trixie-slim AS nym
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
# Bumping the version = edit the tag in this URL.
RUN curl -sSL --retry 3 \
"https://github.com/nymtech/nym/releases/download/nym-binaries-v2026.11-xynomizithra/nym-socks5-client" \
-o /nym-socks5-client && \
chmod +x /nym-socks5-client
RUN mkdir -p /nym-rt/lib64 /nym-rt/libs && \
cp /lib64/ld-linux-x86-64.so.2 /nym-rt/lib64/ && \
ldd /nym-socks5-client | awk '$3 ~ /^\// {print $3}' | xargs -I{} cp {} /nym-rt/libs/
# ── Runtime stage ──
FROM debian:trixie-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
iproute2 iputils-ping dnsutils dnsmasq iptables \
openssh-client openssh-server python3 \
tcpdump netcat-openbsd curl jq iperf3 nginx && \
rm -rf /var/lib/apt/lists/*
# Install nak (Nostr Army Knife) — detect arch and download the correct binary.
# Asset naming: nak-<version>-linux-<arch>
RUN ARCH=$(dpkg --print-architecture) && \
case "$ARCH" in \
amd64) NAK_ARCH="linux-amd64" ;; \
arm64) NAK_ARCH="linux-arm64" ;; \
armhf) NAK_ARCH="linux-arm" ;; \
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
esac && \
NAK_VERSION=$(curl -sSL --retry 3 \
"https://api.github.com/repos/fiatjaf/nak/releases/latest" \
| grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\(.*\)".*/\1/') && \
echo "Installing nak ${NAK_VERSION} for ${NAK_ARCH}" && \
curl -sSL --retry 3 \
"https://github.com/fiatjaf/nak/releases/download/${NAK_VERSION}/nak-${NAK_VERSION}-${NAK_ARCH}" \
-o /usr/local/bin/nak && \
chmod +x /usr/local/bin/nak && \
nak --version
# nym-socks5-client: prebuilt amd64 binary plus its x86-64 loader/glibc.
# On amd64 these COPYs overwrite identical files; on arm64 they add the
# x86-64 runtime alongside the native one (paths don't collide). The
# version check doubles as a binfmt/Rosetta smoke test on arm64 hosts.
COPY --from=nym /nym-socks5-client /usr/local/bin/nym-socks5-client
COPY --from=nym /nym-rt/lib64/ /lib64/
COPY --from=nym /nym-rt/libs/ /lib/x86_64-linux-gnu/
RUN echo "Installed:" && /usr/local/bin/nym-socks5-client --version
# Setup SSH server with no authentication (test only!)
RUN mkdir -p /var/run/sshd && \
ssh-keygen -A && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \
sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords yes/' /etc/ssh/sshd_config && \
sed -i 's/UsePAM yes/UsePAM no/' /etc/ssh/sshd_config && \
passwd -d root
# dnsmasq: forward .fips to FIPS daemon, everything else to Docker DNS
RUN printf '%s\n' \
'port=53' \
'listen-address=127.0.0.1' \
'bind-interfaces' \
'server=/fips/127.0.0.1#5354' \
'server=127.0.0.11' \
'no-resolv' \
>> /etc/dnsmasq.conf
# strfry: binary, musl loader, and its libraries in a private directory.
# /etc/ld-musl-<arch>.path tells the musl loader where to search, keeping
# the musl libraries invisible to the system glibc loader.
COPY --from=strfry /app/strfry /usr/local/bin/strfry
COPY --from=strfry /strfry-libs/ /opt/strfry-libs/
RUN mv /opt/strfry-libs/ld-musl-*.so.1 /lib/ && \
echo "/opt/strfry-libs" > /etc/ld-musl-$(uname -m).path && \
mkdir -p /usr/src/app/strfry-db && \
strfry --version
# nginx: reverse proxy port 80 (IPv4 + IPv6) → strfry 127.0.0.1:7777
RUN printf 'server {\n\
listen 80;\n\
listen [::]:80;\n\
location / {\n\
proxy_pass http://127.0.0.1:7777;\n\
proxy_http_version 1.1;\n\
proxy_read_timeout 1d;\n\
proxy_send_timeout 1d;\n\
proxy_set_header Upgrade $http_upgrade;\n\
proxy_set_header Connection "Upgrade";\n\
proxy_set_header Host $host;\n\
}\n\
}\n' > /etc/nginx/conf.d/nostr-relay.conf && \
rm -f /etc/nginx/sites-enabled/default
COPY --from=builder /usr/local/bin/fips /usr/local/bin/fipsctl /usr/local/bin/fipstop /usr/local/bin/
COPY examples/sidecar-nostr-mixnet-relay/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,7 @@
target/
target-*/
.git/
.github/
testing/
examples/
!examples/sidecar-nostr-mixnet-relay/
@@ -0,0 +1,173 @@
# FIPS over a Mixnet — Single-Container Demo (Nym)
An isolated environment demonstrating how FIPS peer traffic can travel
through a **mixnet** — a network that hides traffic patterns by routing
each packet through several relays with cover traffic and timing
obfuscation. The mixnet here is [Nym](https://nym.com/), but the FIPS side
is transport-agnostic: it just sees a SOCKS5 proxy, so any mixnet exposing
one would slot in the same way.
**Everything runs in one Docker container**: the FIPS daemon, the mixnet
proxy (`nym-socks5-client`), a [strfry](https://github.com/hoytech/strfry)
Nostr relay behind nginx, and dnsmasq.
```
┌────────────────────────── one container ───────────────────────────┐
│ │
│ nginx :80 ──► strfry :7777 (Nostr relay, fips0-only) │
│ │
│ fips daemon ── transports.nym ──► nym-socks5-client :1080 │
│ │ │ │
│ ▼ ▼ Sphinx packets │
│ fips0 (TUN, fd00::/8) Nym gateway ► 3 mix hops ► │
│ network requester ► peer (TCP) │
│ │
│ iptables: direct route to the peer is DROPped — the FIPS link │
│ can only exist through the mixnet. │
└────────────────────────────────────────────────────────────────────┘
```
How the pieces interlock:
- The FIPS **nym transport** dials peers through a local SOCKS5 proxy; the
proxy routes each TCP stream through the mixnet (gateway → 3 mix hops →
network requester), which performs the final TCP connection to the peer.
The peer address must therefore be a **TCP endpoint** — find public peers
at <https://join.fips.network/>.
- The `nym-socks5-client` is started by the entrypoint **only when the
generated FIPS config contains a `transports.nym` block**
(`FIPS_PEER_TRANSPORT=nym`), and always **before** the FIPS daemon, so
the proxy is listening by the time FIPS dials.
- In nym mode, iptables **drops the direct route to the peer**: if the peer
handshake completes, the traffic provably went through the mixnet.
## Quick start
```bash
# 1. Generate a node identity (any machine with fipsctl, or reuse one):
fipsctl keygen
# 2. Put the nsec into the environment:
export FIPS_NSEC=<your-nsec>
# 3. Build and run (native image; FIPS compiles for your host's arch):
docker compose up --build
```
Watch the logs: the entrypoint auto-discovers a Nym service provider,
bootstraps the SOCKS5 client (`Nym SOCKS5 proxy ready …`), and only then
starts FIPS. After the mixnet handshake completes (can take 30120 s):
```bash
docker compose exec fips fipsctl show transports # nym transport: up
docker compose exec fips fipsctl show peers # test-us03: active
```
## Switching transport: mixnet ↔ direct (TCP/UDP)
The single knob is `FIPS_PEER_TRANSPORT` in `.env` (or an inline override).
It selects how FIPS reaches the peer **and** whether the mixnet proxy runs
at all — the two are always in sync.
```bash
# Default — through the Nym mixnet (anonymized, ~1-2 s RTT):
FIPS_PEER_TRANSPORT=nym docker compose up -d # or just `docker compose up -d`
# Direct TCP (no mixnet, ~50-300 ms RTT). The nym client is NOT started:
FIPS_PEER_TRANSPORT=tcp docker compose up -d
# Direct UDP — also point FIPS_PEER_ADDR at the peer's UDP endpoint:
FIPS_PEER_TRANSPORT=udp FIPS_PEER_ADDR=54.183.70.180:2121 docker compose up -d
```
What changes under the hood for each value:
| `FIPS_PEER_TRANSPORT` | nym client | FIPS config block | peer endpoint used | direct route to peer |
| --- | --- | --- | --- | --- |
| `nym` (default) | started, before FIPS | `transports.nym` | `FIPS_PEER_ADDR` (TCP) via SOCKS5 | **firewalled off** |
| `tcp` | not started | `transports.tcp` | `FIPS_PEER_ADDR` (TCP) direct | allowed |
| `udp` | not started | `transports.udp` | `FIPS_PEER_ADDR` (UDP `:2121`) direct | allowed |
To **switch back to a direct link**, set the value to `tcp` (no other change)
or `udp` (and swap `FIPS_PEER_ADDR` to the `:2121` endpoint), then re-run
`docker compose up -d`. To **return to the mixnet**, set it back to `nym`.
Persist your choice by editing `.env` instead of prefixing the command.
The same node can be compared both ways — direct shows ~50-300 ms RTT,
the mixnet ~1-2 s, which is the visible signature that traffic is routing
through the Sphinx mix hops.
## Verifying the traffic really crosses the mixnet
```bash
# The direct route to the peer is dropped — the only way packets reach
# the peer is via the nym-socks5-client:
docker compose exec fips iptables -L OUTPUT -v -n # DROP rule for peer IP
# Mixnet activity (Sphinx packet flow) in the nym client output:
docker compose logs fips | grep -i nym
# End-to-end data plane across the mesh. FIPS addresses every node by its
# key as <npub>.fips (each npub maps into fd00::/8); short names like
# `test-us03` are only local aliases for the peer you configured. Pick a
# node you are NOT directly linked to — grab a current npub from
# https://join.fips.network/ — so the ICMPv6 echo routes over the mixnet
# to your peer and then hop-by-hop across the mesh to the target:
docker compose exec fips ping6 -c3 <peer-npub>.fips
# A reply while the direct route is DROPped proves the traffic crossed the
# mixnet; the seconds-range RTT is the Sphinx path's signature, and a few
# extra hundred ms over reaching your own peer is the added mesh hops (a
# direct, non-mixnet connection would be ~30 ms).
```
The Nostr relay answers only over the FIPS mesh (fd00::/8) and on the
container's loopback — inbound eth0 traffic, including the host's port-80
mapping, is dropped by the isolation rules. Check it from inside:
```bash
docker compose exec fips curl -s -H "Accept: application/nostr+json" http://127.0.0.1/
```
## Configuration (.env)
| Variable | Default | Meaning |
| --- | --- | --- |
| `FIPS_NSEC` | *(required)* | Node identity, `fipsctl keygen` |
| `FIPS_PEER_NPUB` | test-us03's npub | Peer to dial; empty = standalone |
| `FIPS_PEER_ADDR` | `54.183.70.180:443` | **TCP** endpoint in nym/tcp mode (use `:2121` for udp) |
| `FIPS_PEER_TRANSPORT` | `nym` | `nym` \| `tcp` \| `udp` — see "Switching transport" above |
| `NYM_SERVICE_PROVIDER` | *(auto)* | Network requester; empty = pick the best-scored from [harbourmaster](https://harbourmaster.nymtech.net/) |
| `NYM_CLIENT_ID` | `fips-nym-client` | Nym client identity (kept in the `nym-data` volume) |
With `FIPS_PEER_TRANSPORT=tcp` or `udp` the nym client is **not started at
all** and FIPS connects directly — useful as a baseline comparison.
## Troubleshooting
- **`could not auto-discover a Nym service provider`** — the harbourmaster
API was unreachable or returned no providers; pick one manually from
<https://harbourmaster.nymtech.net/> and set `NYM_SERVICE_PROVIDER`.
- **Slow or failing mixnet bootstrap** — service providers and gateways
vary in quality. Delete the client state and retry with another provider:
`docker compose down -v && NYM_SERVICE_PROVIDER=<other> docker compose up`.
(The provider is baked into the client state at init; changing it
requires wiping the `nym-data` volume.)
- **Peer never becomes active** — confirm the peer's TCP endpoint is
reachable from the open internet (the network requester dials it from
the Nym exit side, not from your machine).
- **Never run this image under emulation** — the image builds native for
a reason: under Rosetta/qemu, the FIPS daemon's ChaCha20-Poly1305
assembly (ring/BoringSSL) silently fails AEAD on larger frames; bloom
filter announces are dropped and multi-hop routing never converges,
while small control traffic keeps working — a maddeningly subtle
failure mode. Only the embedded amd64 `nym-socks5-client` (Nym ships no
other arch) runs emulated on Apple Silicon, which it tolerates.
## Notes
- The container's lifecycle follows the FIPS daemon; strfry, nginx and the
nym client run as background processes inside the same container and are
restarted with it (`restart: unless-stopped`).
- SSH (port 22, no auth) and tools like `tcpdump`, `nak`, `iperf3` are
inside the image for poking around — this is a demo image, do not expose
it beyond your machine.
@@ -0,0 +1,55 @@
networks:
fips-net:
name: ${FIPS_NETWORK:-fips-mixnet-net}
driver: bridge
ipam:
config:
- subnet: ${FIPS_SUBNET:-172.20.2.0/24}
services:
# Single container running ALL services: fips daemon, nym-socks5-client,
# strfry Nostr relay, nginx, dnsmasq. See entrypoint.sh for start order.
fips:
# Builds NATIVE for the host — fips must not run under emulation
# (Rosetta breaks its AEAD on larger frames). Only the embedded
# amd64 nym-socks5-client is emulated on Apple Silicon.
build:
context: ../..
dockerfile: examples/sidecar-nostr-mixnet-relay/Dockerfile
hostname: fips-mixnet
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv6.conf.all.disable_ipv6=0
restart: unless-stopped
ports:
- "2121:2121/udp" # FIPS UDP transport
- "8443:8443/tcp" # FIPS TCP transport
- "80:80/tcp" # Nostr relay WebSocket (via nginx)
environment:
- RUST_LOG=${RUST_LOG:-info}
- FIPS_NSEC=${FIPS_NSEC}
- FIPS_PEER_NPUB=${FIPS_PEER_NPUB:-}
- FIPS_PEER_ADDR=${FIPS_PEER_ADDR:-}
- FIPS_PEER_ALIAS=${FIPS_PEER_ALIAS:-peer}
- FIPS_PEER_TRANSPORT=${FIPS_PEER_TRANSPORT:-nym}
- FIPS_UDP_BIND=${FIPS_UDP_BIND:-0.0.0.0:2121}
- FIPS_TUN_MTU=${FIPS_TUN_MTU:-1280}
- FIPS_UDP_MTU=${FIPS_UDP_MTU:-1472}
- FIPS_NYM_SOCKS5_ADDR=${FIPS_NYM_SOCKS5_ADDR:-127.0.0.1:1080}
- NYM_CLIENT_ID=${NYM_CLIENT_ID:-fips-nym-client}
- NYM_SERVICE_PROVIDER=${NYM_SERVICE_PROVIDER:-}
volumes:
- ./resolv.conf:/etc/resolv.conf:ro
- ./relay/strfry.conf:/usr/src/app/strfry.conf:ro
- relay-data:/usr/src/app/strfry-db
- nym-data:/root/.nym
networks:
fips-net:
ipv4_address: ${FIPS_IPV4:-172.20.2.20}
volumes:
relay-data:
nym-data:
+221
View File
@@ -0,0 +1,221 @@
#!/bin/bash
# Single-container entrypoint: generate the FIPS config, apply iptables
# isolation, start the Nostr relay (strfry + nginx), start the Nym SOCKS5
# client (only when the FIPS config enables the nym transport), and launch
# FIPS last — so the mixnet proxy is provably up before FIPS dials its peer.
set -e
# --- Generate FIPS config from environment variables ---
FIPS_NSEC="${FIPS_NSEC:?FIPS_NSEC is required}"
FIPS_UDP_BIND="${FIPS_UDP_BIND:-0.0.0.0:2121}"
FIPS_TCP_BIND="${FIPS_TCP_BIND:-0.0.0.0:8443}"
FIPS_TUN_MTU="${FIPS_TUN_MTU:-1280}"
FIPS_UDP_MTU="${FIPS_UDP_MTU:-1472}"
FIPS_PEER_TRANSPORT="${FIPS_PEER_TRANSPORT:-nym}"
FIPS_NYM_SOCKS5_ADDR="${FIPS_NYM_SOCKS5_ADDR:-127.0.0.1:1080}"
NYM_CLIENT_ID="${NYM_CLIENT_ID:-fips-nym-client}"
NYM_STARTUP_TIMEOUT="${NYM_STARTUP_TIMEOUT:-180}"
mkdir -p /etc/fips
# Build peers section
PEERS_SECTION=""
if [ -n "$FIPS_PEER_NPUB" ] && [ -n "$FIPS_PEER_ADDR" ]; then
FIPS_PEER_ALIAS="${FIPS_PEER_ALIAS:-peer}"
PEERS_SECTION=" - npub: \"${FIPS_PEER_NPUB}\"
alias: \"${FIPS_PEER_ALIAS}\"
addresses:
- transport: ${FIPS_PEER_TRANSPORT}
addr: \"${FIPS_PEER_ADDR}\"
connect_policy: auto_connect"
fi
# The nym transport block is emitted only in nym mode; the SOCKS5 client
# below starts only when this block is present in the config.
NYM_SECTION=""
if [ "$FIPS_PEER_TRANSPORT" = "nym" ]; then
NYM_SECTION=" nym:
socks5_addr: \"${FIPS_NYM_SOCKS5_ADDR}\"
startup_timeout_secs: 120"
fi
cat > /etc/fips/fips.yaml <<EOF
node:
identity:
nsec: "${FIPS_NSEC}"
tun:
enabled: true
name: fips0
mtu: ${FIPS_TUN_MTU}
dns:
enabled: true
bind_addr: "127.0.0.1"
transports:
udp:
bind_addr: "${FIPS_UDP_BIND}"
# 1472 = Docker bridge IPv4 max (1500 MTU - 8 UDP - 20 IPv4 header).
# Override with FIPS_UDP_MTU=1280 for IPv6-min-safe deploys.
mtu: ${FIPS_UDP_MTU}
tcp:
bind_addr: "${FIPS_TCP_BIND}"
${NYM_SECTION}
peers:
${PEERS_SECTION:- []}
EOF
echo "Generated /etc/fips/fips.yaml"
# --- Start local DNS first ---
# resolv.conf points at 127.0.0.1; dnsmasq must be up before anything
# below (peer-IP resolution, harbourmaster query, nym gateway lookup).
dnsmasq
# --- Apply iptables rules for strict network isolation ---
#
# Goal: only FIPS transport traffic may use eth0. All other eth0 traffic is
# dropped. fips0 and loopback are unrestricted, so the relay (sharing this
# namespace) is reachable only over the FIPS mesh.
#
# In nym mode the direct route to the peer is explicitly DROPped before the
# general TCP accept for the mixnet gateways: if the peer comes up, the
# connection can only have travelled through the mixnet.
# IPv4: allow only FIPS transport on eth0
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --dport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p udp --sport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --dport 2121 -j ACCEPT
iptables -A INPUT -i eth0 -p udp --sport 2121 -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --sport 443 -j ACCEPT
PEER_HOST="${FIPS_PEER_ADDR%:*}"
PEER_PORT="${FIPS_PEER_ADDR##*:}"
case "$FIPS_PEER_TRANSPORT" in
nym)
# Block the direct path to the peer (mixnet-only proof). IPv4 only:
# eth0 IPv6 is dropped wholesale by the ip6tables rules below.
if [ -n "$FIPS_PEER_ADDR" ]; then
PEER_IP=$(getent ahostsv4 "$PEER_HOST" | awk '{print $1; exit}' || true)
if [ -n "$PEER_IP" ]; then
iptables -A OUTPUT -o eth0 -p tcp -d "$PEER_IP" --dport "$PEER_PORT" -j DROP
echo "Direct path to peer ${PEER_HOST} (${PEER_IP}:${PEER_PORT}) blocked — mixnet only"
fi
fi
# … then allow outbound TCP for the nym client's gateway connections.
iptables -A OUTPUT -o eth0 -p tcp -j ACCEPT
iptables -A INPUT -i eth0 -p tcp -m state --state ESTABLISHED,RELATED -j ACCEPT
;;
tcp)
# Allow dialing the peer's TCP endpoint directly, and inbound FIPS TCP.
if [ -n "$FIPS_PEER_ADDR" ]; then
iptables -A OUTPUT -o eth0 -p tcp --dport "$PEER_PORT" -j ACCEPT
iptables -A INPUT -i eth0 -p tcp --sport "$PEER_PORT" -m state --state ESTABLISHED,RELATED -j ACCEPT
fi
iptables -A INPUT -i eth0 -p tcp --dport "${FIPS_TCP_BIND##*:}" -j ACCEPT
iptables -A OUTPUT -o eth0 -p tcp --sport "${FIPS_TCP_BIND##*:}" -j ACCEPT
;;
esac
iptables -A OUTPUT -o eth0 -j DROP
iptables -A INPUT -i eth0 -j DROP
# IPv6: allow fips0 and loopback, block eth0
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A OUTPUT -o fips0 -j ACCEPT
ip6tables -A INPUT -i fips0 -j ACCEPT
ip6tables -A OUTPUT -o eth0 -j DROP
ip6tables -A INPUT -i eth0 -j DROP
echo "iptables isolation rules applied"
# --- Start the Nostr relay app (strfry + nginx) ---
(cd /usr/src/app && exec strfry relay) &
nginx
echo "Nostr relay started (strfry on 127.0.0.1:7777, nginx on :80)"
# --- Start the Nym SOCKS5 client (only if the config enables nym) ---
if [ "$FIPS_PEER_TRANSPORT" = "nym" ]; then
NYM_HOST="${FIPS_NYM_SOCKS5_ADDR%:*}"
NYM_PORT="${FIPS_NYM_SOCKS5_ADDR##*:}"
# Auto-discover a network-requester service provider when none is set.
if [ ! -d "${HOME}/.nym/socks5-clients/${NYM_CLIENT_ID}" ]; then
# The provider is only consulted at init time, so auto-discovery
# runs only when a fresh client must be initialized. Failures
# (harbourmaster down or serving HTML) must not crash the
# container with a bare jq error — hence the `|| true` guards
# and the explicit empty-check below.
if [ -z "$NYM_SERVICE_PROVIDER" ]; then
echo "NYM_SERVICE_PROVIDER not set — querying harbourmaster.nymtech.net …"
NYM_SERVICE_PROVIDER=$(curl -fsSL --retry 3 \
"https://harbourmaster.nymtech.net/v2/services?order_by=routing_score&order_direction=desc&size=100" \
2>/dev/null \
| jq -r '[.items[] | select(.routing_score == 1.0)]
| sort_by(.last_updated_utc) | last
| .service_provider_client_id // empty' 2>/dev/null \
|| true)
if [ -z "$NYM_SERVICE_PROVIDER" ]; then
# Last resort: a provider known to work at the time of
# writing (2026-06). Providers are volatile community
# infra — if the mixnet connects but no traffic flows
# ('no node with identity … is known' warnings), this
# fallback has gone stale: pick a current one from
# https://harbourmaster.nymtech.net/ and set it in .env.
NYM_SERVICE_PROVIDER="${NYM_FALLBACK_PROVIDER:-7sfw3sEtSPwhWLmEasVmPXKxqioCo4GaXRkm9bW6yWGZ.CkhMoH85wfNcV2fwoBjc6QDbcaFZHzKqFFvXWfYMw19y@4ScsM6AVowhKTMWaH98NLntKDwbu2ZMEycUk4mZiZppG}"
echo "WARNING: harbourmaster auto-discovery failed — using the" >&2
echo "baked-in fallback provider (may be stale; see .env):" >&2
echo " ${NYM_SERVICE_PROVIDER}" >&2
else
echo "Auto-selected service provider: ${NYM_SERVICE_PROVIDER}"
fi
fi
echo "Initializing Nym SOCKS5 client '${NYM_CLIENT_ID}' …"
nym-socks5-client init \
--id "${NYM_CLIENT_ID}" \
--provider "${NYM_SERVICE_PROVIDER}" \
--port "${NYM_PORT}" \
--host "${NYM_HOST}"
else
# The provider is baked into the client state at init time — a value
# set or discovered now does NOT apply to an existing client. Surface
# the one actually in effect so a stale/dead provider isn't chased
# silently (symptom: 'no node with identity … is known' warnings).
STORED_PROVIDER=$(grep -m1 -oE '[1-9A-HJ-NP-Za-km-z]{20,}\.[1-9A-HJ-NP-Za-km-z]{20,}@[1-9A-HJ-NP-Za-km-z]{20,}' \
"${HOME}/.nym/socks5-clients/${NYM_CLIENT_ID}/config/config.toml" 2>/dev/null || true)
echo "Reusing existing Nym client state (provider: ${STORED_PROVIDER:-unknown})."
echo "To switch provider, remove the nym-data volume: docker compose down -v"
fi
echo "Starting Nym SOCKS5 client (mixnet bootstrap may take a minute) …"
nym-socks5-client run \
--id "${NYM_CLIENT_ID}" \
--port "${NYM_PORT}" \
--host "${NYM_HOST}" &
# FIPS must not start dialing before the proxy accepts connections.
elapsed=0
until nc -z "$NYM_HOST" "$NYM_PORT" 2>/dev/null; do
if [ "$elapsed" -ge "$NYM_STARTUP_TIMEOUT" ]; then
echo "ERROR: Nym SOCKS5 proxy not ready after ${NYM_STARTUP_TIMEOUT}s" >&2
exit 1
fi
sleep 2
elapsed=$((elapsed + 2))
done
echo "Nym SOCKS5 proxy ready at ${FIPS_NYM_SOCKS5_ADDR} (after ~${elapsed}s)"
fi
# --- Launch FIPS (container lifecycle follows the daemon) ---
echo "Starting FIPS daemon..."
exec fips --config /etc/fips/fips.yaml
@@ -0,0 +1,71 @@
##
## strfry configuration for FIPS mesh deployment.
## Full reference: https://github.com/hoytech/strfry
##
db = "/usr/src/app/strfry-db/"
dbParams {
# Maximum size of the database (bytes). 10 GiB is a safe default.
mapsize = 10737418240
}
relay {
# Bind on all interfaces so the FIPS TUN (IPv4 + IPv6) can reach it.
bind = "0.0.0.0"
port = 7777
nofiles = 0
info {
name = "FIPS Nostr Relay"
description = "A Nostr relay accessible over the FIPS mesh network."
pubkey = ""
contact = ""
}
# Maximum size of an inbound WebSocket message (bytes).
maxWebsocketPayloadSize = 131072
# Send a ping every N seconds to keep connections alive.
autoPingSeconds = 55
# Enable per-message compression.
enableTcpNoDelay = false
rejectFutureEventsSeconds = 900
rejectEphemeralEventsOlderThanSeconds = 60
rejectEventsNewerThanSeconds = 900
maxFilterLimit = 500
maxSubsPerConnection = 20
writePolicy {
# Plugin executable for write-policy decisions (leave empty to allow all).
plugin = ""
}
compression {
enabled = true
slidingWindow = true
}
logging {
dumpInAll = false
dumpInEvents = false
dumpInReqs = false
dbScanPerf = false
}
numThreads {
ingester = 3
reqWorker = 3
reqMonitor = 3
negentropy = 2
}
negentropy {
enabled = true
maxSyncEvents = 1000000
}
}
@@ -0,0 +1 @@
nameserver 127.0.0.1
+20
View File
@@ -481,6 +481,26 @@ fn draw_transport_detail(frame: &mut Frame, app: &App, area: Rect, t: &serde_jso
&helpers::nested_u64(t, "stats", "connect_refused"), &helpers::nested_u64(t, "stats", "connect_refused"),
)); ));
} }
"nym" => {
lines.push(helpers::kv_line(
"MTU Exceeded",
&helpers::nested_u64(t, "stats", "mtu_exceeded"),
));
lines.push(helpers::kv_line(
"SOCKS5 Errors",
&helpers::nested_u64(t, "stats", "socks5_errors"),
));
lines.push(Line::from(""));
lines.push(helpers::section_header("Connections"));
lines.push(helpers::kv_line(
"Established",
&helpers::nested_u64(t, "stats", "connections_established"),
));
lines.push(helpers::kv_line(
"Timeouts",
&helpers::nested_u64(t, "stats", "connect_timeouts"),
));
}
"ethernet" => { "ethernet" => {
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(helpers::section_header("Beacons")); lines.push(helpers::section_header("Beacons"));
+2 -2
View File
@@ -39,8 +39,8 @@ pub use node::{
}; };
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{ pub use transport::{
BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances, BleConfig, DirectoryServiceConfig, EthernetConfig, NymConfig, TcpConfig, TorConfig,
TransportsConfig, UdpConfig, TransportInstances, TransportsConfig, UdpConfig,
}; };
/// Default config filename. /// Default config filename.
+79
View File
@@ -801,6 +801,77 @@ impl BleConfig {
} }
} }
// ============================================================================
// Nym Transport Configuration
// ============================================================================
/// Default Nym SOCKS5 proxy address (nym-socks5-client).
const DEFAULT_NYM_SOCKS5_ADDR: &str = "127.0.0.1:1080";
/// Default Nym connect timeout in milliseconds (300s — Nym mixnet
/// SOCKS5 connections require multiple round-trips through 3 mix nodes
/// with timing obfuscation, which can take several minutes).
const DEFAULT_NYM_CONNECT_TIMEOUT_MS: u64 = 300_000;
/// Default Nym MTU (same as TCP).
const DEFAULT_NYM_MTU: u16 = 1400;
/// Default Nym startup timeout in seconds (time to wait for
/// nym-socks5-client to become ready before giving up).
const DEFAULT_NYM_STARTUP_TIMEOUT_SECS: u64 = 120;
/// Nym transport instance configuration.
///
/// Outbound-only connections through a nym-socks5-client SOCKS5 proxy.
/// The nym-socks5-client must be running separately (e.g., as a sidecar
/// process or container).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NymConfig {
/// SOCKS5 proxy address (host:port). Defaults to "127.0.0.1:1080".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub socks5_addr: Option<String>,
/// Outbound connect timeout in milliseconds. Defaults to 300000 (300s).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_timeout_ms: Option<u64>,
/// Default MTU for Nym connections. Defaults to 1400.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mtu: Option<u16>,
/// Seconds to wait for nym-socks5-client to become ready at startup.
/// Defaults to 120.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_timeout_secs: Option<u64>,
}
impl NymConfig {
/// Get the SOCKS5 proxy address. Default: "127.0.0.1:1080".
pub fn socks5_addr(&self) -> &str {
self.socks5_addr
.as_deref()
.unwrap_or(DEFAULT_NYM_SOCKS5_ADDR)
}
/// Get the connect timeout in milliseconds. Default: 300000.
pub fn connect_timeout_ms(&self) -> u64 {
self.connect_timeout_ms
.unwrap_or(DEFAULT_NYM_CONNECT_TIMEOUT_MS)
}
/// Get the default MTU. Default: 1400.
pub fn mtu(&self) -> u16 {
self.mtu.unwrap_or(DEFAULT_NYM_MTU)
}
/// Get the startup timeout in seconds. Default: 120.
pub fn startup_timeout_secs(&self) -> u64 {
self.startup_timeout_secs
.unwrap_or(DEFAULT_NYM_STARTUP_TIMEOUT_SECS)
}
}
// ============================================================================ // ============================================================================
// TransportsConfig // TransportsConfig
// ============================================================================ // ============================================================================
@@ -827,6 +898,10 @@ pub struct TransportsConfig {
#[serde(default, skip_serializing_if = "is_transport_empty")] #[serde(default, skip_serializing_if = "is_transport_empty")]
pub tor: TransportInstances<TorConfig>, pub tor: TransportInstances<TorConfig>,
/// Nym transport instances.
#[serde(default, skip_serializing_if = "is_transport_empty")]
pub nym: TransportInstances<NymConfig>,
/// BLE transport instances. /// BLE transport instances.
#[serde(default, skip_serializing_if = "is_transport_empty")] #[serde(default, skip_serializing_if = "is_transport_empty")]
pub ble: TransportInstances<BleConfig>, pub ble: TransportInstances<BleConfig>,
@@ -844,6 +919,7 @@ impl TransportsConfig {
&& self.ethernet.is_empty() && self.ethernet.is_empty()
&& self.tcp.is_empty() && self.tcp.is_empty()
&& self.tor.is_empty() && self.tor.is_empty()
&& self.nym.is_empty()
&& self.ble.is_empty() && self.ble.is_empty()
} }
@@ -863,6 +939,9 @@ impl TransportsConfig {
if !other.tor.is_empty() { if !other.tor.is_empty() {
self.tor = other.tor; self.tor = other.tor;
} }
if !other.nym.is_empty() {
self.nym = other.nym;
}
if !other.ble.is_empty() { if !other.ble.is_empty() {
self.ble = other.ble; self.ble = other.ble;
} }
+1 -1
View File
@@ -30,7 +30,7 @@ pub use identity::{
}; };
// Re-export config types // Re-export config types
pub use config::{Config, ConfigError, IdentityConfig, TorConfig, UdpConfig}; pub use config::{Config, ConfigError, IdentityConfig, NymConfig, TorConfig, UdpConfig};
pub use upper::config::{DnsConfig, TunConfig}; pub use upper::config::{DnsConfig, TunConfig};
// Re-export discovery types // Re-export discovery types
+16
View File
@@ -50,6 +50,7 @@ use crate::node::session::SessionEntry;
use crate::peer::{ActivePeer, PeerConnection}; use crate::peer::{ActivePeer, PeerConnection};
#[cfg(unix)] #[cfg(unix)]
use crate::transport::ethernet::EthernetTransport; use crate::transport::ethernet::EthernetTransport;
use crate::transport::nym::NymTransport;
use crate::transport::tcp::TcpTransport; use crate::transport::tcp::TcpTransport;
use crate::transport::tor::TorTransport; use crate::transport::tor::TorTransport;
use crate::transport::udp::UdpTransport; use crate::transport::udp::UdpTransport;
@@ -982,6 +983,21 @@ impl Node {
transports.push(TransportHandle::Tor(tor)); transports.push(TransportHandle::Tor(tor));
} }
// Create Nym transport instances
let nym_instances: Vec<_> = self
.config()
.transports
.nym
.iter()
.map(|(name, config)| (name.map(|s| s.to_string()), config.clone()))
.collect();
for (name, nym_config) in nym_instances {
let transport_id = self.allocate_transport_id();
let nym = NymTransport::new(transport_id, name, nym_config, packet_tx.clone());
transports.push(TransportHandle::Nym(nym));
}
// Create BLE transport instances // Create BLE transport instances
#[cfg(bluer_available)] #[cfg(bluer_available)]
{ {
+34 -2
View File
@@ -6,6 +6,7 @@
#[cfg(test)] #[cfg(test)]
pub mod loopback; pub mod loopback;
pub mod nym;
pub mod tcp; pub mod tcp;
pub mod tor; pub mod tor;
pub mod udp; pub mod udp;
@@ -22,6 +23,7 @@ use ble::DefaultBleTransport;
use ethernet::EthernetTransport; use ethernet::EthernetTransport;
#[cfg(test)] #[cfg(test)]
use loopback::LoopbackTransport; use loopback::LoopbackTransport;
use nym::NymTransport;
use secp256k1::XOnlyPublicKey; use secp256k1::XOnlyPublicKey;
use std::fmt; use std::fmt;
use std::net::SocketAddr; use std::net::SocketAddr;
@@ -259,6 +261,13 @@ impl TransportType {
reliable: true, // in-process channel delivery is lossless reliable: true, // in-process channel delivery is lossless
}; };
/// Nym mixnet transport (via SOCKS5).
pub const NYM: TransportType = TransportType {
name: "nym",
connection_oriented: true,
reliable: true,
};
/// Check if the transport is connectionless. /// Check if the transport is connectionless.
pub fn is_connectionless(&self) -> bool { pub fn is_connectionless(&self) -> bool {
!self.connection_oriented !self.connection_oriented
@@ -891,6 +900,8 @@ pub enum TransportHandle {
Tcp(TcpTransport), Tcp(TcpTransport),
/// Tor transport (via SOCKS5). /// Tor transport (via SOCKS5).
Tor(TorTransport), Tor(TorTransport),
/// Nym mixnet transport (via SOCKS5).
Nym(NymTransport),
/// BLE L2CAP transport. /// BLE L2CAP transport.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
Ble(DefaultBleTransport), Ble(DefaultBleTransport),
@@ -908,6 +919,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.start_async().await, TransportHandle::Ethernet(t) => t.start_async().await,
TransportHandle::Tcp(t) => t.start_async().await, TransportHandle::Tcp(t) => t.start_async().await,
TransportHandle::Tor(t) => t.start_async().await, TransportHandle::Tor(t) => t.start_async().await,
TransportHandle::Nym(t) => t.start_async().await,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.start_async().await, TransportHandle::Ble(t) => t.start_async().await,
#[cfg(test)] #[cfg(test)]
@@ -923,6 +935,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.stop_async().await, TransportHandle::Ethernet(t) => t.stop_async().await,
TransportHandle::Tcp(t) => t.stop_async().await, TransportHandle::Tcp(t) => t.stop_async().await,
TransportHandle::Tor(t) => t.stop_async().await, TransportHandle::Tor(t) => t.stop_async().await,
TransportHandle::Nym(t) => t.stop_async().await,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.stop_async().await, TransportHandle::Ble(t) => t.stop_async().await,
#[cfg(test)] #[cfg(test)]
@@ -938,6 +951,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.send_async(addr, data).await, TransportHandle::Ethernet(t) => t.send_async(addr, data).await,
TransportHandle::Tcp(t) => t.send_async(addr, data).await, TransportHandle::Tcp(t) => t.send_async(addr, data).await,
TransportHandle::Tor(t) => t.send_async(addr, data).await, TransportHandle::Tor(t) => t.send_async(addr, data).await,
TransportHandle::Nym(t) => t.send_async(addr, data).await,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.send_async(addr, data).await, TransportHandle::Ble(t) => t.send_async(addr, data).await,
#[cfg(test)] #[cfg(test)]
@@ -953,6 +967,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.transport_id(), TransportHandle::Ethernet(t) => t.transport_id(),
TransportHandle::Tcp(t) => t.transport_id(), TransportHandle::Tcp(t) => t.transport_id(),
TransportHandle::Tor(t) => t.transport_id(), TransportHandle::Tor(t) => t.transport_id(),
TransportHandle::Nym(t) => t.transport_id(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.transport_id(), TransportHandle::Ble(t) => t.transport_id(),
#[cfg(test)] #[cfg(test)]
@@ -968,6 +983,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.name(), TransportHandle::Ethernet(t) => t.name(),
TransportHandle::Tcp(t) => t.name(), TransportHandle::Tcp(t) => t.name(),
TransportHandle::Tor(t) => t.name(), TransportHandle::Tor(t) => t.name(),
TransportHandle::Nym(t) => t.name(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.name(), TransportHandle::Ble(t) => t.name(),
#[cfg(test)] #[cfg(test)]
@@ -983,6 +999,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.transport_type(), TransportHandle::Ethernet(t) => t.transport_type(),
TransportHandle::Tcp(t) => t.transport_type(), TransportHandle::Tcp(t) => t.transport_type(),
TransportHandle::Tor(t) => t.transport_type(), TransportHandle::Tor(t) => t.transport_type(),
TransportHandle::Nym(t) => t.transport_type(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.transport_type(), TransportHandle::Ble(t) => t.transport_type(),
#[cfg(test)] #[cfg(test)]
@@ -998,6 +1015,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.state(), TransportHandle::Ethernet(t) => t.state(),
TransportHandle::Tcp(t) => t.state(), TransportHandle::Tcp(t) => t.state(),
TransportHandle::Tor(t) => t.state(), TransportHandle::Tor(t) => t.state(),
TransportHandle::Nym(t) => t.state(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.state(), TransportHandle::Ble(t) => t.state(),
#[cfg(test)] #[cfg(test)]
@@ -1013,6 +1031,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.mtu(), TransportHandle::Ethernet(t) => t.mtu(),
TransportHandle::Tcp(t) => t.mtu(), TransportHandle::Tcp(t) => t.mtu(),
TransportHandle::Tor(t) => t.mtu(), TransportHandle::Tor(t) => t.mtu(),
TransportHandle::Nym(t) => t.mtu(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.mtu(), TransportHandle::Ble(t) => t.mtu(),
#[cfg(test)] #[cfg(test)]
@@ -1031,6 +1050,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.link_mtu(addr), TransportHandle::Ethernet(t) => t.link_mtu(addr),
TransportHandle::Tcp(t) => t.link_mtu(addr), TransportHandle::Tcp(t) => t.link_mtu(addr),
TransportHandle::Tor(t) => t.link_mtu(addr), TransportHandle::Tor(t) => t.link_mtu(addr),
TransportHandle::Nym(t) => t.link_mtu(addr),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.link_mtu(addr), TransportHandle::Ble(t) => t.link_mtu(addr),
#[cfg(test)] #[cfg(test)]
@@ -1046,6 +1066,7 @@ impl TransportHandle {
TransportHandle::Ethernet(_) => None, TransportHandle::Ethernet(_) => None,
TransportHandle::Tcp(t) => t.local_addr(), TransportHandle::Tcp(t) => t.local_addr(),
TransportHandle::Tor(_) => None, TransportHandle::Tor(_) => None,
TransportHandle::Nym(_) => None,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(_) => None, TransportHandle::Ble(_) => None,
#[cfg(test)] #[cfg(test)]
@@ -1061,6 +1082,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => Some(t.interface_name()), TransportHandle::Ethernet(t) => Some(t.interface_name()),
TransportHandle::Tcp(_) => None, TransportHandle::Tcp(_) => None,
TransportHandle::Tor(_) => None, TransportHandle::Tor(_) => None,
TransportHandle::Nym(_) => None,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(_) => None, TransportHandle::Ble(_) => None,
#[cfg(test)] #[cfg(test)]
@@ -1100,6 +1122,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.discover(), TransportHandle::Ethernet(t) => t.discover(),
TransportHandle::Tcp(t) => t.discover(), TransportHandle::Tcp(t) => t.discover(),
TransportHandle::Tor(t) => t.discover(), TransportHandle::Tor(t) => t.discover(),
TransportHandle::Nym(t) => t.discover(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.discover(), TransportHandle::Ble(t) => t.discover(),
#[cfg(test)] #[cfg(test)]
@@ -1115,6 +1138,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.auto_connect(), TransportHandle::Ethernet(t) => t.auto_connect(),
TransportHandle::Tcp(t) => t.auto_connect(), TransportHandle::Tcp(t) => t.auto_connect(),
TransportHandle::Tor(t) => t.auto_connect(), TransportHandle::Tor(t) => t.auto_connect(),
TransportHandle::Nym(t) => t.auto_connect(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.auto_connect(), TransportHandle::Ble(t) => t.auto_connect(),
#[cfg(test)] #[cfg(test)]
@@ -1130,6 +1154,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.accept_connections(), TransportHandle::Ethernet(t) => t.accept_connections(),
TransportHandle::Tcp(t) => t.accept_connections(), TransportHandle::Tcp(t) => t.accept_connections(),
TransportHandle::Tor(t) => t.accept_connections(), TransportHandle::Tor(t) => t.accept_connections(),
TransportHandle::Nym(t) => t.accept_connections(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.accept_connections(), TransportHandle::Ble(t) => t.accept_connections(),
#[cfg(test)] #[cfg(test)]
@@ -1139,7 +1164,7 @@ impl TransportHandle {
/// Initiate a non-blocking connection to a remote address. /// Initiate a non-blocking connection to a remote address.
/// ///
/// For connection-oriented transports (TCP, Tor), spawns a background /// For connection-oriented transports (TCP, Tor, Nym), spawns a background
/// task to establish the connection. For connectionless transports /// task to establish the connection. For connectionless transports
/// (UDP, Ethernet), this is a no-op that returns Ok immediately. /// (UDP, Ethernet), this is a no-op that returns Ok immediately.
/// ///
@@ -1151,6 +1176,7 @@ impl TransportHandle {
TransportHandle::Ethernet(_) => Ok(()), // connectionless TransportHandle::Ethernet(_) => Ok(()), // connectionless
TransportHandle::Tcp(t) => t.connect_async(addr).await, TransportHandle::Tcp(t) => t.connect_async(addr).await,
TransportHandle::Tor(t) => t.connect_async(addr).await, TransportHandle::Tor(t) => t.connect_async(addr).await,
TransportHandle::Nym(t) => t.connect_async(addr).await,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.connect_async(addr).await, TransportHandle::Ble(t) => t.connect_async(addr).await,
#[cfg(test)] #[cfg(test)]
@@ -1170,6 +1196,7 @@ impl TransportHandle {
TransportHandle::Ethernet(_) => ConnectionState::Connected, TransportHandle::Ethernet(_) => ConnectionState::Connected,
TransportHandle::Tcp(t) => t.connection_state_sync(addr), TransportHandle::Tcp(t) => t.connection_state_sync(addr),
TransportHandle::Tor(t) => t.connection_state_sync(addr), TransportHandle::Tor(t) => t.connection_state_sync(addr),
TransportHandle::Nym(t) => t.connection_state_sync(addr),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.connection_state_sync(addr), TransportHandle::Ble(t) => t.connection_state_sync(addr),
#[cfg(test)] #[cfg(test)]
@@ -1179,7 +1206,7 @@ impl TransportHandle {
/// Close a specific connection on this transport. /// Close a specific connection on this transport.
/// ///
/// No-op for connectionless transports. For TCP/Tor, removes the /// No-op for connectionless transports. For TCP/Tor/Nym, removes the
/// connection from the pool and drops the stream. /// connection from the pool and drops the stream.
pub async fn close_connection(&self, addr: &TransportAddr) { pub async fn close_connection(&self, addr: &TransportAddr) {
match self { match self {
@@ -1188,6 +1215,7 @@ impl TransportHandle {
TransportHandle::Ethernet(t) => t.close_connection(addr), TransportHandle::Ethernet(t) => t.close_connection(addr),
TransportHandle::Tcp(t) => t.close_connection_async(addr).await, TransportHandle::Tcp(t) => t.close_connection_async(addr).await,
TransportHandle::Tor(t) => t.close_connection_async(addr).await, TransportHandle::Tor(t) => t.close_connection_async(addr).await,
TransportHandle::Nym(t) => t.close_connection_async(addr).await,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => t.close_connection_async(addr).await, TransportHandle::Ble(t) => t.close_connection_async(addr).await,
#[cfg(test)] #[cfg(test)]
@@ -1212,6 +1240,7 @@ impl TransportHandle {
TransportHandle::Ethernet(_) => TransportCongestion::default(), TransportHandle::Ethernet(_) => TransportCongestion::default(),
TransportHandle::Tcp(_) => TransportCongestion::default(), TransportHandle::Tcp(_) => TransportCongestion::default(),
TransportHandle::Tor(_) => TransportCongestion::default(), TransportHandle::Tor(_) => TransportCongestion::default(),
TransportHandle::Nym(_) => TransportCongestion::default(),
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(_) => TransportCongestion::default(), TransportHandle::Ble(_) => TransportCongestion::default(),
#[cfg(test)] #[cfg(test)]
@@ -1249,6 +1278,9 @@ impl TransportHandle {
TransportHandle::Tor(t) => { TransportHandle::Tor(t) => {
serde_json::to_value(t.stats().snapshot()).unwrap_or_default() serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
} }
TransportHandle::Nym(t) => {
serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
TransportHandle::Ble(t) => { TransportHandle::Ble(t) => {
serde_json::to_value(t.stats().snapshot()).unwrap_or_default() serde_json::to_value(t.stats().snapshot()).unwrap_or_default()
+203
View File
@@ -0,0 +1,203 @@
//! Mock SOCKS5 server for testing the Nym transport's connect path.
//!
//! A copy of the Tor transport's mock, implementing just enough of the
//! SOCKS5 protocol (RFC 1928) to support the no-auth (and, defensively,
//! username/password) CONNECT flow, then proxying bytes bidirectionally to a
//! fixed target.
//!
//! Difference from the Tor mock: this one accepts connections in a loop and
//! handles each on its own task. `NymTransport::start_async` first probes the
//! proxy port for readiness (opening and immediately dropping a connection);
//! looping lets the mock shrug that probe off — its handler returns on the
//! short first read — and still serve the real data connection that follows.
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
/// SOCKS5 protocol constants.
const SOCKS_VERSION: u8 = 0x05;
const AUTH_NONE: u8 = 0x00;
const AUTH_PASSWORD: u8 = 0x02;
const CMD_CONNECT: u8 = 0x01;
const ATYP_IPV4: u8 = 0x01;
const ATYP_DOMAIN: u8 = 0x03;
const REP_SUCCESS: u8 = 0x00;
/// Username/password auth sub-negotiation version (RFC 1929).
const AUTH_SUBNEG_VERSION: u8 = 0x01;
const AUTH_SUBNEG_SUCCESS: u8 = 0x00;
/// A minimal mock SOCKS5 proxy server for testing.
///
/// Accepts connections in a loop, performs the SOCKS5 handshake (supporting
/// both no-auth and username/password auth), then connects to a fixed target
/// address and proxies bytes bidirectionally.
pub struct MockSocks5Server {
/// Address the mock proxy is listening on.
addr: SocketAddr,
/// The real target address to connect to (ignores SOCKS5 requested target).
target_addr: SocketAddr,
/// Listener handle.
listener: Option<TcpListener>,
}
impl MockSocks5Server {
/// Create a new mock SOCKS5 server that forwards to the given target.
///
/// Binds to `127.0.0.1:0` (OS-assigned port).
pub async fn new(target_addr: SocketAddr) -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
Ok(Self {
addr,
target_addr,
listener: Some(listener),
})
}
/// Get the proxy's listen address (for `NymConfig.socks5_addr`).
pub fn addr(&self) -> SocketAddr {
self.addr
}
/// Run the proxy, accepting connections in a loop and proxying each.
///
/// Returns a JoinHandle for the accept loop.
pub fn spawn(mut self) -> JoinHandle<()> {
let listener = self.listener.take().expect("listener already consumed");
let target_addr = self.target_addr;
tokio::spawn(async move {
loop {
let (client, _) = match listener.accept().await {
Ok(c) => c,
Err(_) => break,
};
// Handle each connection independently so the readiness probe
// (which opens and drops a connection) cannot block the real
// data connection behind it.
tokio::spawn(handle_conn(client, target_addr));
}
})
}
}
/// Handle a single accepted connection: SOCKS5 handshake then byte proxy.
async fn handle_conn(mut client: tokio::net::TcpStream, target_addr: SocketAddr) {
// === Method negotiation ===
// Client sends: [version, nmethods, methods...]
let mut ver_nmethods = [0u8; 2];
if client.read_exact(&mut ver_nmethods).await.is_err() {
// Readiness probe (or any early close) — nothing to serve.
return;
}
assert_eq!(ver_nmethods[0], SOCKS_VERSION, "expected SOCKS5");
let nmethods = ver_nmethods[1] as usize;
let mut methods = vec![0u8; nmethods];
client.read_exact(&mut methods).await.expect("read methods");
// Prefer username/password auth if offered, fall back to no-auth.
let selected = if methods.contains(&AUTH_PASSWORD) {
AUTH_PASSWORD
} else if methods.contains(&AUTH_NONE) {
AUTH_NONE
} else {
panic!("no supported auth method offered");
};
// Reply: [version, selected_method]
client
.write_all(&[SOCKS_VERSION, selected])
.await
.expect("write method reply");
// === Username/password sub-negotiation (RFC 1929) ===
if selected == AUTH_PASSWORD {
// Client sends: [ver(1), ulen(1), uname(ulen), plen(1), passwd(plen)]
let mut subneg_header = [0u8; 2];
client
.read_exact(&mut subneg_header)
.await
.expect("read subneg header");
assert_eq!(
subneg_header[0], AUTH_SUBNEG_VERSION,
"expected auth subneg v1"
);
let ulen = subneg_header[1] as usize;
let mut uname = vec![0u8; ulen];
client.read_exact(&mut uname).await.expect("read username");
let mut plen_buf = [0u8; 1];
client.read_exact(&mut plen_buf).await.expect("read plen");
let plen = plen_buf[0] as usize;
let mut passwd = vec![0u8; plen];
client.read_exact(&mut passwd).await.expect("read password");
client
.write_all(&[AUTH_SUBNEG_VERSION, AUTH_SUBNEG_SUCCESS])
.await
.expect("write subneg reply");
}
// === Connect request ===
// Client sends: [version, cmd, rsv, atyp, addr..., port]
let mut header = [0u8; 4];
client
.read_exact(&mut header)
.await
.expect("read connect header");
assert_eq!(header[0], SOCKS_VERSION);
assert_eq!(header[1], CMD_CONNECT);
// Read and skip the address (we connect to target_addr regardless).
match header[3] {
ATYP_IPV4 => {
let mut addr_port = [0u8; 6]; // 4 IP + 2 port
client
.read_exact(&mut addr_port)
.await
.expect("read IPv4 addr");
}
ATYP_DOMAIN => {
let mut len_buf = [0u8; 1];
client
.read_exact(&mut len_buf)
.await
.expect("read domain len");
let domain_len = len_buf[0] as usize;
let mut domain_port = vec![0u8; domain_len + 2]; // domain + 2 port
client
.read_exact(&mut domain_port)
.await
.expect("read domain addr");
}
other => panic!("unsupported ATYP: {}", other),
}
// Connect to the real target.
let mut target = tokio::net::TcpStream::connect(target_addr)
.await
.expect("connect to target");
// Reply: success, bind addr = 0.0.0.0:0
let reply = [
SOCKS_VERSION,
REP_SUCCESS,
0x00, // RSV
ATYP_IPV4,
0,
0,
0,
0, // bind addr
0,
0, // bind port
];
client.write_all(&reply).await.expect("write connect reply");
// Proxy bytes bidirectionally.
let _ = tokio::io::copy_bidirectional(&mut client, &mut target).await;
}
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
//! Nym transport statistics.
use portable_atomic::{AtomicU64, Ordering};
use serde::Serialize;
/// Statistics for a Nym transport instance.
///
/// Uses atomic counters for lock-free updates from per-connection
/// receive loops and the send path concurrently.
pub struct NymStats {
pub packets_sent: AtomicU64,
pub bytes_sent: AtomicU64,
pub packets_recv: AtomicU64,
pub bytes_recv: AtomicU64,
pub send_errors: AtomicU64,
pub recv_errors: AtomicU64,
pub mtu_exceeded: AtomicU64,
pub connections_established: AtomicU64,
pub connect_timeouts: AtomicU64,
pub socks5_errors: AtomicU64,
}
impl NymStats {
/// Create a new stats instance with all counters at zero.
pub fn new() -> Self {
Self {
packets_sent: AtomicU64::new(0),
bytes_sent: AtomicU64::new(0),
packets_recv: AtomicU64::new(0),
bytes_recv: AtomicU64::new(0),
send_errors: AtomicU64::new(0),
recv_errors: AtomicU64::new(0),
mtu_exceeded: AtomicU64::new(0),
connections_established: AtomicU64::new(0),
connect_timeouts: AtomicU64::new(0),
socks5_errors: AtomicU64::new(0),
}
}
/// Record a successful send.
pub fn record_send(&self, bytes: usize) {
self.packets_sent.fetch_add(1, Ordering::Relaxed);
self.bytes_sent.fetch_add(bytes as u64, Ordering::Relaxed);
}
/// Record a successful receive.
pub fn record_recv(&self, bytes: usize) {
self.packets_recv.fetch_add(1, Ordering::Relaxed);
self.bytes_recv.fetch_add(bytes as u64, Ordering::Relaxed);
}
/// Record a send error.
pub fn record_send_error(&self) {
self.send_errors.fetch_add(1, Ordering::Relaxed);
}
/// Record a receive error.
pub fn record_recv_error(&self) {
self.recv_errors.fetch_add(1, Ordering::Relaxed);
}
/// Record an MTU exceeded rejection.
pub fn record_mtu_exceeded(&self) {
self.mtu_exceeded.fetch_add(1, Ordering::Relaxed);
}
/// Record a successful outbound connection.
pub fn record_connection_established(&self) {
self.connections_established.fetch_add(1, Ordering::Relaxed);
}
/// Record a connect timeout.
pub fn record_connect_timeout(&self) {
self.connect_timeouts.fetch_add(1, Ordering::Relaxed);
}
/// Record a SOCKS5 protocol error.
pub fn record_socks5_error(&self) {
self.socks5_errors.fetch_add(1, Ordering::Relaxed);
}
/// Take a snapshot of all counters.
pub fn snapshot(&self) -> NymStatsSnapshot {
NymStatsSnapshot {
packets_sent: self.packets_sent.load(Ordering::Relaxed),
bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
packets_recv: self.packets_recv.load(Ordering::Relaxed),
bytes_recv: self.bytes_recv.load(Ordering::Relaxed),
send_errors: self.send_errors.load(Ordering::Relaxed),
recv_errors: self.recv_errors.load(Ordering::Relaxed),
mtu_exceeded: self.mtu_exceeded.load(Ordering::Relaxed),
connections_established: self.connections_established.load(Ordering::Relaxed),
connect_timeouts: self.connect_timeouts.load(Ordering::Relaxed),
socks5_errors: self.socks5_errors.load(Ordering::Relaxed),
}
}
}
impl Default for NymStats {
fn default() -> Self {
Self::new()
}
}
/// Point-in-time snapshot of Nym stats (non-atomic, copyable).
#[derive(Clone, Debug, Default, Serialize)]
pub struct NymStatsSnapshot {
pub packets_sent: u64,
pub bytes_sent: u64,
pub packets_recv: u64,
pub bytes_recv: u64,
pub send_errors: u64,
pub recv_errors: u64,
pub mtu_exceeded: u64,
pub connections_established: u64,
pub connect_timeouts: u64,
pub socks5_errors: u64,
}