Rivet

Operate your plant from a phone – without opening a door into your network

Rivet connects the phone of an authorised person with a controller in the local network. No port forwarding has to be set up on site, and no third-party cloud provider is involved.

Status: in development

Rivet is not a finished product yet. The code is being written and has so far been tried on a test server. The first intended use: solidarity farms (SoLaWis) want to operate their irrigation control from a phone with it.

The problem

Irrigation, heating, pumps or PLCs often have a user interface in the local network. Whoever wants to reach it from elsewhere usually puts it straight on the internet or ties themselves to a vendor cloud.

The solution

A small gateway on site opens the connection to our server from the inside out. Over this path the phone reaches only the services you have released in the portal, nothing else.

What stays under your control

You decide in the portal which phones reach which plant. A blocked phone is out within seconds. A phone's key is created on the phone and never leaves it.

Who it is for

For machine and plant builders who want to service their machines at the customer’s site remotely, without a cloud vendor in between. And for operators who want to operate their own plant from a phone.

What you do on site

  1. Plug the gateway into the local network of the controller (or install Rivet as a package on the device itself).
  2. Open the gateway’s web interface and sign in with the access key.
  3. Enter the registration code from the portal. What can be reached is set in the portal.
  4. Add phones in the portal: scan the QR code with the WireGuard app, switch the tunnel on.

What Rivet is not

  • Not a cloud service of a third party. ForgeIEC runs gateway, server and portal itself.
  • Not a replacement for safety functions. Emergency stop, interlocks and everything that makes a plant safe belong in the plant, not in a remote access.
  • Not a finished product yet (see status above).

Next step

Would you like to try Rivet in a pilot? Request a pilot

Setting up the gateway: enter the registration code, choose the mode.
Adding a phone: scan the QR code with the WireGuard app.

For experts: the technology in detail

Everything below can be checked in the repository forgeiec-rivet (branch development, commit 5f01540, 22 Oct 2026). The specification is CLAUDE.md in that repository. We distinguish strictly between implemented (code and tests present), specified (described in CLAUDE.md, not coded) and planned/open (recorded as an open question or as “later”).

Implementation status

AreaImplementedSpecified, not codedPlanned / open
Gatewayuplink in-process (boringtun + smoltcp), TLS front end with certificate swap at runtime, setup wizard, registration, heartbeat, long poll for phones and forwards, forwards (userspace and kernel path via TUN rivet0 + nftables), router configuration for router gatewaysfetching and swapping themes on the gateway (portal side exists); renewing the API key from the web interface (POST /api/apikey/rotate; only the command forgeiec-rivet reset-api-key exists)own Rivet app for Android/iOS (open question 14)
Serverportal with three levels, API v1, routes.sqlite + nginx module, WireGuard service forgeiec-rivet-wg (interfaces uplink and phones, nftables, mini DNS), DNS sync with Hetzner, wildcard certificates via certbot (DNS-01), emergency rotation, e-mails (account access, setup link), cleanup timer, packages and CIinstall.sh (bootstrap); alert e-mails (gateway offline, certificate expiring, unusual fetches); releases with SHA256SUMS and signatureterminate TLS in nginx instead of wildcards on the gateways (open question 1)
Operationsbuilds for 12 slots, packages forgeiec-rivet and forgeiec-rivet-serverself-test for router gateways (“does the router let more into the LAN?”); interop test in CI against kernel WireGuard or wireguard-go as the gateway’s peerfallback over TCP 443 when UDP is blocked (open question 11); direct access when the server is down (open question 8); switching off the old phone path via the gateway

Architecture

ComponentCodeTechnology (checked in Cargo.toml, requirements.txt or source)Task
Gateway forgeiec-rivetclient/Rust (edition 2024), tokio; boringtun 0.7 without default features (only noise::Tunn); smoltcp 0.14; rustls 0.23 with ring + tokio-rustls; hyper 1; Rocket 0.5 (json, secrets) with rocket_dyn_templates (Tera); fluent-bundle; ureq 2; rustables 0.9 and netlink-* for TUN/nftables; x509-parser, rcgen, qrcodeoutbound uplink to the server, forwarding of released TCP ports into the LAN, setup web interface
Portalserver/portal/Django 5.2, Django REST Framework 3.18, gunicorn (127.0.0.1:8001), cryptography, certbot + certbot-dns-hetzner, hcloudlevels and accounts, registration, heartbeat, certificates, phones, forwards; writes routes.sqlite, controls forgeiec-rivet-wg
WireGuard service forgeiec-rivet-wg (the privileged helper)server/wg-daemon/Rust, rust-netlink (netlink-packet-wireguard, -route, -generic), rustables 0.9; kernel WireGuardholds the kernel interfaces uplink and phones, sets peers and the nftables table ip rivet_server, mini DNS for phones, delivers statistics
nginx module ngx_stream_rivet_moduleserver/nginx-module/C, dynamic stream module, SQLiteTLS passthrough: target per SNI or UDP port from routes.sqlite, without reload

Ports on the server (from server/contrib/nginx/rivet-stream.conf.in, forgeiec-rivet-wg.service.in, settings.py):

PortProtocolPurpose
80TCPredirect to HTTPS
443TCPnginx stream with ssl_preread: portals → local 127.0.0.1:8443, devices → 10.78.x.y:443 via the uplink, unknown → 127.0.0.1:8444
51900UDPWireGuard uplink (server 10.78.0.1/16), one peer per gateway
52000UDPWireGuard phones (server 10.79.0.1/16), one peer per phone
51000–51899, 51901–51999UDPold phone path: one port per gateway, nginx passes it on to 10.78.x.y:51820
53 on 10.79.0.1UDP/TCPmini DNS, only from the phone network 10.79.0.0/16

Data paths

  1. Browser → gateway web interface https://<device>.<customer>.<zone>.forgeiec.io: nginx reads only the host name (SNI), looks the target up in routes.sqlite and passes the still-encrypted connection with a PROXY protocol header through the uplink to 10.78.x.y:443. TLS ends only in the gateway (rustls); nginx decrypts nothing. This interface is for setup.
  2. Phone → service in the LAN (path via the server, CLAUDE.md 4.13): phone WireGuard to UDP 52000 → server interface phones → filter phone_access (phone address, gateway address, TCP port) → SNAT to 10.78.0.2 → uplink → gateway. The gateway accepts from 10.78.0.2 only TCP on ports that have a forward and opens its own TCP connection in the LAN to the target. The phone reaches the service at <uplink-address>:<port>, e.g. http://10.78.0.5:8000, or by name through the mini DNS.
  3. Phone → gateway directly (old path, kept until all phones have moved): WireGuard to the gateway’s UDP port on the server → nginx → uplink → phone tunnel in the gateway (10.77.0.0/24, gateway 10.77.0.1) → kernel path (TUN rivet0, nftables DNAT in table ip rivet) or, without CAP_NET_ADMIN, forwarding in userspace (smoltcp).
  4. Gateway → portal: HTTPS directly over the internet (not through the uplink), so that registration works without an uplink: register, heartbeat, cert, peers (long poll), uplink-key.

Uplink in the gateway (client/src/net/uplink.rs): one boringtun::noise::Tunn with the server as the only peer over an ordinary outbound UDP socket, behind it a smoltcp stack with the address 10.78.x.y. Only traffic to this address is accepted: from 10.78.0.1 (nginx) TCP 443/80, UDP 51820 and ICMP; from 10.78.0.2 TCP only. Fragments and all other senders are dropped. Keepalive 15 s, MTU 1280. After 180 s without a handshake the gateway opens a new UDP socket and resolves the server name again (against one-way dead NAT mappings on mobile networks). At most 256 concurrent connections in the uplink stack.

Operating modes (field uplink_mode in the portal, models.py): rivet (the Rivet gateway holds the tunnel itself), router (the router’s WireGuard holds the tunnel, a Rivet gateway behind it binds with a code), client (plain WireGuard client without Rivet, no heartbeat). First router target according to CLAUDE.md: Teltonika RUT241, guide docs/router-rutos.md.

Addressing and certificates

Names follow four levels (server/portal/gateways/hosts.py; the base domain is a setting, default forgeiec.io):

LevelAddressIn the code
Operator (installation)rivet.<base>RIVET_PORTAL_HOST: operator area, gateway API, uplink endpoint
Organisation<zone>.<base>model Zone
Customer<customer>.<zone>.<base>model Organisation
Device<device>.<customer>.<zone>.<base>Gateway.fqdn; the gateway answers here, not the portal

The README still shows the older scheme <slug>.<zone>.forgeiec.io; the code uses the one above.

  • One wildcard certificate per customer *.<customer>.<zone>.<base>: issued by certbot with certbot-dns-hetzner (DNS-01, ECDSA) from the portal’s venv; renewal by the timer forgeiec-rivet-certs (daily at 03:00 and 15:00).
  • Distribution: the deploy hook import_wildcard_cert stores the private key Fernet-encrypted in the portal database and raises the customer’s cert_version. The gateway sees the higher version in the heartbeat response and fetches GET /api/v1/cert (gateway bearer key, response with Cache-Control: no-store). Every fetch is logged in CertFetchLog. Blocked gateways get nothing (API 403).
  • Checks in the gateway (client/src/tls.rs) before taking it over: PEM readable, key matches the certificate, name covers the gateway’s own address, not expired, newer than the active one. Then written atomically (key 0600) and activated via ArcSwap without restart. Without a valid wildcard the gateway uses a self-signed certificate (rcgen).
  • Portals have their own certificates; a gateway wildcard does not cover them. Emergency rotation per zone in the operator area and in the Django admin (services.emergency_rotation).
  • DNS: the portal maintains the records at Hetzner (gateways/dns.py, timer forgeiec-rivet-dns every 15 minutes) and touches only records carrying its own labels.

Identity and authorisation

SecretHow it is createdWhere it is keptWho sees it
Gateway web interface keyrvt_ + 32 random bytes (base64url), on first startonly SHA-256 in config.json; plain text once in the journal and in initial-api-key (0600)whoever may read that file or the journal on the device
Gateway → portal keyrgw_ + secrets.token_urlsafe(32) at registrationgateway: config.json (0600); portal: only SHA-256only the gateway
Registration codeone-time, valid 14 days by default (RIVET_REGISTRATION_CODE_DAYS)portalthe person setting up the gateway
A phone’s private keyin the WireGuard app or in the browser (WebCrypto X25519, phone_qr.js)only on the phonenobody else; only the public key goes to the portal, a field containing “private” is rejected with 400
Gateway uplink keyin the gateway (mode rivet)config.json (0600)only the gateway
A router’s uplink keyrecommended: in the router itself; alternatively generated in the portal and shown once, not storedrouter–
Customer wildcard keycertbot on the serverportal DB (Fernet) and on every gateway of that customerthe server and the customer’s gateways
Server WireGuard keyspostinst (X25519 from cryptography)/etc/forgeiec/rivet-server/{uplink,phones}.key, 0600 root; the service gets them via LoadCredentialthe server
  • Sign-in at the gateway (client/src/auth.rs, web.rs): constant-time comparison (subtle), encrypted session cookie (Rocket secrets) for 12 hours with a prefix of the key hash, so a new key ends all sessions. Failed attempts: 1 s delay, at most 5 per source IP and 30 in total within 5 minutes.
  • Registration: POST /api/v1/register without authentication, limited to 10/hour per client IP (FORGEIEC_RIVET_REGISTER_RATE). All other API calls use a bearer key that the portal stores only as a hash.
  • Phone setup: QR code and .conf file are created in the browser; the private key never leaves the page. Alternatively a setup link by e-mail: the portal stores only the SHA-256 of the token, valid 7 days, single use.
  • Portal accounts are bound to their level; rights per membership: manage phones, gateways, users. The session is valid only for the host where it was created.

Isolation

  • What a phone reaches (path via the server): only TCP to (phone address, uplink address of a gateway, port) from the access list. It contains the forwards of all active gateways of its customer; rules with access “service” only for phones with access “service”. Plus the mini DNS at 10.79.0.1:53. Everything else from phones is dropped (fw.rs).
  • What a compromised gateway reaches: on the server iifname "uplink" oifname "uplink" drop and iifname "uplink" drop apply: no other gateways, no routing through the server. Towards phones only replies (oifname "phones" only established,related). It does, however, hold its customer’s wildcard key and can impersonate any other gateway of that customer if it can also redirect DNS or traffic (described in CLAUDE.md section 5 as a consciously accepted risk).
  • What the server reaches: from the uplink the gateway accepts only the connections listed above; only the forwards from the portal lead into the LAN. Without a forward nothing is forwarded.
  • What the server sees: HTTPS to the gateway web interface passes through encrypted (passthrough). The inner traffic of phones on the path via the server, however, it does see, because it decrypts the phone tunnels (CLAUDE.md 4.13: “consciously accepted”). Whatever the application does not encrypt itself is plain text there.
  • Overlapping LAN subnets are possible: nothing is routed into any LAN; the server only knows the per-gateway unique 10.78.x.y.

Routing module ngx_stream_rivet_module

How the module works (from server/nginx-module/)
  • Directives sqlite_map_database, sqlite_map <input> $<output> { query …; default …; cache_ttl …; }, allow_backend, sqlite_map_check_changes.
  • Per worker, init_process opens the database with SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, PRAGMA query_only = 1, busy timeout 20 ms; statements are prepared once with sqlite3_prepare_v3(…, SQLITE_PREPARE_PERSISTENT).
  • No reload: a cache per worker (positive and negative, LRU) is dropped as soon as PRAGMA data_version changes or the file was replaced (new inode). New or blocked gateways take effect immediately.
  • Input checked before it reaches SQLite (SNI lower-cased, 1–253 characters, only [a-z0-9.-]), then passed only as the bound parameter ?1. Output checked: only IPv4:port or [IPv6]:port within allow_backend (default 10.78.0.0/16), otherwise default (protection against forwarding to arbitrary targets).
  • If the DB is missing or broken, default applies; nginx keeps running, and portals never depend on the DB (static map).
  • Table routes (fqdn, zone, tls_backend, udp_port, wg_backend, enabled, updated_at); the only writer is Django (gateways/routes.py), manage.py sync_routes rebuilds it.

Operations

WhatValueSource
Heartbeatevery 5 minutes and after start; version, uptime, uplink handshake, per phone handshake and rx/tx, certificate and theme version, applied firewall; no keysclient/src/sync.rs, api.py
Phone list and forwardslong poll GET /api/v1/peers, the server holds up to 60 s; last list kept offline in peers.jsonapi.py, settings.py
Heartbeat cleanuptimer forgeiec-rivet-cleanup, daily, retention 30 daysserver/contrib/
Peer synctimer forgeiec-rivet-uplink-sync every 10 minutesserver/wg-daemon/contrib/
Phones: last seen, data volumefrom the server interface phones (status of forgeiec-rivet-wg), otherwise from the heartbeatPhone.connection in models.py
Gateway serviceaccount blacksmith, no root; CAP_NET_BIND_SERVICE and CAP_NET_ADMIN; ProtectSystem=strict, NoNewPrivilegesclient/contrib/forgeiec-rivet.service
Gateway overview: address, certificate, tunnel to the server, portal.

Note: the README still describes the gateway service without nftables and with CAP_NET_BIND_SERVICE only. The current code additionally needs CAP_NET_ADMIN for the kernel path (TUN rivet0, nftables via netlink, no external programs).

Platforms, packages and build

  • Built for 12 slots, gateway and server alike: Debian 12/13 and Ubuntu 22.04/24.04, each amd64, arm64 and armhf (client/contrib/slot-build.sh --list). As target hardware for the gateway CLAUDE.md names Debian 12 arm64 and Ubuntu 24.04 armhf. According to its README, the nginx module has only been built and tested against nginx 1.26.3 (Debian 13).
  • Packages: forgeiec-rivet (cargo-deb) and forgeiec-rivet-server (CMake/CPack), built natively in a container of the target architecture (podman, qemu-user), not cross-compiled.
  • client/deploy.sh: build (default), rebuild, test, run (dry run without root on 8443/8080), peer, package, slot <s|all>, install, restart, clean, help. server/deploy.sh: configure, rebuild, test, run, package, install, restart, clean, slot, help.
  • CI: Forgejo workflows client.yml and server.yml, each a matrix over the 12 slots; only main publishes to apt.forgeiec.io.

Quality

PartTestsStatus
Gateway (client/)cargo test: 94 passed, 1 ignored (the kernel test kernel_weg runs only under unshare -rn)run on 22 Oct 2026 (--offline, commit 5f01540)
WireGuard service69 test functions; plus tests/kernel-netns.sh with a real WireGuard peer (39 checks according to its README)counted, not run
Portal421 test methods, 4 of them live tests against the Hetzner API (only with a token)counted, not run
nginx moduleC unit tests (103 checks), 20 integration tests with a real nginx, load testresults of 27 Sep 2026 according to server/nginx-module/README.md

The nginx module’s load test (according to its README: 1000 connections with 20 parallel clients, Ryzen 9 9950X in a Podman container) reached about 11,600 connections per second with and without a DB query; the measured latency there is dominated by the Python client, not by the query. There are no other performance or availability figures.

Repository

Layout of forgeiec-rivet
client/          gateway (Rust): src/{main,auth,config,front,tls,web,setup,sync,portal,forwards}.rs,
                 src/net/{uplink,hub,peers,stack}.rs, src/sys/{tun,rtnl,nft}.rs, templates/, locale/
server/portal/   Django project rivet_server, app gateways (models, API, levels, tests)
server/wg-daemon/  forgeiec-rivet-wg (Rust, netlink, nftables, mini DNS)
server/nginx-module/  ngx_stream_rivet_module (C) with unit, integration and load tests
server/contrib/  systemd units and timers, debconf/postinst, nginx templates
docs/            api-v1.md, betrieb-server.md, router-rutos.md
.github/workflows/  client.yml, server.yml

Licence field in both Cargo.toml files: LicenseRef-proprietary.

Threat model (as far as CLAUDE.md states it)

  • Shared wildcard key per customer (consciously accepted): whoever extracts it from a gateway can pose as any address under *.<customer>.<zone>, but not as a gateway of another customer or as a portal. Countermeasures in the specification: exploiting it also requires redirecting DNS or traffic; portals under their own certificates; session cookie only for its own host; emergency rotation; blocked gateways receive no new certificates; short Let’s Encrypt lifetimes.
  • The server is the trust anchor: it distributes the keys and sees the inner traffic of phones on the path via the server (consciously accepted).
  • Router gateways: there, the router’s firewall decides whether the server gets into the LAN, not Rivet.
  • Tampered routing database: the nginx module accepts only targets within allow_backend.
  • ECH would hide the SNI; therefore no DNS HTTPS records with ECH are published for the Rivet zones.
  • UDP blocked or throttled on mobile networks: then the uplink does not come up (open question 11).
  • Dependence on the server: without the server there is no remote access; the LAN itself keeps working (open question 8).

Known limits

  • Forwards for TCP only; at most 20 rules per gateway; IPv4 only inside the tunnel.
  • When a gateway is added to the customer, the phone needs a new configuration (AllowedIPs).
  • No fallback when UDP is blocked; no direct access when the server is down.
  • Theme takeover on the gateway, alert e-mails, install.sh and the self-test for router gateways are still missing (see implementation status).
  • Two phone paths run in parallel until the old one via the gateway is removed.

In contrast to …

The table lists only properties that follow from the architecture.

Port forwarding on the routerVendor cloudCommercial VPN serviceRivet
Inbound port at the siteyesnodepends on the productno, the gateway only connects outbound
Controller visible on the internetyesnonono
Third party in the data pathnoyes (vendor)yes (provider)no, only the ForgeIEC server
Works without a public IP (CGNAT, mobile)noyesyesyes
Release per phone and portnodepends on the productdepends on the productyes, in the portal
Who sees decrypted trafficnobody in betweenthe vendordepends on the productthe ForgeIEC server on the phone path via the server (see isolation)