Skip to content
frekePublic

About

An Erlang/OTP implementation of an I2P router with a SAM v3 client bridge. Proof of concept.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

i2per

i2per is an Erlang/OTP implementation of an I2P router with a SAM v3 client bridge. This repository contains the 0.8.0 release. It implements the modern X25519 and Ed25519 protocol surface, including NTCP2, SSU2, ECIES tunnel creation, LeaseSet2, the I2P streaming protocol, and bounded tunnel relay participation.

The project interoperates with I2P routers. It does not bundle code from i2pd or i2p-java; the repository's NOTICE file records the attribution.

Status: 0.8.0 is in preparation. The release page (https://github.com/freke/i2per/releases) will carry the relx tarball and its provenance MANIFEST, built by CI from the tag rather than by a person remembering to run just release. The documentation site is published from the same tag at https://freke.github.io/i2per/. CHANGELOG.md records what changed, including the known issues an operator should know about.

0.8.0 is the operator-surface release: TUN and the proxy/console land as companion apps, UPnP port mapping ships as a bounded off-by-default service, transit and floodfill gain an auto capacity mode and expiry accounting, and the status monitor renders its held fields — so a router can be run, reached and observed like an i2pd, with the core staying data-only (ADR 0001). The logging floor and test gate carry forward. No wire-format changes over 0.7.0, so a 0.7.0 data_dir is read unchanged. The external i2pd interoperability suite remains a local diagnostic; it is not a required CI gate. See ADR 0001, ADR 0002 and the changelog for the reasoning.

Included in 0.2.1

  • Transports: NTCP2 and SSU2, with one supervised process per connection and per-session isolation.
  • Identity and cryptography: RouterIdentity and Destination keys, Ed25519 signatures, X25519 key agreement, HKDF, ChaCha20-Poly1305, and SipHash-2-4.
  • Network database: RouterInfo validation and storage, floodfill helpers, LeaseSet2 handling, SU3 reseeding, and remote lookups through tunnels.
  • Tunnels: ShortTunnelBuild and OTBRM, ECIES build records, layered tunnel messages, inbound and outbound gateway roles, pool maintenance, and LeaseSet publication.
  • Client APIs: SAM v3 STREAM, DATAGRAM, RAW, NAMING LOOKUP, and STREAM FORWARD, with the signed and reliable I2P streaming protocol.
  • Services: server tunnels for local TCP services, a strict INI configuration loader, a runtime configuration service, an event bus with a published subscribe/1 / unsubscribe/1 entry point, and the standalone i2per_status web service.
  • Observability: a versioned read API (i2p_status_data:view/0) over counters, gauges and node uptime; the telemetry contract (per-transport bytes, transit relay bytes, tunnel build/fail tallies, and the peer_connect_failed / transit_denied / leaseset_publish_failed / lookup_failed events); and a logging floor whose 3am checklist is executable rather than prose.
  • Operations: offline-safe release defaults, reseed certificate anchors, and relx packaging with a provenance MANIFEST.

Included in 0.3.0

  • Charlie relay role: answer RelayIntro requests as a Charlie, verify the request against Alice's RouterInfo, and reply in-session or in a HolePunch to her asserted endpoint.
  • Measured reachability: peer testing feeds a typed ladder from unknown to reachable, with hysteresis and the measured verdict reflected in the published RouterInfo caps.
  • Bus hardening: the event bus records every fact it carries, survives a subscriber death, and measures the far-side backlog instead of assuming it cannot grow.
  • Release hardening: dead sessions clean up coordinator state, the SSU2 listener survives unreachable peer-asserted endpoints, and the relx release builds the 0.3.0 tarball.

Included in 0.4.0

  • Dial convergence: the three dial parks (transit, peer-test, tunnel-build) are each bounded by a single derived number; the dial transport preference can be flipped at runtime without disturbing in-flight dials; coordinators' supervision shape and enable key are settled; connect_via_introducer/5 is covered and its defects fixed; the re-flood is gated on a nonzero reply token; Bob validates the granted relay token; a token'd DatabaseStore is answered with the reply token.
  • Hot path cost: dirty-scheduler crypto flags; per-datagram syscall and log allocation removed in SSU2; sessions drop the retained RouterInfo; undecryptable garlic stops at the first open; the floodfill lookup walks a floodfill-only index.
  • Simplification: unused Noise XX ratchet and ElG2 removed; i2p_sam_proxy no longer ships as a feature; streaming accessors read through and written around.
  • Test gate: eunit shared-state cases isolated.
  • Doc truth: parity.md no longer bills the Charlie hole punch as 1.0.0.

Included in 0.5.0

  • SSU2 congestion control and RTT: SSU2 estimates RTT and runs window-based congestion control, so a router manages its own throughput instead of relying on a fixed resend timer.
  • Hermetic protocol gate: the SSU2 data phase and the NTCP2 XK handshake are captured from a real i2pd and committed, then replayed on every push by i2p_ssu2_vectors_SUITE and i2p_ntcp2_vectors_SUITE.
  • Observability: i2p_status_data:view/0 (view version 5) reports aggregate SSU2 RTT and congestion window, and the status page surfaces them.
  • Scope: 0.5.0 anchors on RC68AGE alone; NetDb-driven Charlie selection is deferred to 0.6.0.

Included in 0.6.0

  • Automatic NetDb-driven Charlie selection: Bob selects a Charlie from the NetDb on an inbound PeerTest message 1 — SSU2 address with the B peer-test capability and a published host/port, uniform random, one Charlie per cycle, per-Alice rate limit — and replies the deterministic code-2 reject when no candidate is available. The manual dial_charlie/3 path remains for tests and operators.
  • Tunnel build v2: the maximum tunnel hop count rises from 3 to 7, matching i2pd; the default build stays 3 hops.
  • NTCP2 vectors in the hermetic gate: the NTCP2 XK handshake is captured from a real i2pd alongside the SSU2 data phase and replayed on every push by i2p_ntcp2_vectors_SUITE, with no i2pd and no network.
  • Test layering: three system tests that sat in the eunit layer now run in Common Test.
  • Scope: 0.6.0 anchors on the reachability cluster; Datagram2 with replay protection is deferred to 0.7.0.

Included in 0.7.0

  • LeaseSet family: the router parses, stores and answers lookups for LeaseSet v1 (store type 1), and decodes EncryptedLeaseSet (store type 5) and MetaLeaseSet (store type 7). X25519 + Ed25519 keys force LeaseSet2 for our own publication.
  • Addressbook publication: i2per publishes its own hosts.txt to a configured hosts file, instead of only subscribing to remote addressbooks.
  • Datagram2: the full codec (flags, options, signature over the target hash) with a bounded per-destination replay window, wired through SAM with send kind and inbound delivery; optional options mapping and offline signatures.
  • Logging floor: checklist enforcement restored, the XZTYAVX level-gated debug path forward-ported, and the ssu2 cc tests moved to Common Test.
  • Scope: 0.7.0 anchors on the LeaseSet family and Datagram2; the operator console, TUN, UPnP/NAT, transit and floodfill promotion, and status monitor completion are deferred to 0.8.0.

Included in 0.8.0

  • Companion apps: i2per_proxy (operator console and SOCKS/HTTP proxy surfaces) and i2per_tun (TUN interface, shape (a) flow translator) talk to the core over Erlang instead of SAM, per ADR 0001; hermetic CT suites cover the TUN datapath.
  • UPnP / NAT traversal: i2p_upnp_srv, bounded and off by default, with SSDP discovery, SOAP port mapping and renewal, hot-switchable. Opens the pinhole only; it does not publish a reachable RouterAddress by itself.
  • Transit and floodfill promotion: auto capacity mode for floodfill (bandwidth class O or higher plus a published NTCP2 address), transit tunnel expiry accounting, and interop evidence; the default flip stays a maintainer call.
  • Status monitor completion: held fields rendered in i2p_status_data:view/0 and the status page (peers, tunnels, NetDb, config, SAM), with derived rates and reachability ladder.
  • Scope: 0.8.0 anchors on the operator surface; parity audit, soak and live smoke are deferred to 0.9.0.

The default profile is a local client. It does not contact the live network, publish a floodfill RouterInfo, or accept public transit unless an operator explicitly enables those choices.

Source

git clone https://github.com/freke/i2per.git
cd i2per

Requirements

The toolchain this tree is built and gated against:

  • Erlang/OTP 28 (erts 16.4.0.6)
  • rebar3 3.27
  • just 1.58
  • erlfmt 1.8

just check runs erlfmt, which is not part of a stock Erlang install, and rebar.config compiles with warnings_as_errors — so a different OTP version can fail on warnings this one does not produce. Use the pinned environment instead of assembling the toolchain by hand.

The pinned environment loads itself. .envrc puts it in every shell you open in this directory, including your editor's, a REPL and anything an agent runs:

git clone https://github.com/freke/i2per && cd i2per
direnv allow      # once per checkout, and deliberately not automatic

That prompt is direnv's, and it is a security boundary rather than a papercut: running an .envrc executes shell code from the checkout, so direnv refuses until you have read it. Approve it once and every later shell is already correct.

Editing .envrc asks again. direnv trusts a specific revision of the file, not the path, so any change to it re-blocks every open shell — and the symptom is rebar3: command not found in a shell that worked a minute ago. Run direnv allow again after editing it.

For a shell that does not have direnv — a script, or CI:

devenv shell -- <command>

Note the --. devenv shell without it starts an interactive subshell, which is why an editor or a REPL opened from inside one still has no toolchain.

Developer mode

Start an interactive Erlang shell with the i2per OTP application already booted:

just shell

Wait for ===> Booted i2per, then run these checks at the Erlang prompt:

application:ensure_all_started(i2per).
true = is_pid(whereis(i2per_sup)).
%% The version string moves with each release; it is the app's own {vsn, ...}.
{ok, "0.8.0"} = application:get_key(i2per, vsn).

The successful results are {ok,[i2per]}, true, and ok. Leave the shell with q()..

This is an offline, in-memory application boot. It does not set data_dir or seeds, so the persistent peer and tunnel services are not started. Use the packaged release section below when you need a full router process.

Build and verify

From the repository root — where the pinned environment is already on PATH:

just smoke-test   # the push tier: lint, unit tests, most of the CT suites
just test         # everything: lint, docs, all eunit, all CT, coverage
just check        # the release gate: `just test` plus dialyzer
just proper       # the property tests alone (also inside `just test`)
just dialyzer     # static analysis alone (also inside `just check`)
just live-smoke   # boot a throwaway router and print its network observables
just doc          # regenerate the local ExDoc site in doc/

The three test layers

The tree separates tests by what is under test, and the recipes are named after that:

recipe runs when
just proper the property modules, over generated inputs main, and on request
just smoke-test lint, every unit module, every CT suite but the slow two every push
just test lint, docs, dialyzer, every eunit module, every CT suite main

Case totals are not written down here on purpose: rebar3 prints the number of tests each run executed, and a figure kept in prose is a second copy of it that no run checks.

Measured at 91s for just smoke-test and 205s for just test on a developer machine. The smoke tier is under five minutes on a GitHub runner.

What the smoke tier skips, and why. Two CT suites: i2p_peer_transport_SUITE (protocol-mandated connect timeouts) and i2p_ssu2_e2e_SUITE (22,000 real datagrams through a live session). Together they are more than the whole five-minute budget, for failures that are about waiting rather than behaviour. just test runs both.

Why properties are not in the smoke tier, given they take 0.23s: a property test finds its counterexample from a random input, so a failure on a push is a bug report that arrives before anyone can reproduce it — and re-rolling the seed is the tempting response to a red that keeps flickering.

The tier boundaries are derived from the tree by scripts/ct-suites.sh (CT) and scripts/eunit-modules.sh (eunit/PropEr), so a new suite is in tomorrow's smoke run by default rather than by an edit someone has to remember.

just check is the release gate: erlfmt, the generated docs, dialyzer, and every test. It is an alias for just test plus just dialyzer, and dialyzer is run before the tests because it reads source and does not depend on which suites ran — so a type error fails the gate in ~49s rather than after the ~4 minutes of eunit and CT. Dialyzer is separately runnable because the compat CI job invokes it on its own. doc/ is generated output and is not committed. The repository test suite is part of the release quality process.

Run the packaged release

To install a published release, download the tarball and its .manifest from the release page, then check the recorded hash against the artifact:

tar xzf i2per-<vsn>-<rev>.tar.gz
grep '^sha256:' i2per-<vsn>-<rev>.tar.gz.manifest
sha256sum i2per-<vsn>-<rev>.tar.gz
cd i2per-<vsn>-<rev> && bin/i2per foreground

The manifest also records the source commit, the source tree hash, the OTP it was built against, and the build date, so a deployment can say exactly which build it is running.

To build one locally:

just release

The build writes a relx tarball and a provenance manifest to dist/. The release script refuses a dirty working copy unless I2PER_ALLOW_DIRTY_RELEASE=1 is set for a diagnostic build. When the local Erlang runtime is linked against Nix, the script builds inside a Debian 13 container so the bundled ERTS and NIFs match the target system.

After extracting the tarball, start the router from that directory with its shipped defaults:

bin/i2per foreground

The default profile stores router state under ./data, keeps Erlang distribution local and disabled, and listens for I2P traffic on loopback. It does not enable reseeding or live network participation. A deployment that needs operator RPC must provide its own node name, cookie, and distribution settings.

Configuration

The router reads i2per.conf from the working directory. Set the application setting i2per.config_file to use another path. A second file, tunnels.conf, declares server tunnels. Unknown keys, unknown sections, and duplicate entries stop boot rather than being silently ignored.

Example i2per.conf:

host = 127.0.0.1
listen_host = 127.0.0.1
port = 9150
live_network = false
ntcp2_published = false
transit_max_tunnels = 10
transit_bandwidth_kbps = 64
tunnel_build_rate = 1
max_ntcp2_connections = 64
max_sam_sessions = 32
max_ssu2_sessions = 32
max_stream_connections = 128

[tunnel_pool]
outbound = 3
inbound = 3

[reseed]
enabled = false
min_routers = 50

UDP transport

How this router uses UDP (SSU2) is one application-environment key, not an i2per.conf entry, and it is not settable through i2p_config_srv:

%% sys.config, or: application:set_env(i2per, ssu2, prefer_udp).
{ssu2, no_udp}.
value serves UDP dials UDP first
no_udp (default) no no
enable_udp yes no
prefer_udp yes yes

Serving UDP and preferring it are separate choices, so enable_udp binds the listener and publishes the address in the RouterInfo while outbound dials still go to NTCP2 first. The fourth combination — serve nothing, dial UDP — is not offered, since there would be no address to dial.

Availability is boot-time; the dial preference is hot. The listener is bound and the RouterInfo address published from the value read at start, so serving UDP (whether this router publishes an SSU2 address at all) is fixed for the life of the process and a restart is the only way to change it. The dial preference -- which transport this router reaches for first -- is not: it can be flipped at runtime with i2p_config_srv:set(ssu2_preferred, true | false) without disturbing in-flight dials or established connections, and the new value is what f:i2p_identity:ssu2_preferred/0 reports and what the status view shows. The running boot-time setting is on the boot's config in force line. Choosing prefer_udp on a UDP-blocked network therefore no longer needs a reboot to undo, but the availability half still does, so it is worth deciding deliberately. The configuration value read at boot is rejected if it is not one of the three, rather than falling back to the default.

The deprecated boolean ssu2_enabled is still read when ssu2 is unset, and means what it always meant: true is prefer_udp, false is no_udp. Setting both is not a contradiction the router resolves by guessing — ssu2 wins.

Addressbook subscriptions are configured through sys.config or the application environment, not the INI file. Each entry names an I2P host and its destination:

application:set_env(i2per, addressbook, #{
    subscriptions => [
        #{host => <<"stats.i2p">>, dest_b64 => <<"DESTINATION_BASE64">>}
    ]
}).

Values already present in sys.config or the application environment take precedence over values in i2per.conf. In particular, edit the release's releases/<vsn>/sys.config to change the shipped ./data directory (the directory is named after the release, so it carries the version — 0.8.0 for this release). A persistent boot requires data_dir and seeds in the application environment. The release profile sets live_network = false and ntcp2_published = false; changing either value is an explicit operator decision.

For live participation, configure trusted reseed sources, set live_network = true, choose listen_host deliberately, and keep ntcp2_published = false until the advertised address and firewall have been verified. Enabling live network access can cause the router to contact remote I2P services and make its identity visible to them.

Server tunnels

tunnels.conf uses one section per local service:

[eepsite]
type = server
host = 127.0.0.1
port = 8081

Each service gets persistent destination keys under <data_dir>/<name>.keys. The section name identifies the server tunnel. SAM STREAM FORWARD is a separate client mechanism configured by destination and local host/port, not by this section name.

Runtime configuration

i2p_config_srv exposes validated configuration to a connected operator node:

i2p_config_srv:get(transit_max_tunnels).
i2p_config_srv:get_all().
i2p_config_srv:set(transit_bandwidth_kbps, 512).
i2p_config_srv:set(host, <<"10.0.0.9">>).

Most runtime keys apply immediately. transit_bandwidth_kbps and tunnel_build_rate are read when the tunnel manager starts, so restart that service before relying on their new values. Listener and identity changes answer {ok, pending_restart} and take effect after the relevant service restarts. The service never writes configuration files. Persist accepted values in the active configuration source before restarting; packaged-release values already present in sys.config must be changed there.

Events

Router state changes are announced on the i2p_events gen_event manager. Events include peer connection changes, tunnel builds and failures, LeaseSet publication, SAM session lifecycle, configuration changes, and SSU2 reachability results. i2p_events:subscribe/1 is the published entry point — a local or a remote collector attaches the same way, and nothing outside the core calls gen_event:* directly:

ok = i2p_events:subscribe(self()).
receive
    {event, Event} -> Event
end.

Delivery is best-effort and never propagates a delivery failure to the emitter. The backlog depth behind a wedged handler is published as the bus_backlog gauge in the read API; a depth that does not fall is what identifies the fault.

Status service

i2per_status is a separate OTP application and is never started by the router. It is included in the repository but not in the production relx artifact. From a node connected to the router node, start it explicitly:

application:set_env(i2per_status, router_node, 'i2per@host').
application:set_env(i2per_status, port, 7662).
application:ensure_all_started(i2per_status).

It serves GET / and GET /status.json. It uses the router event bus for realtime updates and polls the router when the event connection is unavailable. The listener defaults to loopback; set listen_host explicitly before exposing it beyond the local machine.

i2per_status settings:

Setting Default What it does
router_node this node the router's node, reached over Erlang distribution
port 7662 the HTTP listen port
listen_host loopback the HTTP listen address; set it before exposing the page beyond this machine
poll_ms 5000 how often the router is polled when the event connection is unavailable. It is the window the derived rates and ratios are differenced over, so it sets their resolution. Shorten it for hermetic tests or a denser soak series.

Interoperability and live checks

The normal just check gate is hermetic, but it is not self-referential: some of what it checks is replayed from a real i2pd. apps/i2per/test/fixtures/ i2pd_ssu2_data_phase.term holds SSU2 data-phase datagrams — a real peer's ACK blocks, its RouterInfo, its I2NP messages — together with the session keys that make them readable, and i2p_ssu2_vectors_SUITE replays them with no i2pd and no network. apps/i2per/test/fixtures/i2pd_ntcp2_handshake.term holds an NTCP2 XK handshake and the data phase that followed it — our msg1 and msg3 re-encode to the captured bytes and i2pd's msg2 and data frames open under the keys the replay derives — and i2p_ntcp2_vectors_SUITE replays that the same way. To recapture both against a different i2pd:

I2PD_BIN=/path/to/i2pd just capture-vectors

The capture needs an i2pd binary and nothing else: it boots a throwaway router with an empty NetDb, records one SSU2 session and one NTCP2 session against it, and rewrites both fixtures. It is run by hand, on purpose, for the same reason the interop suite below is — a fixture regenerated on every push would be a fixture nobody reads.

To run the explicit interop suite against a real i2pd instance:

I2PD_BIN=/path/to/i2pd scripts/interop_i2pd.sh

This starts a local i2pd, runs the NTCP2 and SSU2 interoperability cases, and removes the temporary instances. It contacts an external service and is never part of the normal gate. just live-smoke is an offline self-check. Use just live-smoke --live only when joining the real I2P network is intended.

Release limitations

The 0.8.0 release does not implement NTCP1, SSU1, I2CP, or the legacy ElGamal/AES crypto formats. SSU2 path migration is outside this release. Floodfill and transit participation are available as bounded features, and transit gains an auto capacity mode plus expiry accounting; neither is enabled by the shipped default profile. Automatic NetDb-driven Charlie selection is implemented, and the tunnel build accepts up to 7 hops. LeaseSet v1, EncryptedLeaseSet and MetaLeaseSet are implemented, Datagram2 (Proposal 163) is implemented with replay protection, and addressbook publication is implemented. The TUN interface ships as the i2per_tun companion app and the operator console as i2per_proxy (ADR 0001); UPnP/NAT traversal ships as i2p_upnp_srv, off by default and hot-switchable, opening the pinhole only (it does not publish a reachable RouterAddress by itself). In-place relup upgrades are not supported; upgrading means installing a new tarball. 0.8.0 keeps the 0.7.0 wire format and adds the operator surface and transit/floodfill promotion; a data_dir written by 0.7.0 is read unchanged.

CHANGELOG.md lists the known issues for this release: the event bus's far side is unbounded if a subscriber node stops draining (OTP 28 offers no cap from the router side).

The 1.0.0 target is frozen in docs/parity.md, which pins a dated i2pd capability snapshot so "parity with i2pd" cannot drift as i2pd moves.

Documentation

  • The published documentation site for the current release is https://freke.github.io/i2per/, built and published by CI from the release tag. just doc regenerates it locally into doc/.
  • Changelog records what changed in each release, with ticket references and the known issues.
  • Protocol reference documents the wire formats implemented by this release.
  • Documentation standards defines the EEP-48 and ExDoc requirements for source changes.
  • ADRs under docs/adr/ record the decisions this release line rests on.

License

i2per is available under the BSD 3-Clause License. The repository's LICENSE and NOTICE files contain the license terms and i2pd interoperability attribution.

About

An Erlang/OTP implementation of an I2P router with a SAM v3 client bridge. Proof of concept.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages