Compare commits

...

56 Commits

Author SHA1 Message Date
mfahampshire d1902531a4 Cleanup lockfile 2026-04-30 21:56:09 +01:00
mfahampshire f0c3fc00a6 Tweak comments 2026-04-30 21:55:45 +01:00
mfahampshire 3df7691e33 Tweak examples 2026-04-30 21:55:32 +01:00
mfahampshire f5229338de Default DNS tweak 2026-04-30 12:01:54 +01:00
mfahampshire 8ab290ea03 Remove old libp2p code - new example usage coming in smolmix-companions-docs branch 2026-04-29 12:36:50 +01:00
mfahampshire 0a8f18bd06 Remove comment ornaments 2026-04-29 10:28:04 +01:00
mfahampshire 7b62fa792c Remove documentation changes (split to separate branch) 2026-04-29 10:28:04 +01:00
mfahampshire 2011aa9018 Temp commit before restructure 2026-04-29 10:28:04 +01:00
mfahampshire 7783da7f9c Temp commit before restructure 2026-04-29 10:28:04 +01:00
mfahampshire 8a927b728a Pre rebase commit 2026-04-29 10:28:04 +01:00
mfahampshire 4ba67f6b55 Restore companion crates from backup branch 2026-04-29 10:28:04 +01:00
mfahampshire 4eca683665 Remove companion crates, scope to smolmix-core 2026-04-29 10:28:04 +01:00
mfahampshire 7bed2f30bf Update nym-sdk README module descriptions
- Replace stale stream_wrapper description with ipr_wrapper + mixnet::stream
- Remove TODO comment
2026-04-29 10:28:04 +01:00
mfahampshire 9b0cc9d691 Add workspace README with architecture overview
- Explain TCP-layer vs message-layer design and why it matters
- Document trait impls and I/O bridges for each companion crate
- Add quick start, crate table, and example commands
2026-04-29 10:28:04 +01:00
mfahampshire 239b91ec16 Restructure smolmix into multi-crate workspace
- Move core tunnel code to smolmix/core/
- Add smolmix-dns: hickory RuntimeProvider over tunnel
- Add smolmix-tls: shared TLS connector extracted from hyper/tungstenite
- Add smolmix-hyper: tower Service<Uri> connector for hyper-util
- Add smolmix-tungstenite: WebSocket over TLS through tunnel
- Add smolmix-libp2p: libp2p 0.56 Transport trait (dial-only)
- Rewrite examples for each crate with clearnet/mixnet comparisons
2026-04-29 10:28:04 +01:00
mfahampshire 781d3ee8d0 Add Tunnel::new_with_ipr, re-export IpPair/Recipient, tidy examples
- Add Tunnel::new_with_ipr() for targeting a specific exit node
- Re-export IpPair and Recipient so users don't need direct deps
- Add DNS leak warning to WebSocket example
- Await hyper connection task in HTTPS example
2026-04-29 10:28:04 +01:00
mfahampshire b205744da5 Update smolmix imports for ipr_wrapper API
- stream_wrapper::{IpMixStream, NetworkEnvironment} → ipr_wrapper::
- connect_tunnel() → check_connected()
- disconnect_stream() → disconnect()
- allocated_ips() returns &IpPair directly (no Option)
2026-04-29 10:28:04 +01:00
mfahampshire 754603f739 Consolidate architecture docs, tidy examples and README
- Add src/ARCHITECTURE.md as single source of truth for architecture
- Include in docs.rs via doc = include_str!
- Strip duplicated diagrams from tunnel.rs, device.rs, README
- Extract tls_connector() helper in HTTPS example to match websocket example
- Use consistent 'smolmix' casing in README
2026-04-29 10:28:04 +01:00
mfahampshire df8e55960a Clean up bridge, device, and tunnel code
- Fix silent error swallowing in handle_incoming() select branch
- Remove cargo-cult 1s sleep before disconnect (SDK handles via TaskTracker)
- Rename confusing bridge fields: tx_receiver/rx_sender -> outgoing_rx/incoming_tx
- Document cancel-safety risk, drop-path shutdown, post-shutdown behavior
- Add comment on error loop rate limiting (SDK's internal 10s timeout)
2026-04-29 10:28:04 +01:00
mfahampshire 601b4d9279 Remove raw smoltcp path, flatten tunnel module
- Drop sync NymIprDevice, create_device(), and raw/ examples
- Flatten src/tunnel/{mod,device}.rs into src/{tunnel,device}.rs
- Remove unused socket-icmp and proto-ipv6 smoltcp features
- Tighten bridge/device visibility to pub(crate)
2026-04-29 10:28:04 +01:00
mfahampshire b319e10494 Add tokio-smoltcp, hyper, tungstenite, hickory deps for tunnel and examples 2026-04-29 10:28:03 +01:00
mfahampshire 820fa74d31 Add DNS example: hickory-proto over smolmix UDP 2026-04-29 10:28:03 +01:00
mfahampshire 95a93cba5c Add WebSocket example: tokio-tungstenite over tokio-rustls over smolmix 2026-04-29 10:28:03 +01:00
mfahampshire 8ce8d25f6b Add HTTPS example: hyper over tokio-rustls over smolmix 2026-04-29 10:28:03 +01:00
mfahampshire 54844d0516 Move raw smoltcp examples to examples/raw/ 2026-04-29 10:28:03 +01:00
mfahampshire 726663d4e4 Re-export Tunnel API and add init_logging convenience function 2026-04-29 10:28:03 +01:00
mfahampshire 614934bc68 Add Tunnel API with TcpStream and UdpSocket over tokio-smoltcp 2026-04-29 10:28:03 +01:00
mfahampshire 583627f27a rename mixtcp -> smolmix 2026-04-29 10:28:03 +01:00
mfahampshire 6caccd9e4c Add UDP example to readme 2026-04-29 10:28:03 +01:00
mfahampshire 33237e97c4 Add UDP example 2026-04-29 10:28:03 +01:00
mfahampshire 16f9c0d5e9 - Cleanup
- Add graceful shutdown (gets rid of red herring errors in examples)
- Declutter logging - move a lot of bridge info! -> trace!
- Move rustls, nym-bin-common, bytes to dev-dependencies
- Extract TlsOverTcp to mod.rs
- Make timing more granular
- Update readme
2026-04-29 10:28:03 +01:00
mfahampshire 5e63a70215 Add mixtcp crate for smoltcp-based IP tunneling
Introduces mixtcp, a smoltcp-based device that tunnels TCP/IP through
the mixnet via IpMixStream and Exit Gateway IPRs.

Components:
- NymIprDevice: smoltcp::phy::Device impl using channel-based I/O
- NymIprBridge: async task bridging the device to IpMixStream
- create_device(): helper to set up the complete stack

Examples:
- cloudflare_ping: ICMP ping through the mixnet
- https_client: HTTPS requests through the mixnet
- tls: TLS connections through the mixnet
2026-04-29 10:28:03 +01:00
mfahampshire 7efb81a601 Final pass 2026-04-29 10:28:03 +01:00
mfahampshire f9900f6946 Streamline smolmix docs 2026-04-29 10:28:03 +01:00
mfahampshire 7c18405390 Add files to versions file 2026-04-29 10:28:03 +01:00
mfahampshire 266ef7c3c9 Update mixfetch example 2026-04-29 10:28:03 +01:00
mfahampshire 4bb0b4f283 Update example code doc comments 2026-04-29 10:28:02 +01:00
mfahampshire 144617f5e1 Update license + add old tutorial code as examples 2026-04-29 10:28:02 +01:00
mfahampshire 778f84aae6 Relative -> absolute links 2026-04-29 10:28:02 +01:00
mfahampshire 9d42538f23 Relative -> absolute links 2026-04-29 10:28:02 +01:00
mfahampshire 7eaad3d99f Add versions file for doc-wide versioning 2026-04-29 10:28:02 +01:00
mfahampshire 3b6b3844ff Autoupdate cmponents 2026-04-29 10:28:02 +01:00
mfahampshire 57443d590b Add test-tutorials/ to gitignore 2026-04-29 10:28:02 +01:00
mfahampshire 4fdd9f2024 Temp commit 2026-04-29 10:28:02 +01:00
mfahampshire ba45688c0a License information + remove TODO from docs.rs visibile comment + reorg
readme
2026-04-29 10:28:02 +01:00
mfahampshire 7b43476754 Reorg of tutorials and architecture pages 2026-04-29 10:28:02 +01:00
mfahampshire 618d78dbb5 In-process versions file 2026-04-29 10:28:02 +01:00
mfahampshire eee7bdf348 gitignore 2026-04-29 10:28:02 +01:00
mfahampshire 9fd8dffb28 Component generation 2026-04-29 10:28:02 +01:00
mfahampshire 8c71fac545 Temp commit 2026-04-29 10:28:01 +01:00
mfahampshire abf0ff3b9e Update auto-generated command and API outputs 2026-04-29 10:28:01 +01:00
mfahampshire 7d10ced448 Add Exit Gateway services page (NR vs IPR) and link from existing docs 2026-04-29 10:28:01 +01:00
mfahampshire d25bd22c44 Add smolmix docs: landing page, tutorials, and developer page links 2026-04-29 10:28:01 +01:00
mfahampshire 7ed7633c6e Add Exit Gateway services page (NR vs IPR) and link from existing docs 2026-04-29 10:28:01 +01:00
mfahampshire 2372b47f46 Add smolmix docs: landing page, tutorials, and developer page links 2026-04-29 10:28:01 +01:00
mfahampshire 943afb66ba Smolmix documentation 2026-04-29 10:28:01 +01:00
74 changed files with 4221 additions and 3057 deletions
+1
View File
@@ -77,3 +77,4 @@ CLAUDE.md
/notes
/target-otel
test-tutorials/
Generated
+921 -8
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -148,6 +148,9 @@ members = [
"sdk/ffi/shared",
"sdk/rust/nym-sdk",
"smolmix/core",
"smolmix/dns",
"smolmix/hyper",
"smolmix/tls",
"service-providers/common",
"service-providers/ip-packet-router",
"service-providers/network-requester",
@@ -335,7 +338,7 @@ rayon = "1.5.1"
regex = "1.10.6"
reqwest = { version = "0.13.1", default-features = false }
rs_merkle = "1.5.0"
rustls = { version = "0.23.37", default-features = false }
rustls = { version = "0.23.37", default-features = false, features = ["std", "ring"] }
schemars = "0.8.22"
semver = "1.0.26"
serde = "1.0.219"
@@ -350,6 +353,9 @@ serde_plain = "1.0.2"
sha2 = "0.10.3"
si-scale = "0.2.3"
smolmix = { version = "0.0.1", path = "smolmix/core" }
smolmix-dns = { version = "0.0.1", path = "smolmix/dns" }
smolmix-hyper = { version = "0.0.1", path = "smolmix/hyper" }
smolmix-tls = { version = "0.0.1", path = "smolmix/tls" }
smoltcp = "0.12"
snow = "0.9.6"
sphinx-packet = "=0.6.0"
@@ -5,7 +5,7 @@
},
"mixmining_reserve": {
"denom": "unym",
"amount": "168575020719057"
"amount": "166613455567357"
},
"vesting_tokens": {
"denom": "unym",
@@ -13,6 +13,6 @@
},
"circulating_supply": {
"denom": "unym",
"amount": "831424979280943"
"amount": "833386544432643"
}
}
@@ -1,6 +1,6 @@
{
"nodes": 752,
"locations": 76,
"mixnodes": 273,
"exit_gateways": 470
"nodes": 743,
"locations": 77,
"mixnodes": 262,
"exit_gateways": 474
}
@@ -1 +1 @@
831_424_979
833_386_544
@@ -1 +1 @@
61_050_638
61_194_673
@@ -1 +1 @@
61_050_637
61_194_672
@@ -1,7 +1,7 @@
| **Item** | **Description** | **Amount in NYM** |
|:-------------------|:------------------------------------------------------|--------------------:|
| Total Supply | Maximum amount of NYM token in existence | 1_000_000_000 |
| Mixmining Reserve | Tokens releasing for operators rewards | 168_575_020 |
| Mixmining Reserve | Tokens releasing for operators rewards | 166_613_455 |
| Vesting Tokens | Tokens locked outside of circulation for future claim | 0 |
| Circulating Supply | Amount of unlocked tokens | 831_424_979 |
| Stake Saturation | Optimal size of node self-bond + delegation | 254_377 |
| Circulating Supply | Amount of unlocked tokens | 833_386_544 |
| Stake Saturation | Optimal size of node self-bond + delegation | 254_977 |
@@ -1,10 +1,10 @@
{
"interval": {
"reward_pool": "168575020719057.456153922699547282",
"staking_supply": "61050637328128.353993717821521057",
"reward_pool": "166613455567357.391753168447944888",
"staking_supply": "61194672938727.325886595653401629",
"staking_supply_scale_factor": "0.07342892",
"epoch_reward_budget": "4682639464.418262670942297209",
"stake_saturation_point": "254377655533.868141640490923004",
"epoch_reward_budget": "4628151543.537705326476901331",
"stake_saturation_point": "254977803911.363857860815222506",
"sybil_resistance": "0.3",
"active_set_work_factor": "10",
"interval_pool_emission": "0.02"
@@ -1 +1 @@
Thursday, April 9th 2026, 15:33:24 UTC
Thursday, April 30th 2026, 09:40:36 UTC
@@ -6,54 +6,69 @@ Usage: nym-node run [OPTIONS]
Options:
--id <ID> Id of the nym-node to use [env: NYMNODE_ID=] [default: default-nym-node]
--config-file <CONFIG_FILE> Path to a configuration file of this node [env: NYMNODE_CONFIG=]
--accept-operator-terms-and-conditions Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at <https://nymtech.net/terms-and-conditions/operators/v1.0.0> [env: NYMNODE_ACCEPT_OPERATOR_TERMS=]
--accept-operator-terms-and-conditions Explicitly specify whether you agree with the terms and conditions of a nym node operator as defined at
<https://nymtech.net/terms-and-conditions/operators/v1.0.0> [env: NYMNODE_ACCEPT_OPERATOR_TERMS=]
--deny-init Forbid a new node from being initialised if configuration file for the provided specification doesn't already exist [env: NYMNODE_DENY_INIT=]
--init-only If this is a brand new nym-node, specify whether it should only be initialised without actually running the subprocesses [env: NYMNODE_INIT_ONLY=]
--local Flag specifying this node will be running in a local setting [env: NYMNODE_LOCAL=]
--mode [<MODE>...] Specifies the current mode(s) of this nym-node [env: NYMNODE_MODE=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only]
--modes <MODES> Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, entry-gateway, exit-gateway, exit-providers-only]
--modes <MODES> Specifies the current mode(s) of this nym-node as a single flag [env: NYMNODE_MODES=] [possible values: mixnode, entry-gateway, exit-gateway,
exit-providers-only]
-w, --write-changes If this node has been initialised before, specify whether to write any new changes to the config file [env: NYMNODE_WRITE_CONFIG_CHANGES=]
--bonding-information-output <BONDING_INFORMATION_OUTPUT> Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information is still a subject to change and this argument should be treated only as a preview of
future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=]
--bonding-information-output <BONDING_INFORMATION_OUTPUT> Specify output file for bonding information of this nym-node, i.e. its encoded keys. NOTE: the required bonding information is still a subject to change
and this argument should be treated only as a preview of future features [env: NYMNODE_BONDING_INFORMATION_OUTPUT=]
-o, --output <OUTPUT> Specify the output format of the bonding information (`text` or `json`) [env: NYMNODE_OUTPUT=] [default: text] [possible values: text, json]
--public-ips <PUBLIC_IPS> Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In nearly all circumstances, it's going to be identical to the address you're going to use for
bonding [env: NYMNODE_PUBLIC_IPS=]
--public-ips <PUBLIC_IPS> Comma separated list of public ip addresses that will be announced to the nym-api and subsequently to the clients. In nearly all circumstances, it's
going to be identical to the address you're going to use for bonding [env: NYMNODE_PUBLIC_IPS=]
--hostname <HOSTNAME> Optional hostname associated with this gateway that will be announced to the nym-api and subsequently to the clients [env: NYMNODE_HOSTNAME=]
--location <LOCATION> Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'), three-letter alpha3 (e.g. 'POL') or three-digit numeric-3 (e.g. '616') can be
provided [env: NYMNODE_LOCATION=]
--location <LOCATION> Optional **physical** location of this node's server. Either full country name (e.g. 'Poland'), two-letter alpha2 (e.g. 'PL'), three-letter alpha3 (e.g.
'POL') or three-digit numeric-3 (e.g. '616') can be provided [env: NYMNODE_LOCATION=]
--http-bind-address <HTTP_BIND_ADDRESS> Socket address this node will use for binding its http API. default: `[::]:8080` [env: NYMNODE_HTTP_BIND_ADDRESS=]
--landing-page-assets-path <LANDING_PAGE_ASSETS_PATH> Path to assets directory of custom landing page of this node [env: NYMNODE_HTTP_LANDING_ASSETS=]
--http-access-token <HTTP_ACCESS_TOKEN> An optional bearer token for accessing certain http endpoints. Currently only used for prometheus metrics [env: NYMNODE_HTTP_ACCESS_TOKEN=]
--expose-system-info <EXPOSE_SYSTEM_INFO> Specify whether basic system information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_INFO=] [possible values: true, false]
--expose-system-hardware <EXPOSE_SYSTEM_HARDWARE> Specify whether basic system hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true, false]
--expose-crypto-hardware <EXPOSE_CRYPTO_HARDWARE> Specify whether detailed system crypto hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible values: true, false]
--expose-system-hardware <EXPOSE_SYSTEM_HARDWARE> Specify whether basic system hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_SYSTEM_HARDWARE=] [possible values: true,
false]
--expose-crypto-hardware <EXPOSE_CRYPTO_HARDWARE> Specify whether detailed system crypto hardware information should be exposed. default: true [env: NYMNODE_HTTP_EXPOSE_CRYPTO_HARDWARE=] [possible
values: true, false]
--mixnet-bind-address <MIXNET_BIND_ADDRESS> Address this node will bind to for listening for mixnet packets default: `[::]:1789` [env: NYMNODE_MIXNET_BIND_ADDRESS=]
--mixnet-announce-port <MIXNET_ANNOUNCE_PORT> If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_MIXNET_ANNOUNCE_PORT=]
--mixnet-announce-port <MIXNET_ANNOUNCE_PORT> If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env:
NYMNODE_MIXNET_ANNOUNCE_PORT=]
--nym-api-urls <NYM_API_URLS> Addresses to nym APIs from which the node gets the view of the network [env: NYMNODE_NYM_APIS=]
--nyxd-urls <NYXD_URLS> Addresses to nyxd chain endpoint which the node will use for chain interactions [env: NYMNODE_NYXD=]
--enable-console-logging <ENABLE_CONSOLE_LOGGING> Specify whether running statistics of this node should be logged to the console [env: NYMNODE_ENABLE_CONSOLE_LOGGING=] [possible values: true, false]
--wireguard-enabled <WIREGUARD_ENABLED> Specifies whether the wireguard service is enabled on this node [env: NYMNODE_WG_ENABLED=] [possible values: true, false]
--wireguard-bind-address <WIREGUARD_BIND_ADDRESS> Socket address this node will use for binding its wireguard interface. default: `[::]:51822` [env: NYMNODE_WG_BIND_ADDRESS=]
--wireguard-tunnel-announced-port <WIREGUARD_TUNNEL_ANNOUNCED_PORT> Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env: NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX> The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env: NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--wireguard-userspace <WIREGUARD_USERSPACE> Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel WireGuard support [env: NYMNODE_WG_USERSPACE=] [possible values: true,
false]
--wireguard-tunnel-announced-port <WIREGUARD_TUNNEL_ANNOUNCED_PORT> Tunnel port announced to external clients wishing to connect to the wireguard interface. Useful in the instances where the node is behind a proxy [env:
NYMNODE_WG_ANNOUNCED_PORT=]
--wireguard-private-network-prefix <WIREGUARD_PRIVATE_NETWORK_PREFIX> The prefix denoting the maximum number of the clients that can be connected via Wireguard. The maximum value for IPv4 is 32 and for IPv6 is 128 [env:
NYMNODE_WG_PRIVATE_NETWORK_PREFIX=]
--wireguard-userspace <WIREGUARD_USERSPACE> Use userspace implementation of WireGuard (wireguard-go) instead of kernel module. Useful in containerized environments without kernel WireGuard support
[env: NYMNODE_WG_USERSPACE=] [possible values: true, false]
--verloc-bind-address <VERLOC_BIND_ADDRESS> Socket address this node will use for binding its verloc API. default: `[::]:1790` [env: NYMNODE_VERLOC_BIND_ADDRESS=]
--verloc-announce-port <VERLOC_ANNOUNCE_PORT> If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env: NYMNODE_VERLOC_ANNOUNCE_PORT=]
--verloc-announce-port <VERLOC_ANNOUNCE_PORT> If applicable, custom port announced in the self-described API that other clients and nodes will use. Useful when the node is behind a proxy [env:
NYMNODE_VERLOC_ANNOUNCE_PORT=]
--entry-bind-address <ENTRY_BIND_ADDRESS> Socket address this node will use for binding its client websocket API. default: `[::]:9000` [env: NYMNODE_ENTRY_BIND_ADDRESS=]
--announce-ws-port <ANNOUNCE_WS_PORT> Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be used instead [env: NYMNODE_ENTRY_ANNOUNCE_WS_PORT=]
--announce-ws-port <ANNOUNCE_WS_PORT> Custom announced port for listening for websocket client traffic. If unspecified, the value from the `bind_address` will be used instead [env:
NYMNODE_ENTRY_ANNOUNCE_WS_PORT=]
--announce-wss-port <ANNOUNCE_WSS_PORT> If applicable, announced port for listening for secure websocket client traffic [env: NYMNODE_ENTRY_ANNOUNCE_WSS_PORT=]
--enforce-zk-nyms <ENFORCE_ZK_NYMS> Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients [env: NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false]
--enforce-zk-nyms <ENFORCE_ZK_NYMS> Indicates whether this gateway is accepting only coconut credentials for accessing the mixnet or if it also accepts non-paying clients [env:
NYMNODE_ENFORCE_ZK_NYMS=] [possible values: true, false]
--mnemonic <MNEMONIC> Custom cosmos wallet mnemonic used for zk-nym redemption. If no value is provided, a fresh mnemonic is going to be generated [env: NYMNODE_MNEMONIC=]
--upgrade-mode-attestation-url <UPGRADE_MODE_ATTESTATION_URL> Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTATION_URL=]
--upgrade-mode-attester-public-key <UPGRADE_MODE_ATTESTER_PUBLIC_KEY> Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and local networks [env: NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=]
--upgrade-mode-attestation-url <UPGRADE_MODE_ATTESTATION_URL> Endpoint to query to retrieve current upgrade mode attestation. This argument should never be set outside testnets and local networks [env:
NYMNODE_UPGRADE_MODE_ATTESTATION_URL=]
--upgrade-mode-attester-public-key <UPGRADE_MODE_ATTESTER_PUBLIC_KEY> Expected public key of the entity signing the published attestation. This argument should never be set outside testnets and local networks [env:
NYMNODE_UPGRADE_MODE_ATTESTER_PUBKEY=]
--upstream-exit-policy-url <UPSTREAM_EXIT_POLICY_URL> Specifies the url for an upstream source of the exit policy used by this node [env: NYMNODE_UPSTREAM_EXIT_POLICY=]
--open-proxy <OPEN_PROXY> Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env: NYMNODE_OPEN_PROXY=] [possible values: true, false]
--open-proxy <OPEN_PROXY> Specifies whether this exit node should run in 'open-proxy' mode and thus would attempt to resolve **ANY** request it receives [env: NYMNODE_OPEN_PROXY=]
[possible values: true, false]
--lp-control-bind-address <LP_CONTROL_BIND_ADDRESS> Bind address for the TCP LP control traffic. default: `[::]:41264` [env: NYMNODE_LP_CONTROL_BIND_ADDRESS=]
--lp-control-announce-port <LP_CONTROL_ANNOUNCE_PORT> Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the `lp_control_bind_address` will be used instead [env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=]
--lp-control-announce-port <LP_CONTROL_ANNOUNCE_PORT> Custom announced port for listening for the TCP LP control traffic. If unspecified, the value from the `lp_control_bind_address` will be used instead
[env: NYMNODE_LP_CONTROL_ANNOUNCE_PORT=]
--lp-data-bind-address <LP_DATA_BIND_ADDRESS> Bind address for the UDP LP data traffic. default: `[::]:51264` [env: NYMNODE_LP_DATA_BIND_ADDRESS=]
--lp-data-announce-port <LP_DATA_ANNOUNCE_PORT> Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` will be used instead [env: NYMNODE_LP_DATA_ANNOUNCE_PORT=]
--lp-use-mock-ecash <LP_USE_MOCK_ECASH> Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, the LP listener will accept any credential without blockchain verification [env:
NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false]
--lp-data-announce-port <LP_DATA_ANNOUNCE_PORT> Custom announced port for listening for the UDP LP data traffic. If unspecified, the value from the `lp_data_bind_address` will be used instead [env:
NYMNODE_LP_DATA_ANNOUNCE_PORT=]
--lp-use-mock-ecash <LP_USE_MOCK_ECASH> Use mock ecash manager for LP testing. WARNING: Only use this for local testing! Never enable in production. When enabled, the LP listener will accept
any credential without blockchain verification [env: NYMNODE_LP_USE_MOCK_ECASH=] [possible values: true, false]
-h, --help Print help
```
@@ -0,0 +1,15 @@
import { Callout } from "nextra/components";
import { NYM_SDK_VERSION } from "./versions";
const INSTALL_PATH = "/developers/rust/importing";
export const VersionBanner = () => (
<Callout type="info">
Code examples target <strong>v{NYM_SDK_VERSION}</strong> of the Nym crates
on{" "}
<a href="https://crates.io/crates/nym-sdk" target="_blank" rel="noopener noreferrer">
crates.io
</a>
. See <a href={INSTALL_PATH}>Installation</a> for setup instructions.
</Callout>
);
+34
View File
@@ -0,0 +1,34 @@
/**
* Centralised version constants for documentation.
*
* Components (VersionBanner, CodeVerified, etc.) import from here so there is a
* single place to update on each release.
*
* Fenced code blocks still hardcode versions for copy-paste friendliness.
* When bumping versions here, also update the Cargo.toml snippets in:
*
* pages/developers/rust/importing.mdx
* pages/developers/rust/tcpproxy.mdx
* pages/developers/rust/mixnet/tutorial.mdx
* pages/developers/rust/stream/tutorial.mdx
* pages/developers/rust/client-pool/tutorial.mdx
* public/llms.txt
*
* RUST_MSRV is imported directly by all pages that display the Rust version —
* no manual file edits needed for MSRV bumps:
*
* pages/developers/smolmix.mdx
* pages/developers/rust/importing.mdx
*/
// nym-sdk / nym-bin-common / nym-network-defaults — Rust SDK crates
export const NYM_SDK_VERSION = "1.20.4";
// smolmix standalone crate
export const SMOLMIX_VERSION = "0.0.1";
// blake3 exact pin (workaround for transitive digest conflict)
export const BLAKE3_PIN = "=1.7.0";
// Minimum supported Rust version (matches workspace rust-version in root Cargo.toml)
export const RUST_MSRV = "1.87";
@@ -0,0 +1,112 @@
---
title: "smolmix-dns: DNS Resolution Through the Mixnet"
description: "Resolve hostnames privately using smolmix-dns. Routes all DNS queries through the Nym mixnet to prevent hostname leaks."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-28"
---
# smolmix-dns
import { Callout } from 'nextra/components'
import { VersionBanner } from '../../components/version-banner'
<VersionBanner />
If you resolve hostnames using the OS resolver or a clearnet DNS library, the queries travel over your local network — leaking which domains you're visiting even though the TCP traffic goes through the mixnet. `smolmix-dns` routes all DNS queries (both UDP and TCP fallback) through the tunnel.
## Installation
```toml
[dependencies]
smolmix = "0.0.1"
smolmix-dns = "0.0.1"
```
## Usage
```rust
use smolmix::Tunnel;
use smolmix_dns::Resolver;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let tunnel = Tunnel::new().await?;
let resolver = Resolver::new(&tunnel);
// Full hickory-resolver API via Deref:
let lookup = resolver.lookup_ip("example.com").await?;
for ip in lookup.iter() {
println!("{ip}");
}
// Convenience method returning SocketAddr with port:
let addrs = resolver.resolve("nymtech.net", 443).await?;
println!("nymtech.net:443 → {addrs:?}");
tunnel.shutdown().await;
Ok(())
}
```
## Caching
<Callout type="info">
**Reuse your `Resolver` across requests.** hickory-resolver maintains an internal LRU cache for DNS responses. Creating a new `Resolver` per lookup (or using the free function `smolmix_dns::resolve()`) bypasses caching entirely — every lookup triggers a full round-trip through the mixnet.
</Callout>
```rust
// Good: resolver created once, cache shared across lookups
let resolver = Resolver::new(&tunnel);
let a = resolver.resolve("example.com", 443).await?;
let b = resolver.resolve("example.com", 80).await?; // cache hit
// Avoid: creates a fresh resolver (and empty cache) each time
let a = smolmix_dns::resolve(&tunnel, "example.com", 443).await?;
let b = smolmix_dns::resolve(&tunnel, "example.com", 80).await?; // cache miss
```
## Custom upstream DNS
By default, queries go to Cloudflare (`1.1.1.1`). Use `Resolver::with_config()` for other upstreams:
```rust
use smolmix_dns::{Resolver, ResolverConfig};
// Quad9
let resolver = Resolver::with_config(&tunnel, ResolverConfig::quad9());
// Google
let resolver = Resolver::with_config(&tunnel, ResolverConfig::google());
```
## How it works
`smolmix-dns` implements hickory-resolver's `RuntimeProvider` trait, which controls how the resolver creates network connections:
```text
RuntimeProvider::connect_tcp() → tunnel.tcp_connect() → AsyncIoTokioAsStd<TcpStream>
RuntimeProvider::bind_udp() → tunnel.udp_socket() → SmolmixUdpSocket (newtype)
```
All DNS I/O is routed through the smolmix tunnel. The resolver itself is standard hickory-resolver — full DNS protocol support, caching, DNSSEC validation (if configured), etc.
## API reference
| Type/Function | Description |
|---|---|
| `Resolver::new(tunnel)` | Resolver using Cloudflare DNS |
| `Resolver::with_config(tunnel, config)` | Resolver with custom upstream |
| `resolver.resolve(host, port)` | Convenience: returns `Vec<SocketAddr>` |
| `resolver.lookup_ip(host)` | Full hickory API (via `Deref`) |
| `smolmix_dns::resolve(tunnel, host, port)` | One-shot (uncached) convenience |
| `ResolverConfig` | Re-exported hickory config type |
## Example
```sh
cargo run -p smolmix-dns --example resolve
cargo run -p smolmix-dns --example resolve -- --ipr <IPR_ADDRESS>
```
Source: [`dns/examples/resolve.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/dns/examples/resolve.rs)
@@ -0,0 +1,96 @@
---
title: "Building on smolmix"
description: "How to build your own protocol adapters on top of smolmix. Patterns for WebSocket, libp2p, and any AsyncRead+AsyncWrite library."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-28"
---
# Building on smolmix
import { Callout } from 'nextra/components'
import { VersionBanner } from '../../components/version-banner'
<VersionBanner />
The published companion crates ([`smolmix-dns`](/developers/smolmix-dns), [`smolmix-tls`](/developers/smolmix-tls), [`smolmix-hyper`](/developers/smolmix-hyper)) cover the most common use cases. For other protocols, the same principle applies: if a library accepts `AsyncRead + AsyncWrite` (or can be adapted to it), it works over smolmix with minimal glue.
## The pattern
Every companion crate follows the same recipe:
1. Find the library's I/O extension point (trait or type parameter)
2. Bridge smolmix's `TcpStream` to whatever I/O trait the library expects
3. Expose a thin wrapper that hides the bridging
```text
┌─────────────────────────────────────────────────────┐
│ Library's extension point I/O bridge │
├─────────────────────────────────────────────────────┤
│ hickory RuntimeProvider → AsyncIoTokioAsStd │
│ tokio-rustls TlsConnector → (direct — same │
│ AsyncRead/Write) │
│ tower::Service<Uri> → TokioIo + pin enum │
│ tokio-tungstenite → (direct) │
│ libp2p Transport → tokio_util::compat │
└─────────────────────────────────────────────────────┘
```
Most tokio-native libraries need zero bridging — `smolmix::TcpStream` already implements `tokio::io::AsyncRead + AsyncWrite`. Libraries expecting `futures_io::AsyncRead + AsyncWrite` (like hickory and libp2p) need a one-line adapter.
## Examples in the repository
These self-contained examples demonstrate building protocol integration without a dedicated crate:
| Pattern | Example | Lines of glue | What it demonstrates |
|---|---|---:|---|
| WebSocket | [`websocket.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket.rs) | ~10 | Composing DNS + TCP + TLS + protocol upgrade. `tokio-tungstenite` accepts any `AsyncRead + AsyncWrite` directly. |
| libp2p Transport | [`libp2p_ping.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/libp2p_ping.rs) | ~80 | Implementing libp2p's `Transport` trait. Dial-only (no listening). Uses `tokio_util::compat` to bridge tokio↔futures I/O. |
Run them with:
```sh
cargo run -p smolmix --example websocket
cargo run -p smolmix --example libp2p_ping
```
## WebSocket — the minimal case
`tokio-tungstenite` takes any stream implementing `AsyncRead + AsyncWrite`. Since `smolmix-tls` returns a `TlsStream<TcpStream>` which implements those traits, no adapter is needed:
```rust
use smolmix::Tunnel;
use smolmix_dns::Resolver;
use smolmix_tls::connect;
use tokio_tungstenite::client_async;
let tunnel = Tunnel::new().await?;
let resolver = Resolver::new(&tunnel);
let addrs = resolver.resolve("echo.websocket.org", 443).await?;
let tcp = tunnel.tcp_connect(addrs[0]).await?;
let tls = connect(tcp, "echo.websocket.org").await?;
let (mut ws, _) = client_async("wss://echo.websocket.org", tls).await?;
// ws is a fully functional WebSocket connection over the mixnet
```
## libp2p — implementing a Transport
libp2p requires a `Transport` trait implementation. The key insight: libp2p uses `futures_io::AsyncRead + AsyncWrite`, not tokio's version. The bridge is `tokio_util::compat::TokioAsyncReadCompatExt`:
```rust
use tokio_util::compat::TokioAsyncReadCompatExt;
let tcp = tunnel.tcp_connect(addr).await?;
let compat_stream = tcp.compat(); // tokio → futures_io bridge
// Now pass compat_stream to libp2p's noise/yamux upgrade
```
See the full implementation in [`libp2p_ping.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/libp2p_ping.rs) — it handles multiaddr parsing, DNS resolution through the tunnel, and the noise+yamux handshake.
## When to publish a crate vs use an example
<Callout type="info">
Publish a companion crate when the integration involves non-obvious trait implementations or configuration that users would struggle to get right on their own (e.g. hickory's `RuntimeProvider`). Keep it as an example when the glue is straightforward copy-paste (e.g. WebSocket, where the library already accepts the right traits).
</Callout>
@@ -0,0 +1,130 @@
---
title: "smolmix-hyper: HTTP Client Over the Mixnet"
description: "Make HTTP and HTTPS requests through the Nym mixnet using smolmix-hyper. Full hyper API with DNS, TCP, and TLS routed through the tunnel."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-28"
---
# smolmix-hyper
import { Callout } from 'nextra/components'
import { VersionBanner } from '../../components/version-banner'
<VersionBanner />
`smolmix-hyper` is the highest-level companion crate — a familiar HTTP client that routes DNS resolution, TCP connections, and TLS handshakes through the mixnet. This is what most users want: "fetch a URL anonymously".
## Installation
```toml
[dependencies]
smolmix = "0.0.1"
smolmix-hyper = "0.0.1"
```
You also need a rustls crypto provider installed. Add this at the start of `main()`:
```rust
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
```
## GET request
```rust
use smolmix::Tunnel;
use smolmix_hyper::{Client, Request, EmptyBody, BodyExt};
use bytes::Bytes;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let tunnel = Tunnel::new().await?;
let client = Client::new(&tunnel);
let req = Request::get("https://cloudflare.com/cdn-cgi/trace")
.header("Host", "cloudflare.com")
.body(EmptyBody::<Bytes>::new())?;
let resp = client.request(req).await?;
println!("Status: {}", resp.status());
let body = resp.into_body().collect().await?.to_bytes();
println!("{}", String::from_utf8_lossy(&body));
tunnel.shutdown().await;
Ok(())
}
```
The `ip=` line in the response shows the IPR exit gateway's IP — your real IP is hidden by the mixnet.
## POST request
The convenience `Client` wrapper uses `Empty<Bytes>` as its body type (suitable for GET). For POST/PUT, construct a client with a different body type using `SmolmixConnector` directly:
```rust
use http_body_util::Full;
use hyper_util::{client::legacy, rt::TokioExecutor};
use bytes::Bytes;
use smolmix_hyper::SmolmixConnector;
let tunnel = smolmix::Tunnel::new().await?;
let connector = SmolmixConnector::new(&tunnel);
let client = legacy::Client::builder(TokioExecutor::new())
.build::<_, Full<Bytes>>(connector);
let body = Full::new(Bytes::from(r#"{"key": "value"}"#));
let req = hyper::Request::post("https://httpbin.org/post")
.header("Host", "httpbin.org")
.header("Content-Type", "application/json")
.body(body)?;
let resp = client.request(req).await?;
```
## How it works
```text
Client::request(req)
→ SmolmixConnector::call(uri) (tower::Service<Uri>)
→ smolmix_dns::resolve() DNS through tunnel
→ tunnel.tcp_connect() TCP through mixnet
→ smolmix_tls::connect_with() TLS if https://
→ TokioIo<MaybeTlsStream> hyper-compatible stream
→ hyper sends HTTP over that stream
```
The `SmolmixConnector` implements `tower::Service<Uri>` — hyper-util's extension point for custom connection logic. All the privacy-critical parts (DNS, TCP, TLS) go through the tunnel; hyper handles HTTP framing normally on top.
## API reference
| Type/Function | Description |
|---|---|
| `Client::new(tunnel)` | HTTP client with `Empty<Bytes>` body (GET/HEAD) |
| `client(tunnel)` | Free function, same as `Client::new()` |
| `SmolmixConnector::new(tunnel)` | Connector for custom body types |
| `Request`, `Response`, `StatusCode`, `Uri` | Re-exported from hyper |
| `BodyExt`, `EmptyBody` | Re-exported from http-body-util |
| `bytes` | Re-exported bytes crate |
## Performance notes
<Callout type="info">
The first request takes several seconds (mixnet connection setup + multi-hop TCP handshake). Subsequent requests on the same client reuse the tunnel and are significantly faster. hyper-util's connection pooling also helps — HTTP keep-alive connections avoid repeated TCP/TLS setup.
</Callout>
## Examples
```sh
cargo run -p smolmix-hyper --example get
cargo run -p smolmix-hyper --example post
cargo run -p smolmix-hyper --example get -- --ipr <IPR_ADDRESS>
```
Source: [`hyper/examples/get.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/hyper/examples/get.rs), [`hyper/examples/post.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/hyper/examples/post.rs)
@@ -0,0 +1,96 @@
---
title: "smolmix-tls: TLS Over the Mixnet"
description: "Encrypt the final hop between the exit gateway and remote host using smolmix-tls. Pre-configured TLS with webpki roots for smolmix TCP streams."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-28"
---
# smolmix-tls
import { Callout } from 'nextra/components'
import { VersionBanner } from '../../components/version-banner'
<VersionBanner />
The mixnet protects your identity but not the content of traffic between the exit gateway and remote host. `smolmix-tls` provides a pre-configured `TlsConnector` with webpki root certificates — wrapping a smolmix `TcpStream` in TLS with one function call.
<Callout type="warning">
**Always use TLS** (or another encryption layer) when connecting to external services through the mixnet. Without it, the exit gateway can read your traffic in plaintext.
</Callout>
## Installation
```toml
[dependencies]
smolmix = "0.0.1"
smolmix-tls = "0.0.1"
```
You also need a rustls crypto provider installed. Add this at the start of `main()`:
```rust
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
```
## Usage
```rust
use smolmix::Tunnel;
use smolmix_tls::{connect, connector, connect_with};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let tunnel = Tunnel::new().await?;
let tcp = tunnel.tcp_connect("93.184.216.34:443".parse()?).await?;
// One-shot: TLS handshake over an existing TCP stream
let mut tls = connect(tcp, "example.com").await?;
tls.write_all(b"GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n").await?;
let mut buf = Vec::new();
tls.read_to_end(&mut buf).await?;
println!("{}", String::from_utf8_lossy(&buf));
tunnel.shutdown().await;
Ok(())
}
```
## Reusing the connector
If you're making multiple TLS connections, build the connector once and reuse it — avoids rebuilding the root certificate store each time:
```rust
let tls = connector();
let stream1 = connect_with(&tls, tcp1, "a.example.com").await?;
let stream2 = connect_with(&tls, tcp2, "b.example.com").await?;
```
The `TlsConnector` clones cheaply via `Arc`.
## API reference
| Type/Function | Description |
|---|---|
| `connector()` | Create a reusable `TlsConnector` with webpki roots |
| `connect(tcp, hostname)` | One-shot TLS handshake (creates a fresh connector) |
| `connect_with(&connector, tcp, hostname)` | TLS handshake with pre-built connector |
| `TlsStream` | Re-exported `tokio_rustls::client::TlsStream` |
| `TlsConnector` | Re-exported `tokio_rustls::TlsConnector` |
## Example
```sh
cargo run -p smolmix-tls --example connect
cargo run -p smolmix-tls --example connect -- --ipr <IPR_ADDRESS>
```
Source: [`tls/examples/connect.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/tls/examples/connect.rs)
@@ -0,0 +1,145 @@
---
title: "smolmix: TCP/UDP Over the Nym Mixnet"
description: "A userspace IP tunnel that provides standard TcpStream and UdpSocket types over the Nym mixnet. Drop-in compatible with async tokio Rust ecosystem."
schemaType: "TechArticle"
section: "Developers"
lastUpdated: "2026-04-23"
---
# smolmix
import { Callout } from 'nextra/components'
import { RUST_MSRV } from '../../components/versions'
`smolmix` is a TCP/UDP tunnel over the Nym mixnet. It uses a userspace network stack [`smoltcp`](https://docs.rs/smoltcp/latest/smoltcp/) to provide `TcpStream` and `UdpSocket` types that work with the async [`tokio`](https://docs.rs/tokio) Rust ecosystem e.g. [`tokio-rustls`](https://docs.rs/tokio-rustls), [`hyper`](https://docs.rs/hyper), [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite), etc.
The `TcpStream` type implements tokio's `AsyncRead`/`AsyncWrite` traits and `UdpSocket` provides `send_to`/`recv_from` for datagrams.
```text
┌──────────────────────────────────────────────────────────────────┐
│ Your application (TLS, HTTP, WebSocket, DNS, etc.) │
│ └─ smolmix::TcpStream / UdpSocket │
│ └─ smoltcp (userspace TCP/IP) │
│ └─ Nym mixnet → IPR exit gateway → internet │
└──────────────────────────────────────────────────────────────────┘
```
Traffic exits the mixnet at an [IPR (Internet Packet Router)](/network/infrastructure/exit-services#ip-packet-router) exit gateway — the exit IP is the gateway's, not yours.
## Runtime and platform support
### Other runtimes
`smolmix` currently requires `tokio`. The internal pipeline is tokio-based: the bridge task that shuttles packets to the mixnet, the Nym SDK's `MixnetClient`, and the [`tokio-smoltcp`](https://docs.rs/tokio-smoltcp) reactor that drives the userspace TCP/IP stack all run on the tokio runtime.
This means `smolmix` is not compatible with alternative async runtimes like [`smol`](https://docs.rs/smol) or [`async-std`](https://docs.rs/async-std) out of the box. If you need to use `smolmix` from another runtime, the [`async-compat`](https://docs.rs/async-compat) crate can bridge the gap.
### Non-native `smolmix`
A WASM version of `smolmix` is planned for a future release.
## Installation
Add `smolmix` to your `Cargo.toml`:
```toml
[dependencies]
smolmix = "X.Y.Z"
nym-bin-common = { version = "1.20.4", features = ["basic_tracing"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
blake3 = "=1.7.0" # required pin — see note below
```
<Callout type="warning">
**Temporary pin required.** You must pin [`blake3`](https://docs.rs/blake3) `= "=1.7.0"` to avoid a build failure caused by a transitive [`digest`](https://docs.rs/digest) version conflict. This will be resolved in a future release.
</Callout>
`tokio` is a transitive dependency of smolmix, but you need to enable `rt-multi-thread` (smolmix spawns multiple tasks internally) and `macros` (for `#[tokio::main]`).
`nym-bin-common` is optional but recommended — it sets up [`tracing`](https://docs.rs/tracing) logging so you can see mixnet connection progress.
**Minimum Rust version:** {RUST_MSRV}+
### From Git
For unreleased changes, import directly from the repository:
```toml
smolmix = { git = "https://github.com/nymtech/nym", branch = "develop" }
```
## When to use smolmix
| | smolmix | Stream module | mixFetch | SOCKS client |
|---|---|---|---|---|
| **Layer** | Transport (TCP/UDP) | Message (multiplexed streams) | HTTP | TCP (SOCKS proxy) |
| **Controls both sides?** | No — proxy mode | Yes — E2E | No — proxy mode | No — proxy mode |
| **API** | `TcpStream`, `UdpSocket` | `AsyncRead + AsyncWrite` | `fetch()` drop-in | SOCKS4/5 protocol |
| **Composability** | Full — TLS, HTTP, WebSocket, DNS, etc. stack on top | Byte streams only | HTTP(S) only | Application-dependent |
| **Best for** | Reaching external services from Rust with standard networking | Peer-to-peer / E2E protocols between Nym clients | Browser HTTP requests | Legacy apps with SOCKS support |
## Security model
<Callout type="warning">
Traffic is Sphinx-encrypted inside the mixnet, but between the exit gateway and the remote host it travels as **normal internet traffic**. Always encrypt the final hop with TLS ([`rustls`](https://docs.rs/rustls)), Noise Protocol ([`snow`](https://docs.rs/snow)), etc.
</Callout>
### What's protected
| Segment | Mixnet encryption | What's visible |
|---|---|---|
| Your machine → mixnet entry | Sphinx (layered) | Entry gateway sees your IP but not the destination |
| Inside the mixnet (entry + 3 mix layers + exit) | Sphinx (layered) | Each node only knows prev/next hop |
| Exit gateway (IPR) | Sphinx removed — raw IP packet exposed | IPR sees destination IP + port. Payload depends on your application layer (see below). |
| IPR → remote host | None (Sphinx is mixnet-only) | Remote host sees IPR's IP, not yours |
The Sphinx encryption is the **mixnet transport layer** — it protects packets as they traverse the mix nodes. At the exit gateway, the Sphinx layers are stripped and the original IP packet is forwarded to the destination. This is analogous to how a Tor exit node or VPN endpoint unwraps its tunnel.
**What's inside that IP packet is entirely up to you.** If you connect with TLS (as in the [TCP example](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs)), the IPR sees encrypted TLS ciphertext going to a destination IP — it knows *where* but not *what*. If you send plaintext HTTP, the IPR can read the full request and response.
### Trust boundaries
- **You trust the mixnet** to provide unlinkability between sender and receiver. This is enforced cryptographically by the Sphinx packet format and mixing.
- **You trust the IPR exit gateway** in the same way you trust a VPN exit or Tor exit node — it can inspect your raw IP packets. The difference is that the IPR doesn't know *who* is sending the traffic (the mixnet hides your identity).
- **Application-layer encryption closes the gap.** TLS, Noise Protocol, or any authenticated encryption ensures the IPR only sees ciphertext. It can see destination IP and port, but not payload content.
### Comparison with other privacy tools
| | smolmix | Tor | VPN |
|---|---|---|---|
| **Exit node sees traffic?** | Yes (encrypt it) | Yes (encrypt it) | Yes (encrypt it) |
| **Exit node knows sender?** | No (mixnet hides identity) | No (onion routing) | Yes (VPN provider knows) |
| **Timing analysis resistance** | Strong (mixing, cover traffic) | Weak (low-latency) | None |
| **UDP support** | Yes | No (TCP only) | Yes |
## Examples
Runnable examples in [`smolmix/core/examples/`](https://github.com/nymtech/nym/tree/develop/smolmix/core/examples). Each is self-contained — read the `//!` doc comments at the top of each file for a walkthrough.
```sh
cargo run -p smolmix --example <name>
```
All examples accept `--ipr <ADDRESS>` to target a specific exit node (pass a `Recipient` address to `Tunnel::builder().ipr_address()`).
| Example | Source | What it demonstrates |
|---|---|---|
| TCP | [`tcp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp.rs) | HTTPS request via [`hyper`](https://docs.rs/hyper) + [`tokio-rustls`](https://docs.rs/tokio-rustls) — fetches Cloudflare's `/cdn-cgi/trace` to show that the exit IP differs from clearnet |
| TCP download | [`tcp_download.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/tcp_download.rs) | DNS-over-mixnet + multi-request HTTP/1.1 download over a single keep-alive connection — the full real-world pattern |
| UDP | [`udp.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/udp.rs) | DNS lookup via [`hickory-proto`](https://docs.rs/hickory-proto) — sends a raw UDP query to `1.1.1.1:53` through the mixnet |
| UDP multi | [`udp_multi.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/udp_multi.rs) | Multiple DNS lookups with timeout handling + NTP time sync — all over mixnet UDP |
| WebSocket | [`websocket.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/websocket.rs) | WebSocket echo via [`tokio-tungstenite`](https://docs.rs/tokio-tungstenite) + [`tokio-rustls`](https://docs.rs/tokio-rustls) — full TCP → TLS → WebSocket stack composing over smolmix |
| libp2p ping | [`libp2p_ping.rs`](https://github.com/nymtech/nym/blob/develop/smolmix/core/examples/libp2p_ping.rs) | Custom libp2p `Transport` over the mixnet — noise+yamux handshake, dial-only (no listening) |
## Companion crates
Thin adapter crates that plug smolmix into common libraries. Each is published separately so you only pull in the dependencies you need:
| Crate | What it provides | Docs |
|---|---|---|
| [`smolmix-dns`](https://docs.rs/smolmix-dns) | DNS resolution through the tunnel (prevents hostname leaks) | [guide](/developers/smolmix-dns) |
| [`smolmix-tls`](https://docs.rs/smolmix-tls) | TLS connector with webpki roots (encrypts the final hop) | [guide](/developers/smolmix-tls) |
| [`smolmix-hyper`](https://docs.rs/smolmix-hyper) | HTTP client routing DNS + TCP + TLS through the mixnet | [guide](/developers/smolmix-hyper) |
Want to integrate a different protocol? See [Building on smolmix](/developers/smolmix-extending).
## API reference
Full API documentation including internal architecture is available on [docs.rs/smolmix](https://docs.rs/smolmix).
@@ -0,0 +1,84 @@
---
title: "Exit Gateway Services: Network Requester & IP Packet Router"
description: "The two proxy services running on Nym Exit Gateways — the Network Requester (SOCKS proxy) and the IP Packet Router (raw IP tunneling) — how they work, what they see, and who uses them."
schemaType: "TechArticle"
section: "Network"
lastUpdated: "2026-04-15"
---
# Exit Gateway Services
import { Callout } from 'nextra/components'
Exit Gateways are where traffic leaves the Nym network and reaches the wider internet. Each Exit Gateway runs two distinct proxy services that handle different kinds of outbound traffic:
- **Network Requester (NR)** — an application-layer SOCKS proxy
- **IP Packet Router (IPR)** — a raw IP tunnel with address allocation
Both services run on every Exit Gateway. Which one handles your traffic depends on how you connect.
## Network Requester
The Network Requester is a SOCKS4/4a/5 proxy. Clients send SOCKS-formatted requests through the mixnet, and the NR makes the corresponding connection on their behalf — resolving hostnames, opening TCP connections, and relaying data.
```text
Client → mixnet → Exit Gateway (NR) → SOCKS connect → destination
← relay response ←
```
Because it operates at the application layer, the NR:
- Resolves DNS on behalf of the client (the client sends hostnames, not IPs)
- Opens individual TCP connections per SOCKS request
- Can enforce allow/deny lists on destination hosts and ports
- Sees the destination hostname and port, but not the contents if TLS is used
**Used by:** the [SDK's SOCKS client](/developers/rust/mixnet), [standalone SOCKS5 client](/developers/clients/socks5), and [mixFetch](/developers/typescript#mixfetch) (which wraps SOCKS requests in a browser-friendly `fetch` API).
## IP Packet Router
The IP Packet Router operates at the IP layer. Instead of proxying individual connections, it allocates a virtual IP address to the client and routes raw IP packets between the client and the internet — functioning as a tunnel endpoint.
```text
Client → mixnet → Exit Gateway (IPR) → raw IP packets → destination
← raw IP packets ←
```
On connection, the IPR:
1. Allocates an IPv4/IPv6 address pair to the client
2. Accepts raw IP packets (TCP, UDP, or any IP protocol) from the client via the mixnet
3. Sends them to the internet from the gateway's own IP address
4. Routes response packets back through the mixnet to the client
Because it operates at the IP layer, the IPR:
- Does not resolve DNS — the client handles its own DNS (either via clearnet or by sending DNS queries as UDP packets through the tunnel)
- Handles any IP protocol, not just TCP — UDP, ICMP, etc.
- Sees raw IP packets, including destination IPs and ports
- Does not see contents if the client uses TLS or another encryption layer
<Callout type="warning">
In both services, traffic between the Exit Gateway and the destination travels as **normal internet traffic**. The mixnet protects sender anonymity (the destination sees the gateway's IP, not yours), but does not encrypt the final hop. Use TLS or another end-to-end encryption layer to protect payload confidentiality.
</Callout>
**Used by:** [NymVPN anonymous mode](/network/dvpn-mode/protocol) (5-hop mixnet routing to the IPR), and [`smolmix`](/developers/smolmix) (programmatic `TcpStream`/`UdpSocket` access to the IPR via the Rust SDK).
## Comparison
| | Network Requester | IP Packet Router |
|---|---|---|
| **Layer** | Application (SOCKS) | IP (raw packets) |
| **Protocols** | TCP only | TCP, UDP, any IP protocol |
| **DNS** | Resolved by the NR | Client resolves its own |
| **Client gets** | Proxied connections | An allocated IP address |
| **Connection model** | Per-request | Persistent tunnel |
| **Used by** | SDK SOCKS client, mixFetch | NymVPN (anonymous mode), smolmix |
## Trust model
Both services share the same fundamental trust property: **the Exit Gateway can see destinations but not senders.** The mixnet's layered encryption ensures that the Exit Gateway cannot determine who sent a given packet — it only knows where it's going.
Specifically, the Exit Gateway:
- **Can see:** destination IP/hostname, destination port, unencrypted payload content, traffic volume and timing at the exit hop
- **Cannot see:** the sender's IP address, the sender's Nym address, which Entry Gateway the traffic entered through
- **Cannot determine:** the linkage between different requests from the same sender (unless the payload itself contains identifying information)
The sender's identity is protected by the mixnet's 5-hop routing, Sphinx encryption, cover traffic, and packet mixing. The Exit Gateway is the last hop — it decrypts the final Sphinx layer and sees the destination, but the chain of Mix Nodes between Entry and Exit has destroyed any timing or ordering correlation.
-2
View File
@@ -97,8 +97,6 @@ tokio = { workspace = true, features = ["full", "test-util"] }
time = { workspace = true }
nym-bin-common = {workspace = true, features = ["basic_tracing"] }
# extra dependencies for libp2p examples
#libp2p = { git = "https://github.com/ChainSafe/rust-libp2p.git", rev = "e3440d25681df380c9f0f8cfdcfd5ecc0a4f2fb6", features = [ "identify", "macros", "ping", "tokio", "tcp", "dns", "websocket", "noise", "mplex", "yamux", "gossipsub" ]}
tokio-stream = { workspace = true }
tokio-util = { workspace = true, features = ["codec"] }
parking_lot = { workspace = true }
+2 -1
View File
@@ -1,7 +1,8 @@
# Nym Rust SDK
This repo contains several components:
- `mixnet`: exposes Nym Client builders and methods. This is useful if you want to interact directly with the Client, or build transport abstractions.
- `mixnet`: exposes Nym Client builders and methods. This is useful if you want to interact directly with the Client, or build transport abstractions. The `mixnet::stream` submodule provides `MixnetStream` — a TCP-socket-like abstraction over the mixnet with LP Stream framing, sequence numbering, and `AsyncRead`/`AsyncWrite` support.
- `ipr_wrapper`: provides `IpMixStream`, a high-level interface for tunnelling IP packets through Exit Gateways' IpPacketRouter (IPR) over the mixnet. Built on `MixnetStream`, it handles gateway discovery, connect handshakes, and IP packet send/receive. See the [`smolmix`](../../smolmix) crate for a full tunnel implementation using this module.
- `tcp_proxy`: exposes functionality to set up client/server instances that expose a localhost TcpSocket to read/write to like a 'normal' socket connection. `tcp_proxy/bin/` contains standalone `nym-proxy-client` and `nym-proxy-server` binaries.
- `clientpool`: a configurable pool of ephemeral Nym Clients which can be created as a background process and quickly grabbed.
@@ -3,7 +3,18 @@
//! Keys are generated on the first run, then loaded from disk on
//! subsequent runs so the client keeps the same Nym address.
//!
//! Run with: cargo run --example builder_with_storage
//! ## What this demonstrates
//!
//! - `StoragePaths::new_from_dir()` points to a directory for key material
//! - `MixnetClientBuilder::new_with_default_storage()` builds a client that
//! persists its identity (ed25519 + x25519 keypairs) to disk
//! - Run this example twice — the Nym address stays the same
//! - Use this pattern for any real application; ephemeral clients
//! (`connect_new()`) are only for quick experiments
//!
//! ```sh
//! cargo run --example builder_with_storage
//! ```
use nym_sdk::mixnet;
use nym_sdk::mixnet::MixnetMessageSender;
+17 -2
View File
@@ -3,11 +3,26 @@
//! Pre-warmed pool of ephemeral Mixnet clients.
//!
//! The pool keeps a reserve of connected clients so that new connections
//! The pool keeps a reserve of connected clients so that new operations
//! can be served instantly. When the pool is empty, a fallback ephemeral
//! client is created on-demand (with higher latency).
//!
//! Run with: cargo run --example client_pool -- ../../../envs/<NETWORK>.env
//! ## What this demonstrates
//!
//! - `ClientPool::new(n)` creates a pool targeting `n` reserve clients
//! - `pool.start()` runs a background loop that creates clients whenever
//! the pool drops below the reserve
//! - `pool.get_mixnet_client()` pops a client; returns `None` if empty
//! - Clients are consumed, not returned — the pool automatically creates
//! replacements in the background
//! - `pool.disconnect_pool()` shuts down all remaining clients
//!
//! Use the pool when you have bursty traffic and can't afford the
//! several-second delay of creating a client per request.
//!
//! ```sh
//! cargo run --example client_pool -- ../../../envs/<NETWORK>.env
//! ```
use anyhow::Result;
use nym_network_defaults::setup_env;
@@ -1,64 +0,0 @@
# rust-libp2p-nym
This repo contains an example implementation of a libp2p transport using the Nym mixnet. It relies on the ChainSafe's fork of libp2p: https://github.com/ChainSafe/rust-libp2p
## Requirements
- Rust 1.68.2
- `Protoc` protobuf compiler. On Debian/Ubuntu distributed via `apt` as `protobuf-compiler` & on Arch/Manjaro via AUR as `[python-protobuf-compiler](https://aur.archlinux.org/packages/python-protobuf-compiler)`.
## Usage
To instantiate a libp2p swarm using the transport:
```rust
use libp2p::core::{muxing::StreamMuxerBox, transport::Transport};
use libp2p::swarm::{keep_alive::Behaviour, SwarmBuilder};
use libp2p::{identity, PeerId};
use nym_sdk::mixnet::MixnetClient;
use rust_libp2p_nym::transport::NymTransport;
use rust_libp2p_nym::test_utils::create_nym_client;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let local_key = identity::Keypair::generate_ed25519();
let local_peer_id = PeerId::from(local_key.public());
info!("Local peer id: {local_peer_id:?}");
let nym_id = rand::random::<u64>().to_string();
let nym_client = MixnetClient::connect_new().await.unwrap();
let transport = NymTransport::new(nym_client, local_key.clone()).await?;
let _swarm = SwarmBuilder::with_tokio_executor(
transport
.map(|a, _| (a.0, StreamMuxerBox::new(a.1)))
.boxed(),
Behaviour::default(),
local_peer_id,
)
.build();
Ok(())
}
```
## Chat example
To run the libp2p chat example, run the following in one terminal:
```bash
cargo run --example libp2p_chat
# Local peer id: PeerId("12D3KooWLukBu6q2FerWPFhFFhiYaJkhn2sBmceh9UCaXe6hJf5D")
# Listening on "/nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve"
```
In another terminal, run ping again, passing the Nym multiaddress printed previously:
```bash
cargo run --example libp2p_chat -- /nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve
# Local peer id: PeerId("12D3KooWNsuRwG6DHnFJCDR8B3zdvja6xLcfnbtKCsQWJ8eppyWC")
# Dialed /nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve
# Listening on "/nym/2oiRW5C9ivyF3Bo3Gpm4H9EqSKH7A6GpcrRRwVSDVUQ9.EajgCnhzimsP6KskUwKcEj8VFCmHR78s2J6FHWcZ4etR@Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf"
```
You should see that the nodes connected and sent messages to each other:
```bash
# 2023-08-10T14:06:28.116Z INFO libp2p_chat > Got message: 'hello world' with id: 37393732353836333838333537303637303237 from peer: 12D3KooWB6k8ZGDF44N4FMRhgVBNihwk1wMYSumosxiZq9pUTbAz
```
@@ -1,173 +0,0 @@
// Copyright 2018 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! A basic chat application with logs demonstrating libp2p and the gossipsub protocol
//! combined with mDNS for the discovery of peers to gossip with.
//!
//! Using two terminal windows, start two instances, typing the following in each:
//!
//! ```sh
//! cargo run
//! ```
//!
//! Mutual mDNS discovery may take a few seconds. When each peer does discover the other
//! it will print a message like:
//!
//! ```sh
//! mDNS discovered a new peer: {peerId}
//! ```
//!
//! Type a message and hit return: the message is sent and printed in the other terminal.
//! Close with Ctrl-c.
//!
//! You can open more terminal windows and add more peers using the same line above.
//!
//! Once an additional peer is mDNS discovered it can participate in the conversation
//! and all peers will receive messages sent from it.
//!
//! If a participant exits (Control-C or otherwise) the other peers will receive an mDNS expired
//! event and remove the expired peer from the list of known peers.
// use crate::rust_libp2p_nym::transport::NymTransport;
// use futures::{prelude::*, select};
// use libp2p::Multiaddr;
// use libp2p::{
// core::muxing::StreamMuxerBox,
// gossipsub, identity,
// swarm::NetworkBehaviour,
// swarm::{SwarmBuilder, SwarmEvent},
// PeerId, Transport,
// };
// use log::{error, info, LevelFilter};
// use nym_sdk::mixnet::MixnetClient;
// use std::collections::hash_map::DefaultHasher;
use std::error::Error;
// use std::hash::{Hash, Hasher};
// use std::time::Duration;
// use tokio::io;
// use tokio_util::codec;
// #[path = "../libp2p_shared/lib.rs"]
// mod rust_libp2p_nym;
//
// // We create a custom network behaviour that uses Gossipsub
// #[derive(NetworkBehaviour)]
// struct Behaviour {
// gossipsub: gossipsub::Behaviour,
// }
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
unimplemented!("temporarily disabled")
// pretty_env_logger::formatted_timed_builder()
// .filter_level(LevelFilter::Warn)
// .filter(Some("libp2p_chat"), LevelFilter::Info)
// .init();
//
// // Create a random PeerId
// let id_keys = identity::Keypair::generate_ed25519();
// let local_peer_id = PeerId::from(id_keys.public());
// info!("Local peer id: {local_peer_id}");
//
// // To content-address message, we can take the hash of message and use it as an ID.
// let message_id_fn = |message: &gossipsub::Message| {
// let mut s = DefaultHasher::new();
// message.data.hash(&mut s);
// gossipsub::MessageId::from(s.finish().to_string())
// };
//
// // Set a custom gossipsub configuration
// let gossipsub_config = gossipsub::ConfigBuilder::default()
// .heartbeat_interval(Duration::from_secs(10)) // This is set to aid debugging by not cluttering the log space
// .validation_mode(gossipsub::ValidationMode::Strict) // This sets the kind of message validation. The default is Strict (enforce message signing)
// .message_id_fn(message_id_fn) // content-address messages. No two messages of the same content will be propagated.
// .build()
// .expect("Valid config");
//
// // build a gossipsub network behaviour
// let mut gossipsub = gossipsub::Behaviour::new(
// gossipsub::MessageAuthenticity::Signed(id_keys),
// gossipsub_config,
// )
// .expect("Correct configuration");
// // Create a Gossipsub topic
// let topic = gossipsub::IdentTopic::new("test-net");
// // subscribes to our topic
// gossipsub.subscribe(&topic)?;
//
// let client = MixnetClient::connect_new().await.unwrap();
// info!("client address: {}", client.nym_address());
//
// let local_key = identity::Keypair::generate_ed25519();
// let local_peer_id = PeerId::from(local_key.public());
// info!("Local peer id: {local_peer_id:?}");
//
// let transport = NymTransport::new(client, local_key).await?;
//
// let mut swarm = SwarmBuilder::with_tokio_executor(
// transport
// .map(|a, _| (a.0, StreamMuxerBox::new(a.1)))
// .boxed(),
// Behaviour { gossipsub },
// local_peer_id,
// )
// .build();
//
// if let Some(addr) = std::env::args().nth(1) {
// let remote: Multiaddr = addr.parse()?;
// swarm.dial(remote)?;
// info!("Dialed {addr}")
// }
//
// // Read full lines from stdin
// let mut stdin = codec::FramedRead::new(io::stdin(), codec::LinesCodec::new()).fuse();
//
// info!("Enter messages via STDIN and they will be sent to connected peers using Gossipsub");
//
// // Kick it off
// loop {
// select! {
// line = stdin.select_next_some() => {
// if let Err(e) = swarm
// .behaviour_mut().gossipsub
// .publish(topic.clone(), line.expect("Stdin not to close").as_bytes()) {
// error!("Publish error: {e:?}");
// }
// },
// event = swarm.select_next_some() => {
// match event {
// SwarmEvent::Behaviour(BehaviourEvent::Gossipsub(gossipsub::Event::Message {
// propagation_source: peer_id,
// message_id: id,
// message,
// })) => info!(
// "Got message: '{}' with id: {id} from peer: {peer_id}",
// String::from_utf8_lossy(&message.data),
// ),
// SwarmEvent::NewListenAddr { address, .. } => {
// info!("Local node is listening on {address}");
// }
// other => {info!("other event: {:?}", other)}
// }
// }
// }
// }
}
@@ -1,79 +0,0 @@
# rust-libp2p-nym
This repo contains an example implementation of a libp2p transport using the Nym mixnet. It relies on the ChainSafe's fork of libp2p: https://github.com/ChainSafe/rust-libp2p
## Requirements
- Rust 1.68.2
- `Protoc` protobuf compiler. On Debian/Ubuntu distributed via `apt` as `protobuf-compiler` & on Arch/Manjaro via AUR as `[python-protobuf-compiler](https://aur.archlinux.org/packages/python-protobuf-compiler)`.
## Usage
To instantiate a libp2p swarm using the transport:
```rust
use libp2p::core::{muxing::StreamMuxerBox, transport::Transport};
use libp2p::swarm::{keep_alive::Behaviour, SwarmBuilder};
use libp2p::{identity, PeerId};
use nym_sdk::mixnet::MixnetClient;
use rust_libp2p_nym::transport::NymTransport;
use rust_libp2p_nym::test_utils::create_nym_client;
use std::error::Error;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let local_key = identity::Keypair::generate_ed25519();
let local_peer_id = PeerId::from(local_key.public());
info!("Local peer id: {local_peer_id:?}");
let nym_id = rand::random::<u64>().to_string();
let nym_client = MixnetClient::connect_new().await.unwrap();
let transport = NymTransport::new(nym_client, local_key.clone()).await?;
let _swarm = SwarmBuilder::with_tokio_executor(
transport
.map(|a, _| (a.0, StreamMuxerBox::new(a.1)))
.boxed(),
Behaviour::default(),
local_peer_id,
)
.build();
Ok(())
}
```
## Ping example
To run the libp2p ping example, run the following in one terminal:
```bash
cargo run --example libp2p_ping
# Local peer id: PeerId("12D3KooWLukBu6q2FerWPFhFFhiYaJkhn2sBmceh9UCaXe6hJf5D")
# Listening on "/nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve"
```
In another terminal, run ping again, passing the Nym multiaddress printed previously:
```bash
cargo run --example libp2p_ping -- /nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve
# Local peer id: PeerId("12D3KooWNsuRwG6DHnFJCDR8B3zdvja6xLcfnbtKCsQWJ8eppyWC")
# Dialed /nym/FhtkzizQg2JbZ19kGkRKXdjV2QnFbT5ww88ZAKaD4nkF.7Remi4UVYzn1yL3qYtEcQBGh6tzTYxMdYB4uqyHVc5Z4@62F81C9GrHDRja9WCqozemRFSzFPMecY85MbGwn6efve
# Listening on "/nym/2oiRW5C9ivyF3Bo3Gpm4H9EqSKH7A6GpcrRRwVSDVUQ9.EajgCnhzimsP6KskUwKcEj8VFCmHR78s2J6FHWcZ4etR@Fo4f4SQLdoyoGkFae5TpVhRVoXCF8UiypLVGtGjujVPf"
```
You should see that the nodes connected and pinged each other:
```bash
# Mar 30 22:56:36.400 INFO ping: BehaviourEvent: Event { peer: PeerId("12D3KooWGf2oYd6U2nrLzfDrN9zxsjSQjPsMh2oDJPUQ9hiHMNtf"), result: Ok(Ping { rtt: 1.06836675s }) }
```
```bash
# Mar 30 22:56:35.595 INFO ping: BehaviourEvent: Event { peer: PeerId("12D3KooWMd5ak31DXuZq7x1JuFSR6toA5RDQrPaHrfXEhy7vqqpC"), result: Ok(Pong) }
```
In order to run the ping example with vanilla libp2p, which uses tcp, pass the
`--features libp2p-vanilla` flag to the example and follow the instructions on the
rust-libp2p project as usual.
```bash
RUST_LOG=ping=debug cargo run --example ping --features libp2p-vanilla
```
```bash
RUST_LOG=ping=debug cargo run --example ping --features libp2p-vanilla -- "/ip4/127.0.0.1/tcp/$PORT"
```
@@ -1,143 +0,0 @@
// Copyright 2018 Parity Technologies (UK) Ltd.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//! Ping example
//!
//! See ../src/tutorial.rs for a step-by-step guide building the example below.
//!
//! In the first terminal window, run:
//!
//! ```sh
//! cargo run --example ping --features=full
//! ```
//!
//! It will print the PeerId and the listening addresses, e.g. `Listening on
//! "/ip4/0.0.0.0/tcp/24915"`
//!
//! In the second terminal window, start a new instance of the example with:
//!
//! ```sh
//! cargo run --example ping --features=full -- /ip4/127.0.0.1/tcp/24915
//! ```
//!
//! The two nodes establish a connection, negotiate the ping protocol
//! and begin pinging each other.
// use libp2p::futures::StreamExt;
// use libp2p::ping::Success;
// use libp2p::swarm::{keep_alive, NetworkBehaviour, SwarmEvent};
// use libp2p::{identity, ping, Multiaddr, PeerId};
// use log::{debug, info, LevelFilter};
use std::error::Error;
// use std::time::Duration;
//
// #[path = "../libp2p_shared/lib.rs"]
// mod rust_libp2p_nym;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
unimplemented!("temporarily disabled")
//
// pretty_env_logger::formatted_timed_builder()
// .filter_level(LevelFilter::Warn)
// .filter(Some("libp2p_ping"), LevelFilter::Debug)
// .init();
//
// let local_key = identity::Keypair::generate_ed25519();
// let local_peer_id = PeerId::from(local_key.public());
// info!("Local peer id: {local_peer_id:?}");
//
// #[cfg(not(feature = "libp2p-vanilla"))]
// let mut swarm = {
// debug!("Running `ping` example using NymTransport");
// use libp2p::core::{muxing::StreamMuxerBox, transport::Transport};
// use libp2p::swarm::SwarmBuilder;
// use rust_libp2p_nym::transport::NymTransport;
//
// let client = nym_sdk::mixnet::MixnetClient::connect_new().await.unwrap();
//
// let transport = NymTransport::new(client, local_key.clone()).await?;
// SwarmBuilder::with_tokio_executor(
// transport
// .map(|a, _| (a.0, StreamMuxerBox::new(a.1)))
// .boxed(),
// Behaviour::default(),
// local_peer_id,
// )
// .build()
// };
//
// #[cfg(feature = "libp2p-vanilla")]
// let mut swarm = {
// debug!("Running `ping` example using the vanilla libp2p tokio_development_transport");
// let transport = libp2p::tokio_development_transport(local_key)?;
// let mut swarm =
// libp2p::Swarm::with_tokio_executor(transport, Behaviour::default(), local_peer_id);
// swarm.listen_on("/ip4/0.0.0.0/tcp/0".parse()?)?;
// swarm
// };
//
// // Dial the peer identified by the multi-address given as the second
// // command-line argument, if any.
// if let Some(addr) = std::env::args().nth(1) {
// let remote: Multiaddr = addr.parse()?;
// swarm.dial(remote)?;
// info!("Dialed {addr}")
// }
//
// let mut total_ping_rtt: Duration = Duration::from_micros(0);
// let mut counter: u128 = 0;
// loop {
// match swarm.select_next_some().await {
// SwarmEvent::NewListenAddr { address, .. } => info!("Listening on {address:?}"),
// SwarmEvent::Behaviour(event) => {
// // Get the round-trip duration for the pings.
// // This value is already captured in the BehaviourEvent::Ping's `Success::Ping`
// // field.
// debug!("{event:?}");
// if let BehaviourEvent::Ping(ping_event) = event {
// let result: Success = ping_event.result?;
// match result {
// Success::Ping { rtt } => {
// counter += 1;
// total_ping_rtt += rtt;
// let average_ping_rtt = Duration::from_micros(
// (total_ping_rtt.as_micros() / counter).try_into().unwrap(),
// );
// info!("Ping RTT: {rtt:?} AVERAGE RTT: ({counter} pings): {average_ping_rtt:?}");
// }
// Success::Pong => info!("Pong Event"),
// }
// }
// }
// _ => {}
// }
// }
}
//
// /// Our network behaviour.
// ///
// /// For illustrative purposes, this includes the [`KeepAlive`](behaviour::KeepAlive) behaviour so a continuous sequence of
// /// pings can be observed.
// #[derive(NetworkBehaviour, Default)]
// struct Behaviour {
// keep_alive: keep_alive::Behaviour,
// ping: ping::Behaviour,
// }
@@ -1,418 +0,0 @@
use libp2p::core::{muxing::StreamMuxerEvent, PeerId, StreamMuxer};
use log::debug;
use nym_sphinx::addressing::clients::Recipient;
use std::{
collections::{HashMap, HashSet},
pin::Pin,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
task::{Context, Poll, Waker},
};
use tokio::sync::{
mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
oneshot,
};
use super::error::Error;
use super::message::{
ConnectionId, Message, OutboundMessage, SubstreamId, SubstreamMessage, SubstreamMessageType,
TransportMessage,
};
use super::substream::Substream;
/// Connection represents the result of a connection setup process.
/// It implements `StreamMuxer` and thus has stream multiplexing built in.
#[derive(Debug)]
pub struct Connection {
pub(crate) peer_id: PeerId,
pub(crate) remote_recipient: Recipient,
pub(crate) id: ConnectionId,
/// receive inbound messages from the `InnerConnection`
pub(crate) inbound_rx: UnboundedReceiver<SubstreamMessage>,
/// substream ID -> outbound pending substream exists
/// the key is deleted when the response is received, or the request times out
pending_substreams: HashSet<SubstreamId>,
/// substream ID -> substream's inbound_tx channel
substream_inbound_txs: HashMap<SubstreamId, UnboundedSender<Vec<u8>>>,
/// substream ID -> substream's close_tx channel
substream_close_txs: HashMap<SubstreamId, oneshot::Sender<()>>,
/// send messages to the mixnet
/// used for sending `SubstreamMessageType::OpenRequest` messages
/// also passed to each substream so they can write to the mixnet
pub(crate) mixnet_outbound_tx: UnboundedSender<OutboundMessage>,
/// inbound substream open requests; used in poll_inbound
inbound_open_tx: UnboundedSender<Substream>,
inbound_open_rx: UnboundedReceiver<Substream>,
/// closed substream IDs; used in poll_close
close_tx: UnboundedSender<SubstreamId>,
close_rx: UnboundedReceiver<SubstreamId>,
/// message nonce contains the next nonce that should be used when
/// sending a message over the connection
pub(crate) message_nonce: Arc<AtomicU64>,
waker: Option<Waker>,
}
impl Connection {
pub(crate) fn new(
peer_id: PeerId,
remote_recipient: Recipient,
id: ConnectionId,
inbound_rx: UnboundedReceiver<SubstreamMessage>,
mixnet_outbound_tx: UnboundedSender<OutboundMessage>,
) -> Self {
let (inbound_open_tx, inbound_open_rx) = unbounded_channel();
let (close_tx, close_rx) = unbounded_channel();
Connection {
peer_id,
remote_recipient,
id,
inbound_rx,
pending_substreams: HashSet::new(),
substream_inbound_txs: HashMap::new(),
substream_close_txs: HashMap::new(),
mixnet_outbound_tx,
inbound_open_tx,
inbound_open_rx,
close_tx,
close_rx,
message_nonce: Arc::new(AtomicU64::new(1)),
waker: None,
}
}
fn new_outbound_substream(&mut self) -> Result<Substream, Error> {
let substream_id = SubstreamId::generate();
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
// send the substream open request that requests to open a substream with the given ID
self.mixnet_outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.id.clone(),
message: SubstreamMessage {
substream_id: substream_id.clone(),
message_type: SubstreamMessageType::OpenRequest,
},
}),
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
// track pending outbound substreams
// TODO we should probably lock this? storing map values should be atomic
let res = self.new_substream(substream_id.clone());
if res.is_ok() {
self.pending_substreams.insert(substream_id);
}
res
}
// creates a new substream instance with the given ID.
fn new_substream(&mut self, id: SubstreamId) -> Result<Substream, Error> {
// check we don't already have a substream with this ID
if self.substream_inbound_txs.contains_key(&id) {
return Err(Error::SubstreamIdExists(id));
}
let (inbound_tx, inbound_rx) = unbounded_channel::<Vec<u8>>();
let (close_tx, close_rx) = oneshot::channel::<()>();
self.substream_inbound_txs.insert(id.clone(), inbound_tx);
self.substream_close_txs.insert(id.clone(), close_tx);
if let Some(waker) = self.waker.take() {
waker.wake();
}
Ok(Substream::new(
self.remote_recipient,
self.id.clone(),
id,
inbound_rx,
self.mixnet_outbound_tx.clone(),
close_rx,
self.message_nonce.clone(),
))
}
fn handle_close(&mut self, substream_id: SubstreamId) -> Result<(), Error> {
if self.substream_inbound_txs.remove(&substream_id).is_none() {
return Err(Error::SubstreamIdDoesNotExist(substream_id));
}
// notify substream that it's closed
let close_tx = self.substream_close_txs.remove(&substream_id);
close_tx.unwrap().send(()).unwrap();
// notify poll_close that the substream is closed
self.close_tx
.send(substream_id)
.map_err(|e| Error::InboundSendFailure(e.to_string()))
}
}
impl StreamMuxer for Connection {
type Substream = Substream;
type Error = Error;
fn poll_inbound(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<Self::Substream, Self::Error>> {
if let Poll::Ready(Some(substream)) = self.inbound_open_rx.poll_recv(cx) {
return Poll::Ready(Ok(substream));
}
Poll::Pending
}
fn poll_outbound(
mut self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Result<Self::Substream, Self::Error>> {
Poll::Ready(self.new_outbound_substream())
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
if let Poll::Ready(Some(_)) = self.close_rx.poll_recv(cx) {
return Poll::Ready(Ok(()));
}
Poll::Pending
}
fn poll(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<StreamMuxerEvent, Self::Error>> {
while let Poll::Ready(Some(msg)) = self.inbound_rx.poll_recv(cx) {
match msg.message_type {
SubstreamMessageType::OpenRequest => {
// create a new substream with the given ID
let substream = self.new_substream(msg.substream_id.clone())?;
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
// send the response to the remote peer
self.mixnet_outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.id.clone(),
message: SubstreamMessage {
substream_id: msg.substream_id.clone(),
message_type: SubstreamMessageType::OpenResponse,
},
}),
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
debug!("wrote OpenResponse for substream: {:?}", &msg.substream_id);
// send the substream to our own channel to be returned in poll_inbound
self.inbound_open_tx
.send(substream)
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
debug!("new inbound substream: {:?}", &msg.substream_id);
}
SubstreamMessageType::OpenResponse => {
if !self.pending_substreams.remove(&msg.substream_id) {
debug!(
"SubstreamMessageType::OpenResponse no substream pending for ID: {:?}",
&msg.substream_id
);
}
}
SubstreamMessageType::Close => {
self.handle_close(msg.substream_id)?;
}
SubstreamMessageType::Data(data) => {
debug!("SubstreamMessageType::Data: {:?}", &data);
let inbound_tx = self
.substream_inbound_txs
.get_mut(&msg.substream_id)
.expect("must have a substream channel for substream");
// NOTE: this ignores channel closed errors, which is fine because the substream
// might have been closed/dropped
inbound_tx.send(data).ok();
}
}
}
self.waker = Some(cx.waker().clone());
Poll::Pending
}
}
/// PendingConnection represents a connection that's been initiated, but not completed.
pub(crate) struct PendingConnection {
pub(crate) remote_recipient: Recipient,
pub(crate) connection_tx: oneshot::Sender<Connection>,
}
impl PendingConnection {
pub(crate) fn new(
remote_recipient: Recipient,
connection_tx: oneshot::Sender<Connection>,
) -> Self {
PendingConnection {
remote_recipient,
connection_tx,
}
}
}
#[cfg(test)]
mod test {
use super::super::message::InboundMessage;
use super::super::mixnet::initialize_mixnet;
use super::*;
use futures::future::poll_fn;
use futures::{AsyncReadExt, AsyncWriteExt, FutureExt};
use nym_sdk::mixnet::MixnetClient;
async fn inbound_receive_and_send(
connection_id: ConnectionId,
mixnet_inbound_rx: &mut UnboundedReceiver<InboundMessage>,
inbound_tx: &UnboundedSender<SubstreamMessage>,
expected_nonce: u64,
) {
let recv_msg = mixnet_inbound_rx.recv().await.unwrap();
match recv_msg.0 {
Message::TransportMessage(TransportMessage {
nonce,
id,
message: msg,
}) => {
assert_eq!(nonce, expected_nonce);
assert_eq!(id, connection_id);
inbound_tx.send(msg).unwrap();
}
_ => panic!("unexpected message"),
}
}
#[tokio::test]
async fn test_connection_stream_muxer() {
let client = MixnetClient::connect_new().await.unwrap();
let (sender_address, mut sender_mixnet_inbound_rx, sender_outbound_tx) =
initialize_mixnet(client, None).await.unwrap();
let client2 = MixnetClient::connect_new().await.unwrap();
let (recipient_address, mut recipient_mixnet_inbound_rx, recipient_outbound_tx) =
initialize_mixnet(client2, None).await.unwrap();
let connection_id = ConnectionId::generate();
let recipient_peer_id = PeerId::random();
let sender_peer_id = PeerId::random();
// create the connections
let (sender_inbound_tx, sender_inbound_rx) = unbounded_channel::<SubstreamMessage>();
let mut sender_connection = Connection::new(
recipient_peer_id,
recipient_address,
connection_id.clone(),
sender_inbound_rx,
sender_outbound_tx,
);
let (recipient_inbound_tx, recipient_inbound_rx) = unbounded_channel::<SubstreamMessage>();
let mut recipient_connection = Connection::new(
sender_peer_id,
sender_address,
connection_id.clone(),
recipient_inbound_rx,
recipient_outbound_tx,
);
// send the substream OpenRequest to the mixnet
let mut sender_substream = sender_connection.new_outbound_substream().unwrap();
assert!(sender_connection
.pending_substreams
.contains(&sender_substream.substream_id));
assert_eq!(sender_connection.message_nonce.load(Ordering::SeqCst), 2);
// poll the recipient inbound stream; should receive the OpenRequest and create the substream
inbound_receive_and_send(
connection_id.clone(),
&mut recipient_mixnet_inbound_rx,
&recipient_inbound_tx,
1,
)
.await;
poll_fn(|cx| Pin::new(&mut recipient_connection).as_mut().poll(cx)).now_or_never();
assert_eq!(recipient_connection.message_nonce.load(Ordering::SeqCst), 2);
// poll recipient's poll_inbound to receive the substream
let maybe_recipient_substream = poll_fn(|cx| {
Pin::new(&mut recipient_connection)
.as_mut()
.poll_inbound(cx)
})
.now_or_never();
let mut recipient_substream = maybe_recipient_substream.unwrap().unwrap();
// poll sender's connection to receive the OpenResponse and send it to the Connection inbound channel
inbound_receive_and_send(
connection_id.clone(),
&mut sender_mixnet_inbound_rx,
&sender_inbound_tx,
1,
)
.await;
// poll sender's poll_outbound to get the substream
poll_fn(|cx| Pin::new(&mut sender_connection).as_mut().poll(cx)).now_or_never();
assert!(sender_connection.pending_substreams.is_empty());
// finally, write message to the substream
let data = b"hello world";
sender_substream.write_all(data).await.unwrap();
assert_eq!(sender_connection.message_nonce.load(Ordering::SeqCst), 3);
// receive message from the mixnet, push to the recipient Connection inbound channel
inbound_receive_and_send(
connection_id.clone(),
&mut recipient_mixnet_inbound_rx,
&recipient_inbound_tx,
2,
)
.await;
// poll the sender's connection to send the msg from the connection inbound channel to the substream's
poll_fn(|cx| Pin::new(&mut sender_connection).as_mut().poll(cx)).now_or_never();
// poll the recipient's connection to read the msg from the mixnet and mux it into the substream
poll_fn(|cx| Pin::new(&mut recipient_connection).as_mut().poll(cx)).now_or_never();
let mut buf = [0u8; 11];
let n = recipient_substream.read(&mut buf).await.unwrap();
assert_eq!(n, 11);
assert_eq!(buf, data[..]);
// test closing the stream; assert the stream is closed on both sides
sender_substream.close().await.unwrap();
assert_eq!(sender_connection.message_nonce.load(Ordering::SeqCst), 4);
inbound_receive_and_send(
connection_id.clone(),
&mut recipient_mixnet_inbound_rx,
&recipient_inbound_tx,
3,
)
.await;
}
}
@@ -1,64 +0,0 @@
use libp2p::core::multiaddr;
use nym_sphinx::addressing::clients::RecipientFormattingError;
use super::message::SubstreamId;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unimplemented")]
Unimplemented,
#[error("failed to format multiaddress from nym address")]
FailedToFormatMultiaddr(#[from] multiaddr::Error),
#[error("unexpected protocol in multiaddress")]
InvalidProtocolForMultiaddr,
#[error("failed to decode message")]
InvalidMessageBytes,
#[error("no connection found for ConnectionResponse")]
NoConnectionForResponse,
#[error("received ConnectionResponse but connection was already established")]
ConnectionAlreadyEstablished,
#[error("received None recipient in ConnectionRequest")]
NoneRecipientInConnectionRequest,
#[error("cannot handle connection request; already have connection with given ID")]
ConnectionIDExists,
#[error("no connection found for TransportMessage")]
NoConnectionForTransportMessage,
#[error("failed to decode ConnectionMessage; too short")]
ConnectionMessageBytesTooShort,
#[error("failed to decode ConnectionMessage; no recipient")]
ConnectionMessageBytesNoRecipient,
#[error("failed to decode ConnectionMessage; no peer ID")]
ConnectionMessageBytesNoPeerId,
#[error("invalid peer ID bytes")]
InvalidPeerIdBytes,
#[error("invalid recipient bytes")]
InvalidRecipientBytes(#[from] RecipientFormattingError),
#[error("invalid recipient prefix byte")]
InvalidRecipientPrefixByte,
#[error("failed to decode TransportMessage; too short")]
TransportMessageBytesTooShort,
#[error("failed to decode TransportMessage; invalid nonce")]
InvalidNonce,
#[error("invalid substream ID")]
InvalidSubstreamMessageBytes,
#[error("invalid substream message type byte")]
InvalidSubstreamMessageType,
#[error("substrean with given ID already exists")]
SubstreamIdExists(SubstreamId),
#[error("no substream found for given ID")]
SubstreamIdDoesNotExist(SubstreamId),
#[error("recv error: channel closed")]
OneshotRecvFailure(#[from] tokio::sync::oneshot::error::RecvError),
#[error("recv error: channel closed")]
RecvFailure,
#[error("outbound send error")]
OutboundSendFailure(String),
#[error("inbound send error")]
InboundSendFailure(String),
#[error("failed to send new connection; receiver dropped")]
ConnectionSendFailure,
#[error("failed to send initial TransportEvent::NewAddress")]
SendErrorTransportEvent,
#[error("dial timed out")]
DialTimeout(#[from] tokio::time::error::Elapsed),
}
@@ -1,10 +0,0 @@
pub(crate) mod connection;
pub mod error;
pub(crate) mod message;
pub(crate) mod mixnet;
pub(crate) mod queue;
pub mod substream;
pub mod transport;
/// The deafult timeout secs for [`transport::Upgrade`] future.
const DEFAULT_HANDSHAKE_TIMEOUT_SECS: u64 = 5;
@@ -1,335 +0,0 @@
use libp2p::core::PeerId;
use nym_sphinx::addressing::clients::Recipient;
use rand::rngs::OsRng;
use rand::RngCore;
use std::fmt::{Debug, Formatter};
use super::error::Error;
const RECIPIENT_LENGTH: usize = Recipient::LEN;
const CONNECTION_ID_LENGTH: usize = 32;
const SUBSTREAM_ID_LENGTH: usize = 32;
const NONCE_BYTES_LEN: usize = 8; // length of u64
const MIN_CONNECTION_MESSAGE_LEN: usize = CONNECTION_ID_LENGTH + NONCE_BYTES_LEN;
/// ConnectionId is a unique, randomly-generated per-connection ID that's used to
/// identify which connection a message belongs to.
#[derive(Clone, Default, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionId([u8; 32]);
impl ConnectionId {
pub(crate) fn generate() -> Self {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
ConnectionId(bytes)
}
fn from_bytes(bytes: &[u8]) -> Self {
let mut id = [0u8; 32];
id[..].copy_from_slice(&bytes[0..CONNECTION_ID_LENGTH]);
ConnectionId(id)
}
}
impl Debug for ConnectionId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}
/// SubstreamId is a unique, randomly-generated per-substream ID that's used to
/// identify which substream a message belongs to.
#[derive(Clone, Default, Eq, Hash, PartialEq)]
pub struct SubstreamId(pub(crate) [u8; 32]);
impl SubstreamId {
pub(crate) fn generate() -> Self {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
SubstreamId(bytes)
}
fn from_bytes(bytes: &[u8]) -> Self {
let mut id = [0u8; 32];
id[..].copy_from_slice(&bytes[0..SUBSTREAM_ID_LENGTH]);
SubstreamId(id)
}
}
impl Debug for SubstreamId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", hex::encode(self.0))
}
}
#[derive(Debug)]
#[allow(clippy::enum_variant_names)]
pub(crate) enum Message {
ConnectionRequest(ConnectionMessage),
ConnectionResponse(ConnectionMessage),
TransportMessage(TransportMessage),
}
/// ConnectionMessage is exchanged to open a new connection.
#[derive(Debug)]
pub(crate) struct ConnectionMessage {
pub(crate) peer_id: PeerId,
pub(crate) id: ConnectionId,
/// recipient is the sender's Nym address.
/// only required if this is a ConnectionRequest.
pub(crate) recipient: Option<Recipient>,
}
/// TransportMessage is sent over a connection after establishment.
#[derive(Debug, Clone)]
pub(crate) struct TransportMessage {
/// increments by 1 for every TransportMessage sent over a connection.
/// required for ordering, since Nym does not guarantee ordering.
/// ConnectionMessages do not need nonces, as we know that they will
/// be the first messages sent over a connection.
/// the first TransportMessage sent over a connection will have nonce 1.
pub(crate) nonce: u64,
pub(crate) message: SubstreamMessage,
pub(crate) id: ConnectionId,
}
impl Message {
fn try_from_bytes(bytes: Vec<u8>) -> Result<Self, Error> {
if bytes.len() < 2 {
return Err(Error::InvalidMessageBytes);
}
Ok(match bytes[0] {
0 => Message::ConnectionRequest(ConnectionMessage::try_from_bytes(&bytes[1..])?),
1 => Message::ConnectionResponse(ConnectionMessage::try_from_bytes(&bytes[1..])?),
2 => Message::TransportMessage(TransportMessage::try_from_bytes(&bytes[1..])?),
_ => return Err(Error::InvalidMessageBytes),
})
}
}
impl ConnectionMessage {
fn to_bytes(&self) -> Vec<u8> {
let mut bytes = self.id.0.to_vec();
match self.recipient {
Some(recipient) => {
bytes.push(1u8);
bytes.append(&mut recipient.to_bytes().to_vec());
}
None => bytes.push(0u8),
}
bytes.append(&mut self.peer_id.to_bytes());
bytes
}
fn try_from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < CONNECTION_ID_LENGTH + 1 {
return Err(Error::ConnectionMessageBytesTooShort);
}
let id = ConnectionId::from_bytes(&bytes[0..CONNECTION_ID_LENGTH]);
let recipient = match bytes[CONNECTION_ID_LENGTH] {
0u8 => None,
1u8 => {
if bytes.len() < CONNECTION_ID_LENGTH + 1 + RECIPIENT_LENGTH {
return Err(Error::ConnectionMessageBytesNoRecipient);
}
let mut recipient_bytes = [0u8; RECIPIENT_LENGTH];
recipient_bytes[..].copy_from_slice(
&bytes[CONNECTION_ID_LENGTH + 1..CONNECTION_ID_LENGTH + 1 + RECIPIENT_LENGTH],
);
Some(
Recipient::try_from_bytes(recipient_bytes)
.map_err(Error::InvalidRecipientBytes)?,
)
}
_ => {
return Err(Error::InvalidRecipientPrefixByte);
}
};
let peer_id = match recipient {
Some(_) => {
if bytes.len() < CONNECTION_ID_LENGTH + RECIPIENT_LENGTH + 2 {
return Err(Error::ConnectionMessageBytesNoPeerId);
}
PeerId::from_bytes(&bytes[CONNECTION_ID_LENGTH + 1 + RECIPIENT_LENGTH..])
.map_err(|_| Error::InvalidPeerIdBytes)?
}
None => {
if bytes.len() < CONNECTION_ID_LENGTH + 2 {
return Err(Error::ConnectionMessageBytesNoPeerId);
}
PeerId::from_bytes(&bytes[CONNECTION_ID_LENGTH + 1..])
.map_err(|_| Error::InvalidPeerIdBytes)?
}
};
Ok(ConnectionMessage {
peer_id,
recipient,
id,
})
}
}
impl TransportMessage {
fn to_bytes(&self) -> Vec<u8> {
let mut bytes = self.nonce.to_be_bytes().to_vec();
bytes.extend_from_slice(self.id.0.as_ref());
bytes.extend_from_slice(&self.message.to_bytes());
bytes
}
fn try_from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < MIN_CONNECTION_MESSAGE_LEN + 1 {
return Err(Error::TransportMessageBytesTooShort);
}
let nonce = u64::from_be_bytes(
bytes[0..NONCE_BYTES_LEN]
.to_vec()
.try_into()
.map_err(|_| Error::InvalidNonce)?,
);
let id = ConnectionId::from_bytes(&bytes[NONCE_BYTES_LEN..MIN_CONNECTION_MESSAGE_LEN]);
let message = SubstreamMessage::try_from_bytes(&bytes[MIN_CONNECTION_MESSAGE_LEN..])?;
Ok(TransportMessage { nonce, message, id })
}
}
impl Ord for TransportMessage {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.nonce.cmp(&other.nonce)
}
}
impl std::cmp::PartialOrd for TransportMessage {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl std::cmp::Eq for TransportMessage {}
impl std::cmp::PartialEq for TransportMessage {
fn eq(&self, other: &Self) -> bool {
self.nonce == other.nonce
}
}
#[derive(Debug, Clone)]
pub(crate) enum SubstreamMessageType {
OpenRequest,
OpenResponse,
Close,
Data(Vec<u8>),
}
impl SubstreamMessageType {
fn to_u8(&self) -> u8 {
match self {
SubstreamMessageType::OpenRequest => 0,
SubstreamMessageType::OpenResponse => 1,
SubstreamMessageType::Close => 2,
SubstreamMessageType::Data(_) => 3,
}
}
}
/// SubstreamMessage is a message sent over a substream.
#[derive(Debug, Clone)]
pub(crate) struct SubstreamMessage {
pub(crate) substream_id: SubstreamId,
pub(crate) message_type: SubstreamMessageType,
}
impl SubstreamMessage {
pub(crate) fn new_with_data(substream_id: SubstreamId, message: Vec<u8>) -> Self {
SubstreamMessage {
substream_id,
message_type: SubstreamMessageType::Data(message),
}
}
pub(crate) fn new_close(substream_id: SubstreamId) -> Self {
SubstreamMessage {
substream_id,
message_type: SubstreamMessageType::Close,
}
}
pub(crate) fn to_bytes(&self) -> Vec<u8> {
let mut bytes = self.substream_id.0.clone().to_vec();
bytes.push(self.message_type.to_u8());
if let SubstreamMessageType::Data(message) = &self.message_type {
bytes.extend_from_slice(message);
}
bytes
}
pub(crate) fn try_from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < SUBSTREAM_ID_LENGTH + 1 {
return Err(Error::InvalidSubstreamMessageBytes);
}
let substream_id = SubstreamId::from_bytes(&bytes[0..SUBSTREAM_ID_LENGTH]);
let message_type = match bytes[SUBSTREAM_ID_LENGTH] {
0 => SubstreamMessageType::OpenRequest,
1 => SubstreamMessageType::OpenResponse,
2 => SubstreamMessageType::Close,
3 => {
if bytes.len() < SUBSTREAM_ID_LENGTH + 2 {
return Err(Error::InvalidSubstreamMessageBytes);
}
SubstreamMessageType::Data(bytes[SUBSTREAM_ID_LENGTH + 1..].to_vec())
}
_ => return Err(Error::InvalidSubstreamMessageType),
};
Ok(SubstreamMessage {
substream_id,
message_type,
})
}
}
impl Message {
pub(crate) fn to_bytes(&self) -> Vec<u8> {
match self {
Message::ConnectionRequest(msg) => {
let mut bytes = 0_u8.to_be_bytes().to_vec();
bytes.append(&mut msg.to_bytes());
bytes
}
Message::ConnectionResponse(msg) => {
let mut bytes = 1_u8.to_be_bytes().to_vec();
bytes.append(&mut msg.to_bytes());
bytes
}
Message::TransportMessage(msg) => {
let mut bytes = 2_u8.to_be_bytes().to_vec();
bytes.append(&mut msg.to_bytes());
bytes
}
}
}
}
/// InboundMessage represents an inbound mixnet message.
pub(crate) struct InboundMessage(pub(crate) Message);
/// OutboundMessage represents an outbound mixnet message.
#[derive(Debug)]
pub(crate) struct OutboundMessage {
pub(crate) message: Message,
pub(crate) recipient: Recipient,
}
pub(crate) fn parse_message_data(data: &[u8]) -> Result<InboundMessage, Error> {
if data.len() < 2 {
return Err(Error::InvalidMessageBytes);
}
let msg = Message::try_from_bytes(data.to_vec())?;
Ok(InboundMessage(msg))
}
@@ -1,164 +0,0 @@
use futures::{pin_mut, select};
use futures::{FutureExt, StreamExt};
use log::debug;
use nym_sdk::mixnet::{IncludedSurbs, MixnetClient, MixnetClientSender, MixnetMessageSender};
use nym_sphinx::addressing::clients::Recipient;
use nym_sphinx::receiver::ReconstructedMessage;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use super::error::Error;
use super::message::*;
/// initialize_mixnet initializes a read/write connection to a Nym websockets endpoint.
/// It starts a task that listens for inbound messages from the endpoint and writes outbound messages to the endpoint.
pub(crate) async fn initialize_mixnet(
client: MixnetClient,
notify_inbound_tx: Option<UnboundedSender<()>>,
) -> Result<
(
Recipient,
UnboundedReceiver<InboundMessage>,
UnboundedSender<OutboundMessage>,
),
Error,
> {
let recipient = *client.nym_address();
// a channel of inbound messages from the mixnet..
// the transport reads from (listens) to the inbound_rx.
// TODO: this is probably a DOS vector; we should limit the size of the channel.
let (inbound_tx, inbound_rx) = unbounded_channel::<InboundMessage>();
// a channel of outbound messages to be written to the mixnet.
// the transport writes to outbound_tx.
let (outbound_tx, mut outbound_rx) = unbounded_channel::<OutboundMessage>();
let sink = client.split_sender();
let mut stream = client;
tokio::task::spawn(async move {
loop {
let t1 = check_inbound(&mut stream, &inbound_tx, &notify_inbound_tx).fuse();
let t2 = check_outbound(&sink, &mut outbound_rx).fuse();
pin_mut!(t1, t2);
select! {
_ = t1 => {},
_ = t2 => {},
};
}
});
Ok((recipient, inbound_rx, outbound_tx))
}
async fn check_inbound(
client: &mut MixnetClient,
inbound_tx: &UnboundedSender<InboundMessage>,
notify_inbound_tx: &Option<UnboundedSender<()>>,
) -> Result<(), Error> {
if let Some(msg) = client.next().await {
if let Some(notify_tx) = notify_inbound_tx {
notify_tx
.send(())
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
}
handle_inbound(msg, inbound_tx).await?;
}
Err(Error::Unimplemented)
}
async fn handle_inbound(
msg: ReconstructedMessage,
inbound_tx: &UnboundedSender<InboundMessage>,
) -> Result<(), Error> {
let data = parse_message_data(&msg.message)?;
inbound_tx
.send(data)
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
Ok(())
}
async fn check_outbound(
mixnet_sender: &MixnetClientSender,
outbound_rx: &mut UnboundedReceiver<OutboundMessage>,
) -> Result<(), Error> {
match outbound_rx.recv().await {
Some(message) => {
write_bytes(
mixnet_sender,
message.recipient,
&message.message.to_bytes(),
)
.await
}
None => Err(Error::RecvFailure),
}
}
async fn write_bytes(
mixnet_sender: &MixnetClientSender,
recipient: Recipient,
message: &[u8],
) -> Result<(), Error> {
if let Err(_err) = mixnet_sender
.send_message(recipient, message, IncludedSurbs::ExposeSelfAddress)
.await
{
return Err(Error::Unimplemented);
}
debug!(
"wrote message to mixnet: recipient: {:?}",
recipient.to_string()
);
Ok(())
}
#[cfg(test)]
mod test {
use super::super::message::{
self, ConnectionId, Message, SubstreamId, SubstreamMessage, SubstreamMessageType,
TransportMessage,
};
use super::super::mixnet::initialize_mixnet;
use nym_sdk::mixnet::MixnetClient;
#[tokio::test]
async fn test_mixnet_poll_inbound_and_outbound() {
let client = MixnetClient::connect_new().await.unwrap();
let (self_address, mut inbound_rx, outbound_tx) =
initialize_mixnet(client, None).await.unwrap();
let msg_inner = "hello".as_bytes();
let substream_id = SubstreamId::generate();
let msg = Message::TransportMessage(TransportMessage {
nonce: 1, // arbitrary
id: ConnectionId::generate(),
message: SubstreamMessage::new_with_data(substream_id.clone(), msg_inner.to_vec()),
});
// send a message to ourselves through the mixnet
let out_msg = message::OutboundMessage {
message: msg,
recipient: self_address,
};
outbound_tx.send(out_msg).unwrap();
// receive the message from ourselves over the mixnet
let received_msg = inbound_rx.recv().await.unwrap();
if let Message::TransportMessage(recv_msg) = received_msg.0 {
assert_eq!(substream_id, recv_msg.message.substream_id);
if let SubstreamMessageType::Data(data) = recv_msg.message.message_type {
assert_eq!(msg_inner, data.as_slice());
} else {
panic!("expected SubstreamMessage::Data")
}
} else {
panic!("expected Message::TransportMessage")
}
}
}
@@ -1,138 +0,0 @@
use log::{debug, warn};
use std::collections::BTreeSet;
use super::message::TransportMessage;
/// MessageQueue is a queue of messages, ordered by nonce, that we've
/// received but are not yet able to process because we're waiting for
/// a message with the next expected nonce first.
/// This is required because Nym does not guarantee any sort of message
/// ordering, only delivery.
/// TODO: is there a DOS vector here where a malicious peer sends us
/// messages only with nonce higher than the next expected nonce?
pub(crate) struct MessageQueue {
/// nonce of the next message we expect to receive on the
/// connection.
/// any messages with a nonce greater than this are pushed into
/// the queue.
/// if we get a message with a nonce equal to this, then we
/// immediately handle it in the transport and increment the nonce.
next_expected_nonce: u64,
/// the actual queue of messages, ordered by nonce.
/// the head of the queue's nonce is always greater
/// than the next expected nonce.
queue: BTreeSet<TransportMessage>,
}
impl MessageQueue {
pub(crate) fn new() -> Self {
MessageQueue {
next_expected_nonce: 0,
queue: BTreeSet::new(),
}
}
pub(crate) fn print_nonces(&self) {
let nonces = self.queue.iter().map(|msg| msg.nonce).collect::<Vec<_>>();
debug!("MessageQueue: {:?}", nonces);
}
/// sets the next expected nonce to 1, indicating that we've received
/// a ConnectionRequest or ConnectionResponse.
pub(crate) fn set_connection_message_received(&mut self) {
if self.next_expected_nonce != 0 {
panic!("connection message received twice");
}
self.next_expected_nonce = self.next_expected_nonce.wrapping_add(1);
}
/// tries to push a message into the queue.
/// if the message has the next expected nonce, then the message is returned,
/// and should be processed by the caller.
/// in that case, the internal queue's next expected nonce is incremented.
pub(crate) fn try_push(&mut self, msg: TransportMessage) -> Option<TransportMessage> {
if msg.nonce == self.next_expected_nonce {
self.next_expected_nonce = self.next_expected_nonce.wrapping_add(1);
Some(msg)
} else {
if msg.nonce < self.next_expected_nonce {
// this shouldn't happen normally, only if the other node
// is not following the protocol
warn!("received a message with a nonce that is too low");
return None;
}
if !self.queue.insert(msg) {
// this shouldn't happen normally, only if the other node
// is not following the protocol
warn!("received a message with a duplicate nonce");
return None;
}
None
}
}
pub(crate) fn pop(&mut self) -> Option<TransportMessage> {
let head = self.queue.first()?;
if head.nonce == self.next_expected_nonce {
self.next_expected_nonce = self.next_expected_nonce.wrapping_add(1);
Some(self.queue.pop_first().unwrap())
} else {
None
}
}
}
#[cfg(test)]
mod test {
use super::super::message::{ConnectionId, SubstreamId, SubstreamMessage};
use super::*;
impl TransportMessage {
fn new(nonce: u64, message: SubstreamMessage, id: ConnectionId) -> Self {
TransportMessage { nonce, message, id }
}
}
#[test]
fn test_message_queue() {
let mut queue = MessageQueue::new();
let test_substream_message =
SubstreamMessage::new_with_data(SubstreamId::generate(), vec![1, 2, 3]);
let connection_id = ConnectionId::generate();
let msg1 = TransportMessage::new(1, test_substream_message.clone(), connection_id.clone());
let msg2 = TransportMessage::new(2, test_substream_message.clone(), connection_id.clone());
let msg3 = TransportMessage::new(3, test_substream_message.clone(), connection_id.clone());
assert_eq!(queue.try_push(msg1.clone()), None);
assert_eq!(queue.try_push(msg3.clone()), None);
assert_eq!(queue.try_push(msg2.clone()), None);
assert_eq!(queue.pop(), None);
// set expected nonce to 1
queue.set_connection_message_received();
assert_eq!(queue.pop(), Some(msg1));
let msg4 = TransportMessage::new(4, test_substream_message.clone(), connection_id.clone());
assert_eq!(queue.try_push(msg4.clone()), None);
assert_eq!(queue.pop(), Some(msg2));
assert_eq!(queue.pop(), Some(msg3));
assert_eq!(queue.pop(), Some(msg4));
assert_eq!(queue.pop(), None);
assert_eq!(queue.next_expected_nonce, 5);
// should just return the message and increment nonce when message nonce = next expected nonce
let msg5 = TransportMessage::new(5, test_substream_message, connection_id);
assert_eq!(queue.try_push(msg5.clone()), Some(msg5));
assert_eq!(queue.next_expected_nonce, 6);
}
}
@@ -1,412 +0,0 @@
use super::message::{
ConnectionId, Message, OutboundMessage, SubstreamId, SubstreamMessage, TransportMessage,
};
use futures::{
io::{Error as IoError, ErrorKind},
AsyncRead, AsyncWrite,
};
use log::debug;
use nym_sphinx::addressing::clients::Recipient;
use parking_lot::Mutex;
use std::{
pin::Pin,
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
task::{Context, Poll},
};
use tokio::sync::{
mpsc::{UnboundedReceiver, UnboundedSender},
oneshot::Receiver,
};
#[derive(Debug)]
pub struct Substream {
remote_recipient: Recipient,
connection_id: ConnectionId,
pub(crate) substream_id: SubstreamId,
/// inbound messages; inbound_tx is in the corresponding Connection
pub(crate) inbound_rx: UnboundedReceiver<Vec<u8>>,
/// outbound messages; go directly to the mixnet
outbound_tx: UnboundedSender<OutboundMessage>,
/// used to signal when the substream is closed
close_rx: Receiver<()>,
closed: Mutex<bool>,
// buffer of data that's been written to the stream,
// but not yet read by the application.
unread_data: Mutex<Vec<u8>>,
message_nonce: Arc<AtomicU64>,
}
impl Substream {
pub(crate) fn new(
remote_recipient: Recipient,
connection_id: ConnectionId,
substream_id: SubstreamId,
inbound_rx: UnboundedReceiver<Vec<u8>>,
outbound_tx: UnboundedSender<OutboundMessage>,
close_rx: Receiver<()>,
message_nonce: Arc<AtomicU64>,
) -> Self {
Substream {
remote_recipient,
connection_id,
substream_id,
inbound_rx,
outbound_tx,
close_rx,
closed: Mutex::new(false),
unread_data: Mutex::new(vec![]),
message_nonce,
}
}
fn check_closed(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Result<(), IoError> {
let closed_err = IoError::other("stream closed");
// close_rx will return an error if the channel is closed (ie. sender was dropped),
// or if it's empty
let received_closed = self.close_rx.try_recv();
let mut closed = self.closed.lock();
if *closed {
return Err(closed_err);
}
if received_closed.is_ok() {
*closed = true;
return Err(closed_err);
}
Ok(())
}
}
impl AsyncRead for Substream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut [u8],
) -> Poll<Result<usize, IoError>> {
let closed_result = self.as_mut().check_closed(cx);
if let Err(e) = closed_result {
return Poll::Ready(Err(e));
}
let inbound_rx_data = self.inbound_rx.poll_recv(cx);
// first, write any previously unread data to the buf
let mut unread_data = self.unread_data.lock();
let filled_len = if unread_data.len() > 0 {
let unread_len = unread_data.len();
let buf_len = buf.len();
let copy_len = std::cmp::min(unread_len, buf_len);
buf[..copy_len].copy_from_slice(&unread_data[..copy_len]);
*unread_data = unread_data[copy_len..].to_vec();
copy_len
} else {
0
};
if let Poll::Ready(Some(data)) = inbound_rx_data {
if filled_len == buf.len() {
// we've filled the buffer, so we'll have to save the rest for later
let mut new = vec![];
new.extend(unread_data.drain(..));
new.extend(data.iter());
*unread_data = new;
return Poll::Ready(Ok(filled_len));
}
// otherwise, there's still room in the buffer, so we'll copy the rest of the data
let remaining_len = buf.len() - filled_len;
let data_len = data.len();
// we have more data than buffer room remaining, save the extra for later
if remaining_len < data_len {
unread_data.extend_from_slice(&data[remaining_len..]);
}
let copied = std::cmp::min(remaining_len, data_len);
buf[filled_len..filled_len + copied].copy_from_slice(&data[..copied]);
debug!("poll_read copied {} bytes: data {:?}", copied, buf);
return Poll::Ready(Ok(copied));
}
if filled_len > 0 {
debug!("poll_read copied {} bytes: data {:?}", filled_len, buf);
return Poll::Ready(Ok(filled_len));
}
Poll::Pending
}
}
impl AsyncWrite for Substream {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, IoError>> {
if let Err(e) = self.as_mut().check_closed(cx) {
return Poll::Ready(Err(e));
}
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
self.outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.connection_id.clone(),
message: SubstreamMessage::new_with_data(
self.substream_id.clone(),
buf.to_vec(),
),
}),
})
.map_err(|e| IoError::other(format!("poll_write outbound_tx error: {}", e)))?;
Poll::Ready(Ok(buf.len()))
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), IoError>> {
if let Err(e) = self.check_closed(cx) {
return Poll::Ready(Err(e));
}
Poll::Ready(Ok(()))
}
fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Result<(), IoError>> {
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
let mut closed = self.closed.lock();
if *closed {
return Poll::Ready(Err(IoError::other("stream closed")));
}
*closed = true;
// send a close message to the mixnet
self.outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.connection_id.clone(),
message: SubstreamMessage::new_close(self.substream_id.clone()),
}),
})
.map_err(|e| IoError::other(format!("poll_close outbound_rx error: {}", e)))?;
Poll::Ready(Ok(()))
}
}
#[cfg(test)]
mod test {
use super::super::message::{
ConnectionId, Message, SubstreamId, SubstreamMessage, TransportMessage,
};
use super::super::mixnet::initialize_mixnet;
use super::Substream;
use futures::{AsyncReadExt, AsyncWriteExt};
use nym_sdk::mixnet::MixnetClient;
use nym_sphinx::addressing::clients::Recipient;
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
#[tokio::test]
async fn test_substream_poll_read_unread_data() {
let (outbound_tx, _) = tokio::sync::mpsc::unbounded_channel();
let connection_id = ConnectionId::generate();
let substream_id = SubstreamId::generate();
let (inbound_tx, inbound_rx) = tokio::sync::mpsc::unbounded_channel();
let (_, close_rx) = tokio::sync::oneshot::channel();
let mut substream = Substream::new(
Recipient::try_from_base58_string("D1rrpsysCGCYXy9saP8y3kmNpGtJZUXN9SvFoUcqAsM9.9Ssso1ea5NfkbMASdiseDSjTN1fSWda5SgEVjdSN4CvV@GJqd3ZxpXWSNxTfx7B1pPtswpetH4LnJdFeLeuY5KUuN").unwrap(),
connection_id,
substream_id,
inbound_rx,
outbound_tx,
close_rx,
Arc::new(AtomicU64::new(1)),
);
// test writing and reading w/ same length data
let data = b"hello".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 5];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, data.len());
assert_eq!(buf.to_vec(), data);
// test writing data longer than read buffer
let data = b"nootwashere".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 4];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, buf.len());
assert_eq!(buf.to_vec(), b"noot".to_vec());
let mut buf = [0u8; 7];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, buf.len());
assert_eq!(buf.to_vec(), b"washere".to_vec());
// test read buffer larger than written data
let data = b"nootwashere".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 16];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, data.len());
assert_eq!(buf[..data.len()], data);
assert_eq!(buf[data.len()..].to_vec(), vec![0u8; 16 - data.len()]);
// test writing data longer than read buffer multiple times
let data = b"nootwashere".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 4];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, buf.len());
assert_eq!(buf.to_vec(), b"noot".to_vec());
let data = b"asdf".to_vec();
inbound_tx.send(data.clone()).unwrap();
let mut buf = [0u8; 4];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, buf.len());
assert_eq!(buf.to_vec(), b"wash".to_vec());
let mut buf = [0u8; 8];
let read_len = substream.read(&mut buf).await.unwrap();
assert_eq!(read_len, 7);
assert_eq!(buf[..7], b"ereasdf".to_vec());
}
#[tokio::test]
async fn test_substream_read_write() {
let client = MixnetClient::connect_new().await.unwrap();
let (self_address, mut mixnet_inbound_rx, outbound_tx) =
initialize_mixnet(client, None).await.unwrap();
const MSG_INNER: &[u8] = "hello".as_bytes();
let connection_id = ConnectionId::generate();
let substream_id = SubstreamId::generate();
let (inbound_tx, inbound_rx) = tokio::sync::mpsc::unbounded_channel();
let (_, close_rx) = tokio::sync::oneshot::channel();
let mut substream = Substream::new(
self_address,
connection_id,
substream_id,
inbound_rx,
outbound_tx,
close_rx,
Arc::new(AtomicU64::new(1)),
);
// send message to ourselves over the mixnet
substream.write_all(MSG_INNER).await.unwrap();
// receive full message over the mixnet
let recv_msg = mixnet_inbound_rx.recv().await.unwrap();
match recv_msg.0 {
Message::TransportMessage(TransportMessage {
nonce,
id: _,
message:
SubstreamMessage {
substream_id: _,
message_type: msg,
},
}) => {
assert_eq!(nonce, 1);
match msg {
super::super::message::SubstreamMessageType::Data(data) => {
assert_eq!(data, MSG_INNER);
// send message to substream inbound channel
inbound_tx.send(data).unwrap();
}
_ => panic!("unexpected message type"),
}
}
_ => panic!("unexpected message"),
}
// read message from substream
let mut buf = [0u8; MSG_INNER.len()];
substream.read_exact(&mut buf).await.unwrap();
assert_eq!(buf, MSG_INNER);
// close substream
substream.close().await.unwrap();
// try to read/write to closed substream; should error
substream.write_all(MSG_INNER).await.unwrap_err();
substream.read_exact(&mut buf).await.unwrap_err();
// assert a close message was sent over the mixnet
let recv_msg = mixnet_inbound_rx.recv().await.unwrap();
match recv_msg.0 {
Message::TransportMessage(TransportMessage {
nonce: _,
id: _,
message:
SubstreamMessage {
substream_id: _,
message_type: msg,
},
}) => match msg {
super::super::message::SubstreamMessageType::Close => {}
_ => panic!("unexpected message type"),
},
_ => panic!("unexpected message: {:?}", recv_msg.0),
}
}
#[tokio::test]
async fn test_substream_recv_close() {
let client = MixnetClient::connect_new().await.unwrap();
let (self_address, _, outbound_tx) = initialize_mixnet(client, None).await.unwrap();
const MSG_INNER: &[u8] = "hello".as_bytes();
let connection_id = ConnectionId::generate();
let substream_id = SubstreamId::generate();
let (_, inbound_rx) = tokio::sync::mpsc::unbounded_channel();
let (close_tx, close_rx) = tokio::sync::oneshot::channel();
let mut substream = Substream::new(
self_address,
connection_id,
substream_id,
inbound_rx,
outbound_tx,
close_rx,
Arc::new(AtomicU64::new(1)),
);
// close substream
close_tx.send(()).unwrap();
// try to read/write to closed substream; should error
substream.write_all(MSG_INNER).await.unwrap_err();
let mut buf = [0u8; MSG_INNER.len()];
substream.read_exact(&mut buf).await.unwrap_err();
}
}
@@ -1,898 +0,0 @@
use futures::prelude::*;
use libp2p::core::{
identity::Keypair,
multiaddr::{Multiaddr, Protocol},
transport::{ListenerId, TransportError, TransportEvent},
PeerId, Transport,
};
use log::debug;
use nym_sdk::mixnet::MixnetClient;
use nym_sphinx::addressing::clients::Recipient;
use std::{
collections::HashMap,
pin::Pin,
str::FromStr,
task::{Context, Poll, Waker},
};
use tokio::{
sync::{
mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
oneshot,
},
time::{timeout, Duration},
};
use tokio_stream::wrappers::UnboundedReceiverStream;
use super::connection::{Connection, PendingConnection};
use super::error::Error;
use super::message::{
ConnectionId, ConnectionMessage, InboundMessage, Message, OutboundMessage, SubstreamMessage,
TransportMessage,
};
use super::mixnet::initialize_mixnet;
use super::queue::MessageQueue;
use super::DEFAULT_HANDSHAKE_TIMEOUT_SECS;
/// InboundTransportEvent represents an inbound event from the mixnet.
pub enum InboundTransportEvent {
ConnectionRequest(Upgrade),
ConnectionResponse,
TransportMessage,
}
/// NymTransport implements the Transport trait using the Nym mixnet.
pub struct NymTransport {
/// our Nym address
self_address: Recipient,
pub(crate) listen_addr: Multiaddr,
pub(crate) listener_id: ListenerId,
/// our libp2p keypair; currently not really used
keypair: Keypair,
/// established connections -> channel which sends messages received from
/// the mixnet to the corresponding Connection
connections: HashMap<ConnectionId, UnboundedSender<SubstreamMessage>>,
/// outbound pending dials
pending_dials: HashMap<ConnectionId, PendingConnection>,
/// connection message queues
message_queues: HashMap<ConnectionId, MessageQueue>,
/// inbound mixnet messages
inbound_stream: UnboundedReceiverStream<InboundMessage>,
/// outbound mixnet messages
outbound_tx: UnboundedSender<OutboundMessage>,
/// inbound messages for Transport.poll()
poll_rx: UnboundedReceiver<TransportEvent<Upgrade, Error>>,
/// outbound messages to Transport.poll()
poll_tx: UnboundedSender<TransportEvent<Upgrade, Error>>,
waker: Option<Waker>,
/// Timeout for the [`Upgrade`] future.
handshake_timeout: Duration,
}
impl NymTransport {
/// New transport.
#[allow(unused)]
pub async fn new(client: MixnetClient, keypair: Keypair) -> Result<Self, Error> {
Self::new_maybe_with_notify_inbound(client, keypair, None, None).await
}
/// New transport with a timeout.
#[allow(dead_code)]
pub async fn new_with_timeout(
client: MixnetClient,
keypair: Keypair,
timeout: Duration,
) -> Result<Self, Error> {
Self::new_maybe_with_notify_inbound(client, keypair, None, Some(timeout)).await
}
/// Add timeout to transport and return self.
#[allow(dead_code)]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.handshake_timeout = timeout;
self
}
async fn new_maybe_with_notify_inbound(
client: MixnetClient,
keypair: Keypair,
notify_inbound_tx: Option<UnboundedSender<()>>,
timeout: Option<Duration>,
) -> Result<Self, Error> {
let (self_address, inbound_rx, outbound_tx) =
initialize_mixnet(client, notify_inbound_tx).await?;
let listen_addr = nym_address_to_multiaddress(self_address)?;
let listener_id = ListenerId::new();
let (poll_tx, poll_rx) = unbounded_channel::<TransportEvent<Upgrade, Error>>();
poll_tx
.send(TransportEvent::NewAddress {
listener_id,
listen_addr: listen_addr.clone(),
})
.map_err(|_| Error::SendErrorTransportEvent)?;
let inbound_stream = UnboundedReceiverStream::new(inbound_rx);
let handshake_timeout =
timeout.unwrap_or_else(|| Duration::from_secs(DEFAULT_HANDSHAKE_TIMEOUT_SECS));
Ok(Self {
self_address,
listen_addr,
listener_id,
keypair,
connections: HashMap::new(),
pending_dials: HashMap::new(),
message_queues: HashMap::new(),
inbound_stream,
outbound_tx,
poll_rx,
poll_tx,
waker: None,
handshake_timeout,
})
}
pub(crate) fn peer_id(&self) -> PeerId {
PeerId::from_public_key(&self.keypair.public())
}
fn handle_message_queue_on_connection_initiation(
&mut self,
id: &ConnectionId,
) -> Result<(), Error> {
debug!("handle_message_queue_on_connection_initiation");
let Some(inbound_tx) = self.connections.get(id) else {
// this should not happen
return Err(Error::NoConnectionForTransportMessage);
};
match self.message_queues.get_mut(id) {
Some(queue) => {
// update expected nonce
queue.set_connection_message_received();
// push pending inbound some messages in this case
while let Some(msg) = queue.pop() {
debug!(
"popped queued message with nonce {} for connection",
msg.nonce
);
inbound_tx
.send(msg.message.clone())
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
}
}
None => {
// no queue exists for this connection, create one
let queue = MessageQueue::new();
self.message_queues.insert(id.clone(), queue);
let queue = self.message_queues.get_mut(id).unwrap();
queue.set_connection_message_received();
}
};
debug!("returning from handle_message_queue_on_connection_initiation");
Ok(())
}
// handle_connection_response resolves the pending connection corresponding to the response
// (if there is one) into a Connection.
fn handle_connection_response(&mut self, msg: &ConnectionMessage) -> Result<(), Error> {
if self.connections.contains_key(&msg.id) {
return Err(Error::ConnectionAlreadyEstablished);
}
if let Some(pending_conn) = self.pending_dials.remove(&msg.id) {
// resolve connection and put into pending_conn channel
let (conn, conn_tx) = self.create_connection_types(
msg.peer_id,
pending_conn.remote_recipient,
msg.id.clone(),
);
self.connections.insert(msg.id.clone(), conn_tx);
self.handle_message_queue_on_connection_initiation(&msg.id)?;
pending_conn
.connection_tx
.send(conn)
.map_err(|_| Error::ConnectionSendFailure)?;
if let Some(waker) = self.waker.take() {
waker.wake();
}
Ok(())
} else {
Err(Error::NoConnectionForResponse)
}
}
/// handle_connection_request handles an incoming connection request, sends back a
/// connection response, and finally completes the upgrade into a Connection.
fn handle_connection_request(&mut self, msg: &ConnectionMessage) -> Result<Connection, Error> {
if msg.recipient.is_none() {
return Err(Error::NoneRecipientInConnectionRequest);
}
// ensure we don't already have a conn with the same id
if self.connections.contains_key(&msg.id) {
return Err(Error::ConnectionIDExists);
}
let (conn, conn_tx) =
self.create_connection_types(msg.peer_id, msg.recipient.unwrap(), msg.id.clone());
self.connections.insert(msg.id.clone(), conn_tx);
self.handle_message_queue_on_connection_initiation(&msg.id)?;
let resp = ConnectionMessage {
peer_id: self.peer_id(),
recipient: None,
id: msg.id.clone(),
};
self.outbound_tx
.send(OutboundMessage {
message: Message::ConnectionResponse(resp),
recipient: msg.recipient.unwrap(),
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
if let Some(waker) = self.waker.take() {
waker.wake();
}
Ok(conn)
}
fn handle_transport_message(&mut self, msg: TransportMessage) -> Result<(), Error> {
let queue = match self.message_queues.get_mut(&msg.id) {
Some(queue) => queue,
None => {
// no queue exists for this connection, create one
let queue = MessageQueue::new();
self.message_queues.insert(msg.id.clone(), queue);
self.message_queues.get_mut(&msg.id).unwrap()
}
};
queue.print_nonces();
let nonce = msg.nonce;
let Some(msg) = queue.try_push(msg) else {
// don't push the message yet, it's been queued
debug!("message with nonce {} queued for connection", nonce);
return Ok(());
};
let Some(inbound_tx) = self.connections.get(&msg.id) else {
return Err(Error::NoConnectionForTransportMessage);
};
// send original message
debug!(
"sending original message with nonce {} for connection",
nonce
);
inbound_tx
.send(msg.message.clone())
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
// try to pop queued messages and send them on inbound channel
while let Some(msg) = queue.pop() {
debug!(
"popped queued message with nonce {} for connection",
msg.nonce
);
inbound_tx
.send(msg.message.clone())
.map_err(|e| Error::InboundSendFailure(e.to_string()))?;
}
if let Some(waker) = self.waker.clone().take() {
waker.wake();
}
Ok(())
}
fn create_connection_types(
&self,
remote_peer_id: PeerId,
recipient: Recipient,
id: ConnectionId,
) -> (Connection, UnboundedSender<SubstreamMessage>) {
let (inbound_tx, inbound_rx) = unbounded_channel::<SubstreamMessage>();
// representation of a connection; this contains channels for applications to read/write to.
let conn = Connection::new(
remote_peer_id,
recipient,
id,
inbound_rx,
self.outbound_tx.clone(),
);
// inbound_tx is what we write to when receiving messages on the mixnet,
(conn, inbound_tx)
}
/// handle_inbound handles an inbound message from the mixnet, received via self.inbound_stream.
fn handle_inbound(&mut self, msg: Message) -> Result<InboundTransportEvent, Error> {
match msg {
Message::ConnectionRequest(inner) => {
debug!("got inbound connection request {:?}", inner);
match self.handle_connection_request(&inner) {
Ok(conn) => {
let (connection_tx, connection_rx) =
oneshot::channel::<(PeerId, Connection)>();
let upgrade = Upgrade::new(connection_rx);
connection_tx
.send((inner.peer_id, conn))
.map_err(|_| Error::ConnectionSendFailure)?;
Ok(InboundTransportEvent::ConnectionRequest(upgrade))
}
Err(e) => Err(e),
}
}
Message::ConnectionResponse(msg) => {
debug!("got inbound connection response {:?}", msg);
self.handle_connection_response(&msg)
.map(|_| InboundTransportEvent::ConnectionResponse)
}
Message::TransportMessage(msg) => {
debug!("got inbound TransportMessage: {:?}", msg);
self.handle_transport_message(msg)
.map(|_| InboundTransportEvent::TransportMessage)
}
}
}
}
/// Upgrade represents a transport listener upgrade.
/// Note: we immediately upgrade a connection request to a connection,
/// so this only contains a channel for receiving that connection.
pub struct Upgrade {
connection_tx: oneshot::Receiver<(PeerId, Connection)>,
}
impl Upgrade {
fn new(connection_tx: oneshot::Receiver<(PeerId, Connection)>) -> Upgrade {
Upgrade { connection_tx }
}
}
impl Future for Upgrade {
type Output = Result<(PeerId, Connection), Error>;
// poll checks if the upgrade has turned into a connection yet
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.connection_tx
.poll_unpin(cx)
.map_err(|_| Error::RecvFailure)
}
}
impl Transport for NymTransport {
type Output = (PeerId, Connection);
type Error = Error;
type ListenerUpgrade = Upgrade;
type Dial = Pin<Box<dyn Future<Output = Result<Self::Output, Self::Error>> + Send>>;
fn listen_on(&mut self, _: Multiaddr) -> Result<ListenerId, TransportError<Self::Error>> {
// we should only allow listening on the multiaddress containing our Nym address
Ok(self.listener_id)
}
fn remove_listener(&mut self, id: ListenerId) -> bool {
if self.listener_id != id {
return false;
}
// TODO: close channels?
self.poll_tx
.send(TransportEvent::ListenerClosed {
listener_id: id,
reason: Ok(()),
})
.expect("failed to send listener closed event");
true
}
fn dial(&mut self, addr: Multiaddr) -> Result<Self::Dial, TransportError<Self::Error>> {
debug!("dialing {}", addr);
let id = ConnectionId::generate();
// create remote recipient address
let recipient = multiaddress_to_nym_address(addr).map_err(TransportError::Other)?;
// create pending conn structs and store
let (connection_tx, connection_rx) = oneshot::channel::<Connection>();
let inner_pending_conn = PendingConnection::new(recipient, connection_tx);
self.pending_dials.insert(id.clone(), inner_pending_conn);
// put ConnectionRequest message into outbound message channel
let msg = ConnectionMessage {
peer_id: self.peer_id(),
recipient: Some(self.self_address),
id,
};
let outbound_tx = self.outbound_tx.clone();
let mut waker = self.waker.clone();
let handshake_timeout = self.handshake_timeout;
Ok(async move {
outbound_tx
.send(OutboundMessage {
message: Message::ConnectionRequest(msg),
recipient,
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
debug!("sent outbound ConnectionRequest");
if let Some(waker) = waker.take() {
waker.wake();
};
let conn = timeout(handshake_timeout, connection_rx).await??;
Ok((conn.peer_id, conn))
}
.boxed())
}
// dial_as_listener currently just calls self.dial().
fn dial_as_listener(
&mut self,
addr: Multiaddr,
) -> Result<Self::Dial, TransportError<Self::Error>> {
self.dial(addr)
}
fn poll(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<TransportEvent<Self::ListenerUpgrade, Self::Error>> {
// new addresses + listener close events
if let Poll::Ready(Some(res)) = self.poll_rx.recv().boxed().poll_unpin(cx) {
return Poll::Ready(res);
}
// check for and handle inbound messages
while let Poll::Ready(Some(msg)) = self.inbound_stream.poll_next_unpin(cx) {
match self.handle_inbound(msg.0) {
Ok(event) => match event {
InboundTransportEvent::ConnectionRequest(upgrade) => {
debug!("InboundTransportEvent::ConnectionRequest");
return Poll::Ready(TransportEvent::Incoming {
listener_id: self.listener_id,
upgrade,
local_addr: self.listen_addr.clone(),
send_back_addr: self.listen_addr.clone(),
});
}
InboundTransportEvent::ConnectionResponse => {
debug!("InboundTransportEvent::ConnectionResponse");
}
InboundTransportEvent::TransportMessage => {
debug!("InboundTransportEvent::TransportMessage");
}
},
Err(e) => {
return Poll::Ready(TransportEvent::ListenerError {
listener_id: self.listener_id,
error: e,
});
}
};
}
self.waker = Some(cx.waker().clone());
Poll::Pending
}
fn address_translation(&self, _listen: &Multiaddr, _observed: &Multiaddr) -> Option<Multiaddr> {
None
}
}
fn nym_address_to_multiaddress(addr: Recipient) -> Result<Multiaddr, Error> {
Multiaddr::from_str(&format!("/nym/{}", addr)).map_err(Error::FailedToFormatMultiaddr)
}
fn multiaddress_to_nym_address(multiaddr: Multiaddr) -> Result<Recipient, Error> {
let mut multiaddr = multiaddr;
match multiaddr.pop().unwrap() {
Protocol::Nym(addr) => Recipient::from_str(&addr).map_err(Error::InvalidRecipientBytes),
_ => Err(Error::InvalidProtocolForMultiaddr),
}
}
#[cfg(test)]
mod test {
use super::super::connection::Connection;
use super::super::error::Error;
use super::super::message::{
Message, OutboundMessage, SubstreamId, SubstreamMessage, SubstreamMessageType,
TransportMessage,
};
use super::super::substream::Substream;
use super::{nym_address_to_multiaddress, NymTransport};
use futures::{future::poll_fn, AsyncReadExt, AsyncWriteExt, FutureExt};
use libp2p::core::{
identity::Keypair,
transport::{Transport, TransportEvent},
Multiaddr, StreamMuxer,
};
use log::info;
use nym_bin_common::logging::setup_tracing_logger;
use nym_sdk::mixnet::MixnetClient;
use std::{pin::Pin, str::FromStr, sync::atomic::Ordering};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
impl Connection {
fn write(&self, msg: SubstreamMessage) -> Result<(), Error> {
let nonce = self.message_nonce.fetch_add(1, Ordering::SeqCst);
self.mixnet_outbound_tx
.send(OutboundMessage {
recipient: self.remote_recipient,
message: Message::TransportMessage(TransportMessage {
nonce,
id: self.id.clone(),
message: msg,
}),
})
.map_err(|e| Error::OutboundSendFailure(e.to_string()))?;
Ok(())
}
}
impl NymTransport {
async fn new_with_notify_inbound(
client: MixnetClient,
notify_inbound_tx: UnboundedSender<()>,
) -> Result<Self, Error> {
let local_key = Keypair::generate_ed25519();
Self::new_maybe_with_notify_inbound(client, local_key, Some(notify_inbound_tx), None)
.await
}
}
#[tokio::test]
async fn test_transport_connection() {
setup_tracing_logger();
let client = MixnetClient::connect_new().await.unwrap();
let (dialer_notify_inbound_tx, mut dialer_notify_inbound_rx) = unbounded_channel();
let mut dialer_transport =
NymTransport::new_with_notify_inbound(client, dialer_notify_inbound_tx)
.await
.unwrap();
let client2 = MixnetClient::connect_new().await.unwrap();
let (listener_notify_inbound_tx, mut listener_notify_inbound_rx) = unbounded_channel();
let mut listener_transport =
NymTransport::new_with_notify_inbound(client2, listener_notify_inbound_tx)
.await
.unwrap();
let listener_multiaddr =
nym_address_to_multiaddress(listener_transport.self_address).unwrap();
assert_new_address_event(Pin::new(&mut dialer_transport)).await;
assert_new_address_event(Pin::new(&mut listener_transport)).await;
// dial the remote peer
let mut dial = dialer_transport.dial(listener_multiaddr).unwrap();
// poll the dial to send the connection request message
assert!(poll_fn(|cx| Pin::new(&mut dial).as_mut().poll_unpin(cx))
.now_or_never()
.is_none());
listener_notify_inbound_rx.recv().await.unwrap();
// should receive the connection request from the mixnet and send the connection response
let res = poll_fn(|cx| Pin::new(&mut listener_transport).as_mut().poll(cx)).await;
let mut upgrade = match res {
TransportEvent::Incoming {
listener_id,
upgrade,
local_addr,
send_back_addr,
} => {
assert_eq!(listener_id, listener_transport.listener_id);
assert_eq!(local_addr, listener_transport.listen_addr);
assert_eq!(send_back_addr, listener_transport.listen_addr);
upgrade
}
_ => panic!("expected TransportEvent::Incoming, got {:?}", res),
};
dialer_notify_inbound_rx.recv().await.unwrap();
// should receive the connection response from the mixnet
assert!(
poll_fn(|cx| Pin::new(&mut dialer_transport).as_mut().poll(cx))
.now_or_never()
.is_none()
);
info!("waiting for connections...");
// should be able to resolve the connections now
let (_, mut listener_conn) = poll_fn(|cx| Pin::new(&mut upgrade).as_mut().poll_unpin(cx))
.now_or_never()
.expect("the upgrade should be ready")
.expect("the upgrade should not error");
let (_, mut dialer_conn) = poll_fn(|cx| Pin::new(&mut dial).as_mut().poll_unpin(cx))
.now_or_never()
.expect("the upgrade should be ready")
.expect("the upgrade should not error");
info!("connections established");
// write messages from the dialer to the listener and vice versa
send_and_receive_over_conns(
b"hello".to_vec(),
&mut dialer_conn,
&mut listener_conn,
Pin::new(&mut listener_transport),
&mut listener_notify_inbound_rx,
)
.await;
send_and_receive_over_conns(
b"hi".to_vec(),
&mut dialer_conn,
&mut listener_conn,
Pin::new(&mut listener_transport),
&mut listener_notify_inbound_rx,
)
.await;
send_and_receive_over_conns(
b"world".to_vec(),
&mut listener_conn,
&mut dialer_conn,
Pin::new(&mut dialer_transport),
&mut dialer_notify_inbound_rx,
)
.await;
}
async fn assert_new_address_event(mut transport: Pin<&mut NymTransport>) {
match poll_fn(|cx| transport.as_mut().poll(cx)).await {
TransportEvent::NewAddress {
listener_id,
listen_addr,
} => {
assert_eq!(listener_id, transport.listener_id);
assert_eq!(listen_addr, transport.listen_addr);
}
_ => panic!("expected TransportEvent::NewAddress"),
}
}
async fn send_and_receive_over_conns(
msg: Vec<u8>,
conn1: &mut Connection,
conn2: &mut Connection,
mut transport2: Pin<&mut NymTransport>,
notify_inbound_rx: &mut UnboundedReceiver<()>,
) {
// send message over conn1 to conn2
let substream_id = SubstreamId::generate();
conn1
.write(SubstreamMessage::new_with_data(
substream_id.clone(),
msg.clone(),
))
.unwrap();
notify_inbound_rx.recv().await.unwrap();
// poll transport2 to push message from transport to connection
assert!(poll_fn(|cx| transport2.as_mut().poll(cx))
.now_or_never()
.is_none());
let substream_msg = conn2.inbound_rx.recv().await.unwrap();
if let SubstreamMessageType::Data(data) = substream_msg.message_type {
assert_eq!(data, msg);
} else {
panic!("expected data message");
}
}
#[tokio::test]
async fn test_transport_substream() {
let client = MixnetClient::connect_new().await.unwrap();
let (dialer_notify_inbound_tx, mut dialer_notify_inbound_rx) = unbounded_channel();
let mut dialer_transport =
NymTransport::new_with_notify_inbound(client, dialer_notify_inbound_tx)
.await
.unwrap();
let client2 = MixnetClient::connect_new().await.unwrap();
let (listener_notify_inbound_tx, mut listener_notify_inbound_rx) = unbounded_channel();
let mut listener_transport =
NymTransport::new_with_notify_inbound(client2, listener_notify_inbound_tx)
.await
.unwrap();
let listener_multiaddr =
nym_address_to_multiaddress(listener_transport.self_address).unwrap();
assert_new_address_event(Pin::new(&mut dialer_transport)).await;
assert_new_address_event(Pin::new(&mut listener_transport)).await;
// dial the remote peer
let mut dial = dialer_transport.dial(listener_multiaddr).unwrap();
// poll the dial to send the connection request message
assert!(poll_fn(|cx| Pin::new(&mut dial).as_mut().poll_unpin(cx))
.now_or_never()
.is_none());
listener_notify_inbound_rx.recv().await.unwrap();
// should receive the connection request from the mixnet and send the connection response
let res = poll_fn(|cx| Pin::new(&mut listener_transport).as_mut().poll(cx)).await;
let mut upgrade = match res {
TransportEvent::Incoming {
listener_id,
upgrade,
local_addr,
send_back_addr,
} => {
assert_eq!(listener_id, listener_transport.listener_id);
assert_eq!(local_addr, listener_transport.listen_addr);
assert_eq!(send_back_addr, listener_transport.listen_addr);
upgrade
}
_ => panic!("expected TransportEvent::Incoming, got {:?}", res),
};
dialer_notify_inbound_rx.recv().await.unwrap();
// should receive the connection response from the mixnet
assert!(
poll_fn(|cx| Pin::new(&mut dialer_transport).as_mut().poll(cx))
.now_or_never()
.is_none()
);
info!("waiting for connections...");
// should be able to resolve the connections now
let (_, mut listener_conn) = poll_fn(|cx| Pin::new(&mut upgrade).as_mut().poll_unpin(cx))
.now_or_never()
.unwrap()
.unwrap();
let (_, mut dialer_conn) = poll_fn(|cx| Pin::new(&mut dial).as_mut().poll_unpin(cx))
.now_or_never()
.unwrap()
.unwrap();
info!("connections established");
// initiate a new substream from the dialer
let mut dialer_substream =
poll_fn(|cx| Pin::new(&mut dialer_conn).as_mut().poll_outbound(cx))
.await
.unwrap();
listener_notify_inbound_rx.recv().await.unwrap();
// accept the substream on the listener
assert!(
poll_fn(|cx| Pin::new(&mut listener_transport).as_mut().poll(cx))
.now_or_never()
.is_none()
);
poll_fn(|cx| Pin::new(&mut listener_conn).as_mut().poll(cx)).now_or_never();
// poll recipient's poll_inbound to receive the substream; sends a response to the sender
let mut listener_substream =
poll_fn(|cx| Pin::new(&mut listener_conn).as_mut().poll_inbound(cx))
.now_or_never()
.unwrap()
.unwrap();
info!("got listener substream");
dialer_notify_inbound_rx.recv().await.unwrap();
// poll sender to finalize the substream
assert!(
poll_fn(|cx| Pin::new(&mut dialer_transport).as_mut().poll(cx))
.now_or_never()
.is_none()
);
poll_fn(|cx| Pin::new(&mut dialer_conn).as_mut().poll(cx)).now_or_never();
info!("got dialer substream");
// write message from dialer to listener
send_and_receive_substream_message(
b"hello world".to_vec(),
Pin::new(&mut dialer_substream),
Pin::new(&mut listener_substream),
Pin::new(&mut listener_transport),
Pin::new(&mut listener_conn),
&mut listener_notify_inbound_rx,
)
.await;
// write message from listener to dialer
send_and_receive_substream_message(
b"hello back".to_vec(),
Pin::new(&mut listener_substream),
Pin::new(&mut dialer_substream),
Pin::new(&mut dialer_transport),
Pin::new(&mut dialer_conn),
&mut dialer_notify_inbound_rx,
)
.await;
// close the substream from the dialer side
info!("closing dialer substream");
dialer_substream.close().await.unwrap();
listener_notify_inbound_rx.recv().await.unwrap();
info!("dialer substream closed");
// assert we can't read or write to either substream
dialer_substream.write_all(b"hello").await.unwrap_err();
// poll listener transport and conn to receive the substream close message
poll_fn(|cx| Pin::new(&mut listener_transport).as_mut().poll(cx)).now_or_never();
poll_fn(|cx| Pin::new(&mut listener_conn).as_mut().poll(cx)).now_or_never();
listener_substream.write_all(b"hello").await.unwrap_err();
let mut buf = vec![0u8; 5];
dialer_substream.read(&mut buf).await.unwrap_err();
listener_substream.read(&mut buf).await.unwrap_err();
dialer_substream.close().await.unwrap_err();
listener_substream.close().await.unwrap_err();
}
async fn send_and_receive_substream_message(
data: Vec<u8>,
mut sender_substream: Pin<&mut Substream>,
mut recipient_substream: Pin<&mut Substream>,
mut recipient_transport: Pin<&mut NymTransport>,
mut recipient_conn: Pin<&mut Connection>,
recipient_notify_inbound_rx: &mut UnboundedReceiver<()>,
) {
// write message
sender_substream.write_all(&data).await.unwrap();
recipient_notify_inbound_rx.recv().await.unwrap();
// poll recipient for message
poll_fn(|cx| recipient_transport.as_mut().poll(cx)).now_or_never();
poll_fn(|cx| recipient_conn.as_mut().poll(cx)).now_or_never();
let mut buf = vec![0u8; data.len()];
let n = recipient_substream.read(&mut buf).await.unwrap();
assert_eq!(n, data.len());
assert_eq!(buf, data[..]);
}
#[tokio::test]
async fn test_transport_timeout() {
let client = MixnetClient::connect_new().await.unwrap();
let (dialer_notify_inbound_tx, _) = unbounded_channel();
let mut dialer_transport =
NymTransport::new_with_notify_inbound(client, dialer_notify_inbound_tx)
.await
.unwrap()
.with_timeout(std::time::Duration::from_millis(100));
// mock a transport that will never resolve the connection.
let empty_addr = Multiaddr::from_str(
"/nym/Hmer6Ndt3PV13YW53HM8ri4NvqqtfDQUQBhzvKqb1dag.2g478dyxtrQXGWc1Mk2VEqdPcWXpz7EhAcjhdAJtVZdA@AnnYnEtBjB2a5sHmeRCnBq43qxyHDf95Bqd7cwQyKNLR"
)
.expect("unable to parse multiaddress");
let dial = dialer_transport.dial(empty_addr).unwrap();
assert!(dial
.await
.expect_err("should have timed out")
.to_string()
.contains("dial timed out"));
}
}
+16 -2
View File
@@ -1,8 +1,22 @@
//! Minimal message example: send a message to yourself and print it.
//!
//! Uses an ephemeral client — no keys are stored to disk.
//! Uses an ephemeral client — keys are generated in memory and discarded
//! on disconnect.
//!
//! Run with: cargo run --example simple
//! ## What this demonstrates
//!
//! - `MixnetClient::connect_new()` creates an ephemeral client (ed25519 +
//! x25519 keypair, gateway selection, topology fetch — all automatic)
//! - `send_plain_message()` wraps the payload in Sphinx packets and queues
//! it for sending — encryption and mixing happen in background tasks
//! - `on_messages()` drains the inbound queue fed by the gateway
//!
//! For persistent identity (same address across restarts), see
//! `builder_with_storage.rs`. For anonymous replies, see `surb_reply.rs`.
//!
//! ```sh
//! cargo run --example simple
//! ```
use nym_sdk::mixnet;
use nym_sdk::mixnet::MixnetMessageSender;
@@ -3,10 +3,24 @@
//! Demonstrates concurrent streams over the Mixnet.
//!
//! One sender opens streams to two receivers.
//! Both receivers accept, read, and reply concurrently.
//! One sender opens streams to two receivers. Both receivers accept, read,
//! and reply concurrently using `AsyncRead + AsyncWrite` — the same traits
//! as TCP sockets.
//!
//! Run with: cargo run --example stream_simple_read_write
//! ## What this demonstrates
//!
//! - `client.listener()` activates stream mode and returns a `MixnetListener`
//! - `listener.accept()` blocks until a remote peer opens a stream
//! - `client.open_stream(recipient, surbs)` opens an outbound stream
//! - `MixnetStream` implements `AsyncRead + AsyncWrite` — standard tokio
//! I/O (`read`, `write_all`, `flush`) works unchanged
//! - Multiple streams are multiplexed over a single client connection
//! - Replies travel via SURBs — receivers never learn the sender's address
//! - Streams deregister on `drop`; no close handshake is needed
//!
//! ```sh
//! cargo run --example stream_simple_read_write
//! ```
use nym_sdk::mixnet;
use std::time::Duration;
+15 -3
View File
@@ -2,10 +2,22 @@
//!
//! Sends a message to self, extracts the `AnonymousSenderTag` from the
//! incoming message, and replies using `send_reply()` — without knowing
//! the sender's Nym address. The SDK includes SURBs with every message
//! by default, so the recipient can always reply anonymously.
//! the sender's Nym address. The SDK bundles SURBs with every outgoing
//! message by default, so the recipient can always reply anonymously.
//!
//! Run with: cargo run --example surb_reply
//! ## What this demonstrates
//!
//! - Every incoming message carries a `sender_tag` — an opaque
//! [`AnonymousSenderTag`] that enables replies without revealing the
//! sender's address
//! - `send_reply()` consumes a SURB to route the reply back through the
//! mixnet. Each SURB is single-use; the SDK replenishes them automatically
//! - This is the foundation for anonymous communication: the server never
//! learns who is talking to it
//!
//! ```sh
//! cargo run --example surb_reply
//! ```
use nym_sdk::mixnet::{
AnonymousSenderTag, MixnetClientBuilder, MixnetMessageSender, ReconstructedMessage,
+128 -21
View File
@@ -5,37 +5,117 @@ to provide real `TcpStream` and `UdpSocket` types that work transparently
with the async Rust ecosystem — tokio-rustls, hyper, tokio-tungstenite,
libp2p, and anything else built on `AsyncRead + AsyncWrite`.
## Why IP, not messages
## Why TCP, not messages
The Nym SDK works at the **message layer**: you send and receive `Vec<u8>`
payloads through the mixnet. Every protocol must be hand-adapted — you need
custom framing, ordering, connection state, and flow control.
`smolmix` operates at the **IP layer**. A userspace smoltcp stack manages
real TCP state machines (retransmits, windowing, port allocation) and UDP
datagram delivery, and the mixnet becomes a transparent transport underneath.
Any protocol that works over TCP or UDP works over smolmix — with zero
adaptation.
smolmix operates at the **TCP layer**. A userspace smoltcp stack manages
real TCP state machines (retransmits, windowing, port allocation), and the
mixnet becomes a transparent transport underneath. Any protocol that works
over a TCP stream works over smolmix — with zero adaptation.
```text
┌──────────────────────────────────────────────────────────────────┐
│ Application protocols that "just work" over smolmix
│ Application protocols that "just work" over smolmix TcpStream
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐
│ │ TLS │ │ HTTP/1.1 │ │ WebSocket │ │ libp2p │
│ │ (rustls) │ │ (hyper) │ │ (tungstenite)│ │ (noise+yamux) │
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ └───────┬────────┘
│ │ │ │ │ │
│ └─────────────┴──────────────┴─────────────────┘ │
│ │ │
│ tokio_smoltcp::TcpStream │
│ (AsyncRead + AsyncWrite, Send, Unpin) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ TLS │ │ HTTP/1.1 │ │ WebSocket │ │ libp2p │ │
│ │ (rustls) │ │ (hyper) │ │ (tungstenite)│ │ (noise+yamux) │ │
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │ │
│ └─────────────┴──────────────┴─────────────────┘
│ │
│ tokio_smoltcp::TcpStream
│ (AsyncRead + AsyncWrite, Send, Unpin)
├──────────────────────────────────────────────────────────────────┤
│ smolmix Tunnel │
│ (smoltcp → mixnet → IPR) │
│ smolmix Tunnel
│ (smoltcp → mixnet → IPR)
└──────────────────────────────────────────────────────────────────┘
```
Concrete example: the Nym SDK's libp2p integration (`libp2p_shared/`) is
~2400 lines — custom `Connection` (StreamMuxer), custom `Substream`
(AsyncRead/Write), nonce-based ordering, and a custom handshake. With
smolmix, the same integration is ~175 lines: parse a multiaddr, resolve DNS
through the tunnel, and call `tunnel.tcp_connect()`. libp2p's built-in noise
and yamux work natively because they only need `AsyncRead + AsyncWrite`.
Each companion crate is a thin wrapper — typically under 200 lines — that
adapts an existing library to use tunnel I/O instead of OS sockets. The key
insight is that `tokio_smoltcp::TcpStream` implements tokio's `AsyncRead +
AsyncWrite`, so the only work needed is bridging to whatever I/O trait the
target library expects:
| Crate | Lines | Trait implemented | I/O bridge |
|-------|------:|-------------------|------------|
| `smolmix-dns` | ~110 | hickory `RuntimeProvider` | `AsyncIoTokioAsStd` (tokio→futures) |
| `smolmix-tls` | ~60 | *(none — factory only)* | direct (tokio-rustls takes `AsyncRead/Write`) |
| `smolmix-hyper` | ~170 | `tower::Service<Uri>` | `TokioIo` + `pin_project!` enum |
### smolmix-dns
hickory-resolver's extension point is the `RuntimeProvider` trait — it
controls how the resolver creates TCP connections and UDP sockets.
`SmolmixRuntimeProvider` implements this trait, routing all I/O through the
tunnel:
```text
RuntimeProvider::connect_tcp() → tunnel.tcp_connect() → AsyncIoTokioAsStd<TcpStream>
RuntimeProvider::bind_udp() → tunnel.udp_socket() → SmolmixUdpSocket (newtype)
```
hickory expects `futures_io::AsyncRead/Write` for TCP, not tokio's version.
`AsyncIoTokioAsStd` (from hickory-proto) adapts between them — and because
hickory's `DnsTcpStream` has a blanket impl for any `futures_io::AsyncRead +
AsyncWrite`, the wrapped stream satisfies it automatically. Zero glue code.
For UDP, `SmolmixUdpSocket` is a thin newtype over `tokio_smoltcp::UdpSocket`
that implements hickory's `DnsUdpSocket` — just delegates `poll_recv_from`
and `poll_send_to`, whose signatures already match.
### smolmix-tls
No trait adaptation needed. `tokio-rustls` works directly with anything that
implements tokio's `AsyncRead + AsyncWrite` — which `TcpStream` does. This
crate is pure configuration: build a `ClientConfig` with webpki roots, wrap
it in a `TlsConnector`, expose `connect()` and `connect_with()`.
Re-exports `TlsStream` and `TlsConnector` so downstream crates don't need
`tokio-rustls` in their Cargo.toml.
### smolmix-hyper
hyper-util's `Client` uses the `tower::Service<Uri>` trait to open
connections. `SmolmixConnector` implements this: given a URI, it resolves the
hostname, connects TCP, and optionally wraps in TLS.
The interesting part is the return type. hyper needs a stream that implements
`hyper::rt::Read + Write + Connection`. `TokioIo<T>` (from hyper-util)
provides this, but it needs a single type — not "sometimes TLS, sometimes
plain". `MaybeTlsStream` solves this with a two-variant enum and
`pin_project_lite` for safe pin projection through `AsyncRead`/`AsyncWrite`:
```text
SmolmixConnector::call(uri)
→ resolve + tcp_connect + optional TLS
→ MaybeTlsStream::Plain { TcpStream }
or MaybeTlsStream::Tls { TlsStream<TcpStream> }
→ TokioIo<MaybeTlsStream> (implements hyper's Read/Write/Connection)
```
### Advanced patterns (not published crates)
WebSocket and libp2p integration are demonstrated as examples rather than
separate crates — the glue code is minimal enough that users are better served
by copy-pasting and adapting:
- **WebSocket**: `tokio-tungstenite` takes any `AsyncRead + AsyncWrite`, so
`TlsStream<TcpStream>` works directly. See `core/examples/websocket.rs`.
- **libp2p**: implement the `Transport` trait, resolve DNS through the tunnel,
and bridge with `tokio_util::compat`. See `core/examples/libp2p_ping.rs`.
## Quick start
```rust
@@ -53,12 +133,39 @@ let udp = tunnel.udp_socket().await?;
udp.send_to(&packet, "1.1.1.1:53".parse()?).await?;
```
## Crates
| Crate | What it provides |
|-------|-----------------|
| [`smolmix`](core/) | `Tunnel`, `TcpStream`, `UdpSocket` |
| [`smolmix-dns`](dns/) | `Resolver` newtype wrapping hickory-resolver |
| [`smolmix-tls`](tls/) | Shared `TlsConnector` and `connect()` with webpki roots |
| [`smolmix-hyper`](hyper/) | `Client` newtype wrapping hyper-util |
`smolmix-hyper` depends on `smolmix-dns` (hostname resolution through the
tunnel) and `smolmix-tls` (shared TLS setup).
```toml
[dependencies]
smolmix = { workspace = true }
smolmix-dns = { workspace = true } # DNS resolution
smolmix-tls = { workspace = true } # TLS
smolmix-hyper = { workspace = true } # HTTP client
```
## Examples
Each crate has its own examples with clearnet-vs-mixnet comparisons:
```sh
cargo run -p smolmix --example tcp # HTTPS via hyper
cargo run -p smolmix --example udp # DNS via hickory-proto
cargo run -p smolmix --example websocket # WebSocket via tungstenite
cargo run -p smolmix --example tcp # raw TCP
cargo run -p smolmix --example udp # raw UDP
cargo run -p smolmix --example https # HTTPS via tokio-rustls
cargo run -p smolmix --example websocket # WebSocket over TLS
cargo run -p smolmix --example libp2p_ping # libp2p ping through mixnet
cargo run -p smolmix-dns --example resolve # DNS resolution
cargo run -p smolmix-hyper --example get # HTTPS GET via hyper
cargo run -p smolmix-hyper --example post # HTTP POST via hyper
```
## Architecture
+11 -2
View File
@@ -9,7 +9,7 @@ repository.workspace = true
homepage.workspace = true
documentation.workspace = true
rust-version.workspace = true
readme.workspace = true
readme = "src/ARCHITECTURE.md"
publish = true
[dependencies]
@@ -29,7 +29,7 @@ nym-ip-packet-requests = { workspace = true }
thiserror.workspace = true
[dev-dependencies]
futures = { workspace = true }
smolmix-dns = { workspace = true }
tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "time", "net"] }
tokio-tungstenite.workspace = true
webpki-roots.workspace = true
@@ -42,3 +42,12 @@ hyper = { workspace = true, features = ["client", "http1"] }
hyper-util = { workspace = true, features = ["tokio"] }
http-body-util = { workspace = true }
reqwest = { workspace = true, features = ["rustls"] }
chrono = { workspace = true }
libp2p = { version = "0.56", features = ["tokio", "noise", "yamux", "ping", "macros"] }
tokio-util = { workspace = true, features = ["compat"] }
[[example]]
name = "websocket"
[[example]]
name = "libp2p_ping"
-19
View File
@@ -1,19 +0,0 @@
# smolmix
A TCP/UDP tunnel over the Nym mixnet. Uses smoltcp as a userspace network stack and connects to an Exit Gateway's IP Packet Router, so the exit IP is the gateway's — not yours.
`Tunnel` gives you standard `TcpStream` and `UdpSocket` types (from tokio-smoltcp) that work transparently with the async Rust ecosystem: tokio-rustls for TLS, hyper for HTTP, tokio-tungstenite for WebSockets, etc.
## Examples
All examples include a clearnet-vs-mixnet comparison with timing and accept `--ipr <ADDRESS>` for targeting a specific exit node.
```sh
cargo run -p smolmix --example tcp # raw TCP connection
cargo run -p smolmix --example udp # raw UDP datagram
cargo run -p smolmix --example websocket # WebSocket over TLS (raw TcpStream composability)
```
## Architecture
See [`src/ARCHITECTURE.md`](src/ARCHITECTURE.md).
+115
View File
@@ -0,0 +1,115 @@
//! HTTPS GET: clearnet vs Nym mixnet.
//!
//! Fetches Cloudflare's `/cdn-cgi/trace` endpoint via clearnet and through the
//! mixnet. The same `https_get` function handles both — the only difference is
//! the TCP stream passed in:
//!
//! ```text
//! hyper (HTTP/1.1 framing)
//! └─ tokio-rustls (TLS)
//! └─ tokio::net::TcpStream (clearnet)
//! └─ smolmix::TcpStream (mixnet)
//! ```
//!
//! Run with:
//! cargo run -p smolmix --example https
//! cargo run -p smolmix --example https -- --ipr <IPR_ADDRESS>
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Empty};
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use rustls::pki_types::ServerName;
use smolmix::{Recipient, Tunnel};
use tokio::io::{AsyncRead, AsyncWrite};
use tracing::info;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const HOST: &str = "cloudflare.com";
const ADDR: &str = "1.1.1.1:443"; // cloudflare.com — hardcoded to skip DNS
const PATH: &str = "/cdn-cgi/trace";
fn tls_connector() -> tokio_rustls::TlsConnector {
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
tokio_rustls::TlsConnector::from(Arc::new(config))
}
/// HTTPS GET over any TCP stream — works identically with clearnet and mixnet.
async fn https_get(
tcp: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
tls: &tokio_rustls::TlsConnector,
host: &str,
path: &str,
) -> Result<(hyper::StatusCode, String), BoxError> {
let domain = ServerName::try_from(host)?.to_owned();
let tls_stream = tls.connect(domain, tcp).await?;
let (mut sender, conn) = http1::handshake(TokioIo::new(tls_stream)).await?;
tokio::spawn(conn);
let req = hyper::Request::get(path)
.header("host", host)
.body(Empty::<Bytes>::new())?;
let resp = sender.send_request(req).await?;
let status = resp.status();
let body = resp.into_body().collect().await?.to_bytes();
Ok((status, String::from_utf8_lossy(&body).to_string()))
}
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let tls = tls_connector();
let addr = ADDR.parse()?;
// Clearnet baseline
info!("Fetching https://{HOST}{PATH} via clearnet...");
let clearnet_start = tokio::time::Instant::now();
let clearnet_tcp = tokio::net::TcpStream::connect(addr).await?;
let (clearnet_status, clearnet_body) = https_get(clearnet_tcp, &tls, HOST, PATH).await?;
let clearnet_duration = clearnet_start.elapsed();
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
// Mixnet via smolmix
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
let tunnel = if let Some(addr) = ipr_addr {
let recipient: Recipient = addr.parse().expect("invalid IPR address");
Tunnel::new_with_ipr(recipient).await?
} else {
Tunnel::new().await?
};
info!("Fetching https://{HOST}{PATH} via mixnet...");
let mixnet_start = tokio::time::Instant::now();
let mixnet_tcp = tunnel.tcp_connect(addr).await?;
let (_mixnet_status, mixnet_body) = https_get(mixnet_tcp, &tls, HOST, PATH).await?;
let mixnet_duration = mixnet_start.elapsed();
// Compare
info!("Results");
let clearnet_ip = clearnet_body.lines().find(|l| l.starts_with("ip="));
let mixnet_ip = mixnet_body.lines().find(|l| l.starts_with("ip="));
info!("Clearnet IP: {}", clearnet_ip.unwrap_or("?"));
info!("Mixnet IP: {}", mixnet_ip.unwrap_or("?"));
let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
info!("Slowdown: {slowdown:.1}x");
tunnel.shutdown().await;
Ok(())
}
+228
View File
@@ -0,0 +1,228 @@
//! libp2p ping through the Nym mixnet.
//!
//! Demonstrates implementing a libp2p `Transport` over smolmix. The transport
//! is dial-only (no listening through the mixnet) and supports both IP and DNS
//! multiaddrs. DNS is resolved through the tunnel to avoid hostname leaks.
//!
//! This example includes the full `SmolmixTransport` implementation inline —
//! it's ~80 lines of glue code, intended to be copy-pasted and adapted rather
//! than imported as a dependency.
//!
//! Run with:
//! # Terminal 1: start a clearnet libp2p listener
//! # (you'll need a separate binary — see the listener pattern below)
//!
//! # Terminal 2: dial through the mixnet
//! cargo run -p smolmix --example libp2p_ping -- <MULTIADDR>
//! cargo run -p smolmix --example libp2p_ping -- --ipr <IPR_ADDRESS> <MULTIADDR>
use std::future::Future;
use std::io;
use std::net::SocketAddr;
use std::pin::Pin;
use std::task::{Context, Poll};
use futures::StreamExt;
use libp2p::core::multiaddr::Protocol;
use libp2p::core::transport::{DialOpts, ListenerId, TransportError, TransportEvent};
use libp2p::core::upgrade::Version;
use libp2p::core::Transport;
use libp2p::swarm::SwarmEvent;
use libp2p::{noise, ping, yamux, Multiaddr, SwarmBuilder};
use tokio_util::compat::{Compat, TokioAsyncReadCompatExt};
use tracing::info;
use smolmix::{Recipient, Tunnel};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
// -- SmolmixTransport: inline libp2p Transport impl --------------------------
/// A libp2p transport that routes TCP connections through a smolmix Tunnel.
///
/// Dial-only — listening is not supported (no inbound connections through the
/// mixnet). Supports `/ip4/.../tcp/N`, `/ip6/.../tcp/N`, `/dns4/.../tcp/N`,
/// and `/dns/.../tcp/N` multiaddrs.
struct SmolmixTransport {
tunnel: Tunnel,
}
impl SmolmixTransport {
fn new(tunnel: &Tunnel) -> Self {
Self {
tunnel: tunnel.clone(),
}
}
}
enum DialTarget {
Ip(SocketAddr),
Dns { host: String, port: u16 },
}
fn parse_multiaddr(addr: &Multiaddr) -> Option<DialTarget> {
let mut iter = addr.iter();
match iter.next()? {
Protocol::Ip4(ip) => {
if let Some(Protocol::Tcp(port)) = iter.next() {
Some(DialTarget::Ip(SocketAddr::new(ip.into(), port)))
} else {
None
}
}
Protocol::Ip6(ip) => {
if let Some(Protocol::Tcp(port)) = iter.next() {
Some(DialTarget::Ip(SocketAddr::new(ip.into(), port)))
} else {
None
}
}
Protocol::Dns4(host) | Protocol::Dns(host) => {
if let Some(Protocol::Tcp(port)) = iter.next() {
Some(DialTarget::Dns {
host: host.to_string(),
port,
})
} else {
None
}
}
_ => None,
}
}
impl Transport for SmolmixTransport {
type Output = Compat<tokio_smoltcp::TcpStream>;
type Error = io::Error;
type ListenerUpgrade = Pin<Box<dyn Future<Output = Result<Self::Output, Self::Error>> + Send>>;
type Dial = Pin<Box<dyn Future<Output = Result<Self::Output, Self::Error>> + Send>>;
fn listen_on(
&mut self,
_id: ListenerId,
addr: Multiaddr,
) -> Result<(), TransportError<Self::Error>> {
Err(TransportError::MultiaddrNotSupported(addr))
}
fn remove_listener(&mut self, _id: ListenerId) -> bool {
false
}
fn dial(
&mut self,
addr: Multiaddr,
_opts: DialOpts,
) -> Result<Self::Dial, TransportError<Self::Error>> {
let target = parse_multiaddr(&addr).ok_or(TransportError::MultiaddrNotSupported(addr))?;
let tunnel = self.tunnel.clone();
Ok(Box::pin(async move {
let socket_addr = match target {
DialTarget::Ip(addr) => addr,
DialTarget::Dns { host, port } => {
let addrs = smolmix_dns::resolve(&tunnel, &host, port).await?;
addrs.into_iter().next().ok_or_else(|| {
io::Error::new(io::ErrorKind::AddrNotAvailable, "DNS returned no addresses")
})?
}
};
let tcp = tunnel
.tcp_connect(socket_addr)
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(tcp.compat())
}))
}
fn poll(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<TransportEvent<Self::ListenerUpgrade, Self::Error>> {
Poll::Pending
}
}
// -- Main: ping a peer through the mixnet ------------------------------------
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
let args: Vec<String> = std::env::args().collect();
// Parse optional --ipr flag
let ipr_pos = args.iter().position(|a| a == "--ipr");
let ipr_addr = ipr_pos.and_then(|i| args.get(i + 1));
// The multiaddr is the last positional argument (skip --ipr and its value)
let multiaddr_str = args
.iter()
.enumerate()
.filter(|(i, _)| *i != 0 && Some(*i) != ipr_pos && Some(*i) != ipr_pos.map(|p| p + 1))
.map(|(_, s)| s)
.last()
.expect(
"Usage: libp2p_ping [--ipr <IPR_ADDRESS>] <MULTIADDR>\n\
\n\
The MULTIADDR should point to a libp2p node running the ping protocol.\n\
Example: /ip4/1.2.3.4/tcp/4001/p2p/12D3Koo...",
);
let remote: Multiaddr = multiaddr_str.parse()?;
let tunnel = if let Some(addr) = ipr_addr {
let recipient: Recipient = addr.parse().expect("invalid IPR address");
Tunnel::new_with_ipr(recipient).await?
} else {
Tunnel::new().await?
};
// Build the swarm with our inline mixnet transport
let mut swarm = SwarmBuilder::with_new_identity()
.with_tokio()
.with_other_transport(|keypair| {
SmolmixTransport::new(&tunnel)
.upgrade(Version::V1)
.authenticate(noise::Config::new(keypair).expect("noise config"))
.multiplex(yamux::Config::default())
.boxed()
})?
.with_behaviour(|_| ping::Behaviour::default())?
.build();
info!("Local peer ID: {}", swarm.local_peer_id());
info!("Dialing {remote} through the Nym mixnet...");
swarm.dial(remote)?;
let mut pings = 0u32;
loop {
match swarm.select_next_some().await {
SwarmEvent::Behaviour(ping::Event { peer, result, .. }) => match result {
Ok(rtt) => {
pings += 1;
info!("Ping #{pings} to {peer}: {rtt:?}");
if pings >= 5 {
info!("Done — {pings} pings completed.");
break;
}
}
Err(e) => {
info!("Ping error from {peer}: {e}");
break;
}
},
SwarmEvent::ConnectionEstablished { peer_id, .. } => {
info!("Connected to {peer_id}");
}
SwarmEvent::OutgoingConnectionError { error, .. } => {
info!("Connection error: {error}");
break;
}
_ => {}
}
}
tunnel.shutdown().await;
Ok(())
}
+39 -10
View File
@@ -1,13 +1,34 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! HTTPS request through the Nym mixnet.
//!
//! Fetches Cloudflare's `/cdn-cgi/trace` diagnostic endpoint over clearnet
//! (reqwest) and through the mixnet (hyper over tokio-rustls over smolmix),
//! then compares the responses. The exit IP should differ — the mixnet path
//! exits through an IPR gateway.
//! Fetches Cloudflare's `/cdn-cgi/trace` over clearnet (reqwest) and through
//! the mixnet (hyper over tokio-rustls over smolmix), then compares the exit
//! IPs. The mixnet path should show a different IP — traffic exits through
//! an IPR (Internet Packet Router) gateway, not your machine.
//!
//! Run with:
//! cargo run -p smolmix --example tcp
//! cargo run -p smolmix --example tcp -- --ipr <IPR_ADDRESS>
//! ```text
//! hyper (HTTP/1.1 client)
//! └─ tokio-rustls (TLS encryption)
//! └─ smolmix::TcpStream (TCP over mixnet)
//! └─ smoltcp (userspace TCP/IP)
//! └─ Nym mixnet → IPR exit gateway → internet
//! ```
//!
//! ## What this demonstrates
//!
//! - Creating a [`Tunnel`] and connecting TCP through the mixnet
//! - Layering TLS ([`tokio_rustls`]) on a [`smolmix::TcpStream`] — it
//! implements `AsyncRead + AsyncWrite`, so standard crates work unchanged
//! - Using [`hyper`]'s HTTP/1.1 client over a custom transport via
//! [`TokioIo`](hyper_util::rt::TokioIo)
//! - The exit IP differs from clearnet — the remote server sees the IPR
//! gateway's IP, not yours
//!
//! ```sh
//! cargo run -p smolmix --example tcp
//! cargo run -p smolmix --example tcp -- --ipr <IPR_ADDRESS>
//! ```
use std::sync::Arc;
@@ -50,7 +71,10 @@ async fn main() -> Result<(), BoxError> {
let clearnet_duration = clearnet_start.elapsed();
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
// Mixnet: smolmix TCP -> tokio-rustls -> hyper
// -- Mixnet path --
// Create a tunnel, then stack the same TLS + HTTP layers on top.
// The only difference: smolmix::TcpStream instead of tokio::net::TcpStream.
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
@@ -63,7 +87,10 @@ async fn main() -> Result<(), BoxError> {
}
let tunnel = builder.build().await?;
// Phase 1: Setup (TCP + TLS + HTTP handshakes)
// TCP + TLS + HTTP handshakes through the mixnet.
// tcp_connect() returns a TcpStream that implements AsyncRead + AsyncWrite.
// tokio-rustls accepts it directly — no adapters or trait shims needed.
// TokioIo then bridges hyper's I/O traits with tokio's.
let setup_start = tokio::time::Instant::now();
info!("TCP connecting to 1.1.1.1:443 via mixnet...");
@@ -83,7 +110,9 @@ async fn main() -> Result<(), BoxError> {
let setup_duration = setup_start.elapsed();
info!("Setup complete ({:?})", setup_duration);
// Phase 2: Request/response
// Send request and read response.
// From here the code is identical to any hyper client — the mixnet
// transport is invisible to higher layers.
let request_start = tokio::time::Instant::now();
info!("Sending GET {PATH}...");
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! Multi-request HTTPS download through the Nym mixnet.
//!
//! Resolves a hostname via the smolmix-dns resolver, then makes multiple
//! HTTP/1.1 requests over a single keep-alive TCP+TLS connection — all
//! routed through the mixnet. Demonstrates DNS resolution, connection
//! reuse, and progress feedback.
//!
//! ```text
//! hyper (HTTP/1.1 client, keep-alive)
//! └─ tokio-rustls (TLS encryption)
//! └─ smolmix::TcpStream (TCP over mixnet)
//! └─ smoltcp (userspace TCP/IP)
//! └─ Nym mixnet → IPR exit gateway → internet
//! ```
//!
//! ## What this demonstrates
//!
//! - DNS resolution via smolmix-dns (avoids clearnet DNS leaks)
//! - TCP + TLS connection to the resolved IP
//! - HTTP/1.1 keep-alive: multiple requests over one mixnet connection
//! - Progress spinner during downloads
//!
//! Compare with `tcp.rs` which does a single request with clearnet comparison.
//!
//! ```sh
//! cargo run -p smolmix --example tcp_download
//! cargo run -p smolmix --example tcp_download -- --ipr <IPR_ADDRESS>
//! ```
use std::sync::Arc;
use http_body_util::{BodyExt, Empty};
use hyper::body::Bytes;
use hyper::client::conn::http1;
use hyper_util::rt::TokioIo;
use rustls::pki_types::ServerName;
use smolmix::Tunnel;
use smolmix_dns::Resolver;
use tracing::info;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const HOST: &str = "httpbin.org";
/// Sizes (in bytes) to download sequentially over one connection.
const SIZES: &[usize] = &[100, 1_000, 10_000];
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
// Create the tunnel
let mut builder = Tunnel::builder();
if let Some(addr) = ipr_addr {
builder = builder.ipr_address(addr.parse().expect("invalid IPR address"));
}
let tunnel = builder.build().await?;
info!(
"Tunnel ready — allocated IP: {}",
tunnel.allocated_ips().ipv4
);
// DNS resolution via mixnet
let resolver = Resolver::new(&tunnel);
let addrs = resolver.resolve(HOST, 443).await?;
let addr = addrs.into_iter().next().ok_or("no addresses resolved")?;
info!("Resolved {HOST} → {addr} (via mixnet DNS)");
// TCP + TLS through the mixnet
info!("Connecting to {HOST}:443...");
let tcp = tunnel.tcp_connect(addr).await?;
info!("TCP connected to {addr} via mixnet");
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let domain = ServerName::try_from(HOST)?.to_owned();
let tls = connector.connect(domain, tcp).await?;
info!("TLS established with {HOST}");
// HTTP/1.1 connection (reused for all requests)
let io = TokioIo::new(tls);
let (mut sender, conn) = http1::handshake(io).await?;
tokio::spawn(conn);
// Multiple requests over the same connection
let total = SIZES.len();
info!("Sending {total} requests over one connection...");
let overall = std::time::Instant::now();
let mut total_bytes = 0usize;
for (i, &size) in SIZES.iter().enumerate() {
let seq = i + 1;
let start = std::time::Instant::now();
let req = hyper::Request::get(format!("/bytes/{size}"))
.header("Host", HOST)
.body(Empty::<Bytes>::new())?;
let spinner = tokio::spawn(async move {
let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let mut i = 0;
loop {
eprint!(
"\r {} [{seq}/{total}] GET /bytes/{size}",
frames[i % frames.len()]
);
i += 1;
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
}
});
let resp = sender.send_request(req).await?;
let status = resp.status();
let body = resp.into_body().collect().await?.to_bytes();
let elapsed = start.elapsed();
spinner.abort();
eprint!("\r \r");
let speed = body.len() as f64 / elapsed.as_secs_f64();
info!(
"[{seq}/{total}] GET /bytes/{size} → {status} {} in {elapsed:.1?} ({}/s)",
format_bytes(body.len() as u64),
format_bytes(speed as u64),
);
total_bytes += body.len();
}
let elapsed = overall.elapsed();
info!(
"Done! {} in {total} requests over {elapsed:.1?}",
format_bytes(total_bytes as u64),
);
tunnel.shutdown().await;
Ok(())
}
fn format_bytes(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1} MB", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1} KB", n as f64 / 1_000.0)
} else {
format!("{n} B")
}
}
+29 -7
View File
@@ -1,13 +1,31 @@
//! DNS lookup through the Nym mixnet.
//!
//! Resolves `example.com` via clearnet (hickory-resolver) and via the mixnet
//! (hickory-proto UDP query to Cloudflare 1.1.1.1), then compares resolved
//! IPs and timing.
//! (hickory-proto raw UDP query to Cloudflare `1.1.1.1`), then compares
//! resolved IPs and timing.
//!
//! ```text
//! DNS query / response (application-layer UDP)
//! └─ smolmix::UdpSocket (UDP over mixnet)
//! └─ smoltcp (userspace IP stack)
//! └─ Nym mixnet → IPR exit gateway → internet
//! ```
//!
//! Run with:
//! cargo run -p smolmix --example udp
//! cargo run -p smolmix --example udp -- --ipr <IPR_ADDRESS>
//! ## What this demonstrates
//!
//! - Creating a [`Tunnel`] and using its [`UdpSocket`](smolmix::UdpSocket)
//! - The `send_to` / `recv_from` API matches [`tokio::net::UdpSocket`]
//! - Constructing a raw DNS query with [`hickory_proto`] and parsing the
//! response — standard crates work unchanged over smolmix UDP
//! - The DNS server sees the IPR gateway's IP, not yours
//!
//! For a more complete UDP example (multiple lookups + NTP time sync), see
//! the test tutorial in `test-tutorials/smolmix-udp/`.
//!
//! ```sh
//! cargo run -p smolmix --example udp
//! cargo run -p smolmix --example udp -- --ipr <IPR_ADDRESS>
//! ```
use std::net::Ipv4Addr;
@@ -40,7 +58,10 @@ async fn main() -> Result<(), BoxError> {
let clearnet_duration = clearnet_start.elapsed();
info!("Clearnet: {:?} in {:?}", clearnet_ips, clearnet_duration);
// Mixnet: hickory-proto query over smolmix UDP
// We use hickory-proto (not hickory-resolver) because we want to send
// the raw UDP query through the tunnel ourselves, rather than relying
// on the system resolver which would go over clearnet.
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
@@ -60,7 +81,8 @@ async fn main() -> Result<(), BoxError> {
query.add_query(Query::query(Name::from_ascii(domain)?, RecordType::A));
let query_bytes = query.to_vec()?;
// UDP is connectionless — no setup phase, just send/recv
// Send the DNS query through the mixnet.
// UDP is connectionless — no handshake, just send_to / recv_from.
info!("Sending DNS query via mixnet...");
let mixnet_start = tokio::time::Instant::now();
udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;
+183
View File
@@ -0,0 +1,183 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! Multiple DNS lookups + NTP time sync through the Nym mixnet.
//!
//! Resolves several hostnames and syncs the clock via NTP — all over
//! mixnet UDP. Demonstrates timeout handling, socket reuse, and raw
//! protocol construction over smolmix's `UdpSocket`.
//!
//! ```text
//! DNS / NTP (application-layer UDP protocols)
//! └─ smolmix::UdpSocket (UDP over mixnet)
//! └─ smoltcp (userspace IP stack)
//! └─ Nym mixnet → IPR exit gateway → internet
//! ```
//!
//! ## What this demonstrates
//!
//! - Multiple DNS queries over a single `UdpSocket`
//! - Timeout handling with `tokio::time::timeout` (essential for UDP)
//! - NTP time sync via a raw 48-byte UDP packet
//! - Converting NTP epoch (1900) to Unix epoch (1970)
//!
//! Compare with `udp.rs` which does a single DNS lookup with clearnet comparison.
//!
//! ```sh
//! cargo run -p smolmix --example udp_multi
//! cargo run -p smolmix --example udp_multi -- --ipr <IPR_ADDRESS>
//! ```
use std::net::Ipv4Addr;
use hickory_proto::op::{Message, Query};
use hickory_proto::rr::{Name, RData, RecordType};
use smolmix::Tunnel;
use tracing::info;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Hostnames to resolve via mixnet DNS.
const DNS_TARGETS: &[&str] = &["example.com", "cloudflare.com", "nymtech.net"];
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
// Create the tunnel
let mut builder = Tunnel::builder();
if let Some(addr) = ipr_addr {
builder = builder.ipr_address(addr.parse().expect("invalid IPR address"));
}
let tunnel = builder.build().await?;
info!(
"Tunnel ready — allocated IP: {}",
tunnel.allocated_ips().ipv4
);
// Multiple DNS lookups over one UdpSocket
// Each query goes to Cloudflare DNS (1.1.1.1:53) through the mixnet.
// The DNS server sees the IPR exit gateway's IP, not yours.
info!("Private DNS Lookups (via mixnet UDP)");
let udp = tunnel.udp_socket().await?;
for host in DNS_TARGETS {
let start = std::time::Instant::now();
let mut query = Message::new();
query.set_recursion_desired(true);
query.add_query(Query::query(Name::from_ascii(host)?, RecordType::A));
let query_bytes = query.to_vec()?;
udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;
let mut buf = vec![0u8; 1500];
let result =
tokio::time::timeout(std::time::Duration::from_secs(15), udp.recv_from(&mut buf)).await;
match result {
Ok(Ok((n, _))) => {
let rtt = start.elapsed();
let response = Message::from_vec(&buf[..n])?;
let ips: Vec<_> = response
.answers()
.iter()
.filter_map(|r| match r.data() {
RData::A(a) => Some(a.0.to_string()),
_ => None,
})
.collect();
info!("{host:<16} → {} (rtt: {rtt:.1?})", ips.join(", "));
}
Ok(Err(e)) => info!("{host:<16} → ERROR: {e}"),
Err(_) => info!("{host:<16} → TIMEOUT"),
}
}
// NTP time sync via mixnet UDP
// NTP uses a simple 48-byte request/response over UDP port 123.
// We first resolve pool.ntp.org via the mixnet, then send the NTP request.
info!("NTP Time Sync (via mixnet UDP)");
let ntp_ip = resolve_dns(&tunnel, "pool.ntp.org").await?;
info!("Resolved pool.ntp.org → {ntp_ip}");
// NTP request: 48 bytes, LI=0 Version=4 Mode=3 (client)
let mut ntp_req = [0u8; 48];
ntp_req[0] = 0x23;
let ntp_udp = tunnel.udp_socket().await?;
let start = std::time::Instant::now();
let ntp_dest: std::net::SocketAddr = (ntp_ip, 123).into();
ntp_udp.send_to(&ntp_req, ntp_dest).await?;
let mut buf = [0u8; 48];
let result = tokio::time::timeout(
std::time::Duration::from_secs(30),
ntp_udp.recv_from(&mut buf),
)
.await;
match result {
Ok(Ok((n, _))) if n >= 48 => {
let rtt = start.elapsed();
// Transmit timestamp at bytes 40..48 (seconds since 1900-01-01)
let secs = u32::from_be_bytes([buf[40], buf[41], buf[42], buf[43]]);
let frac = u32::from_be_bytes([buf[44], buf[45], buf[46], buf[47]]);
// NTP epoch (1900) → Unix epoch (1970)
// Valid for Era 0 (until 2036-02-07); Era 1 wraps secs to 0.
const NTP_TO_UNIX: u64 = 2_208_988_800;
let unix_secs = secs as u64 - NTP_TO_UNIX;
let millis = (frac as u64 * 1000) >> 32;
let dt =
chrono::DateTime::from_timestamp(unix_secs as i64, (millis * 1_000_000) as u32)
.expect("valid timestamp");
info!("NTP response in {rtt:.1?}");
info!("Unix timestamp: {unix_secs}.{millis:03}");
info!("UTC: {}", dt.format("%Y-%m-%d %H:%M:%S%.3f UTC"));
}
Ok(Ok((n, _))) => info!("Short response: {n} bytes (expected 48)"),
Ok(Err(e)) => info!("ERROR: {e}"),
Err(_) => info!("TIMEOUT (30s)"),
}
tunnel.shutdown().await;
Ok(())
}
/// Resolve a hostname to an IPv4 address via mixnet UDP DNS.
async fn resolve_dns(tunnel: &Tunnel, host: &str) -> Result<Ipv4Addr, BoxError> {
let mut query = Message::new();
query.set_recursion_desired(true);
query.add_query(Query::query(Name::from_ascii(host)?, RecordType::A));
let query_bytes = query.to_vec()?;
let udp = tunnel.udp_socket().await?;
udp.send_to(&query_bytes, "1.1.1.1:53".parse()?).await?;
let mut buf = vec![0u8; 1500];
let (n, _) = udp.recv_from(&mut buf).await?;
let response = Message::from_vec(&buf[..n])?;
let ip = response
.answers()
.iter()
.find_map(|r| match r.data() {
RData::A(a) => Some(a.0),
_ => None,
})
.ok_or("no A record in DNS response")?;
Ok(ip)
}
+28 -13
View File
@@ -1,11 +1,8 @@
//! WebSocket echo over the Nym mixnet.
//!
//! Demonstrates stacking tokio-tungstenite on top of tokio-rustls on top of
//! smolmix TcpStream. Sends a message to a public echo server via clearnet
//! and via the mixnet, then compares responses and timing.
//!
//! The clearnet and mixnet paths use the *exact same* TLS + WebSocket stack —
//! only the underlying TCP transport differs:
//! Sends a message to a public echo server via clearnet and via the mixnet,
//! then compares responses and timing. The clearnet and mixnet paths use the
//! *exact same* TLS + WebSocket stack — only the TCP transport differs.
//!
//! ```text
//! tokio-tungstenite (WebSocket framing)
@@ -14,9 +11,19 @@
//! └─ smolmix::TcpStream (mixnet)
//! ```
//!
//! Run with:
//! cargo run -p smolmix --example websocket
//! cargo run -p smolmix --example websocket -- --ipr <IPR_ADDRESS>
//! ## What this demonstrates
//!
//! - Composability: [`tokio_tungstenite::client_async`] accepts any
//! `AsyncRead + AsyncWrite` stream — it doesn't know or care that
//! TLS is backed by the mixnet rather than a kernel TCP socket
//! - The same `tls_connector()` and WebSocket upgrade code works for both
//! clearnet and mixnet — you only swap the underlying TCP stream
//! - The echo server sees the IPR gateway's IP, not yours
//!
//! ```sh
//! cargo run -p smolmix --example websocket
//! cargo run -p smolmix --example websocket -- --ipr <IPR_ADDRESS>
//! ```
use std::sync::Arc;
@@ -48,7 +55,7 @@ async fn main() -> Result<(), BoxError> {
.install_default()
.expect("Failed to install rustls crypto provider");
// Resolve hostname via clearnet DNS - you can resolve via the Mixnet (see UDP example) but for this test its not necessary
// Resolve hostname via clearnet DNS you can resolve via the mixnet (see UDP example) but for this test it's not necessary
let addr = tokio::net::lookup_host(format!("{WS_HOST}:443"))
.await?
.next()
@@ -75,7 +82,11 @@ async fn main() -> Result<(), BoxError> {
info!("Clearnet: \"{clearnet_text}\" in {clearnet_duration:?}");
// Mixnet: smolmix TCP -> rustls -> tungstenite (same stack)
// -- Mixnet path --
// Exact same stack as clearnet, but over smolmix::TcpStream.
// This is the key composability point: swap the TCP transport
// and everything above it works unchanged.
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
@@ -89,7 +100,11 @@ async fn main() -> Result<(), BoxError> {
let tunnel = builder.build().await?;
info!("Allocated IP: {}", tunnel.allocated_ips().ipv4);
// Phase 1: Setup (TCP + TLS + WebSocket handshakes)
// TCP + TLS + WebSocket handshakes through the mixnet.
// Each layer only knows about the one directly below it:
// tungstenite thinks it's talking to a normal TLS stream
// rustls thinks it's talking to a normal TCP stream
// smolmix handles the mixnet routing transparently
let setup_start = tokio::time::Instant::now();
info!("TCP connecting via mixnet...");
@@ -107,7 +122,7 @@ async fn main() -> Result<(), BoxError> {
let setup_duration = setup_start.elapsed();
info!("Setup complete ({:?})", setup_duration);
// Phase 2: Echo request/response
// Send a message and verify the echo.
let request_start = tokio::time::Instant::now();
mixnet_ws.send(Message::Text(ECHO_MSG.into())).await?;
+1 -2
View File
@@ -1,5 +1,4 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
use crate::error::SmolmixError;
use futures::channel::mpsc;
+1 -2
View File
@@ -1,5 +1,4 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! Async device adapter for tokio-smoltcp.
//!
+11 -2
View File
@@ -1,19 +1,28 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SmolmixError {
/// The internal channel between the bridge and the network device was closed.
///
/// This typically means the bridge task has exited — either because the tunnel
/// was shut down or the mixnet connection was lost.
#[error("Channel closed")]
ChannelClosed,
/// The IPR handshake has not completed.
///
/// Returned from [`Tunnel::from_stream`](crate::Tunnel::from_stream) when
/// the provided `IpMixStream` is not in a connected state.
#[error("Not connected to IPR")]
NotConnected,
/// An error from the Nym SDK (mixnet client, gateway connection, etc.).
#[error("Nym SDK error: {0}")]
NymSdk(#[from] nym_sdk::Error),
/// An I/O error from the underlying TCP/UDP socket operations.
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
+4 -7
View File
@@ -1,5 +1,7 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! TCP/UDP tunnel over the Nym mixnet — standard `TcpStream` and `UdpSocket`
//! types that route all traffic through a mixnet for metadata privacy.
#![doc = include_str!("ARCHITECTURE.md")]
@@ -36,12 +38,7 @@ pub use tunnel::Recipient;
/// ```
pub use tunnel::TcpStream;
/// A mixnet tunnel providing TCP and UDP socket access.
pub use tunnel::Tunnel;
/// Builder for configuring and creating a [`Tunnel`].
///
/// See [`Tunnel::builder()`] for usage.
pub use tunnel::TunnelBuilder;
/// A UDP socket routed through the mixnet. Supports `send_to` / `recv_from`.
+2 -5
View File
@@ -1,5 +1,4 @@
// Copyright 2024 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-2.0-only
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! High-level tunnel providing TCP and UDP sockets over the Nym mixnet.
//!
@@ -49,7 +48,6 @@ struct TunnelInner {
/// (Internet Packet Router). It spawns a background bridge task and a network reactor,
/// then provides familiar socket APIs on top.
///
///
/// # Shutdown
///
/// Call [`shutdown()`](Self::shutdown) for a clean disconnect. Rust has no async `Drop`,
@@ -115,8 +113,7 @@ pub struct Tunnel {
///
/// For full control over the mixnet client (credentials, gateway selection,
/// storage, etc.), configure an [`IpMixStream`] directly and pass it to
/// [`Tunnel::from_stream()`]. Deeper builder integration with `MixnetClientBuilder`
/// will require upstream SDK changes to expose `IpMixStream` internals (TODO).
/// [`Tunnel::from_stream()`].
pub struct TunnelBuilder {
ipr_address: Option<Recipient>,
}
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "smolmix-dns"
description = "DNS resolution through the Nym mixnet via hickory-resolver"
version = "0.0.1"
authors.workspace = true
edition = "2021"
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
rust-version.workspace = true
readme = "README.md"
publish = true
[dependencies]
smolmix = { workspace = true }
hickory-proto = { workspace = true, features = ["tokio"] }
hickory-resolver = { workspace = true, features = ["tokio"] }
tokio-smoltcp = { workspace = true }
[dev-dependencies]
hickory-resolver = { workspace = true, features = ["tokio", "system-config"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
tracing = { workspace = true }
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
[[example]]
name = "resolve"
+52
View File
@@ -0,0 +1,52 @@
# smolmix-dns
DNS resolution through the Nym mixnet. Wraps [hickory-resolver](https://docs.rs/hickory-resolver) with a `Resolver` newtype that routes all DNS queries through a smolmix `Tunnel`, preventing hostname leaks to the local network.
## Quick start
```rust
use smolmix_dns::Resolver;
let tunnel = smolmix::Tunnel::new().await?;
let resolver = Resolver::new(&tunnel);
// Full hickory-resolver API via Deref:
let lookup = resolver.lookup_ip("example.com").await?;
for ip in lookup.iter() { println!("{ip}"); }
// Convenience one-shot:
let addrs = resolver.resolve("example.com", 443).await?;
```
## API
- **`Resolver::new(&tunnel)`** — Quad9 upstream (`9.9.9.9`)
- **`Resolver::with_config(&tunnel, config)`** — custom upstream DNS
- **`Resolver::resolve(&self, host, port)`** — convenience one-shot returning `Vec<SocketAddr>`
- **`Deref` to hickory's `Resolver`** — full `lookup_ip()`, `lookup()`, etc.
- **`resolve(&tunnel, host, port)`** — free function for quick one-shots
- **`resolver(&tunnel)`** — free function returning a `Resolver`
### Re-exports
Commonly-used hickory types are re-exported so you don't need `hickory-resolver` in your `Cargo.toml`:
- `ResolverConfig`, `LookupIp`, `ResolveError`
## Example
Clearnet-vs-mixnet DNS comparison with timing:
```sh
cargo run -p smolmix-dns --example resolve
cargo run -p smolmix-dns --example resolve -- --ipr <IPR_ADDRESS>
```
## Dependencies
```toml
[dependencies]
smolmix-dns = { workspace = true }
```
This crate depends on `smolmix` (for the `Tunnel` type), `hickory-proto`, and `hickory-resolver`.
+85
View File
@@ -0,0 +1,85 @@
//! DNS resolution: clearnet vs Nym mixnet comparison.
//!
//! Resolves a hostname via clearnet (hickory-resolver) and via the mixnet
//! (smolmix-dns), then compares resolved IPs and timing.
//!
//! Run with:
//! cargo run -p smolmix-dns --example resolve
//! cargo run -p smolmix-dns --example resolve -- --ipr <IPR_ADDRESS>
use std::net::Ipv4Addr;
use hickory_resolver::TokioResolver;
use smolmix::{Recipient, Tunnel};
use smolmix_dns::Resolver;
use tracing::info;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
let domain = "example.com";
// Clearnet baseline via hickory-resolver
info!("Clearnet DNS lookup for '{domain}'...");
let clearnet_resolver = TokioResolver::builder_tokio()?.build();
let clearnet_start = tokio::time::Instant::now();
let lookup = clearnet_resolver.lookup_ip(domain).await?;
let clearnet_ips: Vec<Ipv4Addr> = lookup
.iter()
.filter_map(|ip| match ip {
std::net::IpAddr::V4(v4) => Some(v4),
_ => None,
})
.collect();
let clearnet_duration = clearnet_start.elapsed();
info!("Clearnet: {:?} in {:?}", clearnet_ips, clearnet_duration);
// Mixnet via smolmix-dns
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
let tunnel = if let Some(addr) = ipr_addr {
let recipient: Recipient = addr.parse().expect("invalid IPR address");
Tunnel::new_with_ipr(recipient).await?
} else {
Tunnel::new().await?
};
let resolver = Resolver::new(&tunnel);
// Full hickory API via Deref:
let mixnet_start = tokio::time::Instant::now();
let lookup = resolver.lookup_ip(domain).await?;
let mixnet_ips: Vec<Ipv4Addr> = lookup
.iter()
.filter_map(|ip| match ip {
std::net::IpAddr::V4(v4) => Some(v4),
_ => None,
})
.collect();
let mixnet_duration = mixnet_start.elapsed();
// Convenience method for a second lookup:
let addrs = resolver.resolve("nymtech.net", 443).await?;
info!("Resolved nymtech.net:443 → {addrs:?}");
// Compare
info!("Results");
info!("Clearnet: {:?} ({:?})", clearnet_ips, clearnet_duration);
info!("Mixnet: {:?} ({:?})", mixnet_ips, mixnet_duration);
let ips_match = !mixnet_ips.is_empty() && mixnet_ips.iter().all(|ip| clearnet_ips.contains(ip));
info!("IPs match: {ips_match}");
let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
info!("Slowdown: {slowdown:.1}x");
tunnel.shutdown().await;
Ok(())
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! DNS resolution through the Nym mixnet.
//!
//! # Why a separate DNS crate?
//!
//! If you resolve hostnames using the OS resolver or a clearnet DNS library,
//! the queries travel over your local network — leaking which domains you're
//! visiting even though the TCP traffic itself goes through the mixnet. This
//! crate routes all DNS queries (both UDP and TCP) through the smolmix
//! [`Tunnel`], so hostname lookups are as private as the rest of your traffic.
//!
//! # How it works
//!
//! [hickory-resolver](https://docs.rs/hickory-resolver)'s extension point is
//! the [`RuntimeProvider`] trait — it controls how the resolver creates TCP
//! connections and UDP sockets. [`SmolmixRuntimeProvider`] implements this
//! trait, routing all I/O through the tunnel:
//!
//! ```text
//! RuntimeProvider::connect_tcp() → tunnel.tcp_connect() → AsyncIoTokioAsStd<TcpStream>
//! RuntimeProvider::bind_udp() → tunnel.udp_socket() → SmolmixUdpSocket (newtype)
//! ```
//!
//! hickory expects `futures_io::AsyncRead/Write` for TCP, not tokio's version.
//! `AsyncIoTokioAsStd` (from hickory-proto) adapts between them — and because
//! hickory's `DnsTcpStream` has a blanket impl for any `futures_io::AsyncRead +
//! AsyncWrite`, the wrapped stream satisfies it automatically.
//!
//! For UDP, `SmolmixUdpSocket` is a thin newtype over `tokio_smoltcp::UdpSocket`
//! that implements hickory's [`DnsUdpSocket`](hickory_proto::udp::DnsUdpSocket)
//! — just delegates `poll_recv_from` and `poll_send_to`.
//!
//! # Quick start
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! use smolmix_dns::Resolver;
//!
//! let tunnel = smolmix::Tunnel::new().await?;
//! let resolver = Resolver::new(&tunnel);
//!
//! // Full hickory API via Deref:
//! let lookup = resolver.lookup_ip("example.com").await?;
//! for ip in lookup.iter() { println!("{ip}"); }
//!
//! // Convenience one-shot:
//! let addrs = resolver.resolve("example.com", 443).await?;
//! # Ok(())
//! # }
//! ```
//!
//! # Caching
//!
//! hickory-resolver maintains an internal LRU cache for DNS responses. To
//! benefit from caching, **reuse the [`Resolver`] across requests** rather
//! than creating a new one per lookup. The free function [`resolve()`]
//! constructs a fresh resolver each time and does not cache.
//!
//! # Custom upstream DNS
//!
//! By default, queries go to Quad9 (`9.9.9.9`). Use
//! [`Resolver::with_config()`] for other upstreams:
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! use smolmix_dns::{Resolver, ResolverConfig};
//!
//! let tunnel = smolmix::Tunnel::new().await?;
//! let resolver = Resolver::with_config(&tunnel, ResolverConfig::cloudflare());
//! # Ok(())
//! # }
//! ```
//!
//! [`RuntimeProvider`]: hickory_proto::runtime::RuntimeProvider
mod provider;
use std::io;
use std::net::SocketAddr;
use std::ops::Deref;
use hickory_resolver::config::NameServerConfigGroup;
use hickory_resolver::name_server::GenericConnector;
use hickory_proto::runtime::TokioHandle;
use smolmix::Tunnel;
/// Re-exported from hickory-resolver. Used with [`Resolver::with_config()`]
/// to select a custom upstream DNS server (Quad9, Cloudflare, Google, etc.).
pub use hickory_resolver::config::ResolverConfig;
/// Re-exported from hickory-resolver. The result of a successful `lookup_ip()`
/// call — iterate with `.iter()` to get `IpAddr` values.
pub use hickory_resolver::lookup_ip::LookupIp;
/// Re-exported from hickory-resolver. The error type for DNS resolution failures.
pub use hickory_resolver::ResolveError;
/// The runtime provider that routes DNS I/O through the tunnel.
///
/// You don't usually need to use this directly — [`Resolver::new()`] wires it
/// up for you. Exposed for advanced use cases (custom resolver configurations
/// beyond what `with_config` covers).
pub use provider::SmolmixRuntimeProvider;
/// Inner resolver type alias for readability.
type HickoryResolver = hickory_resolver::Resolver<GenericConnector<SmolmixRuntimeProvider>>;
/// A DNS resolver that routes all queries through a smolmix [`Tunnel`].
///
/// Wraps a hickory-resolver `Resolver` and exposes its full API via [`Deref`].
/// All DNS traffic (both TCP and UDP) travels through the mixnet.
pub struct Resolver {
inner: HickoryResolver,
}
impl Resolver {
/// Create a resolver using Quad9 (`9.9.9.9`) as upstream DNS.
pub fn new(tunnel: &Tunnel) -> Self {
Self::with_config(tunnel, ResolverConfig::quad9())
}
/// Create a resolver with a custom upstream DNS configuration.
///
/// IPv6 nameservers are filtered out because the tunnel's smoltcp
/// interface is IPv4-only (see [`tokio_smoltcp::NetConfig`]). Passing a
/// config with *only* IPv6 nameservers will cause lookups to fail.
pub fn with_config(tunnel: &Tunnel, config: ResolverConfig) -> Self {
// tokio-smoltcp only supports a single IpCidr (IPv4), so contacting
// an IPv6 nameserver panics in smoltcp's wire layer (IP version
// mismatch). Strip IPv6 nameservers until the tunnel supports
// dual-stack.
let config = ipv4_only_nameservers(config);
let provider = SmolmixRuntimeProvider {
tunnel: tunnel.clone(),
handle: TokioHandle::default(),
};
let connector = GenericConnector::new(provider);
Self {
inner: hickory_resolver::Resolver::builder_with_config(config, connector).build(),
}
}
/// Resolve a hostname to socket addresses through the tunnel.
///
/// Convenience method for one-shot lookups. Returns all resolved addresses
/// paired with the given `port`.
pub async fn resolve(&self, host: &str, port: u16) -> io::Result<Vec<SocketAddr>> {
let lookup = self
.inner
.lookup_ip(host)
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(lookup.iter().map(|ip| SocketAddr::new(ip, port)).collect())
}
}
impl Deref for Resolver {
type Target = HickoryResolver;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
/// Create a hickory [`Resolver`] that routes all DNS through the tunnel.
///
/// Uses Quad9 (`9.9.9.9`) as the upstream DNS server. Equivalent to
/// [`Resolver::new()`].
pub fn resolver(tunnel: &Tunnel) -> Resolver {
Resolver::new(tunnel)
}
/// Resolve a hostname through the tunnel (uncached).
///
/// Convenience wrapper for one-shot lookups. Creates a fresh [`Resolver`]
/// internally, so **DNS responses are not cached** across calls. If you're
/// making multiple lookups, create a [`Resolver`] once and reuse it.
pub async fn resolve(tunnel: &Tunnel, host: &str, port: u16) -> io::Result<Vec<SocketAddr>> {
let r = resolver(tunnel);
r.resolve(host, port).await
}
/// Strip IPv6 nameservers from a resolver config.
///
/// hickory's preset configs (quad9, cloudflare, google) all include IPv6
/// nameservers. The smolmix tunnel is IPv4-only (tokio-smoltcp's `NetConfig`
/// takes a single `IpCidr`), so sending a UDP packet to an IPv6 nameserver
/// panics in smoltcp's wire layer with "IP version mismatch".
fn ipv4_only_nameservers(config: ResolverConfig) -> ResolverConfig {
let ipv4_servers: Vec<_> = config
.name_servers()
.iter()
.filter(|ns| ns.socket_addr.is_ipv4())
.cloned()
.collect();
ResolverConfig::from_parts(
config.domain().cloned(),
config.search().to_vec(),
NameServerConfigGroup::from(ipv4_servers),
)
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! hickory-resolver [`RuntimeProvider`] routing all DNS I/O through a [`Tunnel`].
use std::future::Future;
use std::io;
use std::net::SocketAddr;
use std::pin::Pin;
use std::task::{Context, Poll};
use hickory_proto::runtime::iocompat::AsyncIoTokioAsStd;
use hickory_proto::runtime::{RuntimeProvider, TokioHandle, TokioTime};
use hickory_proto::udp::DnsUdpSocket;
use smolmix::Tunnel;
/// UDP socket wrapper routing DNS queries through the tunnel.
///
/// Thin newtype around [`tokio_smoltcp::UdpSocket`] implementing hickory's
/// [`DnsUdpSocket`] trait. The poll methods delegate directly since the
/// signatures are identical.
pub struct SmolmixUdpSocket(tokio_smoltcp::UdpSocket);
impl DnsUdpSocket for SmolmixUdpSocket {
type Time = TokioTime;
fn poll_recv_from(
&self,
cx: &mut Context<'_>,
buf: &mut [u8],
) -> Poll<io::Result<(usize, SocketAddr)>> {
self.0.poll_recv_from(cx, buf)
}
fn poll_send_to(
&self,
cx: &mut Context<'_>,
buf: &[u8],
target: SocketAddr,
) -> Poll<io::Result<usize>> {
self.0.poll_send_to(cx, buf, target)
}
}
/// Runtime provider that routes all DNS I/O through a [`Tunnel`].
///
/// Implements hickory's [`RuntimeProvider`] so the resolver sends TCP and UDP DNS
/// traffic over the mixnet instead of the local network.
#[derive(Clone)]
pub struct SmolmixRuntimeProvider {
pub(crate) tunnel: Tunnel,
pub(crate) handle: TokioHandle,
}
impl RuntimeProvider for SmolmixRuntimeProvider {
type Handle = TokioHandle;
type Timer = TokioTime;
type Udp = SmolmixUdpSocket;
type Tcp = AsyncIoTokioAsStd<tokio_smoltcp::TcpStream>;
fn create_handle(&self) -> Self::Handle {
self.handle.clone()
}
fn connect_tcp(
&self,
server_addr: SocketAddr,
_bind_addr: Option<SocketAddr>,
_timeout: Option<std::time::Duration>,
) -> Pin<Box<dyn Send + Future<Output = io::Result<Self::Tcp>>>> {
let tunnel = self.tunnel.clone();
Box::pin(async move {
let tcp = tunnel
.tcp_connect(server_addr)
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(AsyncIoTokioAsStd(tcp))
})
}
fn bind_udp(
&self,
_local_addr: SocketAddr,
_server_addr: SocketAddr,
) -> Pin<Box<dyn Send + Future<Output = io::Result<Self::Udp>>>> {
let tunnel = self.tunnel.clone();
Box::pin(async move {
let udp = tunnel
.udp_socket()
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
Ok(SmolmixUdpSocket(udp))
})
}
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "smolmix-hyper"
description = "HTTP client routing all traffic through the Nym mixnet via hyper"
version = "0.0.1"
authors.workspace = true
edition = "2021"
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
rust-version.workspace = true
readme = "README.md"
publish = true
[dependencies]
smolmix = { workspace = true }
smolmix-dns = { workspace = true }
smolmix-tls = { workspace = true }
bytes = { workspace = true }
hyper = { workspace = true, features = ["client", "http1"] }
hyper-util = { workspace = true, features = ["tokio", "client-legacy"] }
http-body-util = { workspace = true }
tokio-smoltcp = { workspace = true }
pin-project-lite = "0.2"
tower = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
reqwest = { workspace = true }
rustls = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
tracing = { workspace = true }
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
[[example]]
name = "get"
[[example]]
name = "post"
+72
View File
@@ -0,0 +1,72 @@
# smolmix-hyper
HTTP client routing all traffic through the Nym mixnet. Wraps [hyper-util](https://docs.rs/hyper-util)'s `Client` with a newtype that handles DNS resolution, TCP connections, and TLS — all through a smolmix `Tunnel`.
## Quick start
```rust
use smolmix_hyper::{Client, Request, EmptyBody, BodyExt};
use bytes::Bytes;
let tunnel = smolmix::Tunnel::new().await?;
let client = Client::new(&tunnel);
let req = Request::get("https://example.com")
.header("Host", "example.com")
.body(EmptyBody::<Bytes>::new())?;
let resp = client.request(req).await?;
let body = resp.into_body().collect().await?.to_bytes();
```
## API
- **`Client::new(&tunnel)`** — create an HTTP client (body type: `Empty<Bytes>`, suitable for GET)
- **`Deref` to hyper-util's `Client`** — full `request()`, `get()`, etc.
- **`SmolmixConnector::new(&tunnel)`** — for custom body types (e.g. POST with `Full<Bytes>`)
- **`client(&tunnel)`** — free function returning a `Client`
### Re-exports
Commonly-used types are re-exported so you don't need `hyper`, `http-body-util`, or `bytes` in your `Cargo.toml`:
- `Request`, `Response`, `StatusCode`, `Uri`
- `BodyExt`, `EmptyBody` (alias for `http_body_util::Empty`)
- `bytes` (the crate, for `Bytes`)
### POST and custom body types
The `Client` newtype is typed for `Empty<Bytes>` (GET requests). For POST, use `SmolmixConnector` directly:
```rust
use smolmix_hyper::SmolmixConnector;
use hyper_util::client::legacy::Client;
use http_body_util::Full;
let connector = SmolmixConnector::new(&tunnel);
let client: Client<SmolmixConnector, Full<Bytes>> =
Client::builder(TokioExecutor::new()).build(connector);
let req = Request::post("https://httpbin.org/post")
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(r#"{"key": "value"}"#)))?;
let resp = client.request(req).await?;
```
## Examples
Clearnet-vs-mixnet HTTP comparisons with timing:
```sh
cargo run -p smolmix-hyper --example get # HTTPS GET
cargo run -p smolmix-hyper --example post # HTTP POST with JSON body
cargo run -p smolmix-hyper --example get -- --ipr <IPR_ADDRESS>
```
## Dependencies
```toml
[dependencies]
smolmix-hyper = { workspace = true }
```
This crate depends on `smolmix` and `smolmix-dns` (DNS resolution goes through the tunnel automatically).
+85
View File
@@ -0,0 +1,85 @@
//! HTTPS GET: clearnet vs Nym mixnet comparison.
//!
//! Fetches Cloudflare's `/cdn-cgi/trace` endpoint over clearnet (reqwest) and
//! through the mixnet (smolmix-hyper), then compares responses and timing.
//!
//! Run with:
//! cargo run -p smolmix-hyper --example get
//! cargo run -p smolmix-hyper --example get -- --ipr <IPR_ADDRESS>
use smolmix::{Recipient, Tunnel};
use smolmix_hyper::{BodyExt, Client, EmptyBody, Request};
use tracing::info;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let host = "cloudflare.com";
let path = "/cdn-cgi/trace";
// Clearnet baseline via reqwest
info!("Fetching via clearnet...");
let clearnet_start = tokio::time::Instant::now();
let clearnet_resp = reqwest::get(format!("https://{host}{path}")).await?;
let clearnet_status = clearnet_resp.status();
let clearnet_body = clearnet_resp.text().await?;
let clearnet_duration = clearnet_start.elapsed();
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
// Mixnet via smolmix-hyper
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
let tunnel = if let Some(addr) = ipr_addr {
let recipient: Recipient = addr.parse().expect("invalid IPR address");
Tunnel::new_with_ipr(recipient).await?
} else {
Tunnel::new().await?
};
let client = Client::new(&tunnel);
let mixnet_start = tokio::time::Instant::now();
let req = Request::get(format!("https://{host}{path}"))
.header("Host", host)
.body(EmptyBody::<bytes::Bytes>::new())?;
let resp = client.request(req).await?;
let mixnet_status = resp.status();
let body_bytes = resp.into_body().collect().await?.to_bytes();
let mixnet_body = String::from_utf8_lossy(&body_bytes);
let mixnet_duration = mixnet_start.elapsed();
// Compare
info!("Results");
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
info!("Mixnet: {} in {:?}", mixnet_status, mixnet_duration);
info!("Status match: {}", clearnet_status == mixnet_status);
let fields = ["fl=", "visit_scheme=https", "uag="];
for field in fields {
let clearnet_has = clearnet_body.contains(field);
let mixnet_has = mixnet_body.contains(field);
info!(" {field:<25} clearnet={clearnet_has} mixnet={mixnet_has}");
}
let clearnet_ip = clearnet_body.lines().find(|l| l.starts_with("ip="));
let mixnet_ip = mixnet_body.lines().find(|l| l.starts_with("ip="));
info!(" Clearnet IP: {}", clearnet_ip.unwrap_or("?"));
info!(" Mixnet IP: {}", mixnet_ip.unwrap_or("?"));
let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
info!(" Slowdown: {slowdown:.1}x");
tunnel.shutdown().await;
Ok(())
}
+98
View File
@@ -0,0 +1,98 @@
//! HTTP POST: clearnet vs Nym mixnet comparison.
//!
//! Sends a POST request with a JSON body to httpbin.org via clearnet (reqwest)
//! and through the mixnet (smolmix-hyper with SmolmixConnector), then compares
//! responses and timing.
//!
//! Demonstrates using `SmolmixConnector` directly for requests that carry a body
//! (the `Client` newtype uses `Empty<Bytes>` — for POST you build a custom client).
//!
//! Run with:
//! cargo run -p smolmix-hyper --example post
//! cargo run -p smolmix-hyper --example post -- --ipr <IPR_ADDRESS>
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::Request;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use smolmix::{Recipient, Tunnel};
use smolmix_hyper::SmolmixConnector;
use tracing::info;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const URL: &str = "https://httpbin.org/post";
const JSON_BODY: &str = r#"{"message": "hello from the Nym mixnet!"}"#;
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
// Clearnet baseline via reqwest
info!("POST via clearnet...");
let clearnet_start = tokio::time::Instant::now();
let clearnet_resp = reqwest::Client::new()
.post(URL)
.header("Content-Type", "application/json")
.body(JSON_BODY)
.send()
.await?;
let clearnet_status = clearnet_resp.status();
let clearnet_body = clearnet_resp.text().await?;
let clearnet_duration = clearnet_start.elapsed();
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
// Mixnet via smolmix-hyper
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
let tunnel = if let Some(addr) = ipr_addr {
let recipient: Recipient = addr.parse().expect("invalid IPR address");
Tunnel::new_with_ipr(recipient).await?
} else {
Tunnel::new().await?
};
// For POST requests, use SmolmixConnector directly with a Full<Bytes> body
let connector = SmolmixConnector::new(&tunnel);
let client: Client<SmolmixConnector, Full<Bytes>> =
Client::builder(TokioExecutor::new()).build(connector);
info!("POST via mixnet...");
let mixnet_start = tokio::time::Instant::now();
let req = Request::post(URL)
.header("Host", "httpbin.org")
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(JSON_BODY)))?;
let resp = client.request(req).await?;
let mixnet_status = resp.status();
let body_bytes = resp.into_body().collect().await?.to_bytes();
let mixnet_body = String::from_utf8_lossy(&body_bytes);
let mixnet_duration = mixnet_start.elapsed();
// Compare
info!("Results");
info!("Clearnet: {} in {:?}", clearnet_status, clearnet_duration);
info!("Mixnet: {} in {:?}", mixnet_status, mixnet_duration);
info!("Status match: {}", clearnet_status == mixnet_status);
// Check that the echo'd body contains our message
let clearnet_has_msg = clearnet_body.contains("hello from the Nym mixnet!");
let mixnet_has_msg = mixnet_body.contains("hello from the Nym mixnet!");
info!("Body echo clearnet: {clearnet_has_msg}");
info!("Body echo mixnet: {mixnet_has_msg}");
let slowdown = mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
info!("Slowdown: {slowdown:.1}x");
tunnel.shutdown().await;
Ok(())
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! [`tower::Service<Uri>`] connector routing TCP + TLS through a [`Tunnel`].
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use hyper::Uri;
use hyper_util::rt::TokioIo;
use tower::Service;
use smolmix::Tunnel;
use smolmix_dns::Resolver;
use smolmix_tls::TlsConnector;
use crate::tls_stream::MaybeTlsStream;
/// A hyper connector that routes TCP (and optionally TLS) through a [`Tunnel`].
///
/// Implements [`tower::Service<Uri>`] so it plugs directly into hyper-util's `Client`.
/// DNS resolution also goes through the tunnel, preventing hostname leaks.
///
/// DNS lookups are cached internally via a shared [`smolmix_dns::Resolver`] —
/// repeat requests to the same host reuse cached records (subject to TTL).
///
/// Use this directly when you need a custom body type (e.g. `Full<Bytes>` for
/// POST requests) — see the [crate-level docs](crate#sending-request-bodies-post-put-etc)
/// for an example.
#[derive(Clone)]
pub struct SmolmixConnector {
tunnel: Tunnel,
tls: TlsConnector,
resolver: Arc<Resolver>,
}
impl SmolmixConnector {
/// Create a new connector for the given tunnel.
///
/// Sets up a TLS connector with the standard webpki root certificates and
/// a DNS resolver that caches lookups across requests.
pub fn new(tunnel: &Tunnel) -> Self {
Self {
tunnel: tunnel.clone(),
tls: smolmix_tls::connector(),
resolver: Arc::new(Resolver::new(tunnel)),
}
}
}
impl Service<Uri> for SmolmixConnector {
type Response = TokioIo<MaybeTlsStream>;
type Error = io::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, uri: Uri) -> Self::Future {
let tunnel = self.tunnel.clone();
let tls = self.tls.clone();
let resolver = self.resolver.clone();
Box::pin(async move {
let scheme = uri.scheme_str().unwrap_or("https");
let host = uri
.host()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "URI missing host"))?
.to_owned();
let port = uri
.port_u16()
.unwrap_or(if scheme == "https" { 443 } else { 80 });
let addrs = resolver.resolve(&host, port).await?;
let addr = addrs
.into_iter()
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::AddrNotAvailable, "no addresses"))?;
let tcp = tunnel
.tcp_connect(addr)
.await
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
let stream = if scheme == "https" {
let tls_stream = smolmix_tls::connect_with(&tls, tcp, &host).await?;
MaybeTlsStream::Tls { inner: tls_stream }
} else {
MaybeTlsStream::Plain { inner: tcp }
};
Ok(TokioIo::new(stream))
})
}
}
+159
View File
@@ -0,0 +1,159 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! HTTP client routing all traffic through the Nym mixnet.
//!
//! # What this crate does
//!
//! Wraps [hyper-util]'s legacy `Client` with a [`SmolmixConnector`] that
//! routes DNS resolution, TCP connections, and TLS handshakes through a
//! smolmix [`Tunnel`]. From the outside it behaves like a normal HTTP client,
//! but all traffic travels through the mixnet.
//!
//! # How it works
//!
//! hyper-util's `Client` uses the [`tower::Service<Uri>`] trait to open
//! connections. [`SmolmixConnector`] implements this: given a URI, it resolves
//! the hostname via [`smolmix_dns`], connects TCP via the tunnel, and wraps
//! in TLS for `https://` URIs:
//!
//! ```text
//! SmolmixConnector::call(uri)
//! → resolver.resolve(host, port) DNS through tunnel (cached)
//! → tunnel.tcp_connect(addr) TCP through mixnet
//! → smolmix_tls::connect_with(tls, tcp, host) TLS if https
//! → MaybeTlsStream::Plain { TcpStream }
//! or MaybeTlsStream::Tls { TlsStream<TcpStream> }
//! → TokioIo<MaybeTlsStream> (implements hyper's Read/Write/Connection)
//! ```
//!
//! [`MaybeTlsStream`] is a two-variant enum with [`pin_project_lite`] for safe
//! pin projection through `AsyncRead`/`AsyncWrite`. hyper-util's `TokioIo`
//! wraps it to satisfy hyper's own I/O traits.
//!
//! # Quick start (GET)
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! use smolmix_hyper::{Client, Request, EmptyBody, BodyExt};
//! use bytes::Bytes;
//!
//! let tunnel = smolmix::Tunnel::new().await?;
//! let client = Client::new(&tunnel);
//!
//! let req = Request::get("https://example.com")
//! .header("Host", "example.com")
//! .body(EmptyBody::<Bytes>::new())?;
//! let resp = client.request(req).await?;
//! let body = resp.into_body().collect().await?.to_bytes();
//! println!("{}", String::from_utf8_lossy(&body));
//! # Ok(())
//! # }
//! ```
//!
//! # Sending request bodies (POST, PUT, etc.)
//!
//! The convenience [`Client`] wrapper uses `Empty<Bytes>` as its body type,
//! which is suitable for GET/HEAD/DELETE. For requests that carry a body,
//! construct a hyper-util client directly with [`SmolmixConnector`]:
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! use http_body_util::Full;
//! use hyper_util::{client::legacy, rt::TokioExecutor};
//! use bytes::Bytes;
//! use smolmix_hyper::SmolmixConnector;
//!
//! let tunnel = smolmix::Tunnel::new().await?;
//! let connector = SmolmixConnector::new(&tunnel);
//! let client = legacy::Client::builder(TokioExecutor::new())
//! .build::<_, Full<Bytes>>(connector);
//!
//! let body = Full::new(Bytes::from(r#"{"key": "value"}"#));
//! let req = hyper::Request::post("https://httpbin.org/post")
//! .header("Host", "httpbin.org")
//! .header("Content-Type", "application/json")
//! .body(body)?;
//! let resp = client.request(req).await?;
//! # Ok(())
//! # }
//! ```
//!
//! # Re-exports
//!
//! This crate re-exports the most commonly needed types so you don't need
//! `hyper`, `http-body-util`, or `bytes` in your `Cargo.toml` for basic use:
//!
//! - [`Request`], [`Response`], [`StatusCode`], [`Uri`] — from hyper
//! - [`BodyExt`], [`EmptyBody`] — from http-body-util
//! - [`bytes`] — the bytes crate
//!
//! [hyper-util]: https://docs.rs/hyper-util
//! [`pin_project_lite`]: https://docs.rs/pin-project-lite
mod connector;
mod tls_stream;
use std::ops::Deref;
use bytes::Bytes;
use http_body_util::Empty;
use hyper_util::client::legacy;
use hyper_util::rt::TokioExecutor;
use smolmix::Tunnel;
/// Re-exported [`bytes`](https://docs.rs/bytes) crate for constructing request bodies.
pub use bytes;
/// Extension trait for consuming HTTP response bodies. Provides `.collect()`,
/// `.frame()`, etc.
pub use http_body_util::BodyExt;
/// An empty HTTP body. Use `EmptyBody::<Bytes>::new()` for GET/HEAD requests.
pub use http_body_util::Empty as EmptyBody;
/// Re-exported hyper types for building requests without depending on hyper directly.
pub use hyper::{Request, Response, StatusCode, Uri};
pub use connector::SmolmixConnector;
pub use tls_stream::MaybeTlsStream;
/// Inner hyper-util client type alias for readability.
type HyperClient = legacy::Client<SmolmixConnector, Empty<Bytes>>;
/// An HTTP client that routes all traffic through a smolmix [`Tunnel`].
///
/// Wraps a hyper-util `Client` and exposes its full API via [`Deref`]. DNS
/// resolution, TCP connections, and TLS all travel through the mixnet.
///
/// The body type is [`Empty<Bytes>`], suitable for GET requests. For requests
/// that carry a body, construct a [`SmolmixConnector`] directly and pass it
/// to [`hyper_util::client::legacy::Client::builder`].
pub struct Client {
inner: HyperClient,
}
impl Client {
/// Create a new HTTP client for the given tunnel.
pub fn new(tunnel: &Tunnel) -> Self {
let connector = SmolmixConnector::new(tunnel);
Self {
inner: legacy::Client::builder(TokioExecutor::new()).build(connector),
}
}
}
impl Deref for Client {
type Target = HyperClient;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
/// Create a hyper-util [`Client`] that routes all traffic through the tunnel.
///
/// Equivalent to [`Client::new()`].
pub fn client(tunnel: &Tunnel) -> Client {
Client::new(tunnel)
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! TLS stream abstraction for plain and encrypted connections.
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use hyper_util::client::legacy::connect::{Connected, Connection};
use pin_project_lite::pin_project;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
pin_project! {
/// A stream that may or may not be wrapped in TLS.
///
/// Returned by [`SmolmixConnector`](crate::SmolmixConnector) — you won't
/// normally interact with this directly.
#[project = MaybeTlsProj]
pub enum MaybeTlsStream {
Plain { #[pin] inner: tokio_smoltcp::TcpStream },
Tls { #[pin] inner: smolmix_tls::TlsStream<tokio_smoltcp::TcpStream> },
}
}
impl AsyncRead for MaybeTlsStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
match self.project() {
MaybeTlsProj::Plain { inner } => inner.poll_read(cx, buf),
MaybeTlsProj::Tls { inner } => inner.poll_read(cx, buf),
}
}
}
impl AsyncWrite for MaybeTlsStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
match self.project() {
MaybeTlsProj::Plain { inner } => inner.poll_write(cx, buf),
MaybeTlsProj::Tls { inner } => inner.poll_write(cx, buf),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match self.project() {
MaybeTlsProj::Plain { inner } => inner.poll_flush(cx),
MaybeTlsProj::Tls { inner } => inner.poll_flush(cx),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match self.project() {
MaybeTlsProj::Plain { inner } => inner.poll_shutdown(cx),
MaybeTlsProj::Tls { inner } => inner.poll_shutdown(cx),
}
}
}
impl Connection for MaybeTlsStream {
fn connected(&self) -> Connected {
Connected::new()
}
}
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "smolmix-tls"
description = "TLS connector with webpki roots for smolmix tunneled connections"
version = "0.0.1"
authors.workspace = true
edition = "2021"
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation.workspace = true
rust-version.workspace = true
readme = "README.md"
publish = true
[dependencies]
tokio-smoltcp = { workspace = true }
tokio-rustls = { workspace = true }
rustls = { workspace = true }
webpki-roots = { workspace = true }
[dev-dependencies]
smolmix = { workspace = true }
smolmix-dns = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "net", "io-util"] }
tracing = { workspace = true }
nym-bin-common = { workspace = true, features = ["basic_tracing"] }
[[example]]
name = "connect"
+42
View File
@@ -0,0 +1,42 @@
# smolmix-tls
Shared TLS setup for smolmix tunneled connections. Provides a pre-configured `TlsConnector` with webpki root certificates and convenience functions for TLS over `TcpStream`, used internally by `smolmix-hyper`.
## Quick start
```rust
use smolmix_tls::{connect, connector, connect_with};
let tunnel = smolmix::Tunnel::new().await?;
let tcp = tunnel.tcp_connect("93.184.216.34:443".parse()?).await?;
// One-shot: TLS handshake over an existing TCP stream.
let tls_stream = connect(tcp, "example.com").await?;
// Reusable: create a connector once, use for many connections.
let tls = connector();
let stream1 = connect_with(&tls, tcp1, "a.example.com").await?;
let stream2 = connect_with(&tls, tcp2, "b.example.com").await?;
```
## API
- **`connector()`** -- create a reusable `TlsConnector` with webpki root certificates (clones cheaply via `Arc`)
- **`connect(tcp, hostname)`** -- one-shot TLS handshake (creates a fresh connector internally)
- **`connect_with(&connector, tcp, hostname)`** -- TLS handshake with a pre-built connector (avoids rebuilding the root store)
### Re-exports
Commonly-used types are re-exported so you don't need `tokio-rustls` in your `Cargo.toml`:
- `TlsStream` -- the connected TLS stream type (`tokio_rustls::client::TlsStream`)
- `TlsConnector` -- the connector type (`tokio_rustls::TlsConnector`)
## Dependencies
```toml
[dependencies]
smolmix-tls = { workspace = true }
```
This crate depends on `tokio-rustls`, `rustls`, and `webpki-roots`.
+250
View File
@@ -0,0 +1,250 @@
//! TLS connection: clearnet vs Nym mixnet comparison.
//!
//! Performs a TLS handshake and HTTPS GET request via both clearnet (tokio-rustls
//! over a system TCP socket) and the mixnet (smolmix-tls over a tunnel), then
//! compares timing and verifies both see the same content.
//!
//! Run with:
//! cargo run -p smolmix-tls --example connect
//! cargo run -p smolmix-tls --example connect -- --ipr <IPR_ADDRESS>
use std::sync::Arc;
use rustls::ClientConfig;
use smolmix::{Recipient, Tunnel};
use smolmix_dns::Resolver;
use smolmix_tls::{connect_with, connector};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::info;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
#[tokio::main]
async fn main() -> Result<(), BoxError> {
nym_bin_common::logging::setup_tracing_logger();
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let host = "example.com";
let port = 443u16;
// Clearnet baseline via tokio + tokio-rustls
info!("Clearnet TLS to {host}:{port}...");
let clearnet_start = std::time::Instant::now();
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(config));
let tcp = tokio::net::TcpStream::connect((host, port)).await?;
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())?;
let mut tls = tls_connector.connect(server_name, tcp).await?;
tls.write_all(
format!("GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await?;
let mut clearnet_buf = Vec::new();
tls.read_to_end(&mut clearnet_buf).await?;
let clearnet_duration = clearnet_start.elapsed();
let clearnet_status = extract_status(&clearnet_buf);
info!(
"{clearnet_status} {} in {clearnet_duration:.1?}",
format_bytes(clearnet_buf.len() as u64)
);
// Mixnet tunnel setup
let args: Vec<String> = std::env::args().collect();
let ipr_addr = args
.iter()
.position(|a| a == "--ipr")
.and_then(|i| args.get(i + 1));
let tunnel = if let Some(addr_str) = ipr_addr {
let recipient: Recipient = addr_str.parse().expect("invalid IPR address");
Tunnel::new_with_ipr(recipient).await?
} else {
Tunnel::new().await?
};
info!(
"Tunnel ready — allocated IP: {}",
tunnel.allocated_ips().ipv4
);
let tls_conn = connector();
let resolver = Resolver::new(&tunnel);
let overall_start = std::time::Instant::now();
// DNS resolution via mixnet
info!("Mixnet TLS to {host}:{port}...");
let spinner = spin(&format!("Resolving {host} via mixnet DNS..."));
let dns_start = std::time::Instant::now();
let addrs = resolver.resolve(host, port).await?;
let addr = addrs.into_iter().next().ok_or("no addresses resolved")?;
let dns_duration = dns_start.elapsed();
spinner.abort();
eprint!("\r \r");
info!("DNS: {host} → {addr} ({dns_duration:.1?})");
// TCP connection through mixnet
let spinner = spin(&format!("TCP connecting to {addr}..."));
let tcp_start = std::time::Instant::now();
let tcp = tunnel.tcp_connect(addr).await?;
let tcp_duration = tcp_start.elapsed();
spinner.abort();
eprint!("\r \r");
info!("TCP: connected to {addr} ({tcp_duration:.1?})");
// TLS handshake
let spinner = spin(&format!("TLS handshake with {host}..."));
let tls_start = std::time::Instant::now();
let mut tls = connect_with(&tls_conn, tcp, host).await?;
let tls_duration = tls_start.elapsed();
spinner.abort();
eprint!("\r \r");
info!("TLS: handshake complete ({tls_duration:.1?})");
// First HTTP GET (keep-alive)
let spinner = spin("GET / (first request)...");
let http1_start = std::time::Instant::now();
tls.write_all(format!("GET / HTTP/1.1\r\nHost: {host}\r\n\r\n").as_bytes())
.await?;
let mixnet_buf = read_http_response(&mut tls).await?;
let http1_duration = http1_start.elapsed();
spinner.abort();
let mixnet_status = extract_status(&mixnet_buf);
info!(
"GET1: {mixnet_status} {} ({http1_duration:.1?})",
format_bytes(mixnet_buf.len() as u64)
);
// Second HTTP GET over same connection (no DNS/TCP/TLS overhead)
let spinner = spin("GET / (reusing connection)...");
let http2_start = std::time::Instant::now();
tls.write_all(
format!("GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await?;
let mut mixnet_buf2 = Vec::new();
tls.read_to_end(&mut mixnet_buf2).await?;
let http2_duration = http2_start.elapsed();
spinner.abort();
let mixnet_status2 = extract_status(&mixnet_buf2);
info!(
"GET2: {mixnet_status2} {} ({http2_duration:.1?}) (reused connection)",
format_bytes(mixnet_buf2.len() as u64)
);
let mixnet_duration = overall_start.elapsed();
// Compare
info!("Results");
info!(
"Clearnet: {} in {clearnet_duration:.1?}",
format_bytes(clearnet_buf.len() as u64)
);
info!(
"Mixnet #1: {} in {mixnet_duration:.1?} (DNS {dns_duration:.1?} + TCP {tcp_duration:.1?} + TLS {tls_duration:.1?} + HTTP {http1_duration:.1?})",
format_bytes(mixnet_buf.len() as u64),
);
info!(
"Mixnet #2: {} in {http2_duration:.1?} (reused connection)",
format_bytes(mixnet_buf2.len() as u64),
);
let slowdown1 =
mixnet_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
let slowdown2 = http2_duration.as_millis() as f64 / clearnet_duration.as_millis().max(1) as f64;
info!("Slowdown: {slowdown1:.1}x (cold) / {slowdown2:.1}x (warm)");
tunnel.shutdown().await;
Ok(())
}
fn spin(msg: &str) -> tokio::task::JoinHandle<()> {
let msg = msg.to_string();
tokio::spawn(async move {
let frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let mut i = 0;
loop {
eprint!("\r {} {}", frames[i % frames.len()], msg);
i += 1;
tokio::time::sleep(std::time::Duration::from_millis(80)).await;
}
})
}
/// Read a complete HTTP/1.1 response from a keep-alive connection.
///
/// Parses headers to find `Content-Length`, then reads exactly that many body
/// bytes. Returns the full response (headers + body) as a single buffer.
async fn read_http_response<R: tokio::io::AsyncRead + Unpin>(
reader: &mut R,
) -> Result<Vec<u8>, BoxError> {
let mut buf = Vec::with_capacity(4096);
let mut tmp = [0u8; 1024];
// Read until we find the end-of-headers marker
let header_end = loop {
let n = reader.read(&mut tmp).await?;
if n == 0 {
return Err("connection closed before headers complete".into());
}
buf.extend_from_slice(&tmp[..n]);
if let Some(pos) = find_subsequence(&buf, b"\r\n\r\n") {
break pos + 4;
}
};
// Parse Content-Length from headers
let headers = std::str::from_utf8(&buf[..header_end]).unwrap_or("");
let content_length = headers
.lines()
.find_map(|line| {
let (key, val) = line.split_once(':')?;
if key.trim().eq_ignore_ascii_case("content-length") {
val.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
// Read remaining body bytes
let body_so_far = buf.len() - header_end;
let remaining = content_length.saturating_sub(body_so_far);
if remaining > 0 {
let mut body_buf = vec![0u8; remaining];
reader.read_exact(&mut body_buf).await?;
buf.extend_from_slice(&body_buf);
}
Ok(buf)
}
fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn extract_status(buf: &[u8]) -> &str {
let s = std::str::from_utf8(&buf[..buf.len().min(40)]).unwrap_or("");
s.lines().next().unwrap_or("")
}
fn format_bytes(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1} MB", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1} KB", n as f64 / 1_000.0)
} else {
format!("{n} B")
}
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2024-2026 - Nym Technologies SA <contact@nymtech.net>
//! Shared TLS configuration for smolmix tunneled connections.
//!
//! # Why a separate TLS crate?
//!
//! Every protocol that needs encryption over smolmix (HTTPS, WebSocket, etc.)
//! requires the same setup: build a `ClientConfig` with webpki root
//! certificates, wrap it in a `TlsConnector`. Rather than duplicating this
//! in every crate, `smolmix-tls` provides a single source of truth.
//!
//! The crate is deliberately minimal — 60 lines of pure configuration, no
//! trait impls needed. `tokio-rustls` works directly with anything that
//! implements tokio's `AsyncRead + AsyncWrite`, which `smolmix::TcpStream`
//! does out of the box.
//!
//! # Usage patterns
//!
//! ```no_run
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! # let tunnel = smolmix::Tunnel::new().await?;
//! // One-shot: creates a fresh connector internally.
//! // Simple but rebuilds the root cert store each time.
//! let tcp = tunnel.tcp_connect("93.184.216.34:443".parse()?).await?;
//! let tls = smolmix_tls::connect(tcp, "example.com").await?;
//!
//! // Reusable: create a connector once, use for many connections.
//! // The TlsConnector wraps an Arc<ClientConfig> — cloning is cheap.
//! let connector = smolmix_tls::connector();
//! let tcp1 = tunnel.tcp_connect("1.1.1.1:443".parse()?).await?;
//! let stream1 = smolmix_tls::connect_with(&connector, tcp1, "one.one.one.one").await?;
//! # Ok(())
//! # }
//! ```
//!
//! # What's inside
//!
//! - [`connector()`] — builds a `TlsConnector` with Mozilla's root CA bundle
//! ([`webpki-roots`](https://docs.rs/webpki-roots))
//! - [`connect()`] — one-shot TLS handshake (convenience, creates connector internally)
//! - [`connect_with()`] — TLS handshake using a pre-built connector (preferred for repeated use)
//! - Re-exports [`TlsStream`] and [`TlsConnector`] so downstream code doesn't
//! need `tokio-rustls` in its `Cargo.toml`
//!
//! # Security
//!
//! The connector uses rustls with the standard webpki root certificates and
//! no client authentication. SNI (Server Name Indication) is set from the
//! hostname you pass to `connect`/`connect_with`. There is no way to disable
//! certificate verification — this is intentional.
use std::io;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use tokio_smoltcp::TcpStream;
pub use tokio_rustls::client::TlsStream;
pub use tokio_rustls::TlsConnector;
/// Create a [`TlsConnector`] configured with the standard webpki root certificates.
///
/// The returned connector can be cloned cheaply (it wraps an `Arc<ClientConfig>`)
/// and reused across many connections.
pub fn connector() -> TlsConnector {
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
TlsConnector::from(Arc::new(config))
}
/// Perform a TLS handshake over an existing TCP stream.
///
/// Creates a fresh [`TlsConnector`] with webpki roots. For repeated connections,
/// prefer [`connect_with()`] to avoid rebuilding the root store each time.
pub async fn connect(tcp: TcpStream, hostname: &str) -> io::Result<TlsStream<TcpStream>> {
connect_with(&connector(), tcp, hostname).await
}
/// Perform a TLS handshake using a pre-built connector.
///
/// Extracts the SNI hostname from `hostname` and connects.
pub async fn connect_with(
tls: &TlsConnector,
tcp: TcpStream,
hostname: &str,
) -> io::Result<TlsStream<TcpStream>> {
let domain = ServerName::try_from(hostname.to_owned())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
tls.connect(domain, tcp).await
}