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
+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