TollGate Fleet Design
Deployment design · proposal

TollGate fleet design

Several OpenWrt routers, all broadcasting !TollGate with a captive portal. Paid credit follows the phone from router to router, only we can issue byte tokens, and everyone reaches the internet through one VPS.

2026-10-19 Built on tollgate-suite-wrt 0.1.0-rc.2 · tollgate-rs v0.1.0-rc.2 Phones use the portal only (no TollGate software on the phone)

Goals and how they are met

One network, one portal

Every router broadcasts the same open SSID, !TollGate, and runs its own captive portal (proxyd).

Roam without losing credit

A user's balance is a purse of tokens held at the shared mint. Any router can draw from it, so the routers never talk to each other.

Only we issue tokens

One mint, on the VPS. Its issuing endpoint is on loopback, used by us and by the market next to it. Free vouchers are off.

Consistent IP

All traffic leaves through the VPS, so every phone has the same public address on every router. Identical LAN settings let a phone usually keep its local IP too.

Topology

Fleet topology A VPS running mintd, merchantd and a WireGuard exit connects to the internet. Two routers each connect to the VPS with WireGuard over FIPS, and to each other over the FIPS mesh. Phones join a router over Wi-Fi and roam between them. VPS mintd · the one byte mint (issuing on loopback) merchantd · market: sats → byte tokens WireGuard 10.99.0.1 · NAT · DNS resolver optional: exitd for outside FIPS clients Internet NAT Router A SSID !TollGate · own DHCP proxyd · portal and purses tollgated · gate, shape, meter wg0 10.99.0.A over FIPS Router B SSID !TollGate · own DHCP proxyd · portal and purses tollgated · gate, shape, meter wg0 10.99.0.B over FIPS WireGuard over FIPS WireGuard over FIPS FIPS mesh Wi-Fi Wi-Fi same phone roams; credit follows
Payment logic runs on each router. The VPS is the shared mint and the shared exit, and keeps no per-user records of its own.

What runs where

VPS

  • mintd: the only byte mint. Its record of spent tokens is the only state shared across the fleet. The public listener serves the standard Cashu API; the private (issuing) listener stays on loopback.
  • merchantd: the market that sells bytes for sats. It issues through mintd's private listener, so it must live next to the mint. All sat revenue collects in its wallet.
  • WireGuard on 10.99.0.1/24, one peer per router, listening only on fips0. NAT out to the internet. A DNS resolver on 10.99.0.1.
  • Optional: exitd, for selling to FIPS clients outside the fleet. Not used by the routers.

Every router (same config, own keys)

  • hostapd: open SSID !TollGate.
  • dnsmasq: its own DHCP. Every router uses the same LAN subnet and gateway address, with dhcp-authoritative, so a roaming phone usually keeps its IP.
  • proxyd: captive portal, one TollGate session per phone, and the purse logic (new). A small float of byte tokens in its wallet for refunds.
  • tollgated (tollgate-ip): gates, shapes and meters each phone on br-lan, as today. Settles channels at the VPS mint.
  • FIPS and wg0: LAN traffic is policy-routed into the tunnel, with a kill switch so it never leaks out the router's own WAN.
  • Not on routers: mintd, merchantd.

Tokens and money

Issuing. mintd runs with auto_accept: false, so the portal shows no "Get free tokens" option and nobody can mint from the public listener. We issue byte tokens over an SSH tunnel to the private listener and hand them out as text or QR codes. Users paste or scan them on the portal's Pay screen.

Selling. If a router's portal offers Lightning, proxyd pays the invoice at a sat mint (market.lightning_mint), buys byte tokens from the VPS market, and puts them into the user's purse. Sats tokens from accepted mints follow the same route. Either way, the sats end up in merchantd's wallet on the VPS.

Routers do not need merchantd. tollgated uses merchantd only to buy from an upstream (fund) and to keep tokens from other mints (deposit). The routers have no upstream to pay, and channel funds in the mint's own tokens are always burned at settlement.

Router float. When a phone leaves, its router refunds unused credit into the purse, including credit tollgated already holds as budget. That part comes from proxyd's wallet. We top each router's float up by minting byte tokens, which costs us nothing because they are our own unit.

Decide before issuing

The mint's URL. Every token names its mint by URL, so changing it later strands tokens already handed out.

  • Public HTTPS name (for example https://mint.example.net), behind TLS on the VPS. Routers reach it through the tunnel, and users can keep tokens you give them in any Cashu wallet. Recommended.
  • http://<vps-npub>.fips:3338. Reachable only over the mesh, so tokens can't sit in an ordinary wallet.

Roaming: the purse at the mint

A Cashu token can't be redeemed twice, so a user can't paste the same token again at the next router. Instead, a payment becomes a purse: tokens whose secrets and blinding factors are derived from a seed S (NUT-13). Anyone holding S can ask the mint to restore those tokens (NUT-09) and see which are still unspent (NUT-07). The unspent ones are the user's balance. tollgate-net already uses NUT-09 to recover a Spilman channel's change.

S = HMAC(fleet_secret, MAC), so every router can derive a phone's purse as soon as the phone associates. The portal also shows S as a receipt (QR or phrase) so the user can recover it if the phone changes its MAC.

Rules each router follows

  • On payment: swap the user's token into many small tokens under S, rather than into the router's wallet.
  • While browsing: pull one chunk from the purse into the wallet only when the session needs its next channel.
  • On leaving: when hostapd reports the phone gone for 30–60 s, end the session, mint credit − drawn back under S, and forget the session key.
  • On "already spent": another router or a stale session got there first. End the session.
  • Never sweep: routers pull one chunk at a time, so a late refund from the previous router is picked up on the next pull.
  • Each router writes new tokens on its own path, HMAC(S, router_npub), so two routers never create the same output. Restore scans every router's path; the list of routers is fixed.

How this fits with Spilman channels

On each router, both ends of every channel are ours: proxyd pays and tollgated receives. Channels only ever lock tokens that router already pulled from the purse. What the user is owed is always credit − what tollgated drew, wherever the tokens currently sit.

Deleting the session key means nobody can reclaim the budget tollgated holds for that phone. It expires within the hour, and its tokens are burned on settlement. Unsigned channel remainders return to proxyd's wallet when the channel closes or its refund time passes.

EventWhat a user can lose
Normal roam (old router sees the disconnect and refunds)Nothing
Old router crashes or loses power before refundingAt most one chunk plus roughly two channels' worth, plus the budget tollgated held. Smaller chunks and a shorter channel_secs shrink this.
Same purse used at two routers at onceNothing for the user. The mint lets only one pull win; double use is limited to what was already pulled.
Phone changes its MACNothing, if the user pastes or scans the receipt. Without it, the purse waits at the mint.

Flows

  1. Join

    The phone joins the open !TollGate SSID. It is gated: DNS and the portal work, nothing else. The OS's captive-portal check opens the portal page.

  2. Pay

    The user pastes or scans a token we gave them, or pays by Lightning. proxyd swaps the value into the phone's purse S and shows the receipt. The user picks a speed or "Pay for what you use".

  3. Browse

    proxyd pulls a chunk from the purse and funds a channel to its own tollgated. tollgated carries the phone's traffic from br-lan to wg0, over FIPS to the VPS, and out through NAT.

  4. Roam

    • Router A: hostapd reports the disconnect. After the grace period, A ends the session, refunds unused credit into S, and forgets the key.
    • Router B: the phone associates. B derives S from its MAC, restores the purse, checks which tokens are unspent, pulls a chunk, and puts the phone online with no portal.
    • A phone that keeps its old IP on the same SSID may never run its captive-portal check again. If B has no purse for it, B deauthenticates it once so the portal appears.
  5. Run out

    The purse is empty, so the phone is gated and kicked off the Wi-Fi, and the portal comes back. Each router only kicks phones on its own Wi-Fi, which the existing kick-on-expiry code already does.

  6. Issue tokens

    We open an SSH tunnel to the VPS's private listener, mint byte tokens, and hand them out. Only processes on the VPS can reach that listener.

Where state lives

StateWhereLost if
A user's balancePurse S at the mintNot lost while the mint survives: the mint is the authority
Pulled chunk, open channels, held budgetThe router serving the phoneThat router crashes before refunding (see the table above)
Router floatEach router's proxyd walletThe router is lost. Keep the float small.
Sat revenuemerchantd's wallet on the VPSThe VPS and its backups are lost
Mint keys and spent-token recordVPS: mint.seed, mint.sqliteEverything. Without the seed, every outstanding token is void; without the record, spent tokens redeem again. Back both up off-site.
fleet_secretEvery routerA compromised router can drain any purse (it cannot mint)

Network: WireGuard over FIPS

Each router runs WireGuard to the VPS, with the VPS's FIPS fd address as the endpoint. Paid traffic stays on the kernel's forward path (br-lan to wg0), so tollgated's ip enforcer gates, shapes and meters it exactly as it does today. UDP works, so QUIC, calls and games are fine. exitd's SOCKS5 proxy would not do here: it carries TCP only.

Tunnel addresses are 10.99.0.0/24: the VPS is .1, router N is .N. Every router can keep 192.168.1.0/24 on its LAN because each one masquerades into wg0.

Router (UCI, abridged)

# /etc/config/network
config interface 'wg0'
	option proto 'wireguard'
	option private_key '<router privkey>'
	list addresses '10.99.0.N/24'
	option mtu '<fips0 mtu - 80>'

config wireguard_wg0
	option public_key '<vps pubkey>'
	option endpoint_host '<vps fd address>'
	option endpoint_port '51820'
	list allowed_ips '0.0.0.0/0'
	option route_allowed_ips '0'
	option persistent_keepalive '25'

config route
	option interface 'wg0'
	option target '0.0.0.0/0'
	option table '200'

config route            # kill switch
	option type 'unreachable'
	option target '0.0.0.0/0'
	option table '200'
	option metric '1000'

config rule             # forwarded LAN traffic only
	option in 'lan'
	option lookup '200'
	option priority '30000'

# /etc/config/firewall: zone 'tun' on wg0
#   masq 1, mtu_fix 1, forwarding lan -> tun
#   remove the stock lan -> wan forwarding

# dnsmasq: server=10.99.0.1, no-resolv

VPS

# /etc/wireguard/wg0.conf
[Interface]
Address = 10.99.0.1/24
ListenPort = 51820
PrivateKey = <vps privkey>
MTU = <fips0 mtu - 80>

[Peer]                  # one per router
PublicKey = <router N pubkey>
AllowedIPs = 10.99.0.N/32

# sysctl: net.ipv4.ip_forward=1
# nft: oifname "eth0" ip saddr 10.99.0.0/24 masquerade
#      plus an MSS clamp on forwarded traffic
# firewall: udp/51820 accepted only on fips0
# resolver listening on 10.99.0.1

The rule matches in 'lan', not the source address. proxyd sends packets from the router with a phone's IP as the source, and tollgated's replies to them use fwmark 0x1 lookup 100. Both start on the router itself, so they never match a rule for traffic arriving from the LAN.

Build status

Works today
  • Captive portal, per-device gating, shaping and metering
  • Payment routes: byte tokens, sats tokens, Lightning
  • Kick on expiry
  • OpenWrt packaging; tested on a router
Configuration
  • WireGuard on routers and VPS (a wg-exit-setup script could match fips-exit-setup)
  • mintd and merchantd on the VPS; both off on routers
  • Routers' mint.url, mint.local and market.url pointed at the VPS
  • auto_accept: false
  • One SSID and one LAN plan on every router
Code to write
  • Purse in proxyd and the suite library (pay, budget): redeem into S, pull chunks, restore and check state
  • Refund on disconnect, using hostapd's ubus events
  • Start a session automatically when a known MAC associates
  • Count what tollgated still holds as owed when a session stops
  • fleet_secret in the configuration; receipt on the portal
  • Nothing in tollgate-rs

Risks

RiskEffectMitigation
VPS downNo internet anywhere. The portal still loads but nobody can pay, roam or browse.Monitoring; a standby VPS with the same WireGuard keys and a mint restored from backup
Mint down, exit upPhones browse until their current chunk runs out; no payments or roamingSize chunks to cover a short outage
MAC spoofingAn attacker who copies a phone's MAC can use its purseSame trust level as today's IP-and-MAC identity; receipts let a user move a purse if needed
Compromised routerCan drain purses using fleet_secret, and its floatCannot mint: the private listener stays on the VPS. Rotate fleet_secret fleet-wide.
Shared public IPAbuse complaints, CAPTCHAs and rate limits land on one address; the VPS can't tell users apartKeep proxyd's per-device logs on routers for tracing

Open checks

tollgated runs cleanly with mint.url and mint.local on the VPS and no merchantd on the router, and settles channels over FIPS without stalling.

mintd answers NUT-07 state checks and NUT-09 restores for the purse; tollgate-net already relies on NUT-09 for channel change. Input fee set to 0, since small tokens multiply fees. How often its keyset rotates (restore scans each keyset).

fips0 MTU, and the WireGuard MTU set 80 below it. Test a large download and a TLS handshake.

A router reaches the VPS's fd address on UDP 51820, both directly and across mesh hops.

Kill switch: with wg0 down, a paid phone loses internet rather than falling back to the WAN.

What Android and iOS do when roaming between routers with the same SSID and subnet: whether they keep the IP, and whether they run the captive-portal check again.